fix: MiniJinja can't find partials (#301)
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run

Fixes an issue where use of MiniJinja
[`include`](https://jinja.palletsprojects.com/en/stable/templates/#include)
control structure (`{% include "filename.ext" %}`) causes a render error
`template not found: tried to include non-existing template
"filename.ext"`

### Example broken diagram
``` dot
digraph ValidatePlan {
    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    test_inline_prompt [label="moo" prompt="{% include 'test.tpl.md' %}"]
                                          // ^^^^^^^^^^^^^^^^^^^^^^^^^
    start -> test_inline_prompt -> exit
}
```

### Fix
The core issue was that template rendering knew the source name for
diagnostics, but did not have a loader rooted at the prompt/goal file
location. Includes therefore failed even when the included file existed
next to the rendered file. The fix adds optional loader support to
`fabro-template`, then wires workflow rendering to the existing
`FileResolver` so includes resolve relative to the file currently being
rendered.

For `fabro validate`, there was a second manifest-specific problem:
validation runs through a bundled manifest, and the manifest builder
only bundled explicit `prompt.md` / `goal.md` files, not static
MiniJinja `include` dependencies inside those files. The manifest
builder now scans prompt/goal template text for literal `{% include
"file" %}` / `{% include 'file' %}` references and bundles those files
too. Missing or unsafe include names are left for MiniJinja/runtime
validation rather than expanding scope.

(For clarity: The fix does not support variables or arrays in
`include`.)
This commit is contained in:
Aleksi Asikainen 2026-05-17 19:15:45 -07:00 • committed by GitHub
parent 302e2445b4
commit 492aba7fff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1597 additions and 61 deletions

2
Cargo.lock generated
View file

@ -2081,6 +2081,7 @@ dependencies = [
"fabro-config",
"fabro-github",
"fabro-graphviz",
"fabro-template",
"fabro-types",
"fabro-workflow",
"git2",
@ -2416,6 +2417,7 @@ name = "fabro-template"
version = "0.236.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
"fabro-util",
"miette",
"minijinja",

View file

@ -79,7 +79,7 @@ slatedb = { version = "0.11.2", features = ["zstd"] }
object_store = { version = "0.12.5", features = ["aws"] }
rust-embed = "8"
percent-encoding = "2"
minijinja = "2"
minijinja = "=2.19.0"
miette = { version = "7.6", features = ["fancy"] }
fabro-http = { path = "lib/crates/fabro-http" }
fabro-options-metadata = { path = "lib/crates/fabro-options-metadata" }

View file

@ -206,6 +206,41 @@ fn bare_fabro_picks_up_sibling_workflow_toml_inputs() {
");
}
#[test]
fn validate_accepts_static_template_dependencies() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("templates/static_dependencies/workflow.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: TemplateIncludes (4 nodes, 3 edges)
Graph: [FIXTURES]/templates/static_dependencies/workflow.fabro
Validation: OK
");
}
#[test]
fn validate_reports_missing_template_dependency() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("templates/missing_dependency/workflow.fabro"));
let mut filters = context.filters();
filters.push((
r"(?:\.\./)*\.\.\[FIXTURES\]/".to_string(),
"[FIXTURES]/".to_string(),
));
fabro_snapshot!(filters, cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× failed to discover template dependencies: missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro`
");
}
#[test]
fn invalid() {
let context = test_context!();

View file

@ -6,7 +6,7 @@
use std::path::PathBuf;
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
use fabro_workflow::ManifestPath;
use fabro_types::ManifestPath;
#[test]
fn cli_built_manifest_resolves_user_global_at_path() {

View file

@ -18,6 +18,7 @@ fabro-api = { path = "../fabro-api" }
fabro-config = { path = "../fabro-config" }
fabro-github = { path = "../fabro-github" }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-template = { path = "../fabro-template" }
fabro-types = { path = "../fabro-types" }
fabro-workflow = { path = "../fabro-workflow" }
git2.workspace = true

View file

@ -5,6 +5,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use fabro_api::types;
@ -16,10 +17,15 @@ use fabro_config::{
};
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
use fabro_template::{
BundleTemplateStore, FilesystemTemplateStore, RecordingTemplateStore, TemplateContext,
TemplateRenderMode, TemplateSource, discover_static_dependency_closure, render_source,
};
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
use fabro_types::{DirtyStatus, GitContext, PreRunPushOutcome, RunId, WorkflowSettings};
use fabro_workflow::ManifestPath;
use fabro_types::{
DirtyStatus, GitContext, ManifestPath, PreRunPushOutcome, RunId, WorkflowSettings,
};
use fabro_workflow::git::{
GitSyncStatus, branch_needs_push, head_sha, push_branch_noninteractive, sync_status,
};
@ -124,6 +130,7 @@ pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option<RunLaye
struct CollectContext<'a> {
cwd: &'a Path,
inputs: HashMap<String, toml::Value>,
workflows: HashMap<String, types::ManifestWorkflow>,
visited_workflows: HashSet<String>,
}
@ -184,6 +191,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let mut context = CollectContext {
cwd: &input.cwd,
inputs: workflow_settings.run.inputs.clone(),
workflows: HashMap::new(),
visited_workflows: HashSet::new(),
};
@ -325,49 +333,97 @@ fn collect_workflow_files(
workflow.absolute_dot_path.display()
)
})?;
let workflow_base_dir = workflow
.absolute_dot_path
.parent()
.unwrap_or_else(|| Path::new("."));
let workflow_template_root = manifest_parent_or_dot(&workflow.dot_path)?;
if let Some(goal_ref) = graph.attrs.get("goal").and_then(AttrValue::as_str) {
if goal_ref.starts_with('@') {
collect_bundled_file(
let bundled = collect_bundled_file(
files,
workflow
.absolute_dot_path
.parent()
.unwrap_or_else(|| Path::new(".")),
workflow_base_dir,
context.cwd,
goal_ref.trim_start_matches('@'),
types::ManifestFileRefType::FileInline,
manifest_attr_reference_kind(AttributeScope::Graph, "goal", goal_ref)?,
Some(workflow.dot_path.clone()),
)?;
let source = std::fs::read_to_string(&bundled.absolute_path)
.with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?;
collect_template_include_files(
files,
context.cwd,
TemplateSource {
path: bundled.path.clone(),
content: source,
},
&manifest_parent_or_dot(&bundled.path)?,
Some(&bundled.path),
&context.inputs,
)?;
} else {
collect_template_include_files(
files,
context.cwd,
TemplateSource {
path: workflow.dot_path.clone(),
content: goal_ref.to_owned(),
},
&workflow_template_root,
Some(&workflow.dot_path),
&context.inputs,
)?;
}
}
for node in graph.nodes.values() {
if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) {
if prompt_ref.starts_with('@') {
collect_bundled_file(
let bundled = collect_bundled_file(
files,
workflow
.absolute_dot_path
.parent()
.unwrap_or_else(|| Path::new(".")),
workflow_base_dir,
context.cwd,
prompt_ref.trim_start_matches('@'),
types::ManifestFileRefType::FileInline,
manifest_attr_reference_kind(AttributeScope::Node, "prompt", prompt_ref)?,
Some(workflow.dot_path.clone()),
)?;
let source =
std::fs::read_to_string(&bundled.absolute_path).with_context(|| {
format!("Failed to read {}", bundled.absolute_path.display())
})?;
collect_template_include_files(
files,
context.cwd,
TemplateSource {
path: bundled.path.clone(),
content: source,
},
&manifest_parent_or_dot(&bundled.path)?,
Some(&bundled.path),
&context.inputs,
)?;
} else {
collect_template_include_files(
files,
context.cwd,
TemplateSource {
path: workflow.dot_path.clone(),
content: prompt_ref.to_owned(),
},
&workflow_template_root,
Some(&workflow.dot_path),
&context.inputs,
)?;
}
}
if let Some(import_ref) = node.attrs.get("import").and_then(AttrValue::as_str) {
let imported = collect_bundled_file(
files,
workflow
.absolute_dot_path
.parent()
.unwrap_or_else(|| Path::new(".")),
workflow_base_dir,
context.cwd,
import_ref,
types::ManifestFileRefType::Import,
@ -397,20 +453,90 @@ fn collect_workflow_files(
manifest_attr_reference_kind(AttributeScope::Node, "stack.child_workflow", child_ref)?
.validate(child_ref)
.map_err(anyhow::Error::new)?;
collect_workflow_entry(
context,
Path::new(child_ref),
workflow
.absolute_dot_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)?;
collect_workflow_entry(context, Path::new(child_ref), workflow_base_dir)?;
}
}
Ok(())
}
fn collect_template_include_files(
files: &mut HashMap<String, types::ManifestFileEntry>,
cwd: &Path,
source: TemplateSource,
template_root: &ManifestPath,
from: Option<&ManifestPath>,
inputs: &HashMap<String, toml::Value>,
) -> Result<()> {
let source_path = source.path.clone();
let store = FilesystemTemplateStore::new(cwd.to_path_buf(), template_root.clone());
let closure = discover_static_dependency_closure([source], &store)
.map_err(|err| anyhow!("failed to discover template dependencies: {err}"))?;
verify_recorded_template_dependencies(
&source_path,
&closure,
template_root,
files,
from,
inputs,
)?;
for (path, source) in closure.sources {
if path == source_path {
continue;
}
let key = path.to_string();
files
.entry(key)
.or_insert_with(|| types::ManifestFileEntry {
content: source.content,
ref_: types::ManifestFileRef {
from: from.map(std::string::ToString::to_string),
original: path.to_string(),
type_: types::ManifestFileRefType::FileInline,
},
});
}
Ok(())
}
fn verify_recorded_template_dependencies(
source_path: &ManifestPath,
closure: &fabro_template::TemplateDependencyClosure,
template_root: &ManifestPath,
files: &HashMap<String, types::ManifestFileEntry>,
from: Option<&ManifestPath>,
inputs: &HashMap<String, toml::Value>,
) -> Result<()> {
let Some(source) = closure.sources.get(source_path) else {
return Ok(());
};
let mut bundled_files = closure
.sources
.iter()
.map(|(path, source)| (path.clone(), source.content.clone()))
.collect::<HashMap<_, _>>();
for (path, entry) in files {
if let Some(path) = ManifestPath::from_wire(path) {
bundled_files.insert(path, entry.content.clone());
}
}
let allowed = bundled_files.keys().cloned().collect();
let store = RecordingTemplateStore::with_allowed(
BundleTemplateStore::new(template_root.clone(), bundled_files),
allowed,
);
let ctx = TemplateContext::for_input_scan(inputs.clone());
render_source(source, &ctx, Arc::new(store), TemplateRenderMode::Lenient).with_context(
|| {
let from =
from.map_or_else(|| source_path.to_string(), std::string::ToString::to_string);
format!("failed to verify template dependencies for {from}")
},
)?;
Ok(())
}
fn manifest_attr_reference_kind(
scope: AttributeScope,
key: &str,
@ -704,6 +830,12 @@ fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result<ManifestPath>
.ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display()))
}
fn manifest_parent_or_dot(path: &ManifestPath) -> Result<ManifestPath> {
let parent = path.parent_or_dot().to_string_lossy();
ManifestPath::from_wire(&parent)
.ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}"))
}
pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
args.auto_approve.is_none()
&& args.dry_run.is_none()
@ -865,6 +997,164 @@ mod tests {
);
}
#[test]
fn build_manifest_bundles_static_minijinja_includes_from_prompts_and_goals() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path();
let workflow_dir = project.join(".fabro/workflows/demo");
std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap();
std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap();
std::fs::write(
workflow_dir.join("workflow.toml"),
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.unwrap();
std::fs::write(
workflow_dir.join("workflow.fabro"),
r#"digraph Demo {
graph [goal="@prompts/goal.md"]
start [shape=Mdiamond]
exit [shape=Msquare]
file_prompt [prompt="@prompts/plan.md"]
inline_prompt [prompt="{% include 'inline.tpl.md' %}"]
start -> file_prompt -> inline_prompt -> exit
}"#,
)
.unwrap();
std::fs::write(
workflow_dir.join("prompts/goal.md"),
r#"{% include "goal.tpl.md" %}"#,
)
.unwrap();
std::fs::write(workflow_dir.join("prompts/goal.tpl.md"), "ship it").unwrap();
std::fs::write(
workflow_dir.join("prompts/plan.md"),
r#"{% include "plan.tpl.md" %}"#,
)
.unwrap();
std::fs::write(workflow_dir.join("prompts/plan.tpl.md"), "plan it").unwrap();
std::fs::write(workflow_dir.join("inline.tpl.md"), "inline it").unwrap();
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
..Default::default()
})
.unwrap();
let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"];
assert!(
root.files
.contains_key(".fabro/workflows/demo/prompts/goal.tpl.md")
);
assert!(
root.files
.contains_key(".fabro/workflows/demo/prompts/plan.tpl.md")
);
assert!(
root.files
.contains_key(".fabro/workflows/demo/inline.tpl.md")
);
}
#[test]
fn build_manifest_bundles_static_minijinja_includes_from_all_branches_and_macros() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path();
let workflow_dir = project.join(".fabro/workflows/demo");
std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap();
std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap();
std::fs::write(
workflow_dir.join("workflow.toml"),
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.unwrap();
std::fs::write(
workflow_dir.join("workflow.fabro"),
r#"digraph Demo {
graph [goal="ship"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [prompt="@prompts/plan.md"]
start -> work -> exit
}"#,
)
.unwrap();
std::fs::write(
workflow_dir.join("prompts/plan.md"),
r#"{% if inputs.use_a %}{% include "a.md" %}{% else %}{% include "b.md" %}{% endif %}
{% from "helpers.md" import render_advanced_prompt %}"#,
)
.unwrap();
std::fs::write(workflow_dir.join("prompts/a.md"), "A").unwrap();
std::fs::write(workflow_dir.join("prompts/b.md"), "B").unwrap();
std::fs::write(
workflow_dir.join("prompts/helpers.md"),
r#"{% macro render_advanced_prompt() %}{% include "advanced.md" %}{% endmacro %}"#,
)
.unwrap();
std::fs::write(workflow_dir.join("prompts/advanced.md"), "advanced").unwrap();
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
..Default::default()
})
.unwrap();
let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"];
for path in [
".fabro/workflows/demo/prompts/a.md",
".fabro/workflows/demo/prompts/b.md",
".fabro/workflows/demo/prompts/helpers.md",
".fabro/workflows/demo/prompts/advanced.md",
] {
assert!(root.files.contains_key(path), "missing {path}");
}
}
#[test]
fn build_manifest_rejects_dynamic_minijinja_include_discovery() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path();
let workflow_dir = project.join(".fabro/workflows/demo");
std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap();
std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap();
std::fs::write(
workflow_dir.join("workflow.toml"),
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.unwrap();
std::fs::write(
workflow_dir.join("workflow.fabro"),
r#"digraph Demo {
graph [goal="ship"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [prompt="@prompts/plan.md"]
start -> work -> exit
}"#,
)
.unwrap();
std::fs::write(
workflow_dir.join("prompts/plan.md"),
r"{% include inputs.partial %}",
)
.unwrap();
let err = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
..Default::default()
})
.unwrap_err();
assert!(
err.to_string().contains("dynamic template dependency"),
"unexpected error: {err:#}"
);
}
#[test]
fn build_manifest_bundles_project_config_daytona_dockerfile_relative_to_project_config() {
let temp = tempfile::tempdir().unwrap();

View file

@ -30,14 +30,14 @@ use fabro_types::settings::run::{
DaytonaNetworkLayer, DaytonaSettings, DockerSettings, DockerfileSource, RunGoal, RunMode,
RunNamespace,
};
use fabro_types::{RunId, WorkflowSettings};
use fabro_types::{ManifestPath, RunId, WorkflowSettings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::operations::{CreateRunInput, ValidateInput, WorkflowInput, validate};
use fabro_workflow::pipeline::Validated;
use fabro_workflow::run_materialization::materialize_run;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
use fabro_workflow::{Error as WorkflowError, ManifestPath};
use futures_util::stream::{self, StreamExt};
use tokio::process::Command;
use tokio::time;
@ -137,7 +137,11 @@ pub(crate) fn prepare_manifest(
.build()
.context("failed to resolve manifest settings")?;
settings.run.inputs.extend(args_overrides.input_overrides);
if let Some(goal) = manifest.goal.as_ref() {
if let Some(goal) = manifest
.goal
.as_ref()
.filter(|goal| goal.type_ != types::ManifestGoalType::Graph)
{
settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text)));
}
let title = manifest
@ -2311,7 +2315,7 @@ digraph Demo {
//! the strict `SettingsLayer` schema, so unknown fields anywhere in
//! the document trip `deny_unknown_fields`.
use fabro_workflow::ManifestPath;
use fabro_types::ManifestPath;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig};
use super::super::root_workflow_run_layer;

View file

@ -15,8 +15,9 @@ workspace = true
[dependencies]
anyhow.workspace = true
fabro-util = { path = "../fabro-util" }
fabro-types = { path = "../fabro-types" }
miette.workspace = true
minijinja = { workspace = true, features = ["debug"] }
minijinja = { workspace = true, features = ["debug", "unstable_machinery"] }
serde.workspace = true
thiserror.workspace = true
toml.workspace = true

View file

@ -0,0 +1,192 @@
use std::collections::{HashMap, HashSet, VecDeque};
use fabro_types::ManifestPath;
use minijinja::machinery::ast::{Expr, Stmt};
use minijinja::machinery::{self, WhitespaceConfig};
use minijinja::syntax::SyntaxConfig;
use minijinja::value::Value;
use thiserror::Error;
use crate::TemplateError;
use crate::store::{TemplateLoadError, TemplateSource, TemplateStore};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TemplateDependencyKind {
Include,
Extends,
Import,
FromImport,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TemplateDependency {
pub kind: TemplateDependencyKind,
pub reference: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExtractedTemplateDependencies {
pub static_references: Vec<TemplateDependency>,
pub dynamic_references: Vec<TemplateDependencyKind>,
}
#[derive(Debug, Error)]
pub enum TemplateDiscoveryError {
#[error(transparent)]
Parse(#[from] TemplateError),
#[error(transparent)]
Load(#[from] TemplateLoadError),
#[error("missing template dependency `{reference}` from `{parent}`")]
Missing {
parent: ManifestPath,
reference: String,
},
#[error("dynamic template dependency in `{parent}` must be declared explicitly")]
Dynamic { parent: ManifestPath },
}
#[derive(Clone, Debug, Default)]
pub struct TemplateDependencyClosure {
pub sources: HashMap<ManifestPath, TemplateSource>,
}
impl TemplateDependencyClosure {
#[must_use]
pub fn paths(&self) -> HashSet<ManifestPath> {
self.sources.keys().cloned().collect()
}
}
pub fn extract_template_dependencies(
source_name: &str,
source: &str,
) -> Result<ExtractedTemplateDependencies, TemplateError> {
// MiniJinja's AST is behind `unstable_machinery`; keep direct usage in
// this module so future MiniJinja upgrades have a single adapter surface.
let parsed = machinery::parse(
source,
source_name,
SyntaxConfig,
WhitespaceConfig::default(),
)
.map_err(TemplateError::from)?;
let mut dependencies = ExtractedTemplateDependencies::default();
collect_stmt_dependencies(&parsed, &mut dependencies);
Ok(dependencies)
}
pub fn discover_static_dependency_closure(
roots: impl IntoIterator<Item = TemplateSource>,
store: &dyn TemplateStore,
) -> Result<TemplateDependencyClosure, TemplateDiscoveryError> {
let mut sources = HashMap::new();
let mut queue = VecDeque::new();
for source in roots {
if sources
.insert(source.path.clone(), source.clone())
.is_none()
{
queue.push_back(source);
}
}
while let Some(source) = queue.pop_front() {
let dependencies =
extract_template_dependencies(&source.path.to_string(), &source.content)?;
if !dependencies.dynamic_references.is_empty() {
return Err(TemplateDiscoveryError::Dynamic {
parent: source.path,
});
}
for dependency in dependencies.static_references {
let loaded = store
.load(&source.path, &dependency.reference)?
.ok_or_else(|| TemplateDiscoveryError::Missing {
parent: source.path.clone(),
reference: dependency.reference.clone(),
})?;
if sources
.insert(loaded.path.clone(), loaded.clone())
.is_none()
{
queue.push_back(loaded);
}
}
}
Ok(TemplateDependencyClosure { sources })
}
pub(crate) fn has_loader_dependent_tags(
source_name: &str,
source: &str,
) -> Result<Option<TemplateDependencyKind>, TemplateError> {
let dependencies = extract_template_dependencies(source_name, source)?;
Ok(dependencies
.static_references
.first()
.map(|dependency| dependency.kind)
.or_else(|| dependencies.dynamic_references.first().copied()))
}
fn collect_stmt_dependencies(stmt: &Stmt<'_>, dependencies: &mut ExtractedTemplateDependencies) {
match stmt {
Stmt::Template(template) => collect_stmt_list(&template.children, dependencies),
Stmt::ForLoop(for_loop) => {
collect_stmt_list(&for_loop.body, dependencies);
collect_stmt_list(&for_loop.else_body, dependencies);
}
Stmt::IfCond(if_cond) => {
collect_stmt_list(&if_cond.true_body, dependencies);
collect_stmt_list(&if_cond.false_body, dependencies);
}
Stmt::WithBlock(with_block) => collect_stmt_list(&with_block.body, dependencies),
Stmt::SetBlock(set_block) => collect_stmt_list(&set_block.body, dependencies),
Stmt::AutoEscape(auto_escape) => collect_stmt_list(&auto_escape.body, dependencies),
Stmt::FilterBlock(filter_block) => collect_stmt_list(&filter_block.body, dependencies),
Stmt::Block(block) => collect_stmt_list(&block.body, dependencies),
Stmt::Include(include) => {
collect_loader_expr(TemplateDependencyKind::Include, &include.name, dependencies);
}
Stmt::Extends(extends) => {
collect_loader_expr(TemplateDependencyKind::Extends, &extends.name, dependencies);
}
Stmt::Import(import) => {
collect_loader_expr(TemplateDependencyKind::Import, &import.expr, dependencies);
}
Stmt::FromImport(from_import) => collect_loader_expr(
TemplateDependencyKind::FromImport,
&from_import.expr,
dependencies,
),
Stmt::Macro(macro_) => collect_stmt_list(&macro_.body, dependencies),
Stmt::CallBlock(call_block) => collect_stmt_list(&call_block.macro_decl.body, dependencies),
Stmt::EmitExpr(_) | Stmt::EmitRaw(_) | Stmt::Set(_) | Stmt::Do(_) => {}
}
}
fn collect_stmt_list(stmts: &[Stmt<'_>], dependencies: &mut ExtractedTemplateDependencies) {
for stmt in stmts {
collect_stmt_dependencies(stmt, dependencies);
}
}
fn collect_loader_expr(
kind: TemplateDependencyKind,
expr: &Expr<'_>,
dependencies: &mut ExtractedTemplateDependencies,
) {
if let Some(reference) = const_string(expr) {
dependencies
.static_references
.push(TemplateDependency { kind, reference });
} else {
dependencies.dynamic_references.push(kind);
}
}
fn const_string(expr: &Expr<'_>) -> Option<String> {
let value: Value = expr.as_const()?;
value.as_str().map(ToOwned::to_owned)
}

View file

@ -1,12 +1,43 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use fabro_types::ManifestPath;
use fabro_util::env::Env;
use miette::{LabeledSpan, NamedSource, SourceCode, SourceSpan};
use minijinja::value::{Object, Value};
use minijinja::{AutoEscape, Environment, ErrorKind, UndefinedBehavior};
mod dependency;
mod store;
pub use dependency::{
ExtractedTemplateDependencies, TemplateDependency, TemplateDependencyClosure,
TemplateDependencyKind, TemplateDiscoveryError, discover_static_dependency_closure,
extract_template_dependencies,
};
pub use store::{
BundleTemplateStore, CachedTemplateStore, FilesystemTemplateStore, RecordingTemplateStore,
TemplateLoadError, TemplateSource, TemplateStore,
};
pub type TemplateLoader = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TemplateRenderMode {
Strict,
Lenient,
}
impl TemplateRenderMode {
fn undefined_behavior(self) -> UndefinedBehavior {
match self {
Self::Strict => UndefinedBehavior::Strict,
Self::Lenient => UndefinedBehavior::Chainable,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct TemplateContext {
goal: Option<String>,
@ -117,6 +148,14 @@ where
/// boundaries that walk `Error::source()` (anyhow, miette, `collect_chain`).
#[derive(Debug)]
pub enum TemplateError {
LoaderDependentString {
source_name: Option<String>,
tag: TemplateDependencyKind,
},
Load {
source_name: Option<String>,
source: Box<TemplateLoadError>,
},
Syntax {
line: Option<u32>,
source_name: Option<String>,
@ -147,6 +186,13 @@ pub enum TemplateError {
impl fmt::Display for TemplateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LoaderDependentString { tag, .. } => {
write!(
f,
"loader-dependent template tag `{tag:?}` requires a rooted template source"
)
}
Self::Load { .. } => write!(f, "template load error"),
Self::Syntax { line, .. } => {
write!(f, "template syntax error{}", fmt_location(*line))
}
@ -168,6 +214,8 @@ impl fmt::Display for TemplateError {
impl std::error::Error for TemplateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::LoaderDependentString { .. } => None,
Self::Load { source, .. } => Some(source.as_ref()),
Self::Syntax { source, .. }
| Self::UndefinedVariable { source, .. }
| Self::Render { source, .. } => Some(source.as_ref()),
@ -243,13 +291,17 @@ impl TemplateError {
pub fn expression(&self) -> Option<&str> {
match self {
Self::UndefinedVariable { expression, .. } => expression.as_deref(),
Self::Syntax { .. } | Self::Render { .. } => None,
Self::LoaderDependentString { .. }
| Self::Load { .. }
| Self::Syntax { .. }
| Self::Render { .. } => None,
}
}
#[must_use]
pub fn line(&self) -> Option<u32> {
match self {
Self::LoaderDependentString { .. } | Self::Load { .. } => None,
Self::Syntax { line, .. }
| Self::UndefinedVariable { line, .. }
| Self::Render { line, .. } => *line,
@ -259,6 +311,9 @@ impl TemplateError {
#[must_use]
pub fn source_name(&self) -> Option<&str> {
match self {
Self::LoaderDependentString { source_name, .. } | Self::Load { source_name, .. } => {
source_name.as_deref()
}
Self::Syntax { source_name, .. }
| Self::UndefinedVariable { source_name, .. }
| Self::Render { source_name, .. } => source_name.as_deref(),
@ -268,6 +323,7 @@ impl TemplateError {
#[must_use]
pub fn source_text(&self) -> Option<&str> {
match self {
Self::LoaderDependentString { .. } | Self::Load { .. } => None,
Self::Syntax { source_text, .. }
| Self::UndefinedVariable { source_text, .. }
| Self::Render { source_text, .. } => source_text.as_deref(),
@ -277,6 +333,7 @@ impl TemplateError {
#[must_use]
pub fn span(&self) -> Option<SourceSpan> {
match self {
Self::LoaderDependentString { .. } | Self::Load { .. } => None,
Self::Syntax { span, .. }
| Self::UndefinedVariable { span, .. }
| Self::Render { span, .. } => *span,
@ -298,6 +355,7 @@ impl TemplateError {
fn source_code_ref(&self) -> Option<&NamedSource<String>> {
match self {
Self::LoaderDependentString { .. } | Self::Load { .. } => None,
Self::Syntax { source_code, .. }
| Self::UndefinedVariable { source_code, .. }
| Self::Render { source_code, .. } => source_code.as_deref(),
@ -308,6 +366,8 @@ impl TemplateError {
impl miette::Diagnostic for TemplateError {
fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
let code = match self {
Self::LoaderDependentString { .. } => "fabro::template::loader_dependent_string",
Self::Load { .. } => "fabro::template::load",
Self::Syntax { .. } => "fabro::template::syntax",
Self::UndefinedVariable { .. } => "fabro::template::undefined_variable",
Self::Render { .. } => "fabro::template::render",
@ -323,6 +383,8 @@ impl miette::Diagnostic for TemplateError {
fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
let span = self.span()?;
let label = match self {
Self::LoaderDependentString { .. } => "loader-dependent tag".to_string(),
Self::Load { .. } => "template load".to_string(),
Self::UndefinedVariable { expression, .. } => expression.as_ref().map_or_else(
|| "undefined variable".to_string(),
|expr| format!("`{expr}`"),
@ -349,7 +411,7 @@ fn is_plain_text(template: &str) -> bool {
}
pub fn render(template: &str, ctx: &TemplateContext) -> Result<String, TemplateError> {
render_with(None, template, ctx, UndefinedBehavior::Strict)
render_with(None, template, ctx, UndefinedBehavior::Strict, None)
}
pub fn render_named(
@ -357,7 +419,28 @@ pub fn render_named(
template: &str,
ctx: &TemplateContext,
) -> Result<String, TemplateError> {
render_with(Some(name.into()), template, ctx, UndefinedBehavior::Strict)
render_with(
Some(name.into()),
template,
ctx,
UndefinedBehavior::Strict,
None,
)
}
pub fn render_named_with_loader(
name: impl Into<String>,
template: &str,
ctx: &TemplateContext,
loader: &TemplateLoader,
) -> Result<String, TemplateError> {
render_with(
Some(name.into()),
template,
ctx,
UndefinedBehavior::Strict,
Some(loader),
)
}
/// Render with chainable undefined handling: undefined variables and attribute
@ -365,7 +448,7 @@ pub fn render_named(
/// passes (e.g. manifest scanning, `fabro validate` on a bare `.fabro`) where
/// the user has not yet bound inputs — strict checking happens elsewhere.
pub fn render_lenient(template: &str, ctx: &TemplateContext) -> Result<String, TemplateError> {
render_with(None, template, ctx, UndefinedBehavior::Chainable)
render_with(None, template, ctx, UndefinedBehavior::Chainable, None)
}
pub fn render_lenient_named(
@ -378,6 +461,22 @@ pub fn render_lenient_named(
template,
ctx,
UndefinedBehavior::Chainable,
None,
)
}
pub fn render_lenient_named_with_loader(
name: impl Into<String>,
template: &str,
ctx: &TemplateContext,
loader: &TemplateLoader,
) -> Result<String, TemplateError> {
render_with(
Some(name.into()),
template,
ctx,
UndefinedBehavior::Chainable,
Some(loader),
)
}
@ -386,14 +485,22 @@ fn render_with(
template: &str,
ctx: &TemplateContext,
undefined: UndefinedBehavior,
loader: Option<&TemplateLoader>,
) -> Result<String, TemplateError> {
if is_plain_text(template) {
return Ok(template.to_owned());
}
if loader.is_none() {
reject_loader_dependent_string(name.as_deref(), template)?;
}
let mut env = Environment::new();
env.set_undefined_behavior(undefined);
env.set_auto_escape_callback(|_| AutoEscape::None);
env.set_debug(true);
if let Some(loader) = loader {
let loader = Arc::clone(loader);
env.set_loader(move |name| Ok(loader(name)));
}
match name {
Some(name) => env.render_named_str(&name, template, ctx.clone().into_value()),
None => env.render_str(template, ctx.clone().into_value()),
@ -401,6 +508,88 @@ fn render_with(
.map_err(TemplateError::from)
}
pub fn render_source(
source: &TemplateSource,
ctx: &TemplateContext,
store: Arc<dyn TemplateStore>,
mode: TemplateRenderMode,
) -> Result<String, TemplateError> {
render_rooted_source(source, ctx, store, mode.undefined_behavior())
}
fn render_rooted_source(
source: &TemplateSource,
ctx: &TemplateContext,
store: Arc<dyn TemplateStore>,
undefined: UndefinedBehavior,
) -> Result<String, TemplateError> {
if is_plain_text(&source.content) {
return Ok(source.content.clone());
}
let mut env = Environment::new();
env.set_undefined_behavior(undefined);
env.set_auto_escape_callback(|_| AutoEscape::None);
env.set_debug(true);
env.set_path_join_callback(|name, parent| joined_template_path(name, parent).into());
let load_error = Arc::new(Mutex::new(None));
let loader_error = Arc::clone(&load_error);
env.set_loader(move |name| {
let parent = ManifestPath::from_wire(".").expect("root manifest path should parse");
match store.load(&parent, name) {
Ok(source) => Ok(source.map(|source| source.content)),
Err(error) => {
*loader_error
.lock()
.expect("template load error mutex should not be poisoned") = Some(error);
Err(minijinja::Error::new(
ErrorKind::InvalidOperation,
"template load failed",
))
}
}
});
env.render_named_str(
&source.path.to_string(),
&source.content,
ctx.clone().into_value(),
)
.map_err(|error| {
if let Some(error) = load_error
.lock()
.expect("template load error mutex should not be poisoned")
.take()
{
TemplateError::Load {
source_name: Some(source.path.to_string()),
source: Box::new(error),
}
} else {
TemplateError::from(error)
}
})
}
fn joined_template_path(name: &str, parent: &str) -> String {
let Some(parent) = ManifestPath::from_wire(parent) else {
return name.to_owned();
};
ManifestPath::from_reference(parent.parent_or_dot(), name)
.map_or_else(|| name.to_owned(), |path| path.to_string())
}
fn reject_loader_dependent_string(name: Option<&str>, template: &str) -> Result<(), TemplateError> {
let source_name = name.unwrap_or("string");
if let Some(tag) = dependency::has_loader_dependent_tags(source_name, template)? {
return Err(TemplateError::LoaderDependentString {
source_name: name.map(ToOwned::to_owned),
tag,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@ -410,6 +599,20 @@ mod tests {
use super::*;
fn manifest_path(value: &str) -> ManifestPath {
ManifestPath::from_wire(value).expect("path should parse")
}
fn bundle_store(files: &[(&str, &str)]) -> Arc<dyn TemplateStore> {
Arc::new(BundleTemplateStore::new(
manifest_path("."),
files
.iter()
.map(|(path, content)| (manifest_path(path), (*content).to_string()))
.collect(),
))
}
#[test]
fn renders_simple_goal_variable() {
let ctx = TemplateContext::new().with_goal("Fix bugs");
@ -523,6 +726,230 @@ mod tests {
assert!(span.is_some());
}
#[test]
fn render_named_with_loader_supports_include() {
let ctx = TemplateContext::new();
let loader: TemplateLoader =
Arc::new(|name| (name == "partial.md").then(|| "included content".to_string()));
let rendered =
render_named_with_loader("prompt.md", r#"{% include "partial.md" %}"#, &ctx, &loader)
.unwrap();
assert_eq!(rendered, "included content");
}
#[test]
fn render_source_supports_rooted_include() {
let ctx = TemplateContext::new();
let source = TemplateSource {
path: manifest_path("prompts/main.md"),
content: r#"{% include "partial.md" %}"#.to_string(),
};
let rendered = render_source(
&source,
&ctx,
bundle_store(&[("prompts/partial.md", "included content")]),
TemplateRenderMode::Strict,
)
.unwrap();
assert_eq!(rendered, "included content");
}
#[test]
fn render_source_supports_nested_include() {
let ctx = TemplateContext::new();
let source = TemplateSource {
path: manifest_path("prompts/main.md"),
content: r#"{% include "partial.md" %}"#.to_string(),
};
let rendered = render_source(
&source,
&ctx,
bundle_store(&[
("prompts/partial.md", r#"{% include "nested.md" %}"#),
("prompts/nested.md", "nested content"),
]),
TemplateRenderMode::Strict,
)
.unwrap();
assert_eq!(rendered, "nested content");
}
#[test]
fn render_source_supports_extends() {
let ctx = TemplateContext::new();
let source = TemplateSource {
path: manifest_path("pages/main.md"),
content: r#"{% extends "layout.md" %}{% block body %}Body{% endblock %}"#.to_string(),
};
let rendered = render_source(
&source,
&ctx,
bundle_store(&[(
"pages/layout.md",
"prefix:{% block body %}{% endblock %}:suffix",
)]),
TemplateRenderMode::Strict,
)
.unwrap();
assert_eq!(rendered, "prefix:Body:suffix");
}
#[test]
fn render_source_supports_import() {
let ctx = TemplateContext::new();
let source = TemplateSource {
path: manifest_path("prompts/main.md"),
content: r#"{% import "macros.md" as macros %}{{ macros.greet("Ada") }}"#.to_string(),
};
let rendered = render_source(
&source,
&ctx,
bundle_store(&[(
"prompts/macros.md",
r"{% macro greet(name) %}hi {{ name }}{% endmacro %}",
)]),
TemplateRenderMode::Strict,
)
.unwrap();
assert_eq!(rendered, "hi Ada");
}
#[test]
fn render_source_supports_from_import() {
let ctx = TemplateContext::new();
let source = TemplateSource {
path: manifest_path("prompts/main.md"),
content: r#"{% from "macros.md" import greet %}{{ greet("Ada") }}"#.to_string(),
};
let rendered = render_source(
&source,
&ctx,
bundle_store(&[(
"prompts/macros.md",
r"{% macro greet(name) %}hi {{ name }}{% endmacro %}",
)]),
TemplateRenderMode::Strict,
)
.unwrap();
assert_eq!(rendered, "hi Ada");
}
#[test]
fn render_source_rejects_unsafe_include() {
let ctx = TemplateContext::new();
let source = TemplateSource {
path: manifest_path("prompts/main.md"),
content: r#"{% include "../outside.md" %}"#.to_string(),
};
let store: Arc<dyn TemplateStore> = Arc::new(BundleTemplateStore::new(
manifest_path("prompts"),
HashMap::new(),
));
let err = render_source(&source, &ctx, store, TemplateRenderMode::Strict).unwrap_err();
assert!(matches!(
err,
TemplateError::Load {
source,
..
} if matches!(*source, TemplateLoadError::EscapesRoot { .. })
));
}
#[test]
fn render_named_rejects_loader_dependent_tags_without_root() {
let ctx = TemplateContext::new();
let err = render_named("main.md", r#"{% include "partial.md" %}"#, &ctx).unwrap_err();
assert!(matches!(err, TemplateError::LoaderDependentString { .. }));
}
#[test]
fn extractor_ignores_comments_raw_blocks_and_plain_text() {
let source = r#"
{# {% include "comment.md" %} #}
{% raw %}{% include "raw.md" %}{% endraw %}
text {% include "text.md" %}
"#;
let dependencies = extract_template_dependencies("test.md", source).unwrap();
assert_eq!(dependencies.static_references, vec![TemplateDependency {
kind: TemplateDependencyKind::Include,
reference: "text.md".to_string(),
}]);
assert!(dependencies.dynamic_references.is_empty());
}
#[test]
fn static_dependency_closure_collects_both_branches() {
let source = TemplateSource {
path: manifest_path("main.md"),
content: r#"{% if inputs.use_a %}{% include "a.md" %}{% else %}{% include "b.md" %}{% endif %}"#
.to_string(),
};
let closure = discover_static_dependency_closure(
[source],
bundle_store(&[("a.md", "A"), ("b.md", "B")]).as_ref(),
)
.unwrap();
assert!(closure.sources.contains_key(&manifest_path("a.md")));
assert!(closure.sources.contains_key(&manifest_path("b.md")));
}
#[test]
fn static_dependency_closure_collects_unused_macro_body_dependencies() {
let source = TemplateSource {
path: manifest_path("main.md"),
content: r#"{% from "helpers.md" import render_advanced_prompt %}"#.to_string(),
};
let closure = discover_static_dependency_closure(
[source],
bundle_store(&[
(
"helpers.md",
r#"{% macro render_advanced_prompt() %}{% include "advanced.md" %}{% endmacro %}"#,
),
("advanced.md", "advanced"),
])
.as_ref(),
)
.unwrap();
assert!(closure.sources.contains_key(&manifest_path("helpers.md")));
assert!(closure.sources.contains_key(&manifest_path("advanced.md")));
}
#[test]
fn static_dependency_closure_rejects_dynamic_include() {
let source = TemplateSource {
path: manifest_path("main.md"),
content: r"{% include inputs.partial %}".to_string(),
};
let err =
discover_static_dependency_closure([source], bundle_store(&[]).as_ref()).unwrap_err();
assert!(matches!(err, TemplateDiscoveryError::Dynamic { .. }));
}
#[test]
fn render_lenient_named_preserves_source_name_for_syntax_errors() {
let ctx = TemplateContext::new();

View file

@ -0,0 +1,287 @@
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use fabro_types::ManifestPath;
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TemplateSource {
pub path: ManifestPath,
pub content: String,
}
pub trait TemplateStore: Send + Sync {
fn load(
&self,
parent: &ManifestPath,
reference: &str,
) -> Result<Option<TemplateSource>, TemplateLoadError>;
}
#[derive(Debug, Error)]
pub enum TemplateLoadError {
#[error("unsafe template reference `{reference}` from `{parent}`")]
UnsafeReference {
parent: ManifestPath,
reference: String,
},
#[error("template reference `{reference}` from `{parent}` escapes template root `{root}`")]
EscapesRoot {
parent: ManifestPath,
reference: String,
root: ManifestPath,
},
#[error("failed to read template `{path}`")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("dynamic template dependency `{path}` is not declared as an asset")]
DynamicDependency { path: ManifestPath },
}
#[derive(Clone, Debug)]
pub struct FilesystemTemplateStore {
cwd: PathBuf,
root: ManifestPath,
}
impl FilesystemTemplateStore {
#[must_use]
pub fn new(cwd: impl Into<PathBuf>, root: ManifestPath) -> Self {
Self {
cwd: cwd.into(),
root,
}
}
}
impl TemplateStore for FilesystemTemplateStore {
#[expect(
clippy::disallowed_methods,
reason = "MiniJinja loaders are synchronous, so rooted template stores use sync file I/O"
)]
fn load(
&self,
parent: &ManifestPath,
reference: &str,
) -> Result<Option<TemplateSource>, TemplateLoadError> {
let logical = resolve_logical_reference(parent, reference, &self.root)?;
let absolute = self.cwd.join(logical.as_path());
let root = self.cwd.join(self.root.as_path());
let canonical = match absolute.canonicalize() {
Ok(path) if path.is_file() => path,
Ok(_) => return Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(TemplateLoadError::Io {
path: absolute,
source: error,
});
}
};
let canonical_root = root
.canonicalize()
.map_err(|source| TemplateLoadError::Io {
path: root.clone(),
source,
})?;
if !canonical.starts_with(&canonical_root) {
return Err(TemplateLoadError::EscapesRoot {
parent: parent.clone(),
reference: reference.to_owned(),
root: self.root.clone(),
});
}
let content =
std::fs::read_to_string(&canonical).map_err(|source| TemplateLoadError::Io {
path: canonical.clone(),
source,
})?;
Ok(Some(TemplateSource {
path: logical,
content,
}))
}
}
#[derive(Clone, Debug, Default)]
pub struct BundleTemplateStore {
root: ManifestPath,
files: HashMap<ManifestPath, String>,
}
impl BundleTemplateStore {
#[must_use]
pub fn new(root: ManifestPath, files: HashMap<ManifestPath, String>) -> Self {
Self { root, files }
}
}
impl TemplateStore for BundleTemplateStore {
fn load(
&self,
parent: &ManifestPath,
reference: &str,
) -> Result<Option<TemplateSource>, TemplateLoadError> {
let path = resolve_logical_reference(parent, reference, &self.root)?;
Ok(self.files.get(&path).map(|content| TemplateSource {
path,
content: content.clone(),
}))
}
}
#[derive(Debug)]
pub struct CachedTemplateStore<T> {
inner: T,
cache: Mutex<HashMap<(ManifestPath, String), Option<TemplateSource>>>,
}
impl<T> CachedTemplateStore<T> {
#[must_use]
pub fn new(inner: T) -> Self {
Self {
inner,
cache: Mutex::new(HashMap::new()),
}
}
}
impl<T> TemplateStore for CachedTemplateStore<T>
where
T: TemplateStore,
{
fn load(
&self,
parent: &ManifestPath,
reference: &str,
) -> Result<Option<TemplateSource>, TemplateLoadError> {
let key = (parent.clone(), reference.to_owned());
if let Some(source) = lock(&self.cache).get(&key).cloned() {
return Ok(source);
}
let source = self.inner.load(parent, reference)?;
lock(&self.cache).insert(key, source.clone());
Ok(source)
}
}
#[derive(Debug)]
pub struct RecordingTemplateStore<T> {
inner: T,
loaded: Mutex<HashSet<ManifestPath>>,
allowed: Option<HashSet<ManifestPath>>,
}
impl<T> RecordingTemplateStore<T> {
#[must_use]
pub fn new(inner: T) -> Self {
Self {
inner,
loaded: Mutex::new(HashSet::new()),
allowed: None,
}
}
#[must_use]
pub fn with_allowed(inner: T, allowed: HashSet<ManifestPath>) -> Self {
Self {
inner,
loaded: Mutex::new(HashSet::new()),
allowed: Some(allowed),
}
}
#[must_use]
pub fn loaded_paths(&self) -> HashSet<ManifestPath> {
lock(&self.loaded).clone()
}
}
impl<T> TemplateStore for RecordingTemplateStore<T>
where
T: TemplateStore,
{
fn load(
&self,
parent: &ManifestPath,
reference: &str,
) -> Result<Option<TemplateSource>, TemplateLoadError> {
let source = self.inner.load(parent, reference)?;
if let Some(source) = source.as_ref() {
if let Some(allowed) = &self.allowed {
if !allowed.contains(&source.path) {
return Err(TemplateLoadError::DynamicDependency {
path: source.path.clone(),
});
}
}
lock(&self.loaded).insert(source.path.clone());
}
Ok(source)
}
}
pub(crate) fn resolve_logical_reference(
parent: &ManifestPath,
reference: &str,
root: &ManifestPath,
) -> Result<ManifestPath, TemplateLoadError> {
if !is_safe_template_reference(reference) {
return Err(TemplateLoadError::UnsafeReference {
parent: parent.clone(),
reference: reference.to_owned(),
});
}
let path =
ManifestPath::from_reference(parent.parent_or_dot(), reference).ok_or_else(|| {
TemplateLoadError::UnsafeReference {
parent: parent.clone(),
reference: reference.to_owned(),
}
})?;
if !is_within_root(&path, root) {
return Err(TemplateLoadError::EscapesRoot {
parent: parent.clone(),
reference: reference.to_owned(),
root: root.clone(),
});
}
Ok(path)
}
pub(crate) fn is_safe_template_reference(reference: &str) -> bool {
!reference.is_empty()
&& !reference.starts_with('~')
&& !reference.contains('\\')
&& !has_windows_drive_prefix(reference)
&& !Path::new(reference).is_absolute()
}
fn has_windows_drive_prefix(path: &str) -> bool {
let mut chars = path.chars();
matches!(
(chars.next(), chars.next()),
(Some(first), Some(':')) if first.is_ascii_alphabetic()
)
}
fn is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool {
if root.as_path().as_os_str().is_empty() {
return !matches!(
path.as_path().components().next(),
Some(Component::ParentDir)
);
}
path.starts_with(root)
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.expect("template store mutex should not be poisoned")
}

View file

@ -14,6 +14,7 @@ pub mod failure_signature;
pub mod graph;
pub mod interview;
pub mod llm_backend;
pub mod manifest_path;
pub mod outcome;
pub mod principal;
pub mod pull_request;
@ -61,6 +62,7 @@ pub use graph::{
};
pub use interview::{InterviewQuestionRecord, QuestionType};
pub use llm_backend::LlmBackend;
pub use manifest_path::{ManifestPath, ManifestPathParseError};
pub use outcome::{
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState,
};

View file

@ -12,6 +12,9 @@ pub struct ManifestPath(PathBuf);
impl ManifestPath {
#[must_use]
pub fn from_reference(current_dir: &Path, reference: &str) -> Option<Self> {
if !is_portable_logical_path(reference) {
return None;
}
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {
return None;
@ -31,6 +34,9 @@ impl ManifestPath {
#[must_use]
pub fn from_wire(value: &str) -> Option<Self> {
if !is_portable_logical_path(value) {
return None;
}
Self::from_reference(Path::new("."), value)
}
@ -50,6 +56,11 @@ impl ManifestPath {
pub fn parent_or_dot(&self) -> &Path {
self.0.parent().unwrap_or_else(|| Path::new("."))
}
#[must_use]
pub fn starts_with(&self, base: &Self) -> bool {
self.0.starts_with(base.as_path())
}
}
impl From<ManifestPath> for PathBuf {
@ -108,6 +119,23 @@ fn normalize_components(path: impl AsRef<Path>) -> Option<PathBuf> {
Some(normalized)
}
fn is_portable_logical_path(path: &str) -> bool {
if path.contains('\\') {
return false;
}
let mut chars = path.chars();
let Some(first) = chars.next() else {
return true;
};
let Some(second) = chars.next() else {
return true;
};
if first.is_ascii_alphabetic() && second == ':' {
return false;
}
true
}
fn relative_path_from(path: &Path, base: &Path) -> Option<PathBuf> {
let path_components = path.components().collect::<Vec<_>>();
let base_components = base.components().collect::<Vec<_>>();
@ -160,6 +188,16 @@ mod tests {
assert!(ManifestPath::from_reference(Path::new("."), "~/.fabro/workflow.fabro").is_none());
}
#[test]
fn from_reference_rejects_backslash_reference() {
assert!(ManifestPath::from_reference(Path::new("."), "prompts\\goal.md").is_none());
}
#[test]
fn from_reference_rejects_windows_drive_reference() {
assert!(ManifestPath::from_reference(Path::new("."), "C:/repo/workflow.fabro").is_none());
}
#[test]
fn from_reference_simple_relative() {
let path = ManifestPath::from_reference(Path::new("flows"), "workflow.fabro").unwrap();
@ -272,6 +310,11 @@ mod tests {
assert!(ManifestPath::from_wire("~/.fabro/workflow.fabro").is_none());
}
#[test]
fn from_wire_rejects_backslash_path() {
assert!(ManifestPath::from_wire("foo\\bar").is_none());
}
#[test]
fn from_wire_renormalizes_uncollapsed_curdir() {
let path = ManifestPath::from_wire("./foo/./bar").unwrap();

View file

@ -6,7 +6,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::ManifestPath;
use fabro_types::ManifestPath;
pub trait FileResolver: Send + Sync {
fn resolve(&self, current_dir: &Path, reference: &str) -> Option<ResolvedFile>;

View file

@ -230,7 +230,6 @@ mod hook_context;
reason = "The lifecycle module remains crate-visible for tests and pending integrations."
)]
pub(crate) mod lifecycle;
mod manifest_path;
pub(crate) mod node_handler;
pub mod operations;
pub mod outcome;
@ -247,7 +246,7 @@ pub use billing_rollup::{
billing_rollup_from_projection,
};
pub use error::{Error, FailureCategory, FailureSignature, FailureSignatureExt, Result};
pub use manifest_path::ManifestPath;
pub use fabro_types::ManifestPath;
pub use steering_hub::SteeringHub;
pub mod run_materialization;
pub(crate) mod run_metadata;

View file

@ -14,12 +14,13 @@ use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::SandboxProvider;
use fabro_store::Database;
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{ForkSourceRef, GitContext, RunId, RunProvenance, WorkflowSettings};
use fabro_types::{
ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
};
use fabro_util::json::normalize_json_value;
use tokio::task::spawn_blocking;
use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow};
use crate::ManifestPath;
use crate::error::Error;
use crate::event::{Event, append_event, to_run_event_at};
use crate::file_resolver::FileResolver;
@ -936,6 +937,55 @@ mod tests {
assert_eq!(validated.graph().goal(), "ship it");
}
#[test]
fn validate_from_file_resolves_minijinja_includes_relative_to_prompt_and_goal_files() {
let dir = tempfile::tempdir().unwrap();
let prompt_dir = dir.path().join("prompts");
let goal_dir = dir.path().join("goals");
std::fs::create_dir_all(&prompt_dir).unwrap();
std::fs::create_dir_all(&goal_dir).unwrap();
std::fs::write(
prompt_dir.join("prompt.md"),
r#"{% include "prompt.tpl.md" %}"#,
)
.unwrap();
std::fs::write(prompt_dir.join("prompt.tpl.md"), "included prompt").unwrap();
std::fs::write(goal_dir.join("goal.md"), r#"{% include "goal.tpl.md" %}"#).unwrap();
std::fs::write(goal_dir.join("goal.tpl.md"), "included goal").unwrap();
let dot_path = dir.path().join("workflow.fabro");
std::fs::write(
&dot_path,
r#"digraph Test {
graph [goal="@goals/goal.md"]
start [shape=Mdiamond]
work [prompt="@prompts/prompt.md"]
exit [shape=Msquare]
start -> work -> exit
}"#,
)
.unwrap();
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(dot_path),
settings: WorkflowSettings::default(),
cwd: dir.path().to_path_buf(),
custom_transforms: Vec::new(),
catalog: test_catalog(),
})
.unwrap();
validated.raise_on_errors().unwrap();
assert_eq!(validated.graph().goal(), "included goal");
assert_eq!(
validated.graph().nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("included prompt")
);
}
#[test]
fn validate_from_bundle_resolves_nested_import_files_relative_to_imported_graph() {
let validated = validate(ValidateInput {
@ -984,6 +1034,57 @@ mod tests {
);
}
#[test]
fn validate_from_bundle_resolves_minijinja_includes_in_prompt_and_goal_files() {
let validated = validate(ValidateInput {
workflow: WorkflowInput::Bundled(BundledWorkflow {
path: ManifestPath::from_wire("workflow.fabro").unwrap(),
source: r#"digraph Test {
graph [goal="@goals/goal.md"]
start [shape=Mdiamond]
work [prompt="@prompts/work.md"]
exit [shape=Msquare]
start -> work -> exit
}"#
.to_string(),
config: None,
files: HashMap::from([
(
ManifestPath::from_wire("goals/goal.md").unwrap(),
r#"{% include "goal.tpl.md" %}"#.to_string(),
),
(
ManifestPath::from_wire("goals/goal.tpl.md").unwrap(),
"Bundled goal".to_string(),
),
(
ManifestPath::from_wire("prompts/work.md").unwrap(),
r#"{% include "work.tpl.md" %}"#.to_string(),
),
(
ManifestPath::from_wire("prompts/work.tpl.md").unwrap(),
"Bundled prompt".to_string(),
),
]),
}),
settings: WorkflowSettings::default(),
cwd: PathBuf::from("."),
custom_transforms: Vec::new(),
catalog: test_catalog(),
})
.unwrap();
validated.raise_on_errors().unwrap();
assert_eq!(validated.graph().goal(), "Bundled goal");
assert_eq!(
validated.graph().nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("Bundled prompt")
);
}
#[tokio::test]
async fn create_returns_validation_failed_with_diagnostics() {
let dot = r#"digraph Test {

View file

@ -15,7 +15,6 @@ use fabro_sandbox::config::{
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DockerSandboxOptions, SandboxProvider, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::RunId;
use fabro_types::settings::run::{
ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerSettings,
DockerfileSource as ResolvedDockerfileSource, HookDefinition as ResolvedHookDefinition,
@ -25,12 +24,12 @@ use fabro_types::settings::run::{
RunNamespace as ResolvedRunSettings, TlsMode as ResolvedTlsMode,
};
use fabro_types::settings::{InterpString, ModelRegistry, ResolvedModelRef};
use fabro_types::{ManifestPath, RunId};
use fabro_vault::Vault;
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
use tokio_util::sync::CancellationToken;
use crate::ManifestPath;
use crate::artifact_upload::ArtifactSink;
use crate::context::Context;
use crate::error::Error;
@ -1130,11 +1129,10 @@ mod tests {
use fabro_store::Database;
use fabro_types::settings::ModelRef;
use fabro_types::settings::run::RunMode;
use fabro_types::{WorkflowSettings, fixtures};
use fabro_types::{ManifestPath, WorkflowSettings, fixtures};
use object_store::memory::InMemory;
use super::*;
use crate::ManifestPath;
use crate::context::Context;
use crate::event::{Emitter, EventBody};
use crate::handler::HandlerRegistry;

View file

@ -7,13 +7,12 @@ use fabro_interview::Interviewer;
use fabro_mcp::config::McpServerSettings;
use fabro_model::{Catalog, FallbackTarget, ProviderId};
use fabro_sandbox::SandboxSpec;
use fabro_types::RunId;
use fabro_types::settings::run::{PullRequestSettings, RunModelControls};
use fabro_types::{ManifestPath, RunId};
use fabro_validate::{Diagnostic, Severity};
use fabro_vault::Vault;
use tokio::sync::RwLock as AsyncRwLock;
use crate::ManifestPath;
use crate::artifact_upload::ArtifactSink;
use crate::context::Context;
use crate::error::Error;

View file

@ -11,9 +11,9 @@ use fabro_auth::CredentialSource;
use fabro_auth::ResolvedCredentials;
use fabro_hooks::{HookContext, HookDecision, HookRunner};
use fabro_model::{Catalog, ProviderId};
use fabro_types::ManifestPath;
use tokio_util::sync::CancellationToken;
use crate::ManifestPath;
use crate::event::Emitter;
use crate::github_token_source::GitHubTokenSource;
use crate::handler::HandlerRegistry;

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_template::TemplateContext;
use fabro_template::{TemplateContext, TemplateLoader};
use fabro_validate::Diagnostic;
use super::Transform;
@ -34,6 +34,34 @@ pub fn resolve_file_ref(
.map_or_else(|| value.to_string(), |resolved| resolved.content))
}
pub(crate) fn template_include_loader(
current_dir: PathBuf,
resolver: Arc<dyn FileResolver>,
) -> TemplateLoader {
Arc::new(move |name| {
if !is_safe_include_name(name) {
return None;
}
resolver
.resolve(&current_dir, name)
.map(|resolved| resolved.content)
})
}
fn is_safe_include_name(name: &str) -> bool {
if name.is_empty() || name.starts_with('~') || Path::new(name).is_absolute() {
return false;
}
name.split('/')
.all(|segment| !segment.starts_with('.') && !segment.contains('\\'))
}
fn parent_dir_or_dot(path: &Path) -> PathBuf {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
/// Inlines `@file` references in node prompts and the graph-level goal.
pub struct FileInliningTransform {
current_dir: PathBuf,
@ -111,7 +139,11 @@ impl FileInliningTransform {
node_id.clone(),
"prompt",
)
.with_source_text(self.source_text.as_deref(), prompt);
.with_source_text(self.source_text.as_deref(), prompt)
.with_include_loader(Some(template_include_loader(
self.current_dir.clone(),
Arc::clone(&self.resolver),
)));
let rendered = render_template_for_target(
prompt,
&ctx,
@ -139,7 +171,11 @@ impl FileInliningTransform {
};
let ctx = TemplateContext::for_input_scan(self.inputs.clone());
let target = TemplateRenderTarget::graph_attr(self.source_name.clone(), "goal")
.with_source_text(self.source_text.as_deref(), goal);
.with_source_text(self.source_text.as_deref(), goal)
.with_include_loader(Some(template_include_loader(
self.current_dir.clone(),
Arc::clone(&self.resolver),
)));
let rendered =
render_template_for_target(goal, &ctx, self.render_mode, &target, diagnostics)?;
let value = self
@ -168,7 +204,11 @@ impl FileInliningTransform {
};
let target = owner_target
.with_source_name(resolved.path.display().to_string())
.with_source_text(Some(&resolved.content), &resolved.content);
.with_source_text(Some(&resolved.content), &resolved.content)
.with_include_loader(Some(template_include_loader(
parent_dir_or_dot(&resolved.path),
Arc::clone(&self.resolver),
)));
Ok(Some(render_file_contents(
&resolved,
ctx,
@ -310,6 +350,71 @@ mod tests {
);
}
#[test]
fn file_inlining_transform_resolves_minijinja_includes_for_prompts_and_goal() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("prompts")).unwrap();
std::fs::create_dir_all(dir.path().join("goals")).unwrap();
std::fs::write(
dir.path().join("prompts/work.md"),
r#"{% include "work.tpl.md" %}"#,
)
.unwrap();
std::fs::write(dir.path().join("prompts/work.tpl.md"), "file prompt").unwrap();
std::fs::write(dir.path().join("inline.tpl.md"), "inline prompt").unwrap();
std::fs::write(
dir.path().join("goals/goal.md"),
r#"{% include "goal.tpl.md" %}"#,
)
.unwrap();
std::fs::write(dir.path().join("goals/goal.tpl.md"), "included goal").unwrap();
let mut graph = Graph::new("test");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("@goals/goal.md".to_string()),
);
let mut file_prompt = Node::new("file_prompt");
file_prompt.attrs.insert(
"prompt".to_string(),
AttrValue::String("@prompts/work.md".to_string()),
);
graph.nodes.insert("file_prompt".to_string(), file_prompt);
let mut inline_prompt = Node::new("inline_prompt");
inline_prompt.attrs.insert(
"prompt".to_string(),
AttrValue::String(r#"{% include "inline.tpl.md" %}"#.to_string()),
);
graph
.nodes
.insert("inline_prompt".to_string(), inline_prompt);
let transform = FileInliningTransform::new(
dir.path().to_path_buf(),
Arc::new(FilesystemFileResolver::new(None)),
);
let graph = transform.apply(graph).unwrap();
assert_eq!(
graph.nodes["file_prompt"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("file prompt")
);
assert_eq!(
graph.nodes["inline_prompt"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("inline prompt")
);
assert_eq!(
graph.attrs.get("goal").and_then(AttrValue::as_str),
Some("included goal")
);
}
#[test]
fn resolve_file_ref_expands_tilde() {
let home = dirs::home_dir().expect("home dir must exist");

View file

@ -7,6 +7,7 @@ use fabro_graphviz::parser;
use fabro_template::TemplateContext;
use fabro_validate::Diagnostic;
use super::file_inlining::template_include_loader;
use super::{FileInliningTransform, Transform};
use crate::error::Error;
use crate::file_resolver::{FileResolver, ResolvedFile};
@ -673,7 +674,11 @@ impl ImportTransform {
let path_ctx = TemplateContext::for_input_scan(self.inputs.clone());
let mut ignored_goal_diagnostics = Vec::new();
let goal_target = TemplateRenderTarget::graph_attr(self.source_name.clone(), "goal")
.with_source_text(self.source_text.as_deref(), graph.goal());
.with_source_text(self.source_text.as_deref(), graph.goal())
.with_include_loader(Some(template_include_loader(
self.current_dir.clone(),
Arc::clone(&self.resolver),
)));
let parent_goal = render_template_for_target(
graph.goal(),
&path_ctx,

View file

@ -2,7 +2,10 @@ use std::collections::HashMap;
use std::fmt::Write as _;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_template::{TemplateContext, TemplateError, render_lenient_named, render_named};
use fabro_template::{
TemplateContext, TemplateError, TemplateLoader, render_lenient_named,
render_lenient_named_with_loader, render_named, render_named_with_loader,
};
use fabro_util::error::collect_chain;
use fabro_validate::{Diagnostic, Severity};
@ -29,7 +32,7 @@ pub enum RenderMode {
Structural,
}
#[derive(Clone, Debug)]
#[derive(Clone)]
pub(crate) struct TemplateRenderTarget {
pub source_name: Option<String>,
pub source_text: Option<String>,
@ -37,6 +40,7 @@ pub(crate) struct TemplateRenderTarget {
pub node_id: Option<String>,
pub edge: Option<(String, String)>,
pub owner: String,
include_loader: Option<TemplateLoader>,
}
impl TemplateRenderTarget {
@ -50,6 +54,7 @@ impl TemplateRenderTarget {
node_id: None,
edge: None,
owner: format!("graph attribute `{attr_name}`"),
include_loader: None,
}
}
@ -68,6 +73,7 @@ impl TemplateRenderTarget {
node_id: Some(node_id.clone()),
edge: None,
owner: format!("node `{node_id}` attribute `{attr_name}`"),
include_loader: None,
}
}
@ -88,6 +94,7 @@ impl TemplateRenderTarget {
node_id: None,
edge: Some((from.clone(), to.clone())),
owner: format!("edge `{from} -> {to}` attribute `{attr_name}`"),
include_loader: None,
}
}
@ -104,6 +111,12 @@ impl TemplateRenderTarget {
self
}
#[must_use]
pub(crate) fn with_include_loader(mut self, include_loader: Option<TemplateLoader>) -> Self {
self.include_loader = include_loader;
self
}
#[must_use]
fn template_source_name(&self) -> String {
self.source_name
@ -120,15 +133,23 @@ pub(crate) fn render_template_for_target(
diagnostics: &mut Vec<Diagnostic>,
) -> Result<String, Error> {
let source_name = target.template_source_name();
let render_strict = || match target.include_loader.as_ref() {
Some(loader) => render_named_with_loader(source_name.clone(), text, ctx, loader),
None => render_named(source_name.clone(), text, ctx),
};
match render_mode {
RenderMode::Strict => render_named(source_name, text, ctx)
.map_err(|err| template_error_for_target(target, err)),
RenderMode::Structural => match render_named(source_name.clone(), text, ctx) {
RenderMode::Strict => render_strict().map_err(|err| template_error_for_target(target, err)),
RenderMode::Structural => match render_strict() {
Ok(rendered) => Ok(rendered),
Err(err @ TemplateError::UndefinedVariable { .. }) => {
diagnostics.push(template_diagnostic(&err, target));
render_lenient_named(source_name, text, ctx)
.map_err(|err| template_error_for_target(target, err))
match target.include_loader.as_ref() {
Some(loader) => {
render_lenient_named_with_loader(source_name, text, ctx, loader)
}
None => render_lenient_named(source_name, text, ctx),
}
.map_err(|err| template_error_for_target(target, err))
}
Err(err) => Err(template_error_for_target(target, err)),
},

View file

@ -2,9 +2,9 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use fabro_types::ManifestPath;
use serde::{Deserialize, Serialize};
use crate::ManifestPath;
use crate::file_resolver::{BundleFileResolver, FileResolver};
#[derive(Clone, Debug, Serialize, Deserialize)]

View file

@ -0,0 +1,9 @@
digraph MissingTemplateDependency {
graph [goal="ship"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
inline_prompt [label="Inline Prompt", prompt="{% include 'missing.tpl.md' %}"]
start -> inline_prompt -> exit
}

View file

@ -0,0 +1 @@
Use the inline include.

View file

@ -0,0 +1 @@
{% include "goal.tpl.md" %}

View file

@ -0,0 +1 @@
Ship the include fix.

View file

@ -0,0 +1 @@
{% include "prompt.tpl.md" %}

View file

@ -0,0 +1 @@
Use the included prompt.

View file

@ -0,0 +1,10 @@
digraph TemplateIncludes {
graph [goal="@prompts/goal.md"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
file_prompt [label="File Prompt", prompt="@prompts/prompt.md"]
inline_prompt [label="Inline Prompt", prompt="{% include 'inline.tpl.md' %}"]
start -> file_prompt -> inline_prompt -> exit
}