| //! Content-addressed cache for the assembled `rust-project.json`. |
| //! |
| //! Discovery has to read every per-crate spec file regardless, but the |
| //! consolidate + assemble + serialize step is short-circuited on a cache |
| //! hit. The key hashes every input — spec contents, toolchain info, |
| //! bazel/workspace/exec-root paths — so an entry is only served when |
| //! every input matches byte-for-byte. |
| |
| use std::{ |
| collections::hash_map::DefaultHasher, |
| fs, |
| hash::{Hash, Hasher}, |
| }; |
| |
| use anyhow::{Context, Result}; |
| use camino::{Utf8Path, Utf8PathBuf}; |
| |
| /// Where cache entries live, relative to the workspace root. |
| /// |
| /// Editor-agnostic by design: the discover binary that produces these |
| /// entries is invoked the same way regardless of which editor's launcher |
| /// kicked it off (`.vscode/`, `.helix/`, `.rules_rust_analyzer/` — all |
| /// dispatch to the same `bazel-bin/.../discover_bazel_rust_project`), so |
| /// they share one cache. |
| /// |
| /// Living inside the workspace (not under Bazel's `output_base`) keeps |
| /// the cache visible to `ls`, easy to clear with `rm -rf`, and immune to |
| /// `bazel clean`. Making Bazel manage it would require turning it into |
| /// an action output, which has its own correctness headaches. |
| const CACHE_DIR_REL: &str = ".rules_rust_analyzer/cache"; |
| |
| /// Schema version mixed into every cache key. Bump on any change to |
| /// the assembled `rust-project.json` shape — old cache entries become |
| /// ignored rather than served as stale. The cache key is content-keyed |
| /// on inputs (not on rules_rust source), so without this bump an |
| /// unchanged workspace would keep serving JSON assembled by an older |
| /// version of the assembler. See the explanatory comment on |
| /// `compute_key` for the incident that motivated this. |
| const CACHE_SCHEMA_VERSION: u32 = 0; |
| |
| /// Compute the cache key for an assembled rust-project.json from the raw |
| /// spec contents plus every auxiliary input the assembler bakes into its |
| /// output. Anything that changes the bytes of the JSON must factor into the |
| /// key, otherwise a stale entry could be served after the user moves to a |
| /// different bazel binary, workspace, or execution root. |
| /// |
| /// `spec_contents` should already be sorted by spec path so the key is |
| /// independent of file-system enumeration order. |
| pub fn compute_key( |
| spec_contents: &[(Utf8PathBuf, String)], |
| toolchain_info: &str, |
| bazel: &Utf8Path, |
| workspace: &Utf8Path, |
| execution_root: &Utf8Path, |
| ) -> String { |
| let mut hasher = DefaultHasher::new(); |
| CACHE_SCHEMA_VERSION.hash(&mut hasher); |
| bazel.as_str().hash(&mut hasher); |
| workspace.as_str().hash(&mut hasher); |
| execution_root.as_str().hash(&mut hasher); |
| toolchain_info.hash(&mut hasher); |
| for (path, content) in spec_contents { |
| path.as_str().hash(&mut hasher); |
| content.hash(&mut hasher); |
| } |
| format!("{:016x}", hasher.finish()) |
| } |
| |
| /// Env var that overrides the cache directory location. `setup` bakes the |
| /// resolved path into the discover launcher (`export RULES_RUST_RA_CACHE_DIR=...`) |
| /// so the discover binary inherits it; users can also set it manually for |
| /// one-off runs. |
| const CACHE_DIR_ENV_VAR: &str = "RULES_RUST_RA_CACHE_DIR"; |
| |
| /// Resolve the cache directory. `$RULES_RUST_RA_CACHE_DIR` wins if set; |
| /// otherwise we fall back to `<workspace>/.rules_rust_analyzer/cache`. |
| /// Callers can use this directly when they want to list / nuke entries. |
| pub fn cache_dir(workspace: &Utf8Path) -> Utf8PathBuf { |
| if let Ok(s) = std::env::var(CACHE_DIR_ENV_VAR) { |
| if !s.is_empty() { |
| return Utf8PathBuf::from(s); |
| } |
| } |
| workspace.join(CACHE_DIR_REL) |
| } |
| |
| fn cache_file(workspace: &Utf8Path, key: &str) -> Utf8PathBuf { |
| cache_dir(workspace).join(format!("{key}.json")) |
| } |
| |
| /// Returns the cached JSON bytes if the key is present. A miss returns |
| /// `Ok(None)`; only IO errors propagate. |
| pub fn get(workspace: &Utf8Path, key: &str) -> Result<Option<Vec<u8>>> { |
| let path = cache_file(workspace, key); |
| match fs::read(&path) { |
| Ok(bytes) => { |
| log::debug!("rust-analyzer merge cache hit: {path}"); |
| Ok(Some(bytes)) |
| } |
| Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
| Err(e) => Err(e).with_context(|| format!("reading cache file {path}")), |
| } |
| } |
| |
| /// Persist the assembled JSON bytes for the given key. Cache writes are |
| /// best-effort: a failure is logged but does not fail the caller, since the |
| /// JSON is also being returned to `rust-analyzer`. |
| pub fn put(workspace: &Utf8Path, key: &str, bytes: &[u8]) { |
| let dir = cache_dir(workspace); |
| if let Err(e) = fs::create_dir_all(&dir) { |
| log::warn!("rust-analyzer merge cache: create_dir_all {dir} failed: {e}"); |
| return; |
| } |
| let path = cache_file(workspace, key); |
| // Write to a temp file and rename so concurrent invocations don't observe |
| // a half-written cache entry. |
| let tmp = path.with_extension("json.tmp"); |
| if let Err(e) = fs::write(&tmp, bytes) { |
| log::warn!("rust-analyzer merge cache: write {tmp} failed: {e}"); |
| return; |
| } |
| if let Err(e) = fs::rename(&tmp, &path) { |
| log::warn!("rust-analyzer merge cache: rename {tmp} -> {path} failed: {e}"); |
| let _ = fs::remove_file(&tmp); |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| #[test] |
| fn compute_key_is_stable() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let ws = Utf8Path::new("/ws"); |
| let er = Utf8Path::new("/er"); |
| let toolchain = "{\"sysroot\":\"a\"}"; |
| let specs = vec![ |
| ( |
| Utf8PathBuf::from("a/foo.json"), |
| String::from("{\"id\":\"a\"}"), |
| ), |
| ( |
| Utf8PathBuf::from("b/bar.json"), |
| String::from("{\"id\":\"b\"}"), |
| ), |
| ]; |
| let k1 = compute_key(&specs, toolchain, bazel, ws, er); |
| let k2 = compute_key(&specs, toolchain, bazel, ws, er); |
| assert_eq!(k1, k2); |
| } |
| |
| #[test] |
| fn compute_key_changes_with_content() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let ws = Utf8Path::new("/ws"); |
| let er = Utf8Path::new("/er"); |
| let toolchain = "{}"; |
| let base = vec![(Utf8PathBuf::from("a.json"), "a".to_string())]; |
| let mutated = vec![(Utf8PathBuf::from("a.json"), "b".to_string())]; |
| assert_ne!( |
| compute_key(&base, toolchain, bazel, ws, er), |
| compute_key(&mutated, toolchain, bazel, ws, er) |
| ); |
| } |
| |
| #[test] |
| fn compute_key_changes_with_toolchain() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let ws = Utf8Path::new("/ws"); |
| let er = Utf8Path::new("/er"); |
| let specs = vec![(Utf8PathBuf::from("a.json"), "a".to_string())]; |
| assert_ne!( |
| compute_key(&specs, "{\"sysroot\":\"a\"}", bazel, ws, er), |
| compute_key(&specs, "{\"sysroot\":\"b\"}", bazel, ws, er), |
| ); |
| } |
| |
| #[test] |
| fn compute_key_changes_with_workspace() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let er = Utf8Path::new("/er"); |
| let specs = vec![(Utf8PathBuf::from("a.json"), "a".to_string())]; |
| assert_ne!( |
| compute_key(&specs, "{}", bazel, Utf8Path::new("/ws1"), er), |
| compute_key(&specs, "{}", bazel, Utf8Path::new("/ws2"), er), |
| ); |
| } |
| } |