fabro/lib/crates/fabro-workflow/tests/materialize_run.rs
Scott Werner ce404cddef
Interpolation foundation (InterpString v2) (#472)
# Interpolation foundation (InterpString v2)

First step of unifying config-string interpolation across Fabro. This PR
is the
**behavior-neutral foundation** only — it introduces the type machinery
and a
clippy gate, but changes no field's interpolation behavior. The actual
field
work follows as separate stacked PRs, sequenced **reduce-first**:
narrowing
changes (demote fields that shouldn't interpolate, de-template DOT
attrs) land
before capability additions (resolve env in MCP / prepare / hooks).

## Why

Config strings interpolate `{{ ... }}` inconsistently today — some
fields
resolve `{{ env.X }}`, others are typed as if they do but silently pass
the
literal template text downstream. We're converging on three field types
(`String`, `InterpString`, and later an importable template for
prompts/goals)
with four namespaces (`env`, `vars`, `secrets`, `inputs`). This PR lays
the
`InterpString` foundation; it does not migrate any field.

## What's in it

- Segments generalize to `Token { namespace, name }` with a `Namespace`
enum
(`env`/`vars`/`secrets`/`inputs`). `secrets`/`inputs` are **reserved** —
  parsed as tokens ahead of their resolvers.
- `ResolveCtx` with per-namespace lookups. `resolve_with()` fails loudly
  (`Unavailable`) for a token whose namespace isn't provided in context;
`substitute_with()` substitutes provided namespaces and preserves the
rest.
`resolve()` / `substitute_variables()` are thin wrappers over one core
path.
- `ResolveEnvError` → `ResolveError { namespace, name, kind: Missing |
Unavailable }`
(message text unchanged for env/vars; the kind no longer bakes the
namespace
  in, so it scales to four namespaces without an enum explosion).
- `Provenance` tracks secret-sourced names alongside env-sourced, for
uniform
  redaction later.
- **`as_source()` is clippy-gated** (`disallowed-methods`). It keeps its
name;
  every call site carries an `#[expect(..., reason)]` classifying it
(serialization, error display, known-leak-pending-fix, demotion-pending,
test). The lint turns the leak surface into a greppable, reasoned
work-list
  and the method stays for its permanent uses (serde round-trip of the
  unresolved template + diagnostics).
- fabro-server: five duplicate `process_env_var` facades and two
duplicate
  `resolve_interp` helpers consolidated into one `crate::interp` module.

## Behavior changes (honest list)

- **`{{ secrets.* }}` / `{{ inputs.* }}` are now reserved.** On main
they
  weren't recognized as tokens → silent literal passthrough. Now, at
`resolve()` consumers they **fail loud** (`Unavailable`) instead of
passing
the literal string through (nobody wants the literal characters as a
value —
  strictly better, but technically a change). At `as_source` sites they
  round-trip unchanged. Actual resolution lands in later enhancing PRs.
- Some fabro-server resolution errors gain a `"failed to resolve
<source>"`
  context line.

Otherwise behavior-neutral: every field resolves exactly as it did on
main.

## What's deferred to follow-up PRs (reduce-first order)

- **Reducing / cleanup (next):** demote leak fields to `String`
  (`run.model.*`, `cli.exec.model.*`, `run.git.author.*`,
  `run.scm.owner/repository`); de-template `condition`/`label`/`model`/
  `provider`/`speed` and `output_schema`.
- **Enhancing (after):** resolve `{{ env.* }}` in MCP transports,
prepare
  steps, and hooks; wire `secrets`/`inputs`.

## Verification

- `cargo build --workspace`
- `cargo nextest run --workspace` → 6449 passed, 181 skipped
- `cargo +nightly fmt --check --all`
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` →
clean

## Reviewer notes

- The reserved-namespace `Unavailable` error for `secrets`/`inputs` is
  **intentional**, not a missing case — they're parsed ahead of their
  resolvers so misuse fails loud instead of leaking.
- `as_source` is clippy-gated but keeps its name deliberately — the gate
is
  the enforcement; renaming was avoided as unnecessary churn.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:51:08 -04:00

99 lines
2.6 KiB
Rust

use fabro_graphviz::graph::Graph;
use fabro_graphviz::parser;
use fabro_model::{Catalog, ProviderId};
use fabro_types::WorkflowSettings;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{PullRequestSettings, RunGoal, RunModelSettings, RunNamespace};
use fabro_workflow::run_materialization::materialize_run;
fn graph(source: &str) -> Graph {
parser::parse(source).expect("graph should parse")
}
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test]
fn materialize_run_applies_graph_and_catalog_defaults() {
let source = r#"digraph Test {
graph [goal="Build feature"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
let settings = WorkflowSettings {
run: RunNamespace {
model: RunModelSettings {
name: Some(InterpString::parse("sonnet")),
..RunModelSettings::default()
},
pull_request: Some(PullRequestSettings {
enabled: false,
..PullRequestSettings::default()
}),
..RunNamespace::default()
},
..WorkflowSettings::default()
};
let materialized = materialize_run(settings, &graph(source), Catalog::builtin(), &[]);
let resolved = &materialized.run;
assert_eq!(
resolved
.model
.name
.as_ref()
.map(InterpString::as_source)
.as_deref(),
Some("claude-sonnet-4-6")
);
assert_eq!(
resolved
.model
.provider
.as_ref()
.map(InterpString::as_source)
.as_deref(),
Some("anthropic")
);
assert_eq!(
materialized.run.goal.as_ref(),
Some(&RunGoal::Inline(InterpString::parse("Build feature")))
);
assert!(resolved.pull_request.is_none());
}
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test]
fn materialize_run_uses_configured_provider_defaults() {
let source = r#"digraph Test {
graph [goal="Build feature"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
let materialized = materialize_run(
WorkflowSettings::default(),
&graph(source),
Catalog::builtin(),
&[ProviderId::openai()],
);
let resolved = &materialized.run;
assert_eq!(
resolved
.model
.provider
.as_ref()
.map(InterpString::as_source)
.as_deref(),
Some("openai")
);
}