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
naps62-yolo bc9279b7ae feat(maestro): add maestro-config crate with unified TOML schema (NAP-233)
Introduces the shared on-disk schema that all binaries (CLI, daemon,
registry) will load from `~/.config/maestro/config.toml`. Sections:

  [shared]    registry_url, token  — consumed by 2+ binaries
  [cli]       cookie
  [daemon]    daemon_id, hostname, daemon_url, bind_addr, state_dir, worktree_root
  [registry]  bind_addr

Exposes:
- MaestroConfigFile { shared, cli, daemon, registry } with strict load,
  load-or-default, atomic save, and per-section empty-table suppression.
- default_config_path() / resolve_config_path() honoring MAESTRO_CONFIG.
- derive_daemon_ws_url() for the daemon's scheme-swap + /daemon suffix
  off the shared http(s):// base URL.
- ConfigError::MissingFile with a hard-cutover message pointing the user
  at `maestrod configure` (legacy paths are no longer read).

Consumers wired in follow-up commits.
2026-05-26 09:21:41 +00:00

390 lines
14 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
//! [shared]
//! registry_url = "https://maestro.example.com"
//! token = "<shared secret>"
//!
//! [cli]
//! cookie = "<jwt cookie>"
//!
//! [daemon]
//! daemon_url = "ws://laptop.lan:7070"
//! bind_addr = "127.0.0.1:7070"
//!
//! [registry]
//! bind_addr = "127.0.0.1:8080"
//! ```
//!
//! The CLI consumes `[shared].registry_url` as its HTTP base; the daemon
//! derives its WS endpoint with [`derive_daemon_ws_url`] (scheme swap +
//! `/daemon` suffix).
//!
//! This crate owns the file schema, path resolution, atomic save, and the
//! URL derivation helper. Each binary applies its own env-var precedence and
//! defaults on top of [`MaestroConfigFile`].
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 `maestrod configure` to create it. \
(Legacy ~/.config/maestrod/config.toml and ~/.config/maestro/{{cli,registry}}.toml \
are no longer read.)",
path.display()
)]
MissingFile { path: PathBuf },
#[error("invalid registry_url {input:?}: {reason}")]
InvalidRegistryUrl { input: String, reason: String },
}
/// On-disk representation of the unified config. Every section is optional
/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry
/// host) is valid.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MaestroConfigFile {
#[serde(default, skip_serializing_if = "SharedSection::is_empty")]
pub shared: SharedSection,
#[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 = "RegistrySection::is_empty")]
pub registry: RegistrySection,
}
/// Fields consumed by more than one binary (CLI + daemon, daemon + registry).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SharedSection {
/// Registry base URL, e.g. `https://maestro.example.com`. CLI uses it
/// as-is for HTTP; the daemon derives its WS endpoint via
/// [`derive_daemon_ws_url`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_url: Option<String>,
/// Shared HS256 secret. Registry signs JWTs with it, daemons verify
/// (and authenticate registry calls) with it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
impl SharedSection {
fn is_empty(&self) -> bool {
self.registry_url.is_none() && self.token.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliSection {
/// Persisted `maestro_session` cookie value (a JWT). Opaque to the CLI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie: Option<String>,
}
impl CliSection {
fn is_empty(&self) -> bool {
self.cookie.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
/// Reachable URL clients dial for the daemon's per-session WS endpoint.
/// Distinct from `bind_addr` because the bind may be `0.0.0.0` while
/// the publicly reachable address is a Wireguard / VPN interface.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_url: 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>,
}
impl DaemonSection {
fn is_empty(&self) -> bool {
self.daemon_id.is_none()
&& self.hostname.is_none()
&& self.daemon_url.is_none()
&& self.bind_addr.is_none()
&& self.state_dir.is_none()
&& self.worktree_root.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistrySection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<SocketAddr>,
}
impl RegistrySection {
fn is_empty(&self) -> bool {
self.bind_addr.is_none()
}
}
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 (e.g. CLI's `set-registry` bootstrap,
/// registry's first-run token generation).
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))
}
/// Derive the daemon's WS endpoint from the shared `registry_url`.
///
/// | input | output |
/// |--------------------------|---------------------------------|
/// | `https://r.example` | `wss://r.example/daemon` |
/// | `http://r.example:8080` | `ws://r.example:8080/daemon` |
/// | `http://r/some/prefix/` | `ws://r/some/prefix/daemon` |
///
/// Trailing slashes are normalised so the result never has `//daemon`.
/// Any non-`http(s)` scheme returns [`ConfigError::InvalidRegistryUrl`].
pub fn derive_daemon_ws_url(registry_url: &str) -> Result<String, ConfigError> {
let trimmed = registry_url.trim_end_matches('/');
if let Some(rest) = trimmed.strip_prefix("https://") {
Ok(format!("wss://{rest}/daemon"))
} else if let Some(rest) = trimmed.strip_prefix("http://") {
Ok(format!("ws://{rest}/daemon"))
} else {
Err(ConfigError::InvalidRegistryUrl {
input: registry_url.to_string(),
reason: "must start with http:// or https://".into(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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("maestrod configure"), "msg was: {msg}");
assert!(msg.contains("Legacy"), "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 {
shared: SharedSection {
registry_url: Some("https://r.example".into()),
token: Some("secret".into()),
},
cli: CliSection {
cookie: Some("eyJ...".into()),
},
daemon: DaemonSection {
daemon_id: Some("laptop".into()),
hostname: Some("laptop.lan".into()),
daemon_url: Some("ws://laptop.lan:7070".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")),
},
registry: RegistrySection {
bind_addr: Some("0.0.0.0:8080".parse().unwrap()),
},
};
cfg.save(&path).unwrap();
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
}
#[test]
fn partial_file_loads_with_section_defaults() {
// Only [shared] + [registry] present (typical registry host).
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "abc"
[registry]
bind_addr = "127.0.0.1:9090"
"#,
)
.unwrap();
let cfg = MaestroConfigFile::load(&path).unwrap();
assert_eq!(cfg.shared.token.as_deref(), Some("abc"));
assert!(cfg.shared.registry_url.is_none());
assert_eq!(cfg.cli, CliSection::default());
assert_eq!(cfg.daemon, DaemonSection::default());
assert_eq!(
cfg.registry.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 {
shared: SharedSection {
token: Some("t".into()),
..Default::default()
},
..Default::default()
};
cfg.save(&path).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("[shared]"), "body: {body}");
assert!(!body.contains("[cli]"), "body: {body}");
assert!(!body.contains("[daemon]"), "body: {body}");
assert!(!body.contains("[registry]"), "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}")));
}
#[test]
fn derive_daemon_ws_url_swaps_http_to_ws() {
assert_eq!(
derive_daemon_ws_url("http://r.example").unwrap(),
"ws://r.example/daemon"
);
assert_eq!(
derive_daemon_ws_url("http://r.example:8080").unwrap(),
"ws://r.example:8080/daemon"
);
}
#[test]
fn derive_daemon_ws_url_swaps_https_to_wss() {
assert_eq!(
derive_daemon_ws_url("https://r.example").unwrap(),
"wss://r.example/daemon"
);
}
#[test]
fn derive_daemon_ws_url_normalises_trailing_slash() {
assert_eq!(
derive_daemon_ws_url("https://r.example/").unwrap(),
"wss://r.example/daemon"
);
assert_eq!(
derive_daemon_ws_url("https://r.example/path/").unwrap(),
"wss://r.example/path/daemon"
);
}
#[test]
fn derive_daemon_ws_url_rejects_non_http_schemes() {
let err = derive_daemon_ws_url("ws://r.example/daemon").unwrap_err();
match err {
ConfigError::InvalidRegistryUrl { input, .. } => {
assert_eq!(input, "ws://r.example/daemon");
}
other => panic!("expected InvalidRegistryUrl, got {other:?}"),
}
}
}