mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
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:
parent
69e7f415d8
commit
72865245a4
3 changed files with 120 additions and 16 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue