route project config discovery through file-based builders

This commit is contained in:
Bryan Helmkamp 2026-04-23 17:22:47 -04:00
parent 5748dd3d30
commit 2ec9e8bcdc
No known key found for this signature in database
10 changed files with 64 additions and 65 deletions

View file

@ -11,8 +11,7 @@ use std::io::Write;
use anyhow::{Context, bail};
use fabro_api::types;
use fabro_config::user::{active_settings_path, load_settings_config};
use fabro_types::settings::SettingsLayer;
use fabro_config::user::active_settings_path;
use fabro_util::terminal::Styles;
use tracing::debug;
@ -36,10 +35,9 @@ pub(crate) async fn run(
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: SettingsLayer::default(),
args_layer: Default::default(),
args: None,
run_id: None,
user_layer: load_settings_config(None)?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -21,7 +21,7 @@ pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> {
}
fn run_to(args: &ParseArgs, mut out: impl Write) -> anyhow::Result<()> {
let (dot_path, _cfg) = resolve_workflow(&args.workflow)?;
let dot_path = resolve_workflow(&args.workflow)?;
let source = read_workflow_file(&dot_path)?;
let ast = parse_ast(&source)?;
serde_json::to_writer_pretty(&mut out, &ast)?;

View file

@ -1,5 +1,5 @@
use anyhow::bail;
use fabro_config::user::{active_settings_path, load_settings_config};
use fabro_config::user::active_settings_path;
use fabro_util::terminal::Styles;
use crate::args::PreflightArgs;
@ -26,7 +26,6 @@ pub(crate) async fn execute(
args_layer: preflight_args_layer(&args)?,
args: preflight_manifest_args(&args),
run_id: None,
user_layer: load_settings_config(None)?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -1,4 +1,4 @@
use fabro_config::user::{active_settings_path, load_settings_config};
use fabro_config::user::active_settings_path;
use fabro_types::RunId;
use fabro_util::terminal::Styles;
@ -41,7 +41,6 @@ pub(crate) async fn create_run(
args_layer: cli_args_config,
args: run_manifest_args(args),
run_id,
user_layer: load_settings_config(None)?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -1,6 +1,5 @@
use anyhow::bail;
use fabro_config::user::{active_settings_path, load_settings_config};
use fabro_types::settings::SettingsLayer;
use fabro_config::user::active_settings_path;
use fabro_util::terminal::Styles;
use crate::args::ValidateArgs;
@ -19,10 +18,9 @@ pub(crate) async fn run(
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: SettingsLayer::default(),
args_layer: Default::default(),
args: None,
run_id: None,
user_layer: load_settings_config(None)?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -16,14 +16,14 @@ pub(super) fn create_command(args: &WorkflowCreateArgs, base_ctx: &CommandContex
let printer = base_ctx.printer();
let cwd = std::env::current_dir()?;
let Some((config_path, config)) = discover_project_config(&cwd)? else {
let Some(config_path) = discover_project_config(&cwd)? else {
bail!(
"No .fabro/project.toml found in {cwd} or any parent directory",
cwd = cwd.display()
);
};
let fabro_root = resolve_fabro_root(&config_path, &config);
let fabro_root = resolve_fabro_root(&config_path);
let created = write_workflow_scaffold(args, &fabro_root)?;
if base_ctx.json_output() {

View file

@ -19,14 +19,14 @@ pub(super) fn list_command(_args: &WorkflowListArgs, base_ctx: &CommandContext)
let styles = Styles::detect_stderr();
let cwd = std::env::current_dir()?;
let Some((config_path, config)) = discover_project_config(&cwd)? else {
let Some(config_path) = discover_project_config(&cwd)? else {
bail!(
"No .fabro/project.toml found in {cwd} or any parent directory",
cwd = cwd.display()
);
};
let fabro_root = resolve_fabro_root(&config_path, &config);
let fabro_root = resolve_fabro_root(&config_path);
let project_wf_dir = fabro_root.join("workflows");
let user_wf_dir = Some(fabro_util::Home::from_env().workflows_dir());

View file

@ -28,9 +28,6 @@ pub(crate) struct ManifestBuildInput {
pub args_layer: SettingsLayer,
pub args: Option<types::ManifestArgs>,
pub run_id: Option<RunId>,
/// User-level settings layer. Production callers load via
/// `load_settings_config(None)`; tests pass `SettingsLayer::default()`.
pub user_layer: SettingsLayer,
/// Path to the user settings file (for inclusion in
/// `RunManifest.configs`). `None` skips the user config entry.
pub user_settings_path: Option<PathBuf>,
@ -57,7 +54,7 @@ struct WorkflowScanInput {
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?;
if root_resolution.workflow_config.is_none()
if root_resolution.workflow_toml_path.is_none()
&& !root_resolution.resolved_workflow_path.is_file()
{
return Err(fabro_config::Error::WorkflowNotFound(
@ -70,16 +67,22 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
.parent()
.unwrap_or_else(|| Path::new("."));
let project_config = discover_project_config(workflow_parent)?;
let workflow_layer = root_resolution.workflow_config.clone().unwrap_or_default();
let project_layer = project_config
let mut workflow_settings_builder =
WorkflowSettingsBuilder::new().args_layer(input.args_layer.clone());
if let Some(path) = root_resolution.workflow_toml_path.as_ref() {
workflow_settings_builder = workflow_settings_builder.workflow_file(path)?;
}
if let Some(path) = project_config.as_ref() {
workflow_settings_builder = workflow_settings_builder.project_file(path)?;
}
if let Some(path) = input
.user_settings_path
.as_ref()
.map(|(_, config)| config.clone())
.unwrap_or_default();
let workflow_settings = WorkflowSettingsBuilder::new()
.args_layer(input.args_layer.clone())
.workflow_layer(workflow_layer.clone())
.project_layer(project_layer)
.user_layer(input.user_layer.clone())
.filter(|path| path.is_file())
{
workflow_settings_builder = workflow_settings_builder.user_file(path)?;
}
let workflow_settings = workflow_settings_builder
.build()
.map_err(|errors| anyhow!("failed to resolve manifest settings: {errors}"))?;
let target_path = root_resolution.dot_path.clone();
@ -100,7 +103,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
let mut configs = Vec::new();
if let Some((path, _config)) = project_config {
if let Some(path) = project_config {
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
configs.push(types::ManifestConfig {
@ -624,7 +627,6 @@ mod tests {
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();
@ -704,7 +706,6 @@ file = "prompts/goal.md"
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();
@ -757,7 +758,6 @@ file = "prompts/goal.md"
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();

View file

@ -14,7 +14,7 @@ use crate::resolve::{
resolve_workflow,
};
use crate::user::load_settings_config;
use crate::{Error, Result};
use crate::{Error, Result, run};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveErrors(pub Vec<ResolveError>);
@ -186,18 +186,30 @@ impl WorkflowSettingsBuilder {
self
}
pub fn workflow_file(self, path: &Path) -> Result<Self> {
Ok(self.workflow_layer(run::load_run_config(path)?))
}
#[must_use]
pub fn project_layer(mut self, layer: SettingsLayer) -> Self {
self.project = layer;
self
}
pub fn project_file(self, path: &Path) -> Result<Self> {
Ok(self.project_layer(load_settings_path(path)?))
}
#[must_use]
pub fn user_layer(mut self, layer: SettingsLayer) -> Self {
self.user = layer;
self
}
pub fn user_file(self, path: &Path) -> Result<Self> {
Ok(self.user_layer(load_settings_path(path)?))
}
#[must_use]
pub fn server_layer(mut self, layer: SettingsLayer) -> Self {
self.server = layer;

View file

@ -23,7 +23,6 @@ const CONFIG_FILENAME: &str = ".fabro/project.toml";
pub struct WorkflowPathResolution {
pub resolved_workflow_path: PathBuf,
pub dot_path: PathBuf,
pub workflow_config: Option<SettingsLayer>,
pub workflow_toml_path: Option<PathBuf>,
pub workflow_slug: Option<String>,
}
@ -32,7 +31,7 @@ pub struct WorkflowPathResolution {
///
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_project_config(path: &Path) -> Result<SettingsLayer> {
fn load_project_config(path: &Path) -> Result<SettingsLayer> {
let config = load_settings_path(path)?;
let root = WorkflowSettingsBuilder::project_from_layer(&config)
.map_err(|errors| Error::resolve("Failed to resolve project settings", errors.into()))?
@ -42,14 +41,13 @@ pub fn load_project_config(path: &Path) -> Result<SettingsLayer> {
}
/// Walk ancestor directories from `start` looking for `.fabro/project.toml`.
/// Returns the config file path and parsed config, or `None` if not found.
pub fn discover_project_config(start: &Path) -> Result<Option<(PathBuf, SettingsLayer)>> {
/// Returns the config file path, or `None` if not found.
pub fn discover_project_config(start: &Path) -> Result<Option<PathBuf>> {
for ancestor in start.ancestors() {
let candidate = ancestor.join(CONFIG_FILENAME);
if candidate.is_file() {
tracing::debug!(path = %candidate.display(), "Discovered project config");
let config = load_project_config(&candidate)?;
return Ok(Some((candidate, config)));
return Ok(Some(candidate));
}
}
Ok(None)
@ -93,7 +91,6 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<Workflo
Ok(WorkflowPathResolution {
resolved_workflow_path: path.clone(),
dot_path,
workflow_config: Some(cfg),
workflow_toml_path: Some(path),
workflow_slug,
})
@ -105,7 +102,6 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<Workflo
Ok(WorkflowPathResolution {
resolved_workflow_path: path.clone(),
dot_path: path,
workflow_config: None,
workflow_toml_path: None,
workflow_slug,
})
@ -156,8 +152,8 @@ fn resolve_workflow_arg_impl(
let name = arg.to_string_lossy();
match discover_project_config(start_dir) {
Ok(Some((config_path, config))) => {
let fabro_root = resolve_fabro_root(&config_path, &config);
Ok(Some(config_path)) => {
let fabro_root = resolve_fabro_root(&config_path);
let project_candidate = fabro_root
.join("workflows")
.join(&*name)
@ -339,10 +335,10 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
}
/// Resolve a workflow argument to a DOT path and optional run config.
pub fn resolve_workflow(arg: &Path) -> Result<(PathBuf, Option<SettingsLayer>)> {
pub fn resolve_workflow(arg: &Path) -> Result<PathBuf> {
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))
Ok(resolution.dot_path)
}
/// Check whether retros are enabled in the project config.
@ -350,11 +346,15 @@ pub fn resolve_workflow(arg: &Path) -> Result<(PathBuf, Option<SettingsLayer>)>
pub fn is_retro_enabled() -> bool {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match discover_project_config(&start) {
Ok(Some((_path, config))) => config
.run
.as_ref()
.and_then(|r| r.execution.as_ref())
.and_then(|e| e.retros)
Ok(Some(path)) => load_project_config(&path)
.ok()
.and_then(|config| {
config
.run
.as_ref()
.and_then(|r| r.execution.as_ref())
.and_then(|e| e.retros)
})
.unwrap_or(false),
_ => false,
}
@ -383,11 +383,12 @@ fn normalize_joined_path(base_dir: &Path, reference: &Path) -> PathBuf {
/// Resolve the fabro root directory from a config file path and its config.
/// The returned path is the config file's parent directory joined with the
/// `project.directory` value (default: `.`).
pub fn resolve_fabro_root(config_path: &Path, config: &SettingsLayer) -> PathBuf {
pub fn resolve_fabro_root(config_path: &Path) -> PathBuf {
let project_dir = config_path
.parent()
.expect("config_path should have a parent directory");
let root = WorkflowSettingsBuilder::project_from_layer(config)
let config = load_project_config(config_path).expect("project config should load");
let root = WorkflowSettingsBuilder::project_from_layer(&config)
.expect("project settings should resolve")
.directory;
normalize_joined_path(project_dir, Path::new(&root))
@ -488,9 +489,8 @@ retros = true
let sub = tmp.path().join("sub").join("dir");
fs::create_dir_all(&sub).unwrap();
let (found_path, config) = discover_project_config(&sub).unwrap().unwrap();
let found_path = discover_project_config(&sub).unwrap().unwrap();
assert_eq!(found_path, config_dir.join("project.toml"));
assert_eq!(config.version, Some(1));
}
#[test]
@ -529,9 +529,7 @@ file = "prompts/goal.md"
let config_path = config_dir.join("project.toml");
fs::write(&config_path, "_version = 1\n").unwrap();
let config = load_project_config(&config_path).unwrap();
assert_eq!(resolve_fabro_root(&config_path, &config), config_dir);
assert_eq!(resolve_fabro_root(&config_path), config_dir);
}
#[test]
@ -550,12 +548,7 @@ directory = "../custom"
)
.unwrap();
let config = load_project_config(&config_path).unwrap();
assert_eq!(
resolve_fabro_root(&config_path, &config),
tmp.path().join("custom")
);
assert_eq!(resolve_fabro_root(&config_path), tmp.path().join("custom"));
}
#[test]