maestro-cli/src/config.rs was two pass-throughs, a newtype error, a two-field projection of MaestroConfigFile, and one four-line or_else with a single caller. `resolve_daemon_url` moves onto MaestroConfigFile, next to the schema it reads, and the module goes; its tests move with it. `[web].dev_proxy_target` is deleted end to end — schema, env var, normalisation, three tests, README line, and the startup warning that admitted the server ignores it. MaestroConfigFile::save had no callers outside its own tests. Deleting it also removes every skip_serializing_if, the five is_empty impls, and the Serialize derives that existed only to make its output tidy. BroadcastTuning was a struct that was never instantiated: all three uses were `::DEFAULT.field` in const position and there is no [broadcast] section. Now two plain consts. $MAESTRO_CONFIG was honoured twice, by resolve_config_path and by clap's `env =`. Every binary already gets it through clap, so resolve_config_path and CONFIG_PATH_ENV go and the TUI falls back to default_config_path like everything else. Also collapses three layers of 1:1 forwarding: the umbrella's cli_dispatch shim, and four near-identical 8-line functions in cli/dispatch.rs that differed only in which run_* they called. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
342 lines
11 KiB
Rust
342 lines
11 KiB
Rust
//! Daemon view over the unified Maestro config.
|
|
//!
|
|
//! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and
|
|
//! `[spawn]` from `~/.config/maestro/config.toml`.
|
|
//!
|
|
//! Precedence per field: **env > file > default**. Every field has a default,
|
|
//! so a missing config file is fine — the daemon starts with defaults.
|
|
//!
|
|
//! Default config path: `~/.config/maestro/config.toml` (override via
|
|
//! `$MAESTRO_CONFIG`).
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use maestro_config::MaestroConfigFile;
|
|
|
|
pub use maestro_config::default_config_path;
|
|
|
|
/// Default bind address when neither env nor file specify one.
|
|
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
|
|
|
/// Default recency cap (days) for the durable spawn history (#24) when
|
|
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
|
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
|
|
|
/// Env var that overrides `[daemon].bind_addr`.
|
|
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
|
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
|
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
|
|
|
|
fn default_bind_addr() -> SocketAddr {
|
|
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ConfigError {
|
|
#[error(transparent)]
|
|
Inner(#[from] maestro_config::ConfigError),
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("invalid [spawn].presets: {0}")]
|
|
SpawnPresets(#[from] maestro_config::SpawnConfigError),
|
|
}
|
|
|
|
/// Resolved, validated daemon configuration ready to drive the binary.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DaemonConfig {
|
|
/// Hostname advertised to clients, for display only.
|
|
pub hostname: String,
|
|
/// Home directory of the OS user the daemon runs as, advertised to clients
|
|
/// so they can prefill a spawn form's working directory. Empty when `$HOME`
|
|
/// can't be resolved (NAP-328).
|
|
pub home_dir: String,
|
|
pub bind_addr: SocketAddr,
|
|
pub state_dir: PathBuf,
|
|
pub worktree_root: 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.
|
|
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
|
|
pub pty_replay_bytes: usize,
|
|
/// `TMUX_TMPDIR` exported for every `tmux` invocation (NAP-234).
|
|
/// `None` lets tmux pick its own default (`/tmp/tmux-$UID`); `Some(p)`
|
|
/// forces sockets under `p` so bare `tmux ls` in a matching user shell
|
|
/// sees maestro sessions.
|
|
pub tmux_tmpdir: Option<PathBuf>,
|
|
/// Recency cap (days) for the durable spawn history that backs the spawn
|
|
/// form's "recent dirs" picker (#24). `0` disables history. Default
|
|
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
|
|
pub spawn_history_days: u32,
|
|
/// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the
|
|
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
|
/// clients in `GET /api/info`.
|
|
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
|
}
|
|
|
|
impl DaemonConfig {
|
|
pub fn sidecar_dir(&self) -> PathBuf {
|
|
self.state_dir.join("sessions")
|
|
}
|
|
|
|
pub fn fifo_dir(&self) -> PathBuf {
|
|
self.state_dir.join("fifos")
|
|
}
|
|
|
|
/// JSONL spawn-history log path (#24): `<state_dir>/spawn-history.jsonl`.
|
|
pub fn spawn_history_path(&self) -> PathBuf {
|
|
self.state_dir.join("spawn-history.jsonl")
|
|
}
|
|
}
|
|
|
|
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
|
/// `Option`s; production reads from real env vars in `main`.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct EnvOverrides {
|
|
pub bind_addr: Option<String>,
|
|
pub tmux_tmpdir: Option<String>,
|
|
}
|
|
|
|
impl EnvOverrides {
|
|
/// Read every supported env var from the process environment.
|
|
pub fn from_env() -> Self {
|
|
Self {
|
|
bind_addr: std::env::var(ENV_DAEMON_BIND_ADDR).ok(),
|
|
tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve the runtime configuration: env overrides on top of the unified
|
|
/// file, then defaults. A missing file is not an error.
|
|
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
|
|
let file = MaestroConfigFile::load(path)?;
|
|
|
|
let daemon = file.daemon;
|
|
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
|
|
// empty command) refuses startup with a clear error, like other config
|
|
// validation (#22/#26).
|
|
let spawn_presets = file.spawn.resolve()?;
|
|
|
|
let hostname = daemon.hostname.unwrap_or_else(system_hostname);
|
|
let home_dir = dirs::home_dir()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
|
|
let bind_addr = match env.bind_addr {
|
|
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?,
|
|
None => daemon.bind_addr.unwrap_or_else(default_bind_addr),
|
|
};
|
|
|
|
let state_dir = match daemon.state_dir {
|
|
Some(p) => p,
|
|
None => default_state_dir()?,
|
|
};
|
|
let worktree_root = daemon
|
|
.worktree_root
|
|
.unwrap_or_else(|| state_dir.join("worktrees"));
|
|
|
|
let pty_replay_bytes = daemon
|
|
.pty_replay_bytes
|
|
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
|
|
let tmux_tmpdir = env
|
|
.tmux_tmpdir
|
|
.map(PathBuf::from)
|
|
.or(daemon.tmux_tmpdir)
|
|
.or_else(dirs::runtime_dir);
|
|
let spawn_history_days = daemon
|
|
.spawn_history_days
|
|
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
|
|
|
Ok(DaemonConfig {
|
|
hostname,
|
|
home_dir,
|
|
bind_addr,
|
|
state_dir,
|
|
worktree_root,
|
|
pty_replay_bytes,
|
|
tmux_tmpdir,
|
|
spawn_history_days,
|
|
spawn_presets,
|
|
})
|
|
}
|
|
|
|
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
|
Ok(maestro_config::default_state_dir("maestro")?)
|
|
}
|
|
|
|
/// Read the kernel hostname via `gethostname(2)`. Falls back to `"unknown"`
|
|
/// on the (very unlikely) syscall failure.
|
|
pub fn system_hostname() -> String {
|
|
let mut buf = [0u8; 256];
|
|
let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
|
|
if r != 0 {
|
|
return "unknown".into();
|
|
}
|
|
let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
|
String::from_utf8_lossy(&buf[..nul]).into_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn empty_file_defaults_everything() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.bind_addr, default_bind_addr());
|
|
assert!(cfg.state_dir.ends_with("maestro"));
|
|
assert!(cfg.worktree_root.ends_with("worktrees"));
|
|
assert!(!cfg.hostname.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_file_is_not_an_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("does-not-exist.toml");
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.bind_addr, default_bind_addr());
|
|
assert!(!path.exists(), "load_config must not create the file");
|
|
}
|
|
|
|
#[test]
|
|
fn env_overrides_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
bind_addr = "127.0.0.1:1"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let env = EnvOverrides {
|
|
bind_addr: Some("0.0.0.0:9999".into()),
|
|
..EnvOverrides::default()
|
|
};
|
|
let cfg = load_config(&path, env).unwrap();
|
|
assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_env_bind_addr_returns_diagnostic() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(&path, "").unwrap();
|
|
let env = EnvOverrides {
|
|
bind_addr: Some("not-an-addr".into()),
|
|
..EnvOverrides::default()
|
|
};
|
|
let err = load_config(&path, env).unwrap_err();
|
|
match err {
|
|
ConfigError::Inner(maestro_config::ConfigError::InvalidBindAddr {
|
|
input, env, ..
|
|
}) => {
|
|
assert_eq!(input, "not-an-addr");
|
|
assert_eq!(env, ENV_DAEMON_BIND_ADDR);
|
|
}
|
|
other => panic!("expected InvalidBindAddr, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hostname_from_file_wins_over_gethostname() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
hostname = "vm-42"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.hostname, "vm-42");
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_history_days_defaults_and_overrides() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
|
|
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
|
|
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
spawn_history_days = 0
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.spawn_history_days, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn system_hostname_is_nonempty() {
|
|
let h = system_hostname();
|
|
assert!(!h.is_empty(), "gethostname returned empty string");
|
|
}
|
|
|
|
// NAP-234: tmux_tmpdir precedence is env > file > dirs::runtime_dir().
|
|
// Tested without mutating the process env (which races other tests) by
|
|
// feeding `EnvOverrides` directly.
|
|
|
|
#[test]
|
|
fn tmux_tmpdir_env_beats_file_and_runtime_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
tmux_tmpdir = "/from-file"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let env = EnvOverrides {
|
|
tmux_tmpdir: Some("/from-env".into()),
|
|
..EnvOverrides::default()
|
|
};
|
|
let cfg = load_config(&path, env).unwrap();
|
|
assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-env")));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_tmpdir_file_beats_runtime_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
tmux_tmpdir = "/from-file"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-file")));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
// dirs::runtime_dir() is `Some(_)` on any Linux host with
|
|
// $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon
|
|
// falls through to None and tmux uses /tmp; either is acceptable —
|
|
// but on this test runner the env var is always present.
|
|
assert_eq!(cfg.tmux_tmpdir, dirs::runtime_dir());
|
|
}
|
|
}
|