From 5fb3fd485e1f1656bd8e7291d180c4c13f64477f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 9 Mar 2026 16:28:56 -0400 Subject: [PATCH] Add project config (arc.toml) with discovery and root resolution Co-Authored-By: Claude Opus 4.6 --- arc.toml | 4 + lib/crates/arc-workflows/src/cli/mod.rs | 1 + .../arc-workflows/src/cli/project_config.rs | 177 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 arc.toml create mode 100644 lib/crates/arc-workflows/src/cli/project_config.rs diff --git a/arc.toml b/arc.toml new file mode 100644 index 000000000..1c7be9ba6 --- /dev/null +++ b/arc.toml @@ -0,0 +1,4 @@ +version = 1 + +[arc] +root = "arc/" diff --git a/lib/crates/arc-workflows/src/cli/mod.rs b/lib/crates/arc-workflows/src/cli/mod.rs index 7efe228f2..b37876feb 100644 --- a/lib/crates/arc-workflows/src/cli/mod.rs +++ b/lib/crates/arc-workflows/src/cli/mod.rs @@ -4,6 +4,7 @@ pub mod cp; pub mod parse; pub mod pr; pub mod progress; +pub mod project_config; pub mod run; pub mod run_config; pub mod runs; diff --git a/lib/crates/arc-workflows/src/cli/project_config.rs b/lib/crates/arc-workflows/src/cli/project_config.rs new file mode 100644 index 000000000..f67236cf0 --- /dev/null +++ b/lib/crates/arc-workflows/src/cli/project_config.rs @@ -0,0 +1,177 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context}; +use serde::Deserialize; + +const CONFIG_FILENAME: &str = "arc.toml"; + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ProjectConfig { + pub version: u32, + #[serde(default)] + pub arc: ProjectArcConfig, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ProjectArcConfig { + #[serde(default = "default_root")] + pub root: String, +} + +fn default_root() -> String { + ".".to_string() +} + +impl Default for ProjectArcConfig { + fn default() -> Self { + Self { + root: default_root(), + } + } +} + +/// Parse a project config from a TOML string. +pub fn parse_project_config(content: &str) -> anyhow::Result { + let config: ProjectConfig = + toml::from_str(content).context("Failed to parse project config")?; + if config.version != 1 { + bail!( + "Unsupported project config version: {}. Only version 1 is supported.", + config.version, + ); + } + Ok(config) +} + +/// Load a project config from a file path. +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)?; + tracing::debug!(path = %path.display(), root = %config.arc.root, "Loaded project config"); + Ok(config) +} + +/// Walk ancestor directories from `start` looking for `arc.toml`. +/// Returns the config file path and parsed config, or `None` if not found. +pub fn discover_project_config(start: &Path) -> anyhow::Result> { + 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))); + } + } + Ok(None) +} + +/// Resolve the arc root directory from a config file path and its config. +/// The returned path is the directory containing `arc.toml` joined with the `root` value. +pub fn resolve_arc_root(config_path: &Path, config: &ProjectConfig) -> PathBuf { + let project_dir = config_path + .parent() + .expect("config_path should have a parent directory"); + project_dir.join(&config.arc.root) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn parse_minimal_config() { + let config = parse_project_config("version = 1\n").unwrap(); + assert_eq!( + config, + ProjectConfig { + version: 1, + arc: ProjectArcConfig { + root: ".".to_string(), + }, + } + ); + } + + #[test] + fn parse_full_config() { + let config = parse_project_config("version = 1\n[arc]\nroot = \"arc/\"\n").unwrap(); + assert_eq!(config.arc.root, "arc/"); + } + + #[test] + fn parse_version_mismatch() { + let err = parse_project_config("version = 2\n").unwrap_err(); + assert!( + err.to_string().contains("Unsupported"), + "Expected 'Unsupported' in error, got: {err}" + ); + } + + #[test] + fn parse_unknown_field_rejected() { + let err = parse_project_config("version = 1\nfoo = \"bar\"\n").unwrap_err(); + let chain = format!("{err:#}"); + assert!(chain.contains("unknown field"), "got: {chain}"); + } + + #[test] + fn load_from_disk() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("arc.toml"); + fs::write(&path, "version = 1\n").unwrap(); + let config = load_project_config(&path).unwrap(); + assert_eq!(config.version, 1); + assert_eq!(config.arc.root, "."); + } + + #[test] + fn discover_walks_ancestors() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("arc.toml"), "version = 1\n").unwrap(); + let sub = tmp.path().join("sub").join("dir"); + fs::create_dir_all(&sub).unwrap(); + + let (found_path, config) = discover_project_config(&sub).unwrap().unwrap(); + assert_eq!(found_path, tmp.path().join("arc.toml")); + assert_eq!(config.version, 1); + } + + #[test] + fn discover_returns_none_when_absent() { + let tmp = TempDir::new().unwrap(); + let result = discover_project_config(tmp.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn resolve_arc_root_with_subdirectory() { + let config_path = Path::new("/repo/arc.toml"); + let config = ProjectConfig { + version: 1, + arc: ProjectArcConfig { + root: "arc/".to_string(), + }, + }; + assert_eq!( + resolve_arc_root(config_path, &config), + Path::new("/repo/arc/") + ); + } + + #[test] + fn resolve_arc_root_with_dot() { + let config_path = Path::new("/repo/arc.toml"); + let config = ProjectConfig { + version: 1, + arc: ProjectArcConfig { + root: ".".to_string(), + }, + }; + assert_eq!(resolve_arc_root(config_path, &config), Path::new("/repo/.")); + } +}