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 c856333e4a
ci / rust (push) Successful in 2m40s
docs(maestro): describe the single-host architecture
Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.

The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
2026-08-22 10:26:49 +01:00

714 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"
//!
//! [web]
//! bind_addr = "127.0.0.1:8080"
//! ```
//!
//! 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::{Deserialize, Serialize};
/// 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";
/// Env var that, when set, overrides the resolved config-file path.
pub const CONFIG_PATH_ENV: &str = "MAESTRO_CONFIG";
#[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(
"maestro config not found at {}\n\
Run `maestro daemon configure` to create it.",
path.display()
)]
MissingFile { path: PathBuf },
#[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, Serialize, Deserialize)]
pub struct MaestroConfigFile {
#[serde(default, skip_serializing_if = "CliSection::is_empty")]
pub cli: CliSection,
#[serde(default, skip_serializing_if = "DaemonSection::is_empty")]
pub daemon: DaemonSection,
#[serde(default, skip_serializing_if = "WebSection::is_empty")]
pub web: WebSection,
#[serde(default, skip_serializing_if = "TuiSection::is_empty")]
pub tui: TuiSection,
#[serde(default, skip_serializing_if = "SpawnSection::is_empty")]
pub spawn: SpawnSection,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, 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, skip_serializing_if = "Option::is_none")]
pub daemon_url: Option<String>,
}
impl CliSection {
fn is_empty(&self) -> bool {
self.daemon_url.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonSection {
/// Hostname advertised to clients, purely for display. Defaults to
/// `gethostname(2)`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<SocketAddr>,
/// Root for everything daemon-local: sidecars, FIFOs, etc.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_dir: Option<PathBuf>,
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
/// with a `worktree` spec.
#[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
pub spawn_history_days: Option<u32>,
}
impl DaemonSection {
fn is_empty(&self) -> bool {
self.hostname.is_none()
&& self.bind_addr.is_none()
&& self.state_dir.is_none()
&& self.worktree_root.is_none()
&& self.pty_replay_bytes.is_none()
&& self.tmux_tmpdir.is_none()
&& self.spawn_history_days.is_none()
}
}
/// `[web]` — `maestro-web` binary settings (NAP-241).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebSection {
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a LAN/VPN
/// address to share with your own devices on a trusted segment; there is no
/// auth layer, so never bind a public address.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<SocketAddr>,
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
/// Default `http://127.0.0.1:7070`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_url: Option<String>,
/// When set, requests for `/` are proxied to a Vite dev server at this
/// URL instead of being served from the embedded bundle. Use it during
/// frontend development so live reload works.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dev_proxy_target: Option<String>,
}
impl WebSection {
fn is_empty(&self) -> bool {
self.bind_addr.is_none() && self.daemon_url.is_none() && self.dev_proxy_target.is_none()
}
}
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
///
/// 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, Serialize, Deserialize)]
pub struct TuiSection {
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leader: Option<String>,
/// Maximum interval between chord keys before a held prefix is dropped.
/// Default `1000`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chord_timeout_ms: Option<u64>,
/// Normal-mode bindings: canonical input → action string. Applied over
/// the shipped defaults.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub keymap_normal: BTreeMap<String, String>,
/// Insert-mode bindings, same semantics as `keymap_normal`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub keymap_insert: BTreeMap<String, String>,
}
impl TuiSection {
pub fn is_empty(&self) -> bool {
self.leader.is_none()
&& self.chord_timeout_ms.is_none()
&& self.keymap_normal.is_empty()
&& self.keymap_insert.is_empty()
}
}
/// `[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, Serialize, 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, skip_serializing_if = "Option::is_none")]
pub presets: Option<Vec<SpawnPreset>>,
}
impl SpawnSection {
fn is_empty(&self) -> bool {
self.presets.is_none()
}
/// 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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset {
pub id: String,
pub label: String,
pub command: String,
}
/// 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 {
/// Strict load — error with [`ConfigError::MissingFile`] if absent.
pub fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Err(ConfigError::MissingFile {
path: path.to_path_buf(),
});
}
let bytes = std::fs::read(path)?;
let s = std::str::from_utf8(&bytes).unwrap_or("");
Ok(toml::from_str(s)?)
}
/// Load if present, else return an empty config. Use for callers that
/// want a default-on-missing read.
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
Self::load(path)
}
/// Atomic write (tmp + rename). Creates parent dirs on demand.
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(self)?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
}
/// `$MAESTRO_CONFIG` if set, else [`default_config_path`].
pub fn resolve_config_path() -> Result<PathBuf, ConfigError> {
if let Some(p) = std::env::var_os(CONFIG_PATH_ENV) {
return Ok(PathBuf::from(p));
}
default_config_path()
}
/// `~/.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. Shared by the daemon and web `bind_addr`
/// resolution paths (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"`, `maestro-web` passes `"maestro-web"`
/// (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))
}
/// Capacities for the daemon's broadcast channels (session events and PTY byte
/// fan-out). These were independent `= 256` constants scattered across the
/// daemon; centralizing them here makes the coincidence explicit and gives a
/// single home for per-deployment tuning later (NAP-273).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BroadcastTuning {
/// Session event channels (`SessionEvent`, `CatalogEvent`).
pub events_capacity: usize,
/// Per-session PTY byte broadcast channel.
pub pty_capacity: usize,
}
impl BroadcastTuning {
/// The historical defaults — every channel was 256 by coincidence.
pub const DEFAULT: Self = Self {
events_capacity: 256,
pty_capacity: 256,
};
}
impl Default for BroadcastTuning {
fn default() -> Self {
Self::DEFAULT
}
}
#[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_tuning_defaults_to_256() {
assert_eq!(BroadcastTuning::default(), BroadcastTuning::DEFAULT);
assert_eq!(BroadcastTuning::DEFAULT.events_capacity, 256);
assert_eq!(BroadcastTuning::DEFAULT.pty_capacity, 256);
}
#[test]
fn missing_file_strict_load_errors_with_pointer_to_configure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let err = MaestroConfigFile::load(&path).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, ConfigError::MissingFile { .. }));
assert!(msg.contains("maestro daemon configure"), "msg was: {msg}");
}
#[test]
fn missing_file_load_or_default_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile::load_or_default(&path).unwrap();
assert_eq!(cfg, MaestroConfigFile::default());
}
#[test]
fn full_file_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile {
cli: CliSection {
daemon_url: Some("http://127.0.0.1:7070".into()),
},
daemon: DaemonSection {
hostname: Some("laptop.lan".into()),
bind_addr: Some("127.0.0.1:7070".parse().unwrap()),
state_dir: Some(PathBuf::from("/var/lib/maestrod")),
worktree_root: Some(PathBuf::from("/var/lib/maestrod/wt")),
pty_replay_bytes: Some(131_072),
tmux_tmpdir: Some(PathBuf::from("/run/user/1000")),
spawn_history_days: Some(14),
},
web: WebSection {
bind_addr: Some("127.0.0.1:8080".parse().unwrap()),
daemon_url: Some("http://127.0.0.1:7070".into()),
dev_proxy_target: Some("http://127.0.0.1:5173".into()),
},
tui: TuiSection {
leader: Some("ctrl-b".into()),
chord_timeout_ms: Some(750),
keymap_normal: BTreeMap::from([("i".into(), "ShowHelp".into())]),
keymap_insert: BTreeMap::new(),
},
spawn: SpawnSection {
presets: Some(vec![SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude --resume".into(),
}]),
},
};
cfg.save(&path).unwrap();
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
}
#[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(),
}]),
};
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![]),
};
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(),
}]),
};
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(),
}]),
};
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(),
},
]),
};
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(),
}]),
};
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 save_omits_empty_spawn_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
MaestroConfigFile {
daemon: DaemonSection {
hostname: Some("laptop".into()),
..Default::default()
},
..Default::default()
}
.save(&path)
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(!body.contains("[spawn]"), "body: {body}");
}
#[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.web, WebSection::default());
assert_eq!(
cfg.daemon.bind_addr,
Some("127.0.0.1:9090".parse().unwrap())
);
}
#[test]
fn save_omits_empty_sections() {
// Avoid littering the file with empty `[cli]` / `[daemon]` tables
// when only one component is configured.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile {
daemon: DaemonSection {
hostname: Some("laptop".into()),
..Default::default()
},
..Default::default()
};
cfg.save(&path).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("[daemon]"), "body: {body}");
assert!(!body.contains("[cli]"), "body: {body}");
assert!(!body.contains("[web]"), "body: {body}");
}
#[test]
fn save_atomic_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/here/config.toml");
MaestroConfigFile::default().save(&path).unwrap();
assert!(path.exists());
}
#[test]
fn resolve_config_path_respects_env_var() {
// We can't safely tweak the process env in a unit test (it races with
// other tests). Exercise the default path branch instead and rely on
// an integration-level smoke that env-override works.
let p = default_config_path().unwrap();
assert!(p.ends_with(format!("{DIR_NAME}/{FILE_NAME}")));
}
}