Structured sessions ignored the --dangerously-skip-permissions flags in
preset commands because the daemon discards CLI flags when spawning the
ACP adapter binary. This adds an `auto_approve` bool to SpawnPreset and
CreateSessionRequest so the operator can declare it in config.toml:
{ id = "claude", command = "claude", auto_approve = true }
When set, the daemon's permission pump immediately responds AllowAlways
to every ACP permission request without surfacing it to clients.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
746 lines
25 KiB
Rust
746 lines
25 KiB
Rust
//! 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<bool>,
|
|
}
|
|
|
|
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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(default)]
|
|
pub bind_addr: Option<SocketAddr>,
|
|
/// Root for everything daemon-local: sidecars, FIFOs, etc.
|
|
#[serde(default)]
|
|
pub state_dir: Option<PathBuf>,
|
|
/// 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 `<repo>/worktrees/<branch>` (#56).
|
|
#[serde(default)]
|
|
pub worktree_root: Option<PathBuf>,
|
|
/// 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<usize>,
|
|
/// `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<PathBuf>,
|
|
/// 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<u32>,
|
|
}
|
|
|
|
/// `[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<TuiTheme>,
|
|
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
|
#[serde(default)]
|
|
pub leader: Option<String>,
|
|
/// Maximum interval between chord keys before a held prefix is dropped.
|
|
/// Default `1000`.
|
|
#[serde(default)]
|
|
pub chord_timeout_ms: Option<u64>,
|
|
/// Normal-mode bindings: canonical input → action string. Applied over
|
|
/// the shipped defaults.
|
|
#[serde(default)]
|
|
pub keymap_normal: BTreeMap<String, String>,
|
|
/// Insert-mode bindings, same semantics as `keymap_normal`.
|
|
#[serde(default)]
|
|
pub keymap_insert: BTreeMap<String, String>,
|
|
}
|
|
|
|
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<Option<TuiTheme>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let raw = Option::<String>::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::<Vec<_>>()
|
|
.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<Vec<SpawnPreset>>,
|
|
/// 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<Vec<String>>,
|
|
/// 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<u32>,
|
|
}
|
|
|
|
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<Vec<SpawnPreset>, 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<SpawnPreset> {
|
|
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<Self, ConfigError> {
|
|
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>) -> 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<PathBuf, ConfigError> {
|
|
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 <ENV> (...)` diagnostic. Used by the daemon's `bind_addr`
|
|
/// resolution path (NAP-273).
|
|
pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
|
|
raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
|
|
env,
|
|
input: raw.to_string(),
|
|
source,
|
|
})
|
|
}
|
|
|
|
/// XDG state directory for a Maestro component, e.g. `~/.local/state/<subdir>`
|
|
/// (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<PathBuf, ConfigError> {
|
|
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<String> = 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<String> = 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<String> = 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::<MaestroConfigFile>(
|
|
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::<MaestroConfigFile>("[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");
|
|
}
|
|
}
|