This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-config/src/lib.rs
T
Miguel Palhas ba02b11f13 feat(maestro): serve the configured leader to the web client
The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.

`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.

Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
2026-09-03 18:33:01 +01:00

736 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(),
},
SpawnPreset {
id: "opencode".into(),
label: "opencode".into(),
command: "opencode".into(),
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
},
]
}
/// 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(),
}
}
/// 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(),
}]),
..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(),
}]),
..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(),
}]),
..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(),
},
SpawnPreset {
id: "a".into(),
label: "a2".into(),
command: "a2".into(),
},
]),
..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(),
}]),
..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");
}
}