diff --git a/crates/source-map-gen/src/lib.rs b/crates/source-map-gen/src/lib.rs index d78344b1..41a8ab0c 100644 --- a/crates/source-map-gen/src/lib.rs +++ b/crates/source-map-gen/src/lib.rs @@ -85,10 +85,14 @@ pub trait LanguageAdapter { /// Reads the existing map, updates only the entries for the provided files, and /// writes back. Entries for files not in `passing_files` are preserved unchanged. /// Running twice with the same input produces identical file content (idempotent). + /// + /// When `root` is `Some`, keys are written as paths relative to `root` so the + /// map stays portable across machines and worktree locations. fn update_source_map( &self, passing_files: &[&Path], source_map_path: &Path, + root: Option<&Path>, ) -> Result<(), String>; } @@ -228,7 +232,14 @@ pub fn check_files(files: &[&Path]) -> CheckResult { /// /// Dispatches each file to the appropriate [`LanguageAdapter`] based on extension. /// Files with unsupported extensions are silently skipped. -pub fn update_source_map(passing_files: &[&Path], source_map_path: &Path) -> Result<(), String> { +/// +/// When `root` is `Some`, keys in the map are written relative to `root` so the +/// map stays portable across machines and worktree locations. +pub fn update_source_map( + passing_files: &[&Path], + source_map_path: &Path, + root: Option<&Path>, +) -> Result<(), String> { let mut by_ext: HashMap> = HashMap::new(); for &file in passing_files { if let Some(ext) = file.extension().and_then(|e| e.to_str()) { @@ -237,7 +248,7 @@ pub fn update_source_map(passing_files: &[&Path], source_map_path: &Path) -> Res } for (ext, ext_files) in &by_ext { if let Some(adapter) = adapter_for_ext(ext) { - adapter.update_source_map(ext_files, source_map_path)?; + adapter.update_source_map(ext_files, source_map_path, root)?; } } Ok(()) @@ -295,7 +306,20 @@ pub fn update_for_worktree( std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {e}"))?; } - update_source_map(&passing, source_map_path) + update_source_map(&passing, source_map_path, Some(worktree_path)) +} + +/// Compute the map key for a file, stripping `root` when present. +/// +/// Returns a root-relative path string when `root` is `Some` and the file is +/// under that root; falls back to the file's own path string otherwise. +pub(crate) fn relative_key(file: &Path, root: Option<&Path>) -> String { + if let Some(r) = root + && let Ok(rel) = file.strip_prefix(r) + { + return rel.to_string_lossy().to_string(); + } + file.to_string_lossy().to_string() } /// Read the existing source map from `path` as a JSON object. @@ -444,10 +468,10 @@ mod tests { let map_path = tmp.path().join("source-map.json"); let files: &[&Path] = &[&rs_path]; - update_source_map(files, &map_path).unwrap(); + update_source_map(files, &map_path, None).unwrap(); let first = std::fs::read_to_string(&map_path).unwrap(); - update_source_map(files, &map_path).unwrap(); + update_source_map(files, &map_path, None).unwrap(); let second = std::fs::read_to_string(&map_path).unwrap(); assert_eq!(first, second, "update_source_map must be idempotent"); @@ -468,7 +492,7 @@ mod tests { "new.rs", "//! Module doc.\n\n/// A function.\npub fn bar() {}\n", ); - update_source_map(&[&rs_path], &map_path).unwrap(); + update_source_map(&[&rs_path], &map_path, None).unwrap(); let content = std::fs::read_to_string(&map_path).unwrap(); assert!( @@ -736,4 +760,72 @@ mod tests { "map must list the documented function" ); } + + /// AC2/AC3: keys written by `update_for_worktree` are project-root-relative, + /// not absolute paths into the worktree directory. + #[test] + fn update_for_worktree_writes_relative_keys() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + write_rs( + tmp.path(), + "lib.rs", + "//! Module doc.\n\n/// A function.\npub fn greet() {}\n", + ); + Command::new("git") + .args(["add", "lib.rs"]) + .current_dir(tmp.path()) + .output() + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "add lib.rs"]) + .current_dir(tmp.path()) + .output() + .expect("git commit"); + + let huskies_dir = tmp.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + let map_path = huskies_dir.join("source-map.json"); + + update_for_worktree(tmp.path(), "HEAD~1", &map_path).unwrap(); + + let content = std::fs::read_to_string(&map_path).unwrap(); + let map: serde_json::Value = serde_json::from_str(&content).unwrap(); + let obj = map.as_object().unwrap(); + + // Every key must be relative — no absolute path prefix. + for key in obj.keys() { + assert!( + !key.starts_with('/'), + "key must be relative, got absolute path: {key}" + ); + assert!( + !key.contains("/.huskies/worktrees/"), + "key must not contain worktree path infix: {key}" + ); + } + + // The key for lib.rs must be exactly "lib.rs". + assert!( + obj.contains_key("lib.rs"), + "expected key 'lib.rs', got keys: {:?}", + obj.keys().collect::>() + ); + } + + /// `relative_key` strips the root prefix from an absolute path. + #[test] + fn relative_key_strips_root_prefix() { + let root = Path::new("/workspace/.huskies/worktrees/978"); + let file = Path::new("/workspace/.huskies/worktrees/978/server/src/foo.rs"); + assert_eq!(relative_key(file, Some(root)), "server/src/foo.rs"); + } + + /// `relative_key` falls back to the full path when root is `None`. + #[test] + fn relative_key_none_root_returns_full_path() { + let file = Path::new("/absolute/path/foo.rs"); + assert_eq!(relative_key(file, None), "/absolute/path/foo.rs"); + } } diff --git a/crates/source-map-gen/src/rust_adapter.rs b/crates/source-map-gen/src/rust_adapter.rs index a3b4d602..4e8b05a5 100644 --- a/crates/source-map-gen/src/rust_adapter.rs +++ b/crates/source-map-gen/src/rust_adapter.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::Path; -use crate::{CheckFailure, CheckResult, LanguageAdapter}; +use crate::{CheckFailure, CheckResult, LanguageAdapter, relative_key}; /// Rust documentation coverage adapter. pub struct RustAdapter; @@ -79,10 +79,11 @@ impl LanguageAdapter for RustAdapter { &self, passing_files: &[&Path], source_map_path: &Path, + root: Option<&Path>, ) -> Result<(), String> { let mut map = crate::read_map(source_map_path)?; for &file in passing_files { - let key = file.to_string_lossy().to_string(); + let key = relative_key(file, root); let items: Vec = Self::extract_items(file) .into_iter() .map(serde_json::Value::String) diff --git a/crates/source-map-gen/src/ts_adapter.rs b/crates/source-map-gen/src/ts_adapter.rs index 40036373..c256c1ad 100644 --- a/crates/source-map-gen/src/ts_adapter.rs +++ b/crates/source-map-gen/src/ts_adapter.rs @@ -9,7 +9,7 @@ use std::fs; use std::path::Path; -use crate::{CheckFailure, CheckResult, LanguageAdapter}; +use crate::{CheckFailure, CheckResult, LanguageAdapter, relative_key}; /// TypeScript documentation coverage adapter. pub struct TypeScriptAdapter; @@ -80,10 +80,11 @@ impl LanguageAdapter for TypeScriptAdapter { &self, passing_files: &[&Path], source_map_path: &Path, + root: Option<&Path>, ) -> Result<(), String> { let mut map = crate::read_map(source_map_path)?; for &file in passing_files { - let key = file.to_string_lossy().to_string(); + let key = relative_key(file, root); let items: Vec = Self::extract_items(file) .into_iter() .map(serde_json::Value::String)