Materialize builtin settings defaults

Embed defaults.toml as a base settings layer and apply it when
materializing effective settings and resolving typed settings.

This also fixes partial CLI table merging so builtin fields survive
higher-precedence overrides, and updates the affected CLI tests and
snapshots.
This commit is contained in:
Bryan Helmkamp 2026-04-12 16:54:01 -04:00
parent 48edaa009d
commit ecbae9b732
16 changed files with 498 additions and 72 deletions

View file

@ -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",

View file

@ -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");

View file

@ -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",

View file

@ -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<SettingsLayer> = 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)
}

View file

@ -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

View file

@ -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)
);
}
}

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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<ResolveError>) -> 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()),

View file

@ -7,6 +7,8 @@ pub fn resolve_features(
_errors: &mut Vec<ResolveError>,
) -> FeaturesSettings {
FeaturesSettings {
session_sandboxes: layer.session_sandboxes.unwrap_or(false),
session_sandboxes: layer
.session_sandboxes
.expect("defaults.toml should provide features.session_sandboxes"),
}
}

View file

@ -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<Settings, Vec<ResolveError>> {
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();

View file

@ -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<ResolveError>) -> ProjectSettings {
ProjectSettings {
name: layer.name.clone(),
@ -11,7 +9,7 @@ pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec<ResolveError>) ->
directory: layer
.directory
.clone()
.unwrap_or_else(|| DEFAULT_PROJECT_DIRECTORY.to_string()),
.expect("defaults.toml should provide project.directory"),
metadata: layer.metadata.clone(),
}
}

View file

@ -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<ResolveError>,
) -> 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<ResolveError>,
) -> 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"),
}
}

View file

@ -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<ResolveError>) -> 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"),
}
}

View file

@ -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<ResolveError>,
@ -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(),
}
}

View file

@ -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");
}