route project namespace resolution through workflow builders

This commit is contained in:
Bryan Helmkamp 2026-04-23 14:38:54 -04:00
parent 30986207cb
commit e20d8d9435
No known key found for this signature in database
7 changed files with 105 additions and 62 deletions

View file

@ -1,7 +1,9 @@
use std::fmt;
use std::path::Path;
use fabro_types::settings::{CliLayer, Combine, RunLayer, SettingsLayer};
use fabro_types::settings::{
CliLayer, Combine, ProjectNamespace, RunLayer, RunNamespace, SettingsLayer, WorkflowNamespace,
};
use fabro_types::{ServerSettings, UserSettings, WorkflowSettings};
use crate::load::load_settings_path;
@ -224,6 +226,33 @@ impl WorkflowSettingsBuilder {
errors,
)
}
pub(crate) fn project_from_layer(
layer: &SettingsLayer,
) -> std::result::Result<ProjectNamespace, ResolveErrors> {
let layer = apply_builtin_defaults(layer.clone());
let mut errors = Vec::new();
let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors);
finish_dense_result(project, errors)
}
pub(crate) fn workflow_from_layer(
layer: &SettingsLayer,
) -> std::result::Result<WorkflowNamespace, ResolveErrors> {
let layer = apply_builtin_defaults(layer.clone());
let mut errors = Vec::new();
let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors);
finish_dense_result(workflow, errors)
}
pub(crate) fn run_from_layer(
layer: &SettingsLayer,
) -> std::result::Result<RunNamespace, ResolveErrors> {
let layer = apply_builtin_defaults(layer.clone());
let mut errors = Vec::new();
let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
finish_dense_result(run, errors)
}
}
fn finish_result<T>(value: T, context: &'static str, errors: Vec<ResolveError>) -> Result<T> {

View file

@ -16,11 +16,7 @@ use fabro_types::settings::SettingsLayer;
use serde::Serialize;
use crate::load::load_settings_path;
use crate::parse::parse_settings_layer;
use crate::{
Error, Result, resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file,
run,
};
use crate::{Error, Result, WorkflowSettingsBuilder, run};
const CONFIG_FILENAME: &str = ".fabro/project.toml";
#[derive(Clone, Debug)]
@ -32,19 +28,14 @@ pub struct WorkflowPathResolution {
pub workflow_slug: Option<String>,
}
/// Parse a project config from a TOML string.
pub fn parse_project_config(content: &str) -> Result<SettingsLayer> {
parse_settings_layer(content).map_err(|err| Error::parse("Failed to parse project config", err))
}
/// Load a project config from a file path.
///
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_project_config(path: &Path) -> Result<SettingsLayer> {
let config = load_settings_path(path)?;
let root = resolve_project_from_file(&config)
.map_err(|errors| Error::resolve("Failed to resolve project settings", errors))?
let root = WorkflowSettingsBuilder::project_from_layer(&config)
.map_err(|errors| Error::resolve("Failed to resolve project settings", errors.into()))?
.directory;
tracing::debug!(path = %path.display(), root = %root, "Loaded project config");
Ok(config)
@ -94,9 +85,10 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<Workflo
if path.extension().is_some_and(|ext| ext == "toml") {
match run::load_run_config(&path) {
Ok(cfg) => {
let workflow = resolve_workflow_from_file(&cfg).map_err(|errors| {
Error::resolve("Failed to resolve workflow settings", errors)
})?;
let workflow =
WorkflowSettingsBuilder::workflow_from_layer(&cfg).map_err(|errors| {
Error::resolve("Failed to resolve workflow settings", errors.into())
})?;
let dot_path = run::resolve_graph_path(&path, &workflow.graph);
Ok(WorkflowPathResolution {
resolved_workflow_path: path.clone(),
@ -121,7 +113,7 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<Workflo
}
pub fn resolve_working_directory(settings: &SettingsLayer, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = resolve_run_from_file(settings)
let Some(work_dir) = WorkflowSettingsBuilder::run_from_layer(settings)
.ok()
.and_then(|settings| settings.working_dir)
.map(|value| value.as_source())
@ -392,7 +384,7 @@ pub fn resolve_fabro_root(config_path: &Path, config: &SettingsLayer) -> PathBuf
let project_dir = config_path
.parent()
.expect("config_path should have a parent directory");
let root = resolve_project_from_file(config)
let root = WorkflowSettingsBuilder::project_from_layer(config)
.expect("project settings should resolve")
.directory;
normalize_joined_path(project_dir, Path::new(&root))
@ -408,14 +400,14 @@ mod tests {
#[test]
fn parse_minimal_config() {
let config = parse_project_config("_version = 1\n").unwrap();
let config = crate::parse_settings_layer("_version = 1\n").unwrap();
assert_eq!(config.version, Some(1));
assert!(config.project.is_none());
}
#[test]
fn parse_with_project_directory() {
let config = parse_project_config(
let config = crate::parse_settings_layer(
r#"
_version = 1
@ -425,14 +417,16 @@ directory = "custom/"
)
.unwrap();
assert_eq!(
resolve_project_from_file(&config).unwrap().directory,
WorkflowSettingsBuilder::project_from_layer(&config)
.unwrap()
.directory,
"custom/"
);
}
#[test]
fn parse_with_run_execution_retros() {
let config = parse_project_config(
let config = crate::parse_settings_layer(
"
_version = 1
@ -453,7 +447,8 @@ retros = true
#[test]
fn parse_rejects_legacy_llm_section() {
let err = parse_project_config("_version = 1\n[llm]\nprovider = \"openai\"\n").unwrap_err();
let err = crate::parse_settings_layer("_version = 1\n[llm]\nprovider = \"openai\"\n")
.unwrap_err();
let text = format!("{err:#}");
assert!(
text.contains("run.model") || text.contains("llm"),
@ -463,7 +458,7 @@ retros = true
#[test]
fn parse_higher_version_errors() {
let err = parse_project_config("_version = 2\n").unwrap_err();
let err = crate::parse_settings_layer("_version = 2\n").unwrap_err();
let chain = format!("{err:#}");
assert!(
chain.contains("Upgrade") || chain.to_lowercase().contains("version"),

View file

@ -1,7 +1,4 @@
use fabro_config::{
parse_settings_layer, resolve_run_from_file, resolve_server_from_file,
resolve_workflow_from_file,
};
use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer};
use fabro_types::settings::cli::OutputFormat;
use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode};
use fabro_types::settings::server::ObjectStoreProvider;
@ -98,15 +95,19 @@ fn apply_builtin_defaults_materializes_expected_layer() {
#[test]
fn resolve_empty_settings_requires_explicit_server_auth_methods() {
let errors = resolve_server_from_file(&SettingsLayer::default())
let errors = ServerSettingsBuilder::from_layer(&SettingsLayer::default())
.expect_err("empty server settings should fail");
assert!(errors.iter().any(|error| {
matches!(
error,
fabro_config::ResolveError::Missing { path } if path == "server.auth.methods"
)
}));
assert!(matches!(
errors,
fabro_config::Error::Resolve { errors, .. }
if errors.iter().any(|error| {
matches!(
error,
fabro_config::ResolveError::Missing { path } if path == "server.auth.methods"
)
})
));
}
#[test]
@ -123,10 +124,10 @@ mode = "dry_run"
"#,
);
let workflow = resolve_workflow_from_file(&layer).expect("workflow settings should resolve");
let run = resolve_run_from_file(&layer).expect("run settings should resolve");
let settings =
WorkflowSettingsBuilder::from_layer(&layer).expect("workflow settings should resolve");
assert_eq!(run.execution.mode, RunMode::DryRun);
assert_eq!(run.execution.approval, ApprovalMode::Prompt);
assert_eq!(workflow.graph, "workflow.fabro");
assert_eq!(settings.run.execution.mode, RunMode::DryRun);
assert_eq!(settings.run.execution.approval, ApprovalMode::Prompt);
assert_eq!(settings.workflow.graph, "workflow.fabro");
}

View file

@ -1,11 +1,13 @@
use fabro_config::{parse_settings_layer, resolve_project_from_file};
use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer};
use fabro_types::settings::SettingsLayer;
#[test]
fn resolves_project_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let project = resolve_project_from_file(&settings).expect("empty settings should resolve");
let project = WorkflowSettingsBuilder::from_layer(&settings)
.expect("empty settings should resolve")
.project;
assert_eq!(project.directory, ".");
assert!(project.name.is_none());
@ -30,7 +32,9 @@ team = "platform"
)
.expect("fixture should parse");
let project = resolve_project_from_file(&settings).expect("project settings should resolve");
let project = WorkflowSettingsBuilder::from_layer(&settings)
.expect("project settings should resolve")
.project;
assert_eq!(project.name.as_deref(), Some("Acme"));
assert_eq!(project.description.as_deref(), Some("Automation"));

View file

@ -1,4 +1,4 @@
use fabro_config::parse_settings_layer;
use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer};
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{InterpString, SettingsLayer};
@ -83,23 +83,30 @@ name = "gpt-5"
"#,
);
let project = fabro_config::resolve_project_from_file(&settings)
.expect("project settings should resolve");
let workflow = fabro_config::resolve_workflow_from_file(&settings)
.expect("workflow settings should resolve");
let workflow_settings =
WorkflowSettingsBuilder::from_layer(&settings).expect("workflow settings should resolve");
let server =
fabro_config::resolve_server_from_file(&settings).expect("server settings should resolve");
let run = fabro_config::resolve_run_from_file(&settings).expect("run settings should resolve");
ServerSettingsBuilder::from_layer(&settings).expect("server settings should resolve");
assert_eq!(project.directory, ".fabro");
assert_eq!(workflow.graph, "graphs/workflow.dot");
assert_eq!(server.storage.root.as_source(), "/srv/fabro");
assert_eq!(workflow_settings.project.directory, ".fabro");
assert_eq!(workflow_settings.workflow.graph, "graphs/workflow.dot");
assert_eq!(server.server.storage.root.as_source(), "/srv/fabro");
assert_eq!(
run.model.provider.as_ref().map(InterpString::as_source),
workflow_settings
.run
.model
.provider
.as_ref()
.map(InterpString::as_source),
Some("openai".to_string())
);
assert_eq!(
run.model.name.as_ref().map(InterpString::as_source),
workflow_settings
.run
.model
.name
.as_ref()
.map(InterpString::as_source),
Some("gpt-5".to_string())
);
}

View file

@ -1,4 +1,4 @@
use fabro_config::parse_settings_layer;
use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer};
use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode, WorktreeMode};
use fabro_types::settings::{InterpString, SettingsLayer};
@ -8,8 +8,9 @@ fn parse(source: &str) -> SettingsLayer {
#[test]
fn resolves_run_defaults_from_empty_settings() {
let settings = fabro_config::resolve_run_from_file(&SettingsLayer::default())
.expect("empty settings should resolve");
let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default())
.expect("empty settings should resolve")
.run;
assert_eq!(settings.execution.mode, RunMode::Normal);
assert_eq!(settings.execution.approval, ApprovalMode::Prompt);
@ -38,7 +39,9 @@ name = "sonnet"
"#,
);
let settings = fabro_config::resolve_run_from_file(&file).expect("run settings should resolve");
let settings = WorkflowSettingsBuilder::from_layer(&file)
.expect("run settings should resolve")
.run;
match settings.goal {
Some(RunGoal::File(path)) => {

View file

@ -1,11 +1,13 @@
use fabro_config::{parse_settings_layer, resolve_workflow_from_file};
use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer};
use fabro_types::settings::SettingsLayer;
#[test]
fn resolves_workflow_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let workflow = resolve_workflow_from_file(&settings).expect("empty settings should resolve");
let workflow = WorkflowSettingsBuilder::from_layer(&settings)
.expect("empty settings should resolve")
.workflow;
assert_eq!(workflow.graph, "workflow.fabro");
assert!(workflow.name.is_none());
@ -30,7 +32,9 @@ tier = "gold"
)
.expect("fixture should parse");
let workflow = resolve_workflow_from_file(&settings).expect("workflow settings should resolve");
let workflow = WorkflowSettingsBuilder::from_layer(&settings)
.expect("workflow settings should resolve")
.workflow;
assert_eq!(workflow.name.as_deref(), Some("Ship"));
assert_eq!(workflow.description.as_deref(), Some("Primary flow"));