//! Daemon view over the unified Maestro config. //! //! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and //! `[spawn]` from `~/.config/maestro/config.toml`. //! //! Precedence per field: **env > file > default**. Every field has a default, //! so a missing config file is fine — the daemon starts with defaults. //! //! Default config path: `~/.config/maestro/config.toml` (override via //! `$MAESTRO_CONFIG`). use std::net::SocketAddr; use std::path::{Path, PathBuf}; use maestro_config::MaestroConfigFile; pub use maestro_config::default_config_path; /// Default bind address when neither env nor file specify one. pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070"; /// Default recency cap (days) for the durable spawn history (#24) when /// `[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`. pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR"; fn default_bind_addr() -> SocketAddr { DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr") } #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error(transparent)] Inner(#[from] maestro_config::ConfigError), #[error("io: {0}")] Io(#[from] std::io::Error), #[error("invalid [spawn].presets: {0}")] SpawnPresets(#[from] maestro_config::SpawnConfigError), } /// Resolved, validated daemon configuration ready to drive the binary. #[derive(Debug, Clone)] pub struct DaemonConfig { /// Hostname advertised to clients, for display only. pub hostname: String, /// Home directory of the OS user the daemon runs as, advertised to clients /// so they can prefill a spawn form's working directory. Empty when `$HOME` /// can't be resolved (NAP-328). pub home_dir: String, pub bind_addr: SocketAddr, pub state_dir: PathBuf, /// `[daemon].worktree_root` override (#56). `None` — the default — places /// worktrees inside their base repo at `/worktrees/`; /// operators who want them elsewhere set this. pub worktree_root: Option, /// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach /// receives this much of the prior PTY output before the live stream. /// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables. pub pty_replay_bytes: usize, /// `TMUX_TMPDIR` exported for every `tmux` invocation (NAP-234). /// `None` lets tmux pick its own default (`/tmp/tmux-$UID`); `Some(p)` /// forces sockets under `p` so bare `tmux ls` in a matching user shell /// sees maestro sessions. pub tmux_tmpdir: Option, /// Recency cap (days) for the durable spawn history that backs the spawn /// form's "recent dirs" picker (#24). `0` disables history. Default /// [`DEFAULT_SPAWN_HISTORY_DAYS`]. pub spawn_history_days: u32, /// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the /// 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, /// Whether the daemon installs the Claude Code activity hook on first /// start when none is present (#106): `[hooks].auto_install`, default /// `true`. pub hooks_auto_install: bool, /// Canonical leader key advertised to clients in `GET /api/info` (#159): /// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon /// never binds it itself; it serves it so the web client and the TUI read /// one knob. pub leader: String, } impl DaemonConfig { pub fn sidecar_dir(&self) -> PathBuf { self.state_dir.join("sessions") } pub fn fifo_dir(&self) -> PathBuf { self.state_dir.join("fifos") } /// JSONL spawn-history log path (#24): `/spawn-history.jsonl`. pub fn spawn_history_path(&self) -> PathBuf { self.state_dir.join("spawn-history.jsonl") } } /// Env-var overrides applied by [`load_config`]. Tests pass explicit /// `Option`s; production reads from real env vars in `main`. #[derive(Debug, Clone, Default)] pub struct EnvOverrides { pub bind_addr: Option, pub tmux_tmpdir: Option, } impl EnvOverrides { /// Read every supported env var from the process environment. pub fn from_env() -> Self { Self { bind_addr: std::env::var(ENV_DAEMON_BIND_ADDR).ok(), tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(), } } } /// Resolve the runtime configuration: env overrides on top of the unified /// file, then defaults. A missing file is not an error. pub fn load_config(path: &Path, env: EnvOverrides) -> Result { let file = MaestroConfigFile::load(path)?; let daemon = file.daemon; // Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id, // empty command) refuses startup with a clear error, like other config // validation (#22/#26). let spawn_presets = file.spawn.resolve()?; let hostname = daemon.hostname.unwrap_or_else(system_hostname); let home_dir = dirs::home_dir() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); let bind_addr = match env.bind_addr { Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?, None => daemon.bind_addr.unwrap_or_else(default_bind_addr), }; let state_dir = match daemon.state_dir { Some(p) => p, None => default_state_dir()?, }; let pty_replay_bytes = daemon .pty_replay_bytes .unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES); let tmux_tmpdir = env .tmux_tmpdir .map(PathBuf::from) .or(daemon.tmux_tmpdir) .or_else(dirs::runtime_dir); let spawn_history_days = daemon .spawn_history_days .unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS); // `~` in scan roots means the daemon's own home; an unresolvable home // leaves the tilde as-is and the scanner treats the root as missing. let project_roots: Vec = 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, bind_addr, state_dir, worktree_root: daemon.worktree_root, pty_replay_bytes, tmux_tmpdir, spawn_history_days, spawn_presets, project_roots, project_scan_depth, hooks_auto_install: file.hooks.auto_install(), leader: file.tui.leader().to_string(), }) } /// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through. fn expand_tilde(path: &str, home: &str) -> 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")?) } /// Read the kernel hostname via `gethostname(2)`. Falls back to `"unknown"` /// on the (very unlikely) syscall failure. pub fn system_hostname() -> String { let mut buf = [0u8; 256]; let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; if r != 0 { return "unknown".into(); } let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); String::from_utf8_lossy(&buf[..nul]).into_owned() } #[cfg(test)] mod tests { use super::*; /// #159: the leader the operator set for the TUI is what the daemon /// advertises, so rebinding it once moves both clients' chord. #[test] fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() { 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.leader, "ctrl-a"); std::fs::write( &path, r#" [tui] leader = "ctrl-b" "#, ) .unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); assert_eq!(cfg.leader, "ctrl-b"); } #[test] fn hooks_auto_install_defaults_true_and_is_opt_out() { 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!(cfg.hooks_auto_install); std::fs::write( &path, r#" [hooks] auto_install = false "#, ) .unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); assert!(!cfg.hooks_auto_install); } #[test] fn empty_file_defaults_everything() { 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.bind_addr, default_bind_addr()); assert!(cfg.state_dir.ends_with("maestro")); assert!(cfg.worktree_root.is_none()); assert!(!cfg.hostname.is_empty()); } #[test] fn missing_file_is_not_an_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("does-not-exist.toml"); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); assert_eq!(cfg.bind_addr, default_bind_addr()); assert!(!path.exists(), "load_config must not create the file"); } #[test] fn env_overrides_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" [daemon] bind_addr = "127.0.0.1:1" "#, ) .unwrap(); let env = EnvOverrides { bind_addr: Some("0.0.0.0:9999".into()), ..EnvOverrides::default() }; let cfg = load_config(&path, env).unwrap(); assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap()); } #[test] fn invalid_env_bind_addr_returns_diagnostic() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write(&path, "").unwrap(); let env = EnvOverrides { bind_addr: Some("not-an-addr".into()), ..EnvOverrides::default() }; let err = load_config(&path, env).unwrap_err(); match err { ConfigError::Inner(maestro_config::ConfigError::InvalidBindAddr { input, env, .. }) => { assert_eq!(input, "not-an-addr"); assert_eq!(env, ENV_DAEMON_BIND_ADDR); } other => panic!("expected InvalidBindAddr, got {other:?}"), } } #[test] fn hostname_from_file_wins_over_gethostname() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" [daemon] hostname = "vm-42" "#, ) .unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); assert_eq!(cfg.hostname, "vm-42"); } #[test] fn spawn_history_days_defaults_and_overrides() { 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.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS); assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl")); std::fs::write( &path, r#" [daemon] spawn_history_days = 0 "#, ) .unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); 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(); assert!(!h.is_empty(), "gethostname returned empty string"); } // NAP-234: tmux_tmpdir precedence is env > file > dirs::runtime_dir(). // Tested without mutating the process env (which races other tests) by // feeding `EnvOverrides` directly. #[test] fn tmux_tmpdir_env_beats_file_and_runtime_dir() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" [daemon] tmux_tmpdir = "/from-file" "#, ) .unwrap(); let env = EnvOverrides { tmux_tmpdir: Some("/from-env".into()), ..EnvOverrides::default() }; let cfg = load_config(&path, env).unwrap(); assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-env"))); } #[test] fn tmux_tmpdir_file_beats_runtime_dir() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" [daemon] tmux_tmpdir = "/from-file" "#, ) .unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-file"))); } #[test] fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() { 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(); // dirs::runtime_dir() is `Some(_)` on any Linux host with // $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon // falls through to None and tmux uses /tmp; either is acceptable — // but on this test runner the env var is always present. assert_eq!(cfg.tmux_tmpdir, dirs::runtime_dir()); } }