diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index d690b9b..4855e77 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -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>, +} + +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, 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 { + 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 { @@ -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 = 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 = 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 = 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).