fix: resolve @path references relative to workflow file, not CWD

`normalize_logical_path()` silently dropped leading `..` components
because `PathBuf::pop()` on an empty buffer is a no-op. For user-global
workflows (~/.fabro/workflows/) invoked from an unrelated CWD, the
manifest builder produces logical paths with leading `..` segments, but
the BundleFileResolver normalized them differently during lookup —
stripping the `..` — causing a key mismatch and leaving `@` references
unresolved.

Preserve `..` when there is no normal component to collapse.

Closes #175

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Abrahms 2026-04-28 09:33:13 +02:00
parent cb0c39ee91
commit c7851f4e08
No known key found for this signature in database
2 changed files with 69 additions and 2 deletions

View file

@ -362,7 +362,11 @@ fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option<PathBuf
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
normalized.pop();
if normalized.file_name().is_some() {
normalized.pop();
} else {
normalized.push("..");
}
}
Component::RootDir | Component::Prefix(_) => return None,
}

View file

@ -50,7 +50,16 @@ pub(crate) fn normalize_logical_path(current_dir: &Path, reference: &str) -> Opt
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
normalized.pop();
// If the last component is a normal name, collapse it.
// Otherwise (empty path or trailing `..`), preserve the
// `..` so that leading parent-dir segments survive — these
// occur when a bundled workflow lives outside the CWD
// (e.g. user-global workflows under ~/.fabro/).
if normalized.file_name().is_some() {
normalized.pop();
} else {
normalized.push("..");
}
}
Component::RootDir | Component::Prefix(_) => return None,
}
@ -150,4 +159,58 @@ mod tests {
let resolver = BundleFileResolver::new(HashMap::new());
assert!(resolver.resolve(Path::new("."), "missing.md").is_none());
}
#[test]
fn normalize_preserves_leading_parent_dir() {
// When a bundled workflow lives outside the CWD (e.g. ~/.fabro/),
// the manifest builder produces logical paths with leading `..`
// segments. The normalizer must preserve these so that the lookup
// key matches the stored key.
assert_eq!(
normalize_logical_path(
Path::new("../.fabro/workflows/demo"),
"prompts/hello.md"
),
Some(PathBuf::from(
"../.fabro/workflows/demo/prompts/hello.md"
))
);
}
#[test]
fn normalize_preserves_multiple_leading_parent_dirs() {
assert_eq!(
normalize_logical_path(Path::new("../../shared/workflows"), "file.md"),
Some(PathBuf::from("../../shared/workflows/file.md"))
);
}
#[test]
fn normalize_collapses_mid_path_parent_dir() {
// ../foo/../bar should normalize to ../bar
assert_eq!(
normalize_logical_path(Path::new("../foo"), "../bar/file.md"),
Some(PathBuf::from("../bar/file.md"))
);
}
#[test]
fn bundle_resolver_resolves_outside_cwd_paths() {
// Reproduces the user-global workflow scenario from issue #175:
// files are keyed with leading `..` because the workflow lives
// outside the CWD.
let resolver = BundleFileResolver::new(HashMap::from([(
PathBuf::from("../.fabro/workflows/demo/prompts/hello.md"),
"prompt content".to_string(),
)]));
let resolved = resolver
.resolve(
Path::new("../.fabro/workflows/demo"),
"prompts/hello.md",
)
.expect("file should resolve for out-of-CWD workflow");
assert_eq!(resolved.content, "prompt content");
}
}