feat(maestro): [spawn].presets config schema + resolution (#22)
ci / rust (push) Successful in 11m45s
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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user