feat(maestro): [spawn].presets config schema + resolution (#22)
ci / rust (push) Successful in 11m45s

Add an optional `[spawn]` table to the unified config with a `presets` list
of `{ id, label, command }` tiles for the spawn form, defined in shared
config so every binary sees the same list (web via daemon advertisement in
#26, TUI later).

`SpawnSection::resolve` returns the presets to show in order: configured
`presets` if present (replacing the built-ins), else the `claude`/`opencode`/
`zsh` built-ins; then the implicit free-text `custom` tile is always
appended. Validates id non-empty/unique/not-reserved and command non-empty.

Closes #22

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:41:05 +00:00
co-authored by Claude Opus 4.8
parent 317f8c52d0
commit 7f3ac21402
+279
View File
@@ -85,6 +85,8 @@ pub struct MaestroConfigFile {
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,
}
/// Fields consumed by more than one binary (CLI + daemon, daemon + registry).
@@ -256,6 +258,128 @@ impl TuiSection {
}
}
/// `[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> {
@@ -472,11 +596,166 @@ mod tests {
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 {
shared: SharedSection {
token: Some("t".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 [shared] + [registry] present (typical registry host).