refactor(settings): resolve project settings

This commit is contained in:
Bryan Helmkamp 2026-04-10 07:10:32 -04:00
parent a61d9f84ae
commit 48c5737bd5
No known key found for this signature in database
9 changed files with 109 additions and 44 deletions

View file

@ -39,6 +39,10 @@ fn resolve_cli(settings: &SettingsFile) -> fabro_types::settings::CliSettings {
fabro_config::resolve_cli_from_file(settings).expect("cli settings should resolve")
}
fn resolve_project(settings: &SettingsFile) -> fabro_types::settings::ProjectSettings {
fabro_config::resolve_project_from_file(settings).expect("project settings should resolve")
}
fn server_settings_fixture() -> SettingsFile {
ConfigLayer::parse(
r#"
@ -299,7 +303,7 @@ fn settings_local_merges_cli_and_project_defaults() {
assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model"));
assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai"));
assert_eq!(cfg.run_goal_inline_str().as_deref(), None);
assert_eq!(cfg.project_directory(), Some("fabro"));
assert_eq!(resolve_project(&cfg).directory, "fabro");
// v2 R22: run.inputs replaces the inherited map wholesale rather than
// merging by key, so the project layer wipes out the CLI layer's inputs.

View file

@ -15,8 +15,8 @@ pub use config::ConfigLayer;
pub use fabro_util::path::expand_tilde;
pub use home::Home;
pub use resolve::{
ResolveError, resolve_cli, resolve_cli_from_file, resolve_run, resolve_run_from_file,
resolve_server, resolve_server_from_file,
ResolveError, resolve_cli, resolve_cli_from_file, resolve_project, resolve_project_from_file,
resolve_run, resolve_run_from_file, resolve_server, resolve_server_from_file,
};
pub use storage::{RunScratch, ServerState, Storage};

View file

@ -12,12 +12,11 @@ use serde::Serialize;
use crate::config::ConfigLayer;
use crate::run;
use fabro_types::settings::{InterpString, SettingsFile};
use crate::{resolve_project_from_file, resolve_run_from_file};
use fabro_types::settings::SettingsFile;
const CONFIG_FILENAME: &str = "fabro.toml";
const RUN_GRAPH_FILE: &str = "workflow.fabro";
const DEFAULT_FABRO_DIRECTORY: &str = "fabro/";
#[derive(Clone, Debug)]
pub struct WorkflowPathResolution {
pub resolved_workflow_path: PathBuf,
@ -38,12 +37,9 @@ pub fn parse_project_config(content: &str) -> anyhow::Result<ConfigLayer> {
/// paths are anchored at the directory of `path` at load time.
pub fn load_project_config(path: &Path) -> anyhow::Result<ConfigLayer> {
let config = ConfigLayer::load(path).context("Failed to parse project config")?;
let root = config
.as_v2()
.project
.as_ref()
.and_then(|p| p.directory.as_deref())
.unwrap_or(DEFAULT_FABRO_DIRECTORY);
let root = resolve_project_from_file(config.as_v2())
.map_err(|errors| anyhow::anyhow!("Failed to resolve project settings: {errors:?}"))?
.directory;
tracing::debug!(path = %path.display(), root = %root, "Loaded project config");
Ok(config)
}
@ -125,11 +121,10 @@ pub fn resolve_workflow_path(
}
pub fn resolve_working_directory(settings: &SettingsFile, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = settings
.run
.as_ref()
.and_then(|run| run.working_dir.as_ref())
.map(InterpString::as_source)
let Some(work_dir) = resolve_run_from_file(settings)
.ok()
.and_then(|settings| settings.working_dir)
.map(|value| value.as_source())
else {
return caller_cwd.to_path_buf();
};
@ -374,12 +369,9 @@ 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");
let root = config
.as_v2()
.project
.as_ref()
.and_then(|p| p.directory.as_deref())
.unwrap_or(DEFAULT_FABRO_DIRECTORY);
let root = resolve_project_from_file(config.as_v2())
.expect("project settings should resolve")
.directory;
project_dir.join(root)
}
@ -408,12 +400,8 @@ directory = "fabro/"
)
.unwrap();
assert_eq!(
config
.as_v2()
.project
.as_ref()
.and_then(|p| p.directory.as_deref()),
Some("fabro/")
resolve_project_from_file(config.as_v2()).unwrap().directory,
"fabro/"
);
}

View file

@ -1,12 +1,16 @@
mod cli;
mod error;
mod project;
mod run;
mod server;
use fabro_types::settings::{CliSettings, RunSettings, ServerSettings, SettingsFile};
use fabro_types::settings::{
CliSettings, ProjectSettings, RunSettings, ServerSettings, SettingsFile,
};
pub use cli::resolve_cli;
pub use error::ResolveError;
pub use project::resolve_project;
pub use run::resolve_run;
pub use server::resolve_server;
@ -32,6 +36,19 @@ pub fn resolve_server_from_file(file: &SettingsFile) -> Result<ServerSettings, V
}
}
pub fn resolve_project_from_file(
file: &SettingsFile,
) -> Result<ProjectSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.project.as_ref().cloned().unwrap_or_default();
let resolved = resolve_project(&layer, &mut errors);
if errors.is_empty() {
Ok(resolved)
} else {
Err(errors)
}
}
pub fn resolve_run_from_file(file: &SettingsFile) -> Result<RunSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.run.as_ref().cloned().unwrap_or_default();

View file

@ -0,0 +1,17 @@
use fabro_types::settings::project::{ProjectLayer, ProjectSettings};
use super::ResolveError;
const DEFAULT_PROJECT_DIRECTORY: &str = "fabro/";
pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec<ResolveError>) -> ProjectSettings {
ProjectSettings {
name: layer.name.clone(),
description: layer.description.clone(),
directory: layer
.directory
.clone()
.unwrap_or_else(|| DEFAULT_PROJECT_DIRECTORY.to_string()),
metadata: layer.metadata.clone(),
}
}

View file

@ -0,0 +1,43 @@
use fabro_config::resolve_project_from_file;
use fabro_types::settings::SettingsFile;
#[test]
fn resolves_project_defaults_from_empty_settings() {
let settings = SettingsFile::default();
let project = resolve_project_from_file(&settings).expect("empty settings should resolve");
assert_eq!(project.directory, "fabro/");
assert!(project.name.is_none());
assert!(project.description.is_none());
assert!(project.metadata.is_empty());
}
#[test]
fn resolves_project_directory_and_metadata() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
r#"
_version = 1
[project]
name = "Acme"
description = "Automation"
directory = ".fabro"
[project.metadata]
team = "platform"
"#,
)
.expect("fixture should parse")
.into();
let project = resolve_project_from_file(&settings).expect("project settings should resolve");
assert_eq!(project.name.as_deref(), Some("Acme"));
assert_eq!(project.description.as_deref(), Some("Automation"));
assert_eq!(project.directory, ".fabro");
assert_eq!(
project.metadata.get("team").map(String::as_str),
Some("platform")
);
}

View file

@ -9,7 +9,6 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::interp::InterpString;
use super::project::ProjectLayer;
use super::run::{
ApprovalMode, GitAuthorLayer, HookEntry, McpEntryLayer, ResolvedGoalSource, ResolvedRunGoal,
RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer, RunGoalLayer,
@ -23,18 +22,6 @@ use super::server::{
use super::tree::SettingsFile;
impl SettingsFile {
// ---------- project-scope ----------
#[must_use]
pub fn project_layer(&self) -> Option<&ProjectLayer> {
self.project.as_ref()
}
#[must_use]
pub fn project_directory(&self) -> Option<&str> {
self.project.as_ref().and_then(|p| p.directory.as_deref())
}
// ---------- run-scope ----------
#[must_use]

View file

@ -35,7 +35,7 @@ pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved};
pub use model_ref::{
AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef,
};
pub use project::ProjectLayer;
pub use project::{ProjectLayer, ProjectSettings};
pub use run::{
ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings,

View file

@ -7,6 +7,15 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// A structurally resolved `[project]` view for consumers.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ProjectSettings {
pub name: Option<String>,
pub description: Option<String>,
pub directory: String,
pub metadata: HashMap<String, String>,
}
/// A sparse `[project]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]