From b6cf87244f8e5c0f215c2bc018b3a55f87360a98 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:30:38 +0100 Subject: [PATCH] feat(maestro): [spawn].project_roots and project_scan_depth config (#54) --- crates/maestro-config/src/lib.rs | 39 ++++++++++++ crates/maestro-daemon/src/config.rs | 98 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 4780720..e7d6520 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -162,6 +162,15 @@ pub struct SpawnSection { /// it resolves to just the `custom` tile. #[serde(default)] pub presets: Option>, + /// Root directories the daemon scans for git repositories (#54), `~` + /// expanded by the daemon against its own home. When absent the daemon + /// scans `["~"]`. + #[serde(default)] + pub project_roots: Option>, + /// How many directory levels below each [`Self::project_roots`] entry the + /// scanner descends (#54). When absent the daemon uses `3`. + #[serde(default)] + pub project_scan_depth: Option, } impl SpawnSection { @@ -387,6 +396,7 @@ mod tests { label: "fish".into(), command: "fish".into(), }]), + ..Default::default() }; let ids: Vec = section .resolve() @@ -403,6 +413,7 @@ mod tests { // only the implicit custom tile. let section = SpawnSection { presets: Some(vec![]), + ..Default::default() }; let ids: Vec = section .resolve() @@ -421,6 +432,7 @@ mod tests { label: "x".into(), command: "x".into(), }]), + ..Default::default() }; assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId); @@ -430,6 +442,7 @@ mod tests { label: "x".into(), command: "".into(), }]), + ..Default::default() }; assert_eq!( empty_cmd.resolve().unwrap_err(), @@ -452,6 +465,7 @@ mod tests { command: "a2".into(), }, ]), + ..Default::default() }; assert_eq!( dup.resolve().unwrap_err(), @@ -464,6 +478,7 @@ mod tests { label: "Custom".into(), command: "whatever".into(), }]), + ..Default::default() }; assert_eq!( reserved.resolve().unwrap_err(), @@ -497,6 +512,30 @@ presets = [ assert_eq!(ids, ["claude", "zsh", "custom"]); } + #[test] + fn spawn_project_scan_fields_default_to_none() { + // Absent [spawn].project_* → None; the daemon applies its own defaults. + assert_eq!(SpawnSection::default().project_roots, None); + assert_eq!(SpawnSection::default().project_scan_depth, None); + } + + #[test] + fn spawn_project_scan_fields_parse_from_toml() { + let cfg = toml::from_str::( + r#" +[spawn] +project_roots = ["~/code", "/srv/work"] +project_scan_depth = 5 +"#, + ) + .expect("parse"); + assert_eq!( + cfg.spawn.project_roots, + Some(vec!["~/code".to_string(), "/srv/work".to_string()]) + ); + assert_eq!(cfg.spawn.project_scan_depth, Some(5)); + } + #[test] fn partial_file_loads_with_section_defaults() { // Only [daemon] present (typical single-host install). diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index dccb4f5..4e8684d 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -23,6 +23,14 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070"; /// `[daemon].spawn_history_days` is unset. `0` in config disables history. pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30; +/// Default scan roots for project discovery (#54) when `[spawn].project_roots` +/// is unset: the whole home directory, with the scanner's own ignore list +/// keeping it cheap. +pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"]; +/// Default directory depth for project discovery (#54) when +/// `[spawn].project_scan_depth` is unset. +pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3; + /// Env var that overrides `[daemon].bind_addr`. pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR"; /// Env var that overrides `[daemon].tmux_tmpdir`. @@ -74,6 +82,12 @@ pub struct DaemonConfig { /// built-ins) with the implicit `custom` tile appended. Advertised to /// clients in `GET /api/info`. pub spawn_presets: Vec, + /// Project-discovery scan roots (#54): `[spawn].project_roots` (default + /// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home. + pub project_roots: Vec, + /// Project-discovery depth (#54): `[spawn].project_scan_depth`, default + /// [`DEFAULT_PROJECT_SCAN_DEPTH`]. + pub project_scan_depth: u32, } impl DaemonConfig { @@ -147,6 +161,25 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result = file + .spawn + .project_roots + .unwrap_or_else(|| { + DEFAULT_PROJECT_ROOTS + .iter() + .map(|s| s.to_string()) + .collect() + }) + .iter() + .map(|root| expand_tilde(root, &home_dir)) + .collect(); + let project_scan_depth = file + .spawn + .project_scan_depth + .unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH); + Ok(DaemonConfig { hostname, home_dir, @@ -157,9 +190,24 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result PathBuf { + match path.strip_prefix("~") { + Some(_) if home.is_empty() => PathBuf::from(path), + Some(rest) => { + let mut p = PathBuf::from(home); + p.push(rest.trim_start_matches('/')); + p + } + None => PathBuf::from(path), + } +} + pub fn default_state_dir() -> Result { Ok(maestro_config::default_state_dir("maestro")?) } @@ -280,6 +328,56 @@ spawn_history_days = 0 assert_eq!(cfg.spawn_history_days, 0); } + #[test] + fn project_scan_defaults_to_home_root_at_depth_3() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "").unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH); + assert_eq!( + cfg.project_roots, + vec![PathBuf::from(cfg.home_dir.clone())], + "bare ~ must expand to the daemon's home" + ); + } + + #[test] + fn project_scan_roots_expand_tilde_and_pass_through_absolutes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[spawn] +project_roots = ["~/code", "/srv/work"] +project_scan_depth = 5 +"#, + ) + .unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.project_scan_depth, 5); + assert_eq!( + cfg.project_roots, + vec![ + PathBuf::from(cfg.home_dir.clone()).join("code"), + PathBuf::from("/srv/work"), + ] + ); + } + + #[test] + fn tilde_expansion_handles_bare_and_nested_forms() { + assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u")); + assert_eq!( + expand_tilde("~/a/b", "/home/u"), + PathBuf::from("/home/u/a/b") + ); + assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs")); + // Unresolvable home leaves the tilde as-is rather than mangling it. + assert_eq!(expand_tilde("~", ""), PathBuf::from("~")); + } + #[test] fn system_hostname_is_nonempty() { let h = system_hostname();