//! Unified Maestro on-disk config. //! //! One TOML file at `~/.config/maestro/config.toml` (override via //! `$MAESTRO_CONFIG`) holds settings for every Maestro binary: //! //! ```toml //! [cli] //! daemon_url = "http://127.0.0.1:7070" //! //! [daemon] //! bind_addr = "127.0.0.1:7070" //! ``` //! //! This crate owns the file schema, path resolution, and atomic save. Each //! binary applies its own env-var precedence and defaults on top of //! [`MaestroConfigFile`]. use std::collections::BTreeMap; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use serde::de::Error as _; use serde::{Deserialize, Deserializer}; /// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`). pub const DIR_NAME: &str = "maestro"; /// File name inside [`DIR_NAME`]. pub const FILE_NAME: &str = "config.toml"; /// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`. pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070"; /// Leader key both clients use when `[tui].leader` is unset (#159). Canonical /// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey` /// both produce. pub const DEFAULT_LEADER: &str = "ctrl-a"; #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("toml decode: {0}")] TomlDecode(#[from] toml::de::Error), #[error("toml encode: {0}")] TomlEncode(#[from] toml::ser::Error), #[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")] NoConfigDir, #[error("invalid {env} ({input:?}): {source}")] InvalidBindAddr { env: &'static str, input: String, source: std::net::AddrParseError, }, } /// On-disk representation of the unified config. Every section is optional /// so a partial file (e.g. just `[daemon]`) is valid. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct MaestroConfigFile { #[serde(default)] pub cli: CliSection, #[serde(default)] pub daemon: DaemonSection, #[serde(default)] pub tui: TuiSection, #[serde(default)] pub spawn: SpawnSection, #[serde(default)] pub hooks: HooksSection, } /// `[hooks]` — agent-hook installation settings (#106). #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct HooksSection { /// Whether the daemon installs the Claude Code activity hook into /// `~/.claude/settings.json` on first start when no maestro hook is /// there yet. Default `true`; set `auto_install = false` to opt out. #[serde(default)] pub auto_install: Option, } impl HooksSection { pub fn auto_install(&self) -> bool { self.auto_install.unwrap_or(true) } } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct CliSection { /// HTTP base URL of the daemon the CLI talks to, e.g. /// `http://127.0.0.1:7070`. When unset, the CLI derives it from /// `[daemon].bind_addr` if a daemon is configured locally, else falls /// back to `http://127.0.0.1:7070`. #[serde(default)] pub daemon_url: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct DaemonSection { /// Hostname advertised to clients, purely for display. Defaults to /// `gethostname(2)`. #[serde(default)] pub hostname: Option, #[serde(default)] pub bind_addr: Option, /// Root for everything daemon-local: sidecars, FIFOs, etc. #[serde(default)] pub state_dir: Option, /// Override root for ephemeral worktrees the daemon creates on /// `CreateSession` with a `worktree` spec. Unset by default: worktrees /// then live inside their base repo at `/worktrees/` (#56). #[serde(default)] 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. /// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay. #[serde(default)] pub pty_replay_bytes: Option, /// `TMUX_TMPDIR` the daemon exports for every `tmux` invocation (NAP-234). /// When unset, the daemon defaults to `$XDG_RUNTIME_DIR` so that bare /// `tmux ls` in a user shell (which usually has the same env var) sees /// maestro sessions. Set to `/tmp` to opt back into tmux's own default — /// useful if the daemon outlives the user's login session and you can't /// enable `loginctl enable-linger`. #[serde(default)] pub tmux_tmpdir: Option, /// Recency cap in days for the durable spawn history that backs the spawn /// form's "recent dirs" picker (#24). `None` ⇒ daemon default (30 days); /// `Some(0)` disables history entirely (no log written, no recents read). #[serde(default)] pub spawn_history_days: Option, } /// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277). /// /// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI /// ships with a default keymap; entries in `keymap_normal` / `keymap_insert` /// are applied **over** the defaults, so a partial override is enough. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct TuiSection { /// Named palette preset. Defaults to the dark preset. #[serde(default, deserialize_with = "deserialize_theme")] pub theme: Option, /// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`. #[serde(default)] pub leader: Option, /// Maximum interval between chord keys before a held prefix is dropped. /// Default `1000`. #[serde(default)] pub chord_timeout_ms: Option, /// Normal-mode bindings: canonical input → action string. Applied over /// the shipped defaults. #[serde(default)] pub keymap_normal: BTreeMap, /// Insert-mode bindings, same semantics as `keymap_normal`. #[serde(default)] pub keymap_insert: BTreeMap, } impl TuiSection { /// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for /// history, but read by every client: the daemon serves it on /// `GET /api/info` so the web follows the same knob (#159). pub fn leader(&self) -> &str { self.leader.as_deref().unwrap_or(DEFAULT_LEADER) } } /// Palette presets supported by the TUI's `[tui].theme` setting. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TuiTheme { /// The graphite dark palette and the default. Dark, /// A high-contrast light palette. Light, /// Ice Cyan — cyan accent on the graphite base. IceCyan, /// Periwinkle — indigo base, blue-violet accent. Periwinkle, /// Amber Phosphor — CRT amber on warm graphite. AmberPhosphor, /// Ember — warm orange-red accent. Ember, /// Orchid — muted violet accent. Orchid, /// Named ANSI colours, allowing the terminal to provide the colour set. Ansi, /// Solarized Dark. SolarizedDark, /// Solarized Light. SolarizedLight, /// Catppuccin Mocha. Catppuccin, /// GitHub Dark. GithubDark, /// GitHub Light. GithubLight, /// Tokyo Night. TokyoNight, /// Gruvbox Dark. Gruvbox, /// Nord. Nord, /// Dracula. Dracula, } /// Every `[tui].theme` name and its variant, in documentation order. pub const TUI_THEMES: &[(&str, TuiTheme)] = &[ ("dark", TuiTheme::Dark), ("light", TuiTheme::Light), ("ice-cyan", TuiTheme::IceCyan), ("periwinkle", TuiTheme::Periwinkle), ("amber-phosphor", TuiTheme::AmberPhosphor), ("ember", TuiTheme::Ember), ("orchid", TuiTheme::Orchid), ("ansi", TuiTheme::Ansi), ("solarized-dark", TuiTheme::SolarizedDark), ("solarized-light", TuiTheme::SolarizedLight), ("catppuccin", TuiTheme::Catppuccin), ("github-dark", TuiTheme::GithubDark), ("github-light", TuiTheme::GithubLight), ("tokyo-night", TuiTheme::TokyoNight), ("gruvbox", TuiTheme::Gruvbox), ("nord", TuiTheme::Nord), ("dracula", TuiTheme::Dracula), ]; fn deserialize_theme<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let raw = Option::::deserialize(deserializer)?; raw.map(|name| { TUI_THEMES .iter() .find(|(n, _)| *n == name) .map(|(_, theme)| *theme) .ok_or_else(|| { let known = TUI_THEMES .iter() .map(|(n, _)| format!("{n:?}")) .collect::>() .join(", "); D::Error::custom(format!( "invalid [tui].theme {name:?}; expected one of {known}" )) }) }) .transpose() } /// `[spawn]` — command presets for the spawn form (#22, part of #21). /// /// Defined in shared config so every binary sees the same list: the web reads /// it via the daemon's catalog advertisement (#26), the TUI adopts it later. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct SpawnSection { /// Command presets offered as one-click tiles. When present, this list /// **replaces** the [`builtin_presets`] defaults (the implicit `custom` /// escape hatch is still appended by [`SpawnSection::resolve`]); when /// absent, the built-ins are used. `presets = []` is "present but empty" — /// 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 { /// The presets to show, in order, with the implicit `custom` tile appended. /// /// Resolution: configured `presets` if present, else [`builtin_presets`]; /// then [`custom_preset`] is appended unconditionally as the "none of the /// above" free-text escape hatch. The configured (or built-in) list is /// validated first — see [`SpawnConfigError`]. pub fn resolve(&self) -> Result, SpawnConfigError> { let mut list = match &self.presets { Some(p) => p.clone(), None => builtin_presets(), }; validate_presets(&list)?; list.push(custom_preset()); Ok(list) } } /// A single spawn-form command preset: a stable `id`, a human `label` for the /// tile, and the `command` to run. The implicit `custom` tile (see /// [`custom_preset`]) reuses this shape with an empty `command` the client /// fills from free-text input. /// /// Defined in `maestro-protocol` because it also rides `GET /api/info`; the /// on-disk `[spawn].presets` schema and the wire payload are one type. pub use maestro_protocol::SpawnPreset; /// Reserved id for the implicit free-text tile. A user-defined preset may not /// claim it (see [`SpawnConfigError::ReservedCustomId`]) since it's always /// appended by [`SpawnSection::resolve`]. pub const CUSTOM_PRESET_ID: &str = "custom"; /// Built-in default presets used when `[spawn].presets` is absent. pub fn builtin_presets() -> Vec { vec![ SpawnPreset { id: "claude".into(), label: "Claude".into(), command: "claude".into(), auto_approve: false, }, SpawnPreset { id: "opencode".into(), label: "opencode".into(), command: "opencode".into(), auto_approve: false, }, SpawnPreset { id: "zsh".into(), label: "Shell".into(), command: "zsh".into(), auto_approve: false, }, ] } /// The implicit `custom` tile appended after the resolved presets. Its empty /// `command` is the signal to the client to take a free-text command instead. pub fn custom_preset() -> SpawnPreset { SpawnPreset { id: CUSTOM_PRESET_ID.into(), label: "Custom…".into(), command: String::new(), auto_approve: false, } } /// A `[spawn].presets` list that violates the schema invariants. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum SpawnConfigError { #[error("spawn preset has an empty id")] EmptyId, #[error("spawn preset {id:?} has an empty command")] EmptyCommand { id: String }, #[error("duplicate spawn preset id {id:?}")] DuplicateId { id: String }, #[error( "spawn preset id {:?} is reserved for the implicit free-text tile", CUSTOM_PRESET_ID )] ReservedCustomId, } /// Enforce: each `id` non-empty, not the reserved `custom`, unique; each /// `command` non-empty. Order of checks is per-field then cross-field so the /// first offending preset produces the most specific error. fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> { let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); for p in presets { if p.id.trim().is_empty() { return Err(SpawnConfigError::EmptyId); } if p.id == CUSTOM_PRESET_ID { return Err(SpawnConfigError::ReservedCustomId); } if p.command.trim().is_empty() { return Err(SpawnConfigError::EmptyCommand { id: p.id.clone() }); } if !seen.insert(p.id.as_str()) { return Err(SpawnConfigError::DuplicateId { id: p.id.clone() }); } } Ok(()) } impl MaestroConfigFile { /// Load the file, or return an empty config if it doesn't exist. Every /// field has a default, so a missing file is never an error. pub fn load(path: &Path) -> Result { if !path.exists() { return Ok(Self::default()); } let bytes = std::fs::read(path)?; let s = std::str::from_utf8(&bytes).unwrap_or(""); Ok(toml::from_str(s)?) } /// Daemon HTTP base a client should talk to. /// /// Precedence: explicit `override_url` (flag or env), then /// `[cli].daemon_url`, then `http://<[daemon].bind_addr>`, then /// [`DEFAULT_DAEMON_URL`]. Always yields a value — reachability is decided /// by whether the connection succeeds. pub fn resolve_daemon_url(&self, override_url: Option) -> String { override_url .or_else(|| self.cli.daemon_url.clone()) .or_else(|| self.daemon.bind_addr.map(|a| format!("http://{a}"))) .unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string()) } } /// `~/.config/maestro/config.toml` (XDG-aware). pub fn default_config_path() -> Result { let base = dirs::config_dir().ok_or(ConfigError::NoConfigDir)?; Ok(base.join(DIR_NAME).join(FILE_NAME)) } /// Parse a `SocketAddr` from a raw (usually env-sourced) string, tagging the /// failure with the originating env-var name so every binary surfaces the same /// `invalid (...)` diagnostic. Used by the daemon's `bind_addr` /// resolution path (NAP-273). pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result { raw.parse().map_err(|source| ConfigError::InvalidBindAddr { env, input: raw.to_string(), source, }) } /// XDG state directory for a Maestro component, e.g. `~/.local/state/` /// (falling back to the data-local dir when `XDG_STATE_HOME`/`$HOME` are /// unset). The daemon passes `"maestro"` (NAP-273). pub fn default_state_dir(subdir: &str) -> Result { let base = dirs::state_dir() .or_else(dirs::data_local_dir) .ok_or(ConfigError::NoConfigDir)?; Ok(base.join(subdir)) } /// Capacity of the session-event broadcast channel (`CatalogEvent`). pub const EVENTS_CAPACITY: usize = 256; /// Capacity of the per-session PTY byte broadcast channel. pub const PTY_CAPACITY: usize = 256; #[cfg(test)] mod tests { use super::*; #[test] fn parse_bind_addr_ok_and_err() { assert_eq!( parse_bind_addr("127.0.0.1:7070", "MAESTRO_X").unwrap(), "127.0.0.1:7070".parse().unwrap() ); let err = parse_bind_addr("not-an-addr", "MAESTRO_X").unwrap_err(); match err { ConfigError::InvalidBindAddr { env, input, .. } => { assert_eq!(env, "MAESTRO_X"); assert_eq!(input, "not-an-addr"); } other => panic!("expected InvalidBindAddr, got {other:?}"), } } #[test] fn default_state_dir_appends_subdir() { // On any host with XDG_STATE_HOME / HOME set (CI included) this is Some. if let Ok(p) = default_state_dir("maestro-xyz") { assert!(p.ends_with("maestro-xyz")); } } #[test] fn broadcast_capacities_are_the_historical_defaults() { assert_eq!(EVENTS_CAPACITY, 256); assert_eq!(PTY_CAPACITY, 256); } #[test] fn missing_file_loads_as_empty_config() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); let cfg = MaestroConfigFile::load(&path).unwrap(); assert_eq!(cfg, MaestroConfigFile::default()); } #[test] fn hooks_auto_install_defaults_true_and_is_opt_out() { // Absent file / absent [hooks] ⇒ auto_install on. assert!(HooksSection::default().auto_install()); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write(&path, "").unwrap(); assert!(MaestroConfigFile::load(&path).unwrap().hooks.auto_install()); std::fs::write( &path, r#" [hooks] auto_install = false "#, ) .unwrap(); assert!(!MaestroConfigFile::load(&path).unwrap().hooks.auto_install()); } #[test] fn spawn_presets_default_to_builtins_plus_custom() { // Absent [spawn].presets → built-ins, with the implicit custom tile last. let resolved = SpawnSection::default().resolve().unwrap(); let ids: Vec<&str> = resolved.iter().map(|p| p.id.as_str()).collect(); assert_eq!(ids, ["claude", "opencode", "zsh", "custom"]); // The custom tile carries an empty command — the free-text signal. let custom = resolved.last().unwrap(); assert_eq!(custom.label, "Custom…"); assert!(custom.command.is_empty()); } #[test] fn spawn_presets_override_replaces_builtins() { // Present list replaces the built-ins entirely; custom still appended. let section = SpawnSection { presets: Some(vec![SpawnPreset { id: "fish".into(), label: "fish".into(), command: "fish".into(), auto_approve: false, }]), ..Default::default() }; let ids: Vec = section .resolve() .unwrap() .into_iter() .map(|p| p.id) .collect(); assert_eq!(ids, ["fish", "custom"]); } #[test] fn spawn_presets_empty_list_resolves_to_just_custom() { // `presets = []` is present-but-empty: it replaces with nothing, leaving // only the implicit custom tile. let section = SpawnSection { presets: Some(vec![]), ..Default::default() }; let ids: Vec = section .resolve() .unwrap() .into_iter() .map(|p| p.id) .collect(); assert_eq!(ids, ["custom"]); } #[test] fn spawn_presets_reject_empty_id_and_command() { let empty_id = SpawnSection { presets: Some(vec![SpawnPreset { id: " ".into(), label: "x".into(), command: "x".into(), auto_approve: false, }]), ..Default::default() }; assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId); let empty_cmd = SpawnSection { presets: Some(vec![SpawnPreset { id: "x".into(), label: "x".into(), command: "".into(), auto_approve: false, }]), ..Default::default() }; assert_eq!( empty_cmd.resolve().unwrap_err(), SpawnConfigError::EmptyCommand { id: "x".into() } ); } #[test] fn spawn_presets_reject_duplicate_and_reserved_id() { let dup = SpawnSection { presets: Some(vec![ SpawnPreset { id: "a".into(), label: "a".into(), command: "a".into(), auto_approve: false, }, SpawnPreset { id: "a".into(), label: "a2".into(), command: "a2".into(), auto_approve: false, }, ]), ..Default::default() }; assert_eq!( dup.resolve().unwrap_err(), SpawnConfigError::DuplicateId { id: "a".into() } ); let reserved = SpawnSection { presets: Some(vec![SpawnPreset { id: "custom".into(), label: "Custom".into(), command: "whatever".into(), auto_approve: false, }]), ..Default::default() }; assert_eq!( reserved.resolve().unwrap_err(), SpawnConfigError::ReservedCustomId ); } #[test] fn spawn_section_parses_from_toml_table() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" [spawn] presets = [ { id = "claude", label = "Claude", command = "claude" }, { id = "zsh", label = "Shell", command = "zsh" }, ] "#, ) .unwrap(); let cfg = MaestroConfigFile::load(&path).unwrap(); let ids: Vec = cfg .spawn .resolve() .unwrap() .into_iter() .map(|p| p.id) .collect(); 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). let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" [daemon] bind_addr = "127.0.0.1:9090" "#, ) .unwrap(); let cfg = MaestroConfigFile::load(&path).unwrap(); assert_eq!(cfg.cli, CliSection::default()); assert_eq!( cfg.daemon.bind_addr, Some("127.0.0.1:9090".parse().unwrap()) ); } #[test] fn tui_theme_parses_named_presets_and_defaults_when_absent() { assert_eq!(TuiSection::default().theme, None); for (name, theme) in TUI_THEMES { let cfg = file_with(&format!("[tui]\ntheme = \"{name}\"\n")); assert_eq!(cfg.tui.theme, Some(*theme)); } } #[test] fn tui_theme_rejects_unknown_names_with_the_field_name() { let err = toml::from_str::("[tui]\ntheme = \"glorb\"\n").unwrap_err(); let message = err.to_string(); assert!( message.contains("[tui].theme"), "diagnostic should name the field: {message}" ); assert!( message.contains("glorb"), "diagnostic should name the invalid value: {message}" ); } fn file_with(body: &str) -> MaestroConfigFile { toml::from_str(body).expect("parse") } #[test] fn daemon_url_prefers_an_explicit_override() { let cfg = file_with("[cli]\ndaemon_url = \"http://from-file:1\"\n"); assert_eq!( cfg.resolve_daemon_url(Some("http://override:2".into())), "http://override:2" ); } #[test] fn daemon_url_falls_back_through_cli_then_bind_addr_then_default() { let from_cli = file_with("[cli]\ndaemon_url = \"http://from-cli:1\"\n"); assert_eq!(from_cli.resolve_daemon_url(None), "http://from-cli:1"); let from_bind = file_with("[daemon]\nbind_addr = \"10.0.0.1:7070\"\n"); assert_eq!(from_bind.resolve_daemon_url(None), "http://10.0.0.1:7070"); let empty = MaestroConfigFile::default(); assert_eq!(empty.resolve_daemon_url(None), DEFAULT_DAEMON_URL); } #[test] fn cli_daemon_url_beats_a_locally_configured_bind_addr() { let cfg = file_with( "[cli]\ndaemon_url = \"http://explicit:1\"\n[daemon]\nbind_addr = \"10.0.0.1:7070\"\n", ); assert_eq!(cfg.resolve_daemon_url(None), "http://explicit:1"); } }