From ed651dcd571699e92162cf657b8eca42d7a6d6b1 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 29 Mar 2026 13:46:43 -0400 Subject: [PATCH] Refactor config resolution around ConfigLayer --- docs/reference/cli.mdx | 2 +- docs/reference/run-directory.mdx | 2 +- lib/crates/fabro-cli/src/cli_config.rs | 8 +- lib/crates/fabro-cli/src/commands/asset/cp.rs | 2 +- .../fabro-cli/src/commands/asset/list.rs | 2 +- .../fabro-cli/src/commands/config/mod.rs | 27 +--- lib/crates/fabro-cli/src/commands/doctor.rs | 2 +- lib/crates/fabro-cli/src/commands/exec.rs | 2 +- lib/crates/fabro-cli/src/commands/graph.rs | 15 +- lib/crates/fabro-cli/src/commands/llm/mod.rs | 2 +- lib/crates/fabro-cli/src/commands/model.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/close.rs | 2 +- .../fabro-cli/src/commands/pr/create.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/list.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/merge.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/view.rs | 2 +- .../fabro-cli/src/commands/preflight.rs | 24 ++- .../fabro-cli/src/commands/repo/init.rs | 2 +- .../fabro-cli/src/commands/run/command.rs | 7 +- lib/crates/fabro-cli/src/commands/run/cp.rs | 2 +- .../fabro-cli/src/commands/run/create.rs | 17 +-- .../fabro-cli/src/commands/run/detached.rs | 2 +- lib/crates/fabro-cli/src/commands/run/diff.rs | 2 +- lib/crates/fabro-cli/src/commands/run/fork.rs | 2 +- lib/crates/fabro-cli/src/commands/run/logs.rs | 2 +- lib/crates/fabro-cli/src/commands/run/mod.rs | 10 +- .../fabro-cli/src/commands/run/overrides.rs | 6 +- .../fabro-cli/src/commands/run/preview.rs | 2 +- .../fabro-cli/src/commands/run/resume.rs | 2 +- .../fabro-cli/src/commands/run/rewind.rs | 2 +- lib/crates/fabro-cli/src/commands/run/ssh.rs | 2 +- lib/crates/fabro-cli/src/commands/run/wait.rs | 2 +- .../fabro-cli/src/commands/runs/inspect.rs | 2 +- .../fabro-cli/src/commands/runs/list.rs | 2 +- lib/crates/fabro-cli/src/commands/runs/rm.rs | 2 +- .../fabro-cli/src/commands/store/dump.rs | 2 +- .../fabro-cli/src/commands/system/df.rs | 2 +- .../fabro-cli/src/commands/system/prune.rs | 2 +- lib/crates/fabro-cli/src/commands/validate.rs | 16 +- lib/crates/fabro-cli/src/main.rs | 6 +- lib/crates/fabro-config/src/cli.rs | 4 +- lib/crates/fabro-config/src/config.rs | 61 +++++++- lib/crates/fabro-config/src/lib.rs | 2 +- lib/crates/fabro-config/src/project.rs | 140 +++++++++++------- lib/crates/fabro-config/src/run.rs | 12 +- lib/crates/fabro-config/src/server.rs | 4 +- lib/crates/fabro-config/src/settings.rs | 10 +- 48 files changed, 238 insertions(+), 193 deletions(-) diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 9b8210e58..de36c829f 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -37,7 +37,7 @@ CLI flags always override `cli.toml` values, which override hardcoded defaults. ## `fabro config show` -Print the merged `FabroConfig` as YAML. +Print the merged resolved configuration as YAML. ```bash fabro config show diff --git a/docs/reference/run-directory.mdx b/docs/reference/run-directory.mdx index b4adaa576..5d4e66e89 100644 --- a/docs/reference/run-directory.mdx +++ b/docs/reference/run-directory.mdx @@ -19,7 +19,7 @@ The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to | File | Format | When written | Description | |---|---|---|---| -| `run.json` | JSON | Run create | Run metadata — `run_id`, `created_at`, `config` (FabroConfig), `graph` (Graph), `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels` | +| `run.json` | JSON | Run create | Run metadata — `run_id`, `created_at`, `config` (resolved configuration), `graph` (Graph), `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels` | | `start.json` | JSON | Run start | Start metadata — `run_id`, `start_time`, `run_branch`, `base_sha` | | `workflow.fabro` | Graphviz | Run create | Copy of the original workflow graph when the raw DOT source is available | | `run.pid` | Text | Legacy only | Legacy process ID file from older runs. Current detached launches use launcher records instead, and current attach/resume no longer read `run.pid`. | diff --git a/lib/crates/fabro-cli/src/cli_config.rs b/lib/crates/fabro-cli/src/cli_config.rs index dd0824ad3..dabedc41f 100644 --- a/lib/crates/fabro-cli/src/cli_config.rs +++ b/lib/crates/fabro-cli/src/cli_config.rs @@ -1,16 +1,14 @@ #[allow(unused_imports)] pub(crate) use fabro_config::cli::*; -use std::path::Path; - +use fabro_config::ConfigLayer; use fabro_config::FabroSettings; -use fabro_config::cli::load_cli_config; #[cfg(feature = "server")] use tracing::debug; -pub(crate) fn load_cli_settings(path: Option<&Path>) -> anyhow::Result { - load_cli_config(path)?.try_into() +pub(crate) fn load_cli_settings() -> anyhow::Result { + ConfigLayer::cli()?.resolve() } #[cfg(feature = "server")] diff --git a/lib/crates/fabro-cli/src/commands/asset/cp.rs b/lib/crates/fabro-cli/src/commands/asset/cp.rs index f4175ec4a..36a557199 100644 --- a/lib/crates/fabro-cli/src/commands/asset/cp.rs +++ b/lib/crates/fabro-cli/src/commands/asset/cp.rs @@ -11,7 +11,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::split_run_path; pub(super) fn cp_command(args: &AssetCpArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let (run_id, asset_path) = parse_source(&args.source); let run = resolve_run(&base, run_id)?; diff --git a/lib/crates/fabro-cli/src/commands/asset/list.rs b/lib/crates/fabro-cli/src/commands/asset/list.rs index f009425bf..f719ad14e 100644 --- a/lib/crates/fabro-cli/src/commands/asset/list.rs +++ b/lib/crates/fabro-cli/src/commands/asset/list.rs @@ -9,7 +9,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::format_size; pub(super) fn list_command(args: &AssetListArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let run = resolve_run(&base, &args.run_id)?; let runtime_state = RuntimeState::new(&run.path); diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 380c54bbf..e02d582a5 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -2,9 +2,7 @@ use std::io::Write; use std::path::Path; use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs}; -use fabro_config::cli::load_cli_config; -use fabro_config::project::{ResolveSettingsInput, discover_project_config, resolve_settings}; -use fabro_config::{FabroConfig, FabroSettings}; +use fabro_config::{ConfigLayer, FabroSettings}; pub(crate) fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> { match ns.command { @@ -13,24 +11,13 @@ pub(crate) fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> { } fn merged_config(workflow: Option<&Path>) -> anyhow::Result { - if let Some(workflow) = workflow { - let cli_config = load_cli_config(None)?; - let cwd = std::env::current_dir()?; - return resolve_settings(ResolveSettingsInput { - workflow_path: workflow.to_path_buf(), - cwd, - defaults: cli_config, - overrides: FabroConfig::default(), - apply_project_config: true, - }); - } - let cwd = std::env::current_dir()?; - let project_config = discover_project_config(&cwd)? - .map(|(_, config)| config) - .unwrap_or_default(); - let cli_config = load_cli_config(None)?; - FabroConfig::combine(project_config, cli_config).try_into() + let base = match workflow { + Some(path) => ConfigLayer::for_workflow(path, &cwd)?, + None => ConfigLayer::project(&cwd)?, + }; + + base.combine(ConfigLayer::cli()?).resolve() } pub(crate) fn show_command(args: &ConfigShowArgs) -> anyhow::Result<()> { diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 26908a230..6d4c57bdc 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -938,7 +938,7 @@ pub(crate) async fn run_doctor(verbose: bool, live: bool) -> i32 { spinner.enable_steady_tick(std::time::Duration::from_millis(80)); // Gather state - let cli_settings = load_cli_settings(None).unwrap_or_default(); + let cli_settings = load_cli_settings().unwrap_or_default(); let config_path = dirs::home_dir().map(|h| h.join(".fabro").join("cli.toml")); let config_exists = config_path.as_ref().is_some_and(|p| p.exists()); diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 8a9a5a4b4..c19e6d012 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -9,7 +9,7 @@ use crate::args::GlobalArgs; use crate::cli_config; pub(crate) async fn execute(mut args: AgentArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = cli_config::load_cli_settings(None)?; + let cli_settings = cli_config::load_cli_settings()?; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled()); let exec_defaults = cli_settings.exec.as_ref(); diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index f235c2687..d37ad6f66 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -3,8 +3,8 @@ use std::io::Write; use std::sync::LazyLock; use anyhow::bail; -use fabro_config::cli::load_cli_config; -use fabro_config::project::{ResolveSettingsInput, resolve_settings, resolve_workflow_path}; +use fabro_config::ConfigLayer; +use fabro_config::project::resolve_workflow_path; use fabro_graphviz::render::render_dot; use fabro_util::terminal::Styles; use fabro_validate::Severity; @@ -19,14 +19,9 @@ static RANKDIR_RE: LazyLock = pub(crate) fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> { let cwd = std::env::current_dir()?; - let cli_defaults = load_cli_config(None)?; - let settings = resolve_settings(ResolveSettingsInput { - workflow_path: args.workflow.clone(), - cwd: cwd.clone(), - defaults: cli_defaults, - overrides: fabro_config::FabroConfig::default(), - apply_project_config: true, - })?; + let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? + .combine(ConfigLayer::cli()?) + .resolve()?; let resolution = resolve_workflow_path(&args.workflow, &cwd)?; let validated = validate(ValidateInput { workflow: WorkflowInput::Path(args.workflow.clone()), diff --git a/lib/crates/fabro-cli/src/commands/llm/mod.rs b/lib/crates/fabro-cli/src/commands/llm/mod.rs index d0408381c..68f269cf4 100644 --- a/lib/crates/fabro-cli/src/commands/llm/mod.rs +++ b/lib/crates/fabro-cli/src/commands/llm/mod.rs @@ -7,7 +7,7 @@ use crate::args::{GlobalArgs, LlmCommand, LlmNamespace}; use crate::cli_config::load_cli_settings; pub(crate) async fn dispatch(ns: LlmNamespace, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; match ns.command { LlmCommand::Prompt(args) => prompt::execute(args, &cli_settings, globals).await, diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index 0fd3f1db6..756a57224 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -11,7 +11,7 @@ pub(crate) async fn execute(command: Option, globals: &GlobalArgs let server = { #[cfg(feature = "server")] { - let cli_settings = cli_config::load_cli_settings(None)?; + let cli_settings = cli_config::load_cli_settings()?; let resolved = cli_config::resolve_mode( globals.mode.clone(), globals.server_url.as_deref(), diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index b7b4cd831..32df09ca7 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -12,7 +12,7 @@ pub(super) async fn close_command( args: PrCloseArgs, github_app: Option, ) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); close_from(&base, args, github_app).await } diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index e39b4495b..644237e09 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -19,7 +19,7 @@ pub(super) async fn create_command( args: PrCreateArgs, github_app: Option, ) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); create_from(&base, args, github_app).await } diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index f12b1fb5c..8028d5647 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -14,7 +14,7 @@ pub(super) async fn list_command( args: PrListArgs, github_app: Option, ) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; list_from(store.as_ref(), &base, args, github_app).await diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index ab6652986..c4cbc5e6d 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -13,7 +13,7 @@ pub(super) async fn merge_command( args: PrMergeArgs, github_app: Option, ) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); merge_from(&base, args, github_app).await } diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index 6d33b3f7c..018d9d788 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -16,7 +16,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::github::build_github_app_credentials; pub(crate) async fn dispatch(ns: PrNamespace) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let github_app = build_github_app_credentials(cli_settings.app_id()); match ns.command { diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index 340cf1c94..f13c00242 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -13,7 +13,7 @@ pub(super) async fn view_command( args: PrViewArgs, github_app: Option, ) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); view_from(&base, args, github_app).await } diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index d3e3c9b88..f1be24fef 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -2,11 +2,8 @@ use std::path::Path; use std::sync::Arc; use anyhow::bail; -use fabro_config::cli::load_cli_config; -use fabro_config::project::{ - ResolveSettingsInput, resolve_settings, resolve_workflow_path, resolve_working_directory, -}; -use fabro_config::{FabroConfig, FabroSettings}; +use fabro_config::project::{resolve_workflow_path, resolve_working_directory}; +use fabro_config::{ConfigLayer, FabroSettings}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_llm::client::Client as LlmClient; use fabro_model::{Catalog, Provider}; @@ -24,20 +21,17 @@ use crate::shared::github::build_github_app_credentials; pub(crate) async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_defaults = load_cli_config(None)?; - let cli_settings: FabroSettings = cli_defaults.clone().try_into()?; + let cli = ConfigLayer::cli()?; + let cli_settings: FabroSettings = cli.clone().resolve()?; args.verbose = args.verbose || cli_settings.verbose_enabled(); let github_app = build_github_app_credentials(cli_settings.app_id()); - let cli_args_config = FabroConfig::try_from(&args)?; + let cli_args_config = ConfigLayer::try_from(&args)?; let cwd = std::env::current_dir()?; - let settings = resolve_settings(ResolveSettingsInput { - workflow_path: args.workflow.clone(), - cwd: cwd.clone(), - defaults: cli_defaults, - overrides: cli_args_config, - apply_project_config: true, - })?; + let settings = cli_args_config + .combine(ConfigLayer::for_workflow(&args.workflow, &cwd)?) + .combine(cli) + .resolve()?; let resolution = resolve_workflow_path(&args.workflow, &cwd)?; let working_directory = resolve_working_directory(&settings, &cwd); diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index 5149cf416..1bd4f1c40 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -153,7 +153,7 @@ async fn check_github_app_installation() { }; // Load CLI config to get app_id and slug - let Ok(cli_settings) = load_cli_settings(None) else { + let Ok(cli_settings) = load_cli_settings() else { return; }; diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index 8cbb1dff4..9a96b7d34 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,18 +1,17 @@ use anyhow::Result; -use fabro_config::cli::load_cli_config; +use fabro_config::ConfigLayer; use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, RunArgs}; pub(crate) async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_defaults = load_cli_config(None)?; - let cli_settings: fabro_config::FabroSettings = cli_defaults.clone().try_into()?; + let cli_settings = ConfigLayer::cli()?.resolve()?; args.verbose = args.verbose || cli_settings.verbose_enabled(); let quiet = args.detach; let prevent_idle_sleep = cli_settings.prevent_idle_sleep_enabled(); - let (run_id, run_dir) = super::create::create_run(&args, cli_defaults, styles, quiet)?; + let (run_id, run_dir) = super::create::create_run(&args, styles, quiet)?; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = crate::sleep_inhibitor::guard(prevent_idle_sleep); diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 676d91eea..2684018e5 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -28,7 +28,7 @@ enum CopyDirection { pub(crate) async fn cp_command(args: CpArgs) -> Result<()> { let direction = parse_direction(&args.src, &args.dst)?; - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); match direction { diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index b65811176..13792b600 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,8 +1,7 @@ use std::path::PathBuf; use crate::args::RunArgs; -use fabro_config::project::{ResolveSettingsInput, resolve_settings}; -use fabro_config::{FabroConfig, FabroSettings}; +use fabro_config::{ConfigLayer, FabroSettings}; use fabro_util::terminal::Styles; use fabro_workflows::error::FabroError; use fabro_workflows::operations::{CreateRunInput, WorkflowInput, create}; @@ -14,7 +13,6 @@ use super::output::{print_diagnostics_from_error, print_workflow_report_from_per /// This does NOT execute the workflow — it only prepares the run directory. pub(crate) fn create_run( args: &RunArgs, - cli_defaults: FabroConfig, styles: &Styles, quiet: bool, ) -> anyhow::Result<(String, PathBuf)> { @@ -22,15 +20,12 @@ pub(crate) fn create_run( .workflow .as_ref() .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?; - let cli_args_config = FabroConfig::try_from(args)?; + let cli_args_config = ConfigLayer::try_from(args)?; let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let settings: FabroSettings = resolve_settings(ResolveSettingsInput { - workflow_path: workflow_path.clone(), - cwd: cwd.clone(), - defaults: cli_defaults, - overrides: cli_args_config, - apply_project_config: true, - })?; + let settings: FabroSettings = cli_args_config + .combine(ConfigLayer::for_workflow(workflow_path, &cwd)?) + .combine(ConfigLayer::cli()?) + .resolve()?; let created = match create(CreateRunInput { workflow: WorkflowInput::Path(workflow_path.clone()), diff --git a/lib/crates/fabro-cli/src/commands/run/detached.rs b/lib/crates/fabro-cli/src/commands/run/detached.rs index f705f1911..811e5d2d3 100644 --- a/lib/crates/fabro-cli/src/commands/run/detached.rs +++ b/lib/crates/fabro-cli/src/commands/run/detached.rs @@ -23,7 +23,7 @@ pub(crate) async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bo }); let run_record = RunRecord::load(&run_dir)?; - let cli_settings = cli_config::load_cli_settings(None)?; + let cli_settings = cli_config::load_cli_settings()?; let on_node: fabro_workflows::OnNodeCallback = Some({ let short_id = super::short_run_id(&run_record.run_id).to_string(); fabro_proctitle::set(&format!("fabro: {short_id}")); diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 7b8992c60..5deeef2ef 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -15,7 +15,7 @@ use crate::cli_config::load_cli_settings; pub(crate) async fn run(args: DiffArgs) -> Result<()> { info!(run_id = %args.run, "Showing diff"); - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index d654eebc8..2c738427f 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -14,7 +14,7 @@ use crate::store::{build_store, open_run_reader}; pub(crate) async fn run(args: &ForkArgs, styles: &Styles) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let durable_store = build_store(&cli_settings.storage_dir())?; let run_id = find_run_id_by_prefix_or_store(&repo, durable_store.as_ref(), &args.run_id).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 223b8674f..f94673bdd 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -15,7 +15,7 @@ use crate::args::LogsArgs; use crate::cli_config::load_cli_settings; pub(crate) async fn run(args: &LogsArgs, styles: &Styles) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index c80e5a268..a936ce072 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,6 +1,5 @@ use anyhow::Result; use fabro_config::FabroSettingsExt; -use fabro_config::cli::load_cli_config; use fabro_util::terminal::Styles; use fabro_workflows::run_lookup::{resolve_run_combined, runs_base}; @@ -35,13 +34,12 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( RunCommands::Run(args) => command::execute(args, globals).await, RunCommands::Create(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_defaults = load_cli_config(None)?; - let (run_id, _run_dir) = create::create_run(&args, cli_defaults, styles, true)?; + let (run_id, _run_dir) = create::create_run(&args, styles, true)?; println!("{run_id}"); Ok(()) } RunCommands::Start { run } => { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?; @@ -51,7 +49,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( } RunCommands::Attach { run } => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?; @@ -80,7 +78,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled()) }; resume::resume_command(args, styles).await diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index df6042888..3d9b63bad 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use anyhow::Result; use fabro_config::run::LlmConfig; -use fabro_config::{FabroConfig, sandbox as sandbox_config}; +use fabro_config::{ConfigLayer, sandbox as sandbox_config}; use fabro_sandbox::SandboxProvider; use crate::args::{PreflightArgs, RunArgs}; @@ -19,7 +19,7 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap { .collect() } -impl TryFrom<&RunArgs> for FabroConfig { +impl TryFrom<&RunArgs> for ConfigLayer { type Error = anyhow::Error; fn try_from(args: &RunArgs) -> Result { @@ -61,7 +61,7 @@ impl TryFrom<&RunArgs> for FabroConfig { } } -impl TryFrom<&PreflightArgs> for FabroConfig { +impl TryFrom<&PreflightArgs> for ConfigLayer { type Error = anyhow::Error; fn try_from(args: &PreflightArgs) -> Result { diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index 0a1125748..3d2daf07b 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -10,7 +10,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::validate_daytona_provider; pub(crate) async fn run(args: PreviewArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 6093db95c..8f9c9df95 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -16,7 +16,7 @@ pub(crate) async fn resume_command( args: ResumeArgs, styles: &'static Styles, ) -> anyhow::Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index 327a6befb..60bf57157 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -18,7 +18,7 @@ use crate::store::{build_store, open_run_reader}; pub(crate) async fn run(args: &RewindArgs, styles: &Styles) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let durable_store = build_store(&cli_settings.storage_dir())?; let run_id = find_run_id_by_prefix_or_store(&repo, durable_store.as_ref(), &args.run_id).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index 84dccad78..eb2f32f2c 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -10,7 +10,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::validate_daytona_provider; pub(crate) async fn run(args: SshArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 9d05766a8..9741c1002 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -13,7 +13,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::format_duration_ms; pub(crate) async fn run(args: &WaitArgs, styles: &Styles) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run_info = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index e66409321..db4fe26c1 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -26,7 +26,7 @@ pub(crate) struct InspectOutput { } pub(crate) async fn run(args: &InspectArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index f9b47dc02..811eee809 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -18,7 +18,7 @@ use crate::shared::{color_if, format_duration_ms, tilde_path}; use super::short_run_id; pub(crate) async fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; let runs = scan_runs_combined(store.as_ref(), &base).await?; diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 701ee1c06..469c08ada 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -16,7 +16,7 @@ use crate::cli_config::load_cli_settings; use super::short_run_id; pub(crate) async fn remove_command(args: &RunsRemoveArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; remove_from(args, store.as_ref(), &base).await diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 4f083817d..b9cedbc07 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -12,7 +12,7 @@ use crate::cli_config::load_cli_settings; use crate::store; pub(crate) async fn dump_command(args: &StoreDumpArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = store::build_store(&cli_settings.storage_dir())?; let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index 6d41ee587..1d3c379b4 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -14,7 +14,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::format_size; pub(super) async fn df_command(args: &DfArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let data_dir = cli_settings.storage_dir(); let runs_base_dir = runs_base(&data_dir); let logs_base_dir = logs_base(&data_dir); diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index bec2d6491..6d702a2ba 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -13,7 +13,7 @@ use crate::cli_config::load_cli_settings; use crate::shared::format_size; pub(super) async fn prune_command(args: &RunsPruneArgs) -> Result<()> { - let cli_settings = load_cli_settings(None)?; + let cli_settings = load_cli_settings()?; let base = runs_base(&cli_settings.storage_dir()); let store = crate::store::build_store(&cli_settings.storage_dir())?; prune_from(args, store.as_ref(), &base).await diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 6dd60fbdd..1ae46609b 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,7 +1,6 @@ use anyhow::bail; -use fabro_config::FabroConfig; -use fabro_config::cli::load_cli_config; -use fabro_config::project::{ResolveSettingsInput, resolve_settings, resolve_workflow_path}; +use fabro_config::ConfigLayer; +use fabro_config::project::resolve_workflow_path; use fabro_util::terminal::Styles; use fabro_validate::Severity; use fabro_workflows::operations::{ValidateInput, WorkflowInput, validate}; @@ -11,14 +10,9 @@ use crate::shared::{print_diagnostics, relative_path}; pub(crate) fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> { let cwd = std::env::current_dir()?; - let cli_defaults = load_cli_config(None)?; - let settings = resolve_settings(ResolveSettingsInput { - workflow_path: args.workflow.clone(), - cwd: cwd.clone(), - defaults: cli_defaults, - overrides: FabroConfig::default(), - apply_project_config: true, - })?; + let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? + .combine(ConfigLayer::cli()?) + .resolve()?; let resolution = resolve_workflow_path(&args.workflow, &cwd)?; let validated = validate(ValidateInput { workflow: WorkflowInput::Path(args.workflow.clone()), diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index a6df3818f..6dc0aee49 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -115,7 +115,7 @@ async fn main_inner() -> (String, Result<()>) { Err(err) => return (command_name, Err(err)), } } else { - match cli_config::load_cli_settings(None) { + match cli_config::load_cli_settings() { Ok(cli_settings) => ( cli_settings.log.as_ref().and_then(|l| l.level.clone()), cli_settings.upgrade_check_enabled(), @@ -126,7 +126,7 @@ async fn main_inner() -> (String, Result<()>) { } #[cfg(not(feature = "server"))] { - match cli_config::load_cli_settings(None) { + match cli_config::load_cli_settings() { Ok(cli_settings) => ( cli_settings.log.as_ref().and_then(|l| l.level.clone()), cli_settings.upgrade_check_enabled(), @@ -188,7 +188,7 @@ async fn main_inner() -> (String, Result<()>) { fabro_server::serve::serve_command(args, styles).await?; } Commands::Doctor { verbose, dry_run } => { - let cli_settings = cli_config::load_cli_settings(None)?; + let cli_settings = cli_config::load_cli_settings()?; let verbose = verbose || cli_settings.verbose_enabled(); let exit_code = commands::doctor::run_doctor(verbose, !dry_run).await; std::process::exit(exit_code); diff --git a/lib/crates/fabro-config/src/cli.rs b/lib/crates/fabro-config/src/cli.rs index f8e9b55e0..3a2e40416 100644 --- a/lib/crates/fabro-config/src/cli.rs +++ b/lib/crates/fabro-config/src/cli.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::anyhow; use serde::{Deserialize, Serialize}; -use crate::config::FabroConfig; +use crate::config::ConfigLayer; pub use fabro_types::settings::cli::{ ClientTlsSettings, ExecSettings, ExecutionMode, OutputFormat, PermissionLevel, ServerSettings, @@ -72,6 +72,6 @@ impl From for ExecSettings { /// Load CLI config from an explicit path or `~/.fabro/cli.toml`, returning defaults if the /// default file doesn't exist. An explicit path that doesn't exist is an error. -pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result { +pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result { crate::load_config_file(path, "cli.toml") } diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index a739b2fb4..68ff2ef57 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -1,18 +1,18 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use crate::cli::{ExecConfig, ExecutionMode, ServerConfig}; +use crate::cli::{self, ExecConfig, ExecutionMode, ServerConfig}; use crate::combine::Combine; use crate::hook::{HookConfig, HookDefinition}; use crate::mcp::McpServerEntry; -use crate::project::ProjectFabroConfig; +use crate::project::{self, ProjectFabroConfig}; use crate::run::{ AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig, }; use crate::sandbox::SandboxConfig; -use crate::server::{ApiConfig, Features, GitConfig, LogConfig, WebConfig}; +use crate::server::{self, ApiConfig, Features, GitConfig, LogConfig, WebConfig}; use crate::settings::FabroSettings; fn is_default_checkpoint(c: &CheckpointConfig) -> bool { @@ -25,7 +25,7 @@ fn is_default_checkpoint(c: &CheckpointConfig) -> bool { /// `parse_project_config`) all return this type. Fields irrelevant to a /// particular source are left unset (`None` / empty). #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct FabroConfig { +pub struct ConfigLayer { // --- Workflow run config fields --- #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, @@ -132,7 +132,7 @@ pub struct FabroConfig { pub fabro: Option, } -impl Combine for FabroConfig { +impl Combine for ConfigLayer { fn combine(self, other: Self) -> Self { let hooks = if self.hooks.is_empty() { other.hooks @@ -182,13 +182,58 @@ impl Combine for FabroConfig { } } -impl FabroConfig { +impl ConfigLayer { #[must_use] pub fn combine(self, other: Self) -> Self { Combine::combine(self, other) } - pub fn try_into_settings(self) -> anyhow::Result { + /// Load workflow config + project config for a workflow path. + /// + /// Resolves the workflow path, loads its config, discovers project config + /// (`fabro.toml`) from the resolved workflow's parent directory, and combines + /// them (workflow takes precedence over project). + pub fn for_workflow(path: &Path, cwd: &Path) -> anyhow::Result { + let resolution = project::resolve_workflow_path(path, cwd)?; + if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() { + anyhow::bail!( + "Workflow not found: {}", + resolution.resolved_workflow_path.display() + ); + } + + let workflow_config = resolution.workflow_config.unwrap_or_default(); + let project_config = project::discover_project_config( + resolution + .resolved_workflow_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )? + .map(|(_, config)| config) + .unwrap_or_default(); + + Ok(workflow_config.combine(project_config)) + } + + /// Discover project config (`fabro.toml`) by walking ancestors from `start`. + pub fn project(start: &Path) -> anyhow::Result { + Ok(project::discover_project_config(start)? + .map(|(_, config)| config) + .unwrap_or_default()) + } + + /// Load CLI defaults from `~/.fabro/cli.toml`. + pub fn cli() -> anyhow::Result { + cli::load_cli_config(None) + } + + /// Load server defaults from `~/.fabro/server.toml`. + pub fn server() -> anyhow::Result { + server::load_server_config(None) + } + + /// Convert this combined config layer into final resolved settings. + pub fn resolve(self) -> anyhow::Result { self.try_into() } } diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 03be6d1ee..eb3f9353c 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -12,7 +12,7 @@ pub mod sandbox; pub mod server; pub mod settings; -pub use config::FabroConfig; +pub use config::ConfigLayer; pub use fabro_types::Combine; pub use fabro_util::path::expand_tilde; pub use settings::{FabroSettings, FabroSettingsExt}; diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 376674025..8b43034f7 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -5,7 +5,7 @@ use anyhow::{Context, bail}; use serde::{Deserialize, Serialize}; use crate::FabroSettings; -use crate::config::FabroConfig; +use crate::config::ConfigLayer; use crate::run; pub use fabro_types::settings::project::ProjectFabroSettings; @@ -23,20 +23,11 @@ pub struct ProjectFabroConfig { pub struct WorkflowPathResolution { pub resolved_workflow_path: PathBuf, pub dot_path: PathBuf, - pub workflow_config: Option, + pub workflow_config: Option, pub workflow_toml_path: Option, pub workflow_slug: Option, } -#[derive(Clone, Debug)] -pub struct ResolveSettingsInput { - pub workflow_path: PathBuf, - pub cwd: PathBuf, - pub defaults: FabroConfig, - pub overrides: FabroConfig, - pub apply_project_config: bool, -} - fn default_root() -> String { ".".to_string() } @@ -50,8 +41,8 @@ impl From for ProjectFabroSettings { } /// Parse a project config from a TOML string. -pub fn parse_project_config(content: &str) -> anyhow::Result { - let config: FabroConfig = toml::from_str(content).context("Failed to parse project config")?; +pub fn parse_project_config(content: &str) -> anyhow::Result { + let config: ConfigLayer = toml::from_str(content).context("Failed to parse project config")?; let version = config.version.unwrap_or(0); if version != SUPPORTED_VERSION { bail!( @@ -62,7 +53,7 @@ pub fn parse_project_config(content: &str) -> anyhow::Result { } /// Load a project config from a file path. -pub fn load_project_config(path: &Path) -> anyhow::Result { +pub fn load_project_config(path: &Path) -> anyhow::Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; let config = parse_project_config(&content)?; @@ -77,7 +68,7 @@ pub fn load_project_config(path: &Path) -> anyhow::Result { /// Walk ancestor directories from `start` looking for `fabro.toml`. /// Returns the config file path and parsed config, or `None` if not found. -pub fn discover_project_config(start: &Path) -> anyhow::Result> { +pub fn discover_project_config(start: &Path) -> anyhow::Result> { for ancestor in start.ancestors() { let candidate = ancestor.join(CONFIG_FILENAME); if candidate.is_file() { @@ -192,36 +183,6 @@ pub fn resolve_working_directory(settings: &FabroSettings, caller_cwd: &Path) -> } } -pub fn resolve_settings(input: ResolveSettingsInput) -> anyhow::Result { - let resolution = resolve_workflow_path(&input.workflow_path, &input.cwd)?; - if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() { - anyhow::bail!( - "Workflow not found: {}", - resolution.resolved_workflow_path.display() - ); - } - - let project_config = if input.apply_project_config { - discover_project_config( - resolution - .resolved_workflow_path - .parent() - .unwrap_or_else(|| Path::new(".")), - )? - .map(|(_, config)| config) - .unwrap_or_default() - } else { - FabroConfig::default() - }; - - input - .overrides - .combine(resolution.workflow_config.unwrap_or_default()) - .combine(project_config) - .combine(input.defaults) - .try_into() -} - fn resolve_workflow_arg_from(arg: &Path, start_dir: &Path) -> anyhow::Result { resolve_workflow_arg_impl(arg, start_dir, user_workflows_dir().as_deref()) } @@ -414,7 +375,7 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option { /// /// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file, /// loads the run config and resolves the graph path within it. -pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option)> { +pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option)> { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let resolution = resolve_workflow_path(arg, &start)?; Ok((resolution.dot_path, resolution.workflow_config)) @@ -437,7 +398,7 @@ pub fn is_retro_enabled() -> bool { /// Resolve the fabro root directory from a config file path and its config. /// The returned path is the directory containing `fabro.toml` joined with the `root` value. -pub fn resolve_fabro_root(config_path: &Path, config: &FabroConfig) -> PathBuf { +pub fn resolve_fabro_root(config_path: &Path, config: &ConfigLayer) -> PathBuf { let project_dir = config_path .parent() .expect("config_path should have a parent directory"); @@ -596,7 +557,7 @@ model = "claude-sonnet-4-6" #[test] fn resolve_fabro_root_with_subdirectory() { let config_path = Path::new("/repo/fabro.toml"); - let config = FabroConfig { + let config = ConfigLayer { version: Some(1), fabro: Some(ProjectFabroConfig { root: Some("fabro/".to_string()), @@ -613,7 +574,7 @@ model = "claude-sonnet-4-6" #[test] fn resolve_fabro_root_with_dot() { let config_path = Path::new("/repo/fabro.toml"); - let config = FabroConfig { + let config = ConfigLayer { version: Some(1), fabro: Some(ProjectFabroConfig { root: Some(".".to_string()), @@ -630,13 +591,92 @@ model = "claude-sonnet-4-6" #[test] fn resolve_fabro_root_without_fabro_section() { let config_path = Path::new("/repo/fabro.toml"); - let config = FabroConfig::default(); + let config = ConfigLayer::default(); assert_eq!( resolve_fabro_root(config_path, &config), Path::new("/repo/.") ); } + #[test] + fn for_workflow_discovers_project_from_workflow_location() { + let tmp = TempDir::new().unwrap(); + let project_dir = tmp.path().join("project"); + let other_dir = tmp.path().join("other"); + let workflow_dir = project_dir.join("workflows").join("demo"); + fs::create_dir_all(&workflow_dir).unwrap(); + fs::create_dir_all(&other_dir).unwrap(); + + fs::write( + project_dir.join("fabro.toml"), + "version = 1\nverbose = true\n", + ) + .unwrap(); + fs::write( + other_dir.join("fabro.toml"), + "version = 1\nverbose = false\n", + ) + .unwrap(); + fs::write(workflow_dir.join("workflow.toml"), "version = 1\n").unwrap(); + + let layer = + ConfigLayer::for_workflow(&workflow_dir.join("workflow.toml"), &other_dir).unwrap(); + + assert_eq!(layer.verbose, Some(true)); + } + + #[test] + fn chained_resolve_preserves_precedence_order() { + let tmp = TempDir::new().unwrap(); + let project_dir = tmp.path().join("project"); + let workflow_dir = project_dir.join("workflows").join("demo"); + fs::create_dir_all(&workflow_dir).unwrap(); + + fs::write( + project_dir.join("fabro.toml"), + "version = 1\nverbose = true\n[llm]\nmodel = \"project-model\"\n", + ) + .unwrap(); + fs::write( + workflow_dir.join("workflow.toml"), + "version = 1\ndry_run = true\n[llm]\nmodel = \"workflow-model\"\n", + ) + .unwrap(); + + let cli_defaults = ConfigLayer { + verbose: Some(false), + llm: Some(crate::run::LlmConfig { + model: Some("cli-model".to_string()), + provider: None, + fallbacks: None, + }), + ..Default::default() + }; + let overrides = ConfigLayer { + dry_run: Some(false), + ..Default::default() + }; + + let settings = overrides + .combine( + ConfigLayer::for_workflow( + &workflow_dir.join("workflow.toml"), + project_dir.as_path(), + ) + .unwrap(), + ) + .combine(cli_defaults) + .resolve() + .unwrap(); + + assert_eq!( + settings.llm.as_ref().and_then(|llm| llm.model.as_deref()), + Some("workflow-model") + ); + assert_eq!(settings.dry_run, Some(false)); + assert_eq!(settings.verbose, Some(true)); + } + #[test] fn resolve_workflow_arg_toml_extension_returned_as_is() { let tmp = TempDir::new().unwrap(); diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index a097b4b17..20feda467 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use tracing::debug; use crate::combine::Combine; -use crate::config::FabroConfig; +use crate::config::ConfigLayer; use crate::sandbox::DockerfileSource; pub use fabro_types::settings::run::{ AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, @@ -129,7 +129,7 @@ impl From for SetupSettings { /// `${env.VARNAME}` references in `[sandbox.env]` are NOT resolved here — /// call [`resolve_sandbox_env`] separately after snapshotting, so that /// plaintext secrets are never written to disk. -pub fn load_run_config(path: &Path) -> anyhow::Result { +pub fn load_run_config(path: &Path) -> anyhow::Result { let contents = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; let mut config = parse_run_config(&contents)?; @@ -144,7 +144,7 @@ pub fn load_run_config(path: &Path) -> anyhow::Result { /// /// Only whole-value references are supported (no partial interpolation). /// Missing host env vars produce a hard error. -pub fn resolve_sandbox_env(config: &mut FabroConfig) -> anyhow::Result<()> { +pub fn resolve_sandbox_env(config: &mut ConfigLayer) -> anyhow::Result<()> { if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) { resolve_env_refs(env)?; } @@ -172,7 +172,7 @@ pub fn resolve_env_refs(env: &mut HashMap) -> anyhow::Result<()> /// If the config contains a `dockerfile = { path = "..." }`, read the file /// and replace it with `DockerfileSource::Inline(contents)`. -fn resolve_dockerfile(config: &mut FabroConfig, config_dir: &Path) -> anyhow::Result<()> { +fn resolve_dockerfile(config: &mut ConfigLayer, config_dir: &Path) -> anyhow::Result<()> { let source = config .sandbox .as_mut() @@ -204,8 +204,8 @@ pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf { } } -pub fn parse_run_config(contents: &str) -> anyhow::Result { - let mut config: FabroConfig = +pub fn parse_run_config(contents: &str) -> anyhow::Result { + let mut config: ConfigLayer = toml::from_str(contents).context("Failed to parse run config TOML")?; if config.graph.is_none() { diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index bad39f0bb..4b910b930 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::anyhow; use serde::{Deserialize, Serialize}; -use crate::config::FabroConfig; +use crate::config::ConfigLayer; use crate::settings::{FabroSettings, FabroSettingsExt}; pub use fabro_types::settings::server::{ ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings, @@ -181,7 +181,7 @@ impl From for LogSettings { /// Load server config from an explicit path or `~/.fabro/server.toml`, returning defaults if the /// default file doesn't exist. An explicit path that doesn't exist is an error. -pub fn load_server_config(path: Option<&Path>) -> anyhow::Result { +pub fn load_server_config(path: Option<&Path>) -> anyhow::Result { crate::load_config_file(path, "server.toml") } diff --git a/lib/crates/fabro-config/src/settings.rs b/lib/crates/fabro-config/src/settings.rs index ce7ab08e4..169156baf 100644 --- a/lib/crates/fabro-config/src/settings.rs +++ b/lib/crates/fabro-config/src/settings.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; pub use fabro_types::settings::FabroSettings; -use crate::config::FabroConfig; +use crate::config::ConfigLayer; pub trait FabroSettingsExt { fn storage_dir(&self) -> PathBuf; @@ -18,10 +18,10 @@ impl FabroSettingsExt for FabroSettings { } } -impl TryFrom for FabroSettings { +impl TryFrom for FabroSettings { type Error = anyhow::Error; - fn try_from(value: FabroConfig) -> Result { + fn try_from(value: ConfigLayer) -> Result { Ok(Self { version: value.version, goal: value.goal, @@ -60,10 +60,10 @@ impl TryFrom for FabroSettings { } } -impl TryFrom<&FabroConfig> for FabroSettings { +impl TryFrom<&ConfigLayer> for FabroSettings { type Error = anyhow::Error; - fn try_from(value: &FabroConfig) -> Result { + fn try_from(value: &ConfigLayer) -> Result { value.clone().try_into() } }