Add ~/.fabro/ fallback for @ file references

When an @file reference can't be resolved against the workflow's
directory, fall back to ~/.fabro/ so users can share prompt files
across workflows without duplication. The workflow directory keeps
higher precedence so project-specific overrides still win.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-14 13:44:16 -04:00
parent 69e7f415d8
commit 72865245a4
No known key found for this signature in database
3 changed files with 120 additions and 16 deletions

View file

@ -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(),

View file

@ -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<PathBuf>,
}
impl FileInliningTransform {
#[must_use]
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
pub fn new(base_dir: PathBuf, fallback_dir: Option<PathBuf>) -> 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")
);
}
}

View file

@ -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