diff --git a/lib/crates/fabro-workflows/src/cli/run.rs b/lib/crates/fabro-workflows/src/cli/run.rs index 2af8a063d..19a7007a5 100644 --- a/lib/crates/fabro-workflows/src/cli/run.rs +++ b/lib/crates/fabro-workflows/src/cli/run.rs @@ -367,7 +367,8 @@ pub async fn run_command( // Inline @file references in the (possibly overridden) goal if let Some(crate::graph::types::AttrValue::String(goal)) = graph.attrs.get("goal") { - let resolved = crate::transform::resolve_file_ref(goal, dot_dir); + let fallback = dirs::home_dir().map(|h| h.join(".fabro")); + let resolved = crate::transform::resolve_file_ref(goal, dot_dir, fallback.as_deref()); if resolved != *goal { graph.attrs.insert( "goal".to_string(), diff --git a/lib/crates/fabro-workflows/src/transform.rs b/lib/crates/fabro-workflows/src/transform.rs index edda439ef..0b906da40 100644 --- a/lib/crates/fabro-workflows/src/transform.rs +++ b/lib/crates/fabro-workflows/src/transform.rs @@ -140,7 +140,7 @@ impl Transform for ProviderInferenceTransform { /// If `value` starts with `@` and the referenced file exists locally, the file /// contents are returned (inlined). Otherwise the original value is returned /// unchanged. -pub fn resolve_file_ref(value: &str, base_dir: &Path) -> String { +pub fn resolve_file_ref(value: &str, base_dir: &Path, fallback_dir: Option<&Path>) -> String { let path_str = match value.strip_prefix('@') { Some(p) => p, None => return value.to_string(), @@ -148,7 +148,8 @@ pub fn resolve_file_ref(value: &str, base_dir: &Path) -> String { // Build the raw path: expand ~ then resolve relative to base_dir let raw = Path::new(path_str); - let expanded = if raw.starts_with("~") { + let is_tilde = raw.starts_with("~"); + let expanded = if is_tilde { match dirs::home_dir() { Some(home) => home.join(raw.strip_prefix("~").unwrap()), None => base_dir.join(path_str), @@ -159,8 +160,22 @@ pub fn resolve_file_ref(value: &str, base_dir: &Path) -> String { // Canonicalize resolves `.`, `..`, symlinks, and checks existence let file_path = match expanded.canonicalize() { - Ok(p) if p.is_file() => p, - _ => return value.to_string(), + Ok(p) if p.is_file() => Some(p), + _ if !is_tilde => { + // Try fallback_dir for relative (non-tilde) paths + fallback_dir.and_then(|fb| { + let fallback_path = fb.join(path_str); + match fallback_path.canonicalize() { + Ok(p) if p.is_file() => Some(p), + _ => None, + } + }) + } + _ => None, + }; + + let Some(file_path) = file_path else { + return value.to_string(); }; match std::fs::read_to_string(&file_path) { @@ -175,21 +190,27 @@ pub fn resolve_file_ref(value: &str, base_dir: &Path) -> String { /// Inlines `@file` references in node prompts and the graph-level goal. pub struct FileInliningTransform { base_dir: PathBuf, + fallback_dir: Option, } impl FileInliningTransform { #[must_use] - pub fn new(base_dir: PathBuf) -> Self { - Self { base_dir } + pub fn new(base_dir: PathBuf, fallback_dir: Option) -> Self { + Self { + base_dir, + fallback_dir, + } } } impl Transform for FileInliningTransform { fn apply(&self, graph: &mut Graph) { + let fallback = self.fallback_dir.as_deref(); + // Inline @file refs in node prompts for node in graph.nodes.values_mut() { if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") { - let resolved = resolve_file_ref(prompt, &self.base_dir); + let resolved = resolve_file_ref(prompt, &self.base_dir, fallback); if resolved != *prompt { node.attrs .insert("prompt".to_string(), AttrValue::String(resolved)); @@ -199,7 +220,7 @@ impl Transform for FileInliningTransform { // Inline @file refs in graph-level goal if let Some(AttrValue::String(goal)) = graph.attrs.get("goal") { - let resolved = resolve_file_ref(goal, &self.base_dir); + let resolved = resolve_file_ref(goal, &self.base_dir, fallback); if resolved != *goal { graph .attrs @@ -698,14 +719,17 @@ mod tests { #[test] fn resolve_file_ref_passthrough_non_at() { let dir = tempfile::tempdir().unwrap(); - assert_eq!(resolve_file_ref("hello world", dir.path()), "hello world"); + assert_eq!( + resolve_file_ref("hello world", dir.path(), None), + "hello world" + ); } #[test] fn resolve_file_ref_passthrough_missing_file() { let dir = tempfile::tempdir().unwrap(); assert_eq!( - resolve_file_ref("@nonexistent.md", dir.path()), + resolve_file_ref("@nonexistent.md", dir.path(), None), "@nonexistent.md" ); } @@ -716,7 +740,7 @@ mod tests { std::fs::write(dir.path().join("prompt.md"), "inlined content").unwrap(); assert_eq!( - resolve_file_ref("@prompt.md", dir.path()), + resolve_file_ref("@prompt.md", dir.path(), None), "inlined content" ); } @@ -764,7 +788,7 @@ mod tests { ); graph.nodes.insert("work".to_string(), node); - let transform = FileInliningTransform::new(dir.path().to_path_buf()); + let transform = FileInliningTransform::new(dir.path().to_path_buf(), None); transform.apply(&mut graph); assert_eq!( @@ -792,7 +816,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); assert_eq!( - resolve_file_ref("@~/.fabro_test_tilde_tmp", dir.path()), + resolve_file_ref("@~/.fabro_test_tilde_tmp", dir.path(), None), "tilde content" ); } @@ -804,8 +828,86 @@ mod tests { std::fs::create_dir(dir.path().join("subdir")).unwrap(); assert_eq!( - resolve_file_ref("@subdir/../file.md", dir.path()), + resolve_file_ref("@subdir/../file.md", dir.path(), None), "dotdot content" ); } + + #[test] + fn resolve_file_ref_falls_back_to_fallback_dir() { + let base = tempfile::tempdir().unwrap(); + let fallback = tempfile::tempdir().unwrap(); + std::fs::write(fallback.path().join("shared.md"), "shared content").unwrap(); + + assert_eq!( + resolve_file_ref("@shared.md", base.path(), Some(fallback.path())), + "shared content" + ); + } + + #[test] + fn resolve_file_ref_base_dir_takes_precedence_over_fallback() { + let base = tempfile::tempdir().unwrap(); + let fallback = tempfile::tempdir().unwrap(); + std::fs::write(base.path().join("prompt.md"), "base content").unwrap(); + std::fs::write(fallback.path().join("prompt.md"), "fallback content").unwrap(); + + assert_eq!( + resolve_file_ref("@prompt.md", base.path(), Some(fallback.path())), + "base content" + ); + } + + #[test] + fn resolve_file_ref_no_fallback_for_tilde_path() { + let base = tempfile::tempdir().unwrap(); + let fallback = tempfile::tempdir().unwrap(); + std::fs::write(fallback.path().join("file.md"), "fallback").unwrap(); + + // Tilde path to nonexistent file should return original value, not try fallback + let result = resolve_file_ref( + "@~/nonexistent_fabro_test.md", + base.path(), + Some(fallback.path()), + ); + assert_eq!(result, "@~/nonexistent_fabro_test.md"); + } + + #[test] + fn resolve_file_ref_fallback_none_behaves_as_before() { + let base = tempfile::tempdir().unwrap(); + assert_eq!( + resolve_file_ref("@missing.md", base.path(), None), + "@missing.md" + ); + } + + #[test] + fn file_inlining_transform_falls_back_to_fallback_dir() { + let base = tempfile::tempdir().unwrap(); + let fallback = tempfile::tempdir().unwrap(); + std::fs::write(fallback.path().join("shared.md"), "shared prompt").unwrap(); + + let mut graph = Graph::new("test"); + let mut node = Node::new("work"); + node.attrs.insert( + "prompt".to_string(), + AttrValue::String("@shared.md".to_string()), + ); + graph.nodes.insert("work".to_string(), node); + + let transform = FileInliningTransform::new( + base.path().to_path_buf(), + Some(fallback.path().to_path_buf()), + ); + transform.apply(&mut graph); + + assert_eq!( + graph.nodes["work"] + .attrs + .get("prompt") + .and_then(AttrValue::as_str), + Some("shared prompt") + ); + } } diff --git a/lib/crates/fabro-workflows/src/workflow.rs b/lib/crates/fabro-workflows/src/workflow.rs index bfd9d85d9..256fa4c2f 100644 --- a/lib/crates/fabro-workflows/src/workflow.rs +++ b/lib/crates/fabro-workflows/src/workflow.rs @@ -65,7 +65,8 @@ impl WorkflowBuilder { // File inlining when base_dir is provided if let Some(dir) = base_dir { - FileInliningTransform::new(dir.to_path_buf()).apply(&mut graph); + let fallback = dirs::home_dir().map(|h| h.join(".fabro")); + FileInliningTransform::new(dir.to_path_buf(), fallback).apply(&mut graph); } // Custom transforms