ci / rust (push) Successful in 2m40s
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.
714 lines
25 KiB
Rust
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}")));
|
|
}
|
|
}
|