diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 6ac58780e..7389394ea 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -585,8 +585,35 @@ fn attach_json_errors_without_prompting_for_human_input() { }, "run_dir": "[RUN_DIR]", "settings": { + "workflow": { + "graph": "workflow.fabro" + }, + "cli": { + "exec": { + "prevent_idle_sleep": false + }, + "output": { + "format": "text", + "verbosity": "normal" + }, + "target": { + "path": "[CLI_SOCKET]", + "type": "unix" + }, + "updates": { + "check": true + } + }, + "features": { + "session_sandboxes": false + }, + "project": { + "directory": "." + }, "run": { "execution": { + "approval": "prompt", + "mode": "normal", "retros": false }, "goal": "Wait for approval", @@ -594,15 +621,17 @@ fn attach_json_errors_without_prompting_for_human_input() { "name": "gpt-5.4", "provider": "openai" }, + "prepare": { + "timeout": "5m" + }, "sandbox": { + "devcontainer": false, + "local": { + "worktree_mode": "clean" + }, + "preserve": false, "provider": "local" } - }, - "cli": { - "target": { - "path": "[CLI_SOCKET]", - "type": "unix" - } } }, "workflow_slug": "human-gate", diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 22e89b974..62506fae5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -383,6 +383,32 @@ fn settings_local_merges_cli_and_project_defaults() { .clone(); let cfg = parse_settings(&output); + assert_eq!( + cfg.project + .as_ref() + .and_then(|project| project.directory.as_deref()), + Some(".") + ); + assert_eq!( + cfg.workflow + .as_ref() + .and_then(|workflow| workflow.graph.as_deref()), + Some("workflow.fabro") + ); + assert_eq!( + cfg.run + .as_ref() + .and_then(|run| run.execution.as_ref()) + .and_then(|execution| execution.approval), + Some(fabro_types::settings::run::ApprovalMode::Prompt) + ); + assert_eq!( + cfg.run + .as_ref() + .and_then(|run| run.sandbox.as_ref()) + .and_then(|sandbox| sandbox.provider.as_deref()), + Some("daytona") + ); assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model")); assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai")); assert_eq!(run_goal_inline(&cfg).as_deref(), None); @@ -852,6 +878,25 @@ shared = "cli" mock.assert(); let cfg = parse_settings(&output); + assert_eq!( + cfg.project + .as_ref() + .and_then(|project| project.directory.as_deref()), + Some(".") + ); + assert_eq!( + cfg.workflow + .as_ref() + .and_then(|workflow| workflow.graph.as_deref()), + Some("workflow.fabro") + ); + assert_eq!( + cfg.run + .as_ref() + .and_then(|run| run.execution.as_ref()) + .and_then(|execution| execution.approval), + Some(fabro_types::settings::run::ApprovalMode::Prompt) + ); assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model")); assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai")); assert_eq!(server_storage_root(&cfg), "/srv/fabro-server"); diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index e4e20a973..137e2937d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -932,9 +932,35 @@ fn json_run_implies_auto_approve_for_human_gates() { }, "run_dir": "[RUN_DIR]", "settings": { + "workflow": { + "graph": "workflow.fabro" + }, + "cli": { + "exec": { + "prevent_idle_sleep": false + }, + "output": { + "format": "text", + "verbosity": "normal" + }, + "target": { + "path": "[CLI_SOCKET]", + "type": "unix" + }, + "updates": { + "check": true + } + }, + "features": { + "session_sandboxes": false + }, + "project": { + "directory": "." + }, "run": { "execution": { "approval": "auto", + "mode": "normal", "retros": false }, "goal": "Route through the default approval path", @@ -942,15 +968,17 @@ fn json_run_implies_auto_approve_for_human_gates() { "name": "[LLM_MODEL]", "provider": "[LLM_PROVIDER]" }, + "prepare": { + "timeout": "5m" + }, "sandbox": { + "devcontainer": false, + "local": { + "worktree_mode": "clean" + }, + "preserve": false, "provider": "local" } - }, - "cli": { - "target": { - "path": "[CLI_SOCKET]", - "type": "unix" - } } }, "workflow_slug": "human-gate", diff --git a/lib/crates/fabro-config/src/defaults.rs b/lib/crates/fabro-config/src/defaults.rs new file mode 100644 index 000000000..dada39238 --- /dev/null +++ b/lib/crates/fabro-config/src/defaults.rs @@ -0,0 +1,21 @@ +use std::sync::LazyLock; + +use fabro_types::settings::SettingsLayer; + +use crate::merge::combine_files; +use crate::parse_settings_layer; + +static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { + parse_settings_layer(include_str!("defaults.toml")) + .expect("embedded defaults.toml must parse as a valid SettingsLayer") +}); + +#[must_use] +pub fn defaults_layer() -> &'static SettingsLayer { + &DEFAULTS_LAYER +} + +#[must_use] +pub fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer { + combine_files(defaults_layer().clone(), layer) +} diff --git a/lib/crates/fabro-config/src/defaults.toml b/lib/crates/fabro-config/src/defaults.toml new file mode 100644 index 000000000..b24275eb1 --- /dev/null +++ b/lib/crates/fabro-config/src/defaults.toml @@ -0,0 +1,54 @@ +# Fabro built-in defaults — embedded at compile time. +# Dynamic and presence-gated defaults remain in Rust. +_version = 1 + +[project] +directory = "." + +[workflow] +graph = "workflow.fabro" + +[run.execution] +mode = "normal" +approval = "prompt" +retros = true + +[run.prepare] +timeout = "5m" + +[run.sandbox] +provider = "local" +preserve = false +devcontainer = false + +[run.sandbox.local] +worktree_mode = "clean" + +[cli.output] +format = "text" +verbosity = "normal" + +[cli.exec] +prevent_idle_sleep = false + +[cli.updates] +check = true + +[server.web] +enabled = true +url = "http://localhost:3000" + +[server.scheduler] +max_concurrent_runs = 5 + +[server.artifacts] +provider = "local" +prefix = "artifacts" + +[server.slatedb] +provider = "local" +prefix = "" +flush_interval = "1ms" + +[features] +session_sandboxes = false diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 46fd13ee9..608adc6aa 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -14,7 +14,7 @@ use fabro_types::settings::run::{RunExecutionLayer, RunLayer}; use fabro_types::settings::server::ServerLayer; use crate::merge::combine_files; -use crate::{Error, Result}; +use crate::{Error, Result, apply_builtin_defaults}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EffectiveSettingsMode { @@ -61,11 +61,10 @@ pub fn resolve_settings( user, } = layers; - match mode { - EffectiveSettingsMode::LocalOnly => Ok(combine_files( - combine_files(combine_files(user, project), workflow), - args, - )), + let settings = match mode { + EffectiveSettingsMode::LocalOnly => { + combine_files(combine_files(combine_files(user, project), workflow), args) + } EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => { let server_settings = server_settings.ok_or(Error::MissingServerSettings)?; // Owner-specific domains (cli, server) may only come from the @@ -99,9 +98,11 @@ pub fn resolve_settings( let server = settings.server.get_or_insert_with(ServerLayer::default); server.storage = Some(server_root); } - Ok(settings) + settings } - } + }; + + Ok(apply_builtin_defaults(settings)) } fn strip_owner_domains(file: &mut SettingsLayer) { @@ -181,7 +182,8 @@ fn apply_local_daemon_overrides( #[cfg(test)] mod tests { - use fabro_types::settings::run::RunGoalLayer; + use fabro_types::settings::cli::OutputFormat; + use fabro_types::settings::run::{ApprovalMode, RunGoalLayer}; use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer}; use fabro_types::settings::{InterpString, SettingsLayer}; @@ -258,6 +260,28 @@ shared = "user" !inputs.contains_key("user_only"), "project.inputs should replace user.inputs wholesale" ); + assert_eq!( + settings + .project + .as_ref() + .and_then(|project| project.directory.as_deref()), + Some(".") + ); + assert_eq!( + settings + .workflow + .as_ref() + .and_then(|workflow| workflow.graph.as_deref()), + Some("workflow.fabro") + ); + assert_eq!( + settings + .run + .as_ref() + .and_then(|run| run.execution.as_ref()) + .and_then(|execution| execution.approval), + Some(ApprovalMode::Prompt) + ); } #[test] @@ -389,6 +413,21 @@ root = "/tmp/should-be-inert" .as_deref(), Some("project goal") ); + assert_eq!( + settings + .workflow + .as_ref() + .and_then(|workflow| workflow.graph.as_deref()), + Some("workflow.fabro") + ); + assert_eq!( + settings + .run + .as_ref() + .and_then(|run| run.sandbox.as_ref()) + .and_then(|sandbox| sandbox.provider.as_deref()), + Some("local") + ); } #[test] @@ -431,5 +470,21 @@ root = "/tmp/should-be-inert" .and_then(|scheduler| scheduler.max_concurrent_runs), Some(7) ); + assert_eq!( + settings + .run + .as_ref() + .and_then(|run| run.sandbox.as_ref()) + .and_then(|sandbox| sandbox.provider.as_deref()), + Some("local") + ); + assert_eq!( + settings + .cli + .as_ref() + .and_then(|cli| cli.output.as_ref()) + .and_then(|output| output.format), + Some(OutputFormat::Text) + ); } } diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 6431800c9..eba76ad1a 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -1,5 +1,7 @@ extern crate self as fabro_config; +mod defaults; + pub mod effective_settings; pub mod envfile; pub mod error; @@ -16,6 +18,7 @@ pub mod user; use std::path::Path; +pub use defaults::{apply_builtin_defaults, defaults_layer}; pub use error::{Error, Result}; use fabro_types::settings::{Settings, SettingsLayer}; pub use fabro_util::path::expand_tilde; diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs index 9ff89feef..f28a56bd2 100644 --- a/lib/crates/fabro-config/src/merge.rs +++ b/lib/crates/fabro-config/src/merge.rs @@ -10,7 +10,8 @@ use std::collections::HashMap; use fabro_types::settings::cli::{ - CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliTargetLayer, + CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliOutputLayer, CliTargetLayer, + CliUpdatesLayer, }; use fabro_types::settings::layer::SettingsLayer; use fabro_types::settings::project::ProjectLayer; @@ -343,8 +344,8 @@ fn combine_cli(lower: CliLayer, higher: CliLayer) -> CliLayer { target: merge_option(lower.target, higher.target, combine_cli_target), auth: higher.auth.or(lower.auth), exec: merge_option(lower.exec, higher.exec, combine_cli_exec), - output: higher.output.or(lower.output), - updates: higher.updates.or(lower.updates), + output: merge_option(lower.output, higher.output, combine_cli_output), + updates: merge_option(lower.updates, higher.updates, combine_cli_updates), logging: higher.logging.or(lower.logging), } } @@ -382,6 +383,19 @@ fn combine_cli_exec_agent( } } +fn combine_cli_output(lower: CliOutputLayer, higher: CliOutputLayer) -> CliOutputLayer { + CliOutputLayer { + format: higher.format.or(lower.format), + verbosity: higher.verbosity.or(lower.verbosity), + } +} + +fn combine_cli_updates(lower: CliUpdatesLayer, higher: CliUpdatesLayer) -> CliUpdatesLayer { + CliUpdatesLayer { + check: higher.check.or(lower.check), + } +} + // ------------------- server ------------------- fn combine_server(lower: ServerLayer, higher: ServerLayer) -> ServerLayer { @@ -681,4 +695,52 @@ a = "replaced" assert_eq!(meta.len(), 1); assert_eq!(meta.get("a"), Some(&"replaced".to_string())); } + + #[test] + fn cli_output_merges_by_field() { + let lower = parse( + r#" +[cli.output] +format = "text" +verbosity = "normal" +"#, + ); + let higher = parse( + r#" +[cli.output] +verbosity = "verbose" +"#, + ); + + let merged = combine_files(lower, higher); + let output = merged.cli.unwrap().output.unwrap(); + assert_eq!( + output.format, + Some(fabro_types::settings::cli::OutputFormat::Text) + ); + assert_eq!( + output.verbosity, + Some(fabro_types::settings::cli::OutputVerbosity::Verbose) + ); + } + + #[test] + fn cli_updates_merges_by_field() { + let lower = parse( + r#" +[cli.updates] +check = true +"#, + ); + let higher = parse( + r#" +[cli.logging] +level = "debug" +"#, + ); + + let merged = combine_files(lower, higher); + let updates = merged.cli.unwrap().updates.unwrap(); + assert_eq!(updates.check, Some(true)); + } } diff --git a/lib/crates/fabro-config/src/resolve/cli.rs b/lib/crates/fabro-config/src/resolve/cli.rs index f588c4fb3..de7c8303a 100644 --- a/lib/crates/fabro-config/src/resolve/cli.rs +++ b/lib/crates/fabro-config/src/resolve/cli.rs @@ -1,7 +1,7 @@ use fabro_types::settings::cli::{ CliAuthSettings, CliExecAgentSettings, CliExecLayer, CliExecModelSettings, CliExecSettings, CliLayer, CliLoggingSettings, CliOutputSettings, CliSettings, CliTargetLayer, - CliTargetSettings, CliTargetTlsSettings, CliUpdatesSettings, OutputFormat, OutputVerbosity, + CliTargetSettings, CliTargetTlsSettings, CliUpdatesSettings, }; use super::{ResolveError, require_interp}; @@ -18,19 +18,19 @@ pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec) -> CliSetti .output .as_ref() .and_then(|output| output.format) - .unwrap_or(OutputFormat::Text), + .expect("defaults.toml should provide cli.output.format"), verbosity: layer .output .as_ref() .and_then(|output| output.verbosity) - .unwrap_or(OutputVerbosity::Normal), + .expect("defaults.toml should provide cli.output.verbosity"), }, updates: CliUpdatesSettings { check: layer .updates .as_ref() .and_then(|updates| updates.check) - .unwrap_or(true), + .expect("defaults.toml should provide cli.updates.check"), }, logging: CliLoggingSettings { level: layer @@ -62,12 +62,12 @@ fn resolve_target( } fn resolve_exec(exec: Option<&CliExecLayer>) -> CliExecSettings { - let Some(exec) = exec else { - return CliExecSettings::default(); - }; + let exec = exec.expect("defaults.toml should provide cli.exec defaults"); CliExecSettings { - prevent_idle_sleep: exec.prevent_idle_sleep.unwrap_or(false), + prevent_idle_sleep: exec + .prevent_idle_sleep + .expect("defaults.toml should provide cli.exec.prevent_idle_sleep"), model: CliExecModelSettings { provider: exec.model.as_ref().and_then(|model| model.provider.clone()), name: exec.model.as_ref().and_then(|model| model.name.clone()), diff --git a/lib/crates/fabro-config/src/resolve/features.rs b/lib/crates/fabro-config/src/resolve/features.rs index 62b963044..e6c8eeced 100644 --- a/lib/crates/fabro-config/src/resolve/features.rs +++ b/lib/crates/fabro-config/src/resolve/features.rs @@ -7,6 +7,8 @@ pub fn resolve_features( _errors: &mut Vec, ) -> FeaturesSettings { FeaturesSettings { - session_sandboxes: layer.session_sandboxes.unwrap_or(false), + session_sandboxes: layer + .session_sandboxes + .expect("defaults.toml should provide features.session_sandboxes"), } } diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 2698134b5..76ae41b29 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -18,7 +18,10 @@ pub use run::resolve_run; pub use server::resolve_server; pub use workflow::resolve_workflow; +use crate::apply_builtin_defaults; + pub fn resolve(file: &SettingsLayer) -> Result> { + let file = apply_builtin_defaults(file.clone()); let mut errors = Vec::new(); let project_layer = file.project.clone().unwrap_or_default(); let workflow_layer = file.workflow.clone().unwrap_or_default(); diff --git a/lib/crates/fabro-config/src/resolve/project.rs b/lib/crates/fabro-config/src/resolve/project.rs index 16d5db288..dbed59297 100644 --- a/lib/crates/fabro-config/src/resolve/project.rs +++ b/lib/crates/fabro-config/src/resolve/project.rs @@ -2,8 +2,6 @@ use fabro_types::settings::project::{ProjectLayer, ProjectSettings}; use super::ResolveError; -const DEFAULT_PROJECT_DIRECTORY: &str = "."; - pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec) -> ProjectSettings { ProjectSettings { name: layer.name.clone(), @@ -11,7 +9,7 @@ pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec) -> directory: layer .directory .clone() - .unwrap_or_else(|| DEFAULT_PROJECT_DIRECTORY.to_string()), + .expect("defaults.toml should provide project.directory"), metadata: layer.metadata.clone(), } } diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index ed1c06265..353715645 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -1,6 +1,6 @@ use fabro_types::settings::InterpString; use fabro_types::settings::run::{ - ApprovalMode, ArtifactsSettings, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSettings, + ArtifactsSettings, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, GitAuthorSettings, HookAgentMarker, HookDefinition, HookEntry, HookTlsMode, HookType, InterviewProviderLayer, InterviewProviderSettings, InterviewsLayer, LocalSandboxSettings, McpEntryLayer, McpServerSettings, McpTransport, @@ -8,7 +8,7 @@ use fabro_types::settings::run::{ NotificationRouteLayer, NotificationRouteSettings, PullRequestSettings, RunAgentLayer, RunAgentSettings, RunArtifactsLayer, RunCheckpointLayer, RunCheckpointSettings, RunExecutionLayer, RunExecutionSettings, RunGitLayer, RunGitSettings, RunGoal, RunGoalLayer, - RunInterviewsSettings, RunLayer, RunMode, RunModelLayer, RunModelSettings, RunPrepareLayer, + RunInterviewsSettings, RunLayer, RunModelLayer, RunModelSettings, RunPrepareLayer, RunPrepareSettings, RunPullRequestLayer, RunSandboxLayer, RunSandboxSettings, RunScmLayer, RunScmSettings, RunSettings, ScmGitHubSettings, StringOrSplice, TlsMode, }; @@ -87,9 +87,7 @@ fn resolve_prepare( prepare: Option<&RunPrepareLayer>, errors: &mut Vec, ) -> RunPrepareSettings { - let Some(prepare) = prepare else { - return RunPrepareSettings::default(); - }; + let prepare = prepare.expect("defaults.toml should provide run.prepare defaults"); let mut commands = Vec::new(); for (index, step) in prepare.steps.iter().enumerate() { @@ -117,14 +115,18 @@ fn resolve_prepare( } fn resolve_execution(execution: Option<&RunExecutionLayer>) -> RunExecutionSettings { - let Some(execution) = execution else { - return RunExecutionSettings::default(); - }; + let execution = execution.expect("defaults.toml should provide run.execution defaults"); RunExecutionSettings { - mode: execution.mode.unwrap_or(RunMode::Normal), - approval: execution.approval.unwrap_or(ApprovalMode::Prompt), - retros: execution.retros.unwrap_or(true), + mode: execution + .mode + .expect("defaults.toml should provide run.execution.mode"), + approval: execution + .approval + .expect("defaults.toml should provide run.execution.approval"), + retros: execution + .retros + .expect("defaults.toml should provide run.execution.retros"), } } @@ -140,14 +142,12 @@ fn resolve_sandbox( sandbox: Option<&RunSandboxLayer>, errors: &mut Vec, ) -> RunSandboxSettings { - let Some(sandbox) = sandbox else { - return RunSandboxSettings::default(); - }; + let sandbox = sandbox.expect("defaults.toml should provide run.sandbox defaults"); let provider = sandbox .provider .clone() - .unwrap_or_else(|| "local".to_string()); + .expect("defaults.toml should provide run.sandbox.provider"); match provider.as_str() { "local" | "docker" | "daytona" => {} other => errors.push(ResolveError::Invalid { @@ -158,8 +158,12 @@ fn resolve_sandbox( RunSandboxSettings { provider, - preserve: sandbox.preserve.unwrap_or(false), - devcontainer: sandbox.devcontainer.unwrap_or(false), + preserve: sandbox + .preserve + .expect("defaults.toml should provide run.sandbox.preserve"), + devcontainer: sandbox + .devcontainer + .expect("defaults.toml should provide run.sandbox.devcontainer"), env: sandbox.env.clone(), local: resolve_local_sandbox(sandbox), daytona: sandbox.daytona.as_ref().map(resolve_daytona), @@ -167,12 +171,15 @@ fn resolve_sandbox( } fn resolve_local_sandbox(sandbox: &RunSandboxLayer) -> LocalSandboxSettings { + let local = sandbox + .local + .as_ref() + .expect("defaults.toml should provide run.sandbox.local"); + LocalSandboxSettings { - worktree_mode: sandbox - .local - .as_ref() - .and_then(|local| local.worktree_mode) - .unwrap_or_default(), + worktree_mode: local + .worktree_mode + .expect("defaults.toml should provide run.sandbox.local.worktree_mode"), } } diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index 66a537798..0b1729458 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use fabro_types::settings::InterpString; use fabro_types::settings::server::{ DiscordIntegrationSettings, GithubIntegrationSettings, GithubOauthSettings, @@ -39,7 +37,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se .scheduler .as_ref() .and_then(|scheduler| scheduler.max_concurrent_runs) - .unwrap_or(5), + .expect("defaults.toml should provide server.scheduler.max_concurrent_runs"), }, logging: ServerLoggingSettings { level: layer @@ -107,11 +105,16 @@ fn resolve_tls( } fn resolve_web(_api: Option<&ServerApiLayer>, layer: Option<&ServerWebLayer>) -> ServerWebSettings { + let layer = layer.expect("defaults.toml should provide server.web defaults"); + ServerWebSettings { - enabled: layer.and_then(|web| web.enabled).unwrap_or(true), + enabled: layer + .enabled + .expect("defaults.toml should provide server.web.enabled"), url: layer - .and_then(|web| web.url.clone()) - .unwrap_or_else(|| InterpString::parse("http://localhost:3000")), + .url + .clone() + .expect("defaults.toml should provide server.web.url"), } } @@ -174,12 +177,12 @@ fn resolve_artifacts( ) -> ServerArtifactsSettings { let provider = layer .and_then(|artifacts| artifacts.provider) - .unwrap_or(ObjectStoreProvider::Local); + .expect("defaults.toml should provide server.artifacts.provider"); ServerArtifactsSettings { prefix: layer .and_then(|artifacts| artifacts.prefix.clone()) - .unwrap_or_else(|| InterpString::parse("artifacts")), + .expect("defaults.toml should provide server.artifacts.prefix"), store: resolve_object_store( provider, layer.and_then(|artifacts| artifacts.local.as_ref()), @@ -198,12 +201,12 @@ fn resolve_slatedb( ) -> ServerSlateDbSettings { let provider = layer .and_then(|slatedb| slatedb.provider) - .unwrap_or(ObjectStoreProvider::Local); + .expect("defaults.toml should provide server.slatedb.provider"); ServerSlateDbSettings { prefix: layer .and_then(|slatedb| slatedb.prefix.clone()) - .unwrap_or_else(|| InterpString::parse("")), + .expect("defaults.toml should provide server.slatedb.prefix"), store: resolve_object_store( provider, layer.and_then(|slatedb| slatedb.local.as_ref()), @@ -214,7 +217,8 @@ fn resolve_slatedb( ), flush_interval: layer .and_then(|slatedb| slatedb.flush_interval) - .map_or_else(|| Duration::from_millis(1), |duration| duration.as_std()), + .map(|duration| duration.as_std()) + .expect("defaults.toml should provide server.slatedb.flush_interval"), } } diff --git a/lib/crates/fabro-config/src/resolve/workflow.rs b/lib/crates/fabro-config/src/resolve/workflow.rs index 770c9a385..dee80d1ba 100644 --- a/lib/crates/fabro-config/src/resolve/workflow.rs +++ b/lib/crates/fabro-config/src/resolve/workflow.rs @@ -2,8 +2,6 @@ use fabro_types::settings::workflow::{WorkflowLayer, WorkflowSettings}; use super::ResolveError; -const DEFAULT_WORKFLOW_GRAPH: &str = "workflow.fabro"; - pub fn resolve_workflow( layer: &WorkflowLayer, _errors: &mut Vec, @@ -14,7 +12,7 @@ pub fn resolve_workflow( graph: layer .graph .clone() - .unwrap_or_else(|| DEFAULT_WORKFLOW_GRAPH.to_string()), + .expect("defaults.toml should provide workflow.graph"), metadata: layer.metadata.clone(), } } diff --git a/lib/crates/fabro-config/tests/defaults.rs b/lib/crates/fabro-config/tests/defaults.rs new file mode 100644 index 000000000..cdeaf5a10 --- /dev/null +++ b/lib/crates/fabro-config/tests/defaults.rs @@ -0,0 +1,117 @@ +use fabro_config::{apply_builtin_defaults, defaults_layer, parse_settings_layer, resolve}; +use fabro_types::settings::SettingsLayer; +use fabro_types::settings::cli::OutputFormat; +use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode}; +use fabro_types::settings::server::ObjectStoreProvider; + +fn parse(source: &str) -> SettingsLayer { + parse_settings_layer(source).expect("fixture should parse") +} + +#[test] +fn embedded_defaults_parse_successfully() { + let defaults = defaults_layer(); + + assert_eq!( + defaults + .project + .as_ref() + .and_then(|project| project.directory.as_deref()), + Some(".") + ); + assert_eq!( + defaults + .workflow + .as_ref() + .and_then(|workflow| workflow.graph.as_deref()), + Some("workflow.fabro") + ); +} + +#[test] +fn apply_builtin_defaults_materializes_expected_layer() { + let layer = apply_builtin_defaults(SettingsLayer::default()); + + assert_eq!( + layer + .project + .as_ref() + .and_then(|project| project.directory.as_deref()), + Some(".") + ); + assert_eq!( + layer + .workflow + .as_ref() + .and_then(|workflow| workflow.graph.as_deref()), + Some("workflow.fabro") + ); + assert_eq!( + layer + .run + .as_ref() + .and_then(|run| run.execution.as_ref()) + .and_then(|execution| execution.mode), + Some(RunMode::Normal) + ); + assert_eq!( + layer + .run + .as_ref() + .and_then(|run| run.execution.as_ref()) + .and_then(|execution| execution.approval), + Some(ApprovalMode::Prompt) + ); + assert_eq!( + layer + .run + .as_ref() + .and_then(|run| run.sandbox.as_ref()) + .and_then(|sandbox| sandbox.local.as_ref()) + .and_then(|local| local.worktree_mode), + Some(WorktreeMode::Clean) + ); + assert_eq!( + layer + .cli + .as_ref() + .and_then(|cli| cli.output.as_ref()) + .and_then(|output| output.format), + Some(OutputFormat::Text) + ); + assert_eq!( + layer + .server + .as_ref() + .and_then(|server| server.artifacts.as_ref()) + .and_then(|artifacts| artifacts.provider), + Some(ObjectStoreProvider::Local) + ); +} + +#[test] +fn resolve_empty_settings_still_produces_valid_defaults() { + let settings = resolve(&SettingsLayer::default()).expect("empty settings should resolve"); + + assert_eq!(settings.project.directory, "."); + assert_eq!(settings.workflow.graph, "workflow.fabro"); + assert_eq!(settings.run.execution.mode, RunMode::Normal); +} + +#[test] +fn higher_precedence_values_override_builtin_defaults() { + let layer = parse( + r#" +_version = 1 + +[run.execution] +mode = "dry_run" +"#, + ); + + let settings = resolve(&layer).expect("settings should resolve"); + + assert_eq!(settings.run.execution.mode, RunMode::DryRun); + assert_eq!(settings.run.execution.approval, ApprovalMode::Prompt); + assert_eq!(settings.workflow.graph, "workflow.fabro"); +}