Skip to main content

muster/domain/config/
workspace.rs

1use getset::{Getters, WithSetters};
2use serde::{Deserialize, Serialize};
3use typed_builder::TypedBuilder;
4
5use crate::domain::{
6    config::{ConfigError, ProcessSpec},
7    process::{Process, ProcessKind},
8    value::PaneId,
9};
10
11/// The full workspace definition: processes grouped by section, matching the
12/// sidebar's AGENTS / TERMINALS / COMMANDS layout. Every section must be present
13/// in the file; use an empty list for a section with no processes.
14#[derive(Clone, Debug, Serialize, Deserialize, Getters, WithSetters, TypedBuilder)]
15#[set_with]
16pub struct WorkspaceConfig {
17    #[getset(get = "pub", set_with = "pub")]
18    agents: Vec<ProcessSpec>,
19    #[getset(get = "pub", set_with = "pub")]
20    terminals: Vec<ProcessSpec>,
21    #[getset(get = "pub", set_with = "pub")]
22    commands: Vec<ProcessSpec>,
23}
24
25impl WorkspaceConfig {
26    /// Validates cross-field rules that Serde cannot express.
27    ///
28    /// # Errors
29    /// Returns [`ConfigError::InvalidStopPolicy`] when an agent or terminal
30    /// configures the command-only graceful shutdown policy.
31    pub fn validate(&self) -> Result<(), ConfigError> {
32        for (kind, specs) in [
33            (ProcessKind::Agent, &self.agents),
34            (ProcessKind::Terminal, &self.terminals),
35        ] {
36            if let Some(spec) = specs.iter().find(|spec| spec.stop().is_some()) {
37                return Err(ConfigError::InvalidStopPolicy {
38                    kind,
39                    name: spec.name().clone(),
40                });
41            }
42        }
43        Ok(())
44    }
45
46    /// Flattens every section into ordered `Process` entities, assigning each a
47    /// stable `PaneId` and tagging it with its section's kind.
48    pub fn to_processes(&self) -> Vec<Process> {
49        let sections = [
50            (ProcessKind::Agent, &self.agents),
51            (ProcessKind::Terminal, &self.terminals),
52            (ProcessKind::Command, &self.commands),
53        ];
54        let mut processes = Vec::new();
55        let mut next_id = 0;
56        for (kind, specs) in sections {
57            for spec in specs {
58                processes.push(spec.to_process(PaneId::new(next_id), kind));
59                next_id += 1;
60            }
61        }
62        processes
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use std::time::Duration;
69
70    use super::*;
71    use crate::domain::process::{ProcessState, RestartPolicy, StopSignal};
72
73    /// Grace period configured by the sample command.
74    const SAMPLE_STOP_GRACE: Duration = Duration::from_secs(5);
75    const SAMPLE: &str = r#"
76agents:
77  - name: Claude Code
78    command: claude
79    description: Primary coding agent
80terminals:
81  - name: Blank terminal
82    command: bash
83commands:
84  - name: npm:dev
85    command: npm run dev
86    restart: on_failure
87    stop:
88      signal: interrupt
89      grace_period: 5s
90"#;
91
92    #[test]
93    fn parses_sections_and_flattens_to_processes() {
94        let config: WorkspaceConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
95        let processes = config.to_processes();
96
97        assert_eq!(processes.len(), 3);
98        assert_eq!(*processes[0].kind(), ProcessKind::Agent);
99        assert_eq!(*processes[1].kind(), ProcessKind::Terminal);
100        assert_eq!(*processes[2].kind(), ProcessKind::Command);
101        assert_eq!(processes[0].name().as_ref(), "Claude Code");
102        assert_eq!(*processes[0].state(), ProcessState::Pending);
103        let stop = processes[2].stop().as_ref().unwrap();
104        assert_eq!(*stop.signal(), StopSignal::Interrupt);
105        assert_eq!(*stop.grace_period(), SAMPLE_STOP_GRACE);
106    }
107
108    #[test]
109    fn rejects_empty_process_name() {
110        let bad = r#"
111agents:
112  - name: "  "
113    command: claude
114terminals: []
115commands: []
116"#;
117        assert!(serde_yaml_ng::from_str::<WorkspaceConfig>(bad).is_err());
118    }
119
120    #[test]
121    fn absent_restart_defaults_to_never() {
122        let config: WorkspaceConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
123        assert_eq!(config.agents()[0].restart_policy(), RestartPolicy::Never);
124        assert_eq!(
125            config.commands()[0].restart_policy(),
126            RestartPolicy::OnFailure
127        );
128    }
129
130    /// Agents and terminals cannot opt into command-only shutdown behavior.
131    #[test]
132    fn rejects_a_stop_policy_outside_commands() {
133        let invalid: WorkspaceConfig = serde_yaml_ng::from_str(
134            r#"
135agents:
136  - name: Claude
137    stop:
138      signal: terminate
139      grace_period: 5s
140terminals: []
141commands: []
142"#,
143        )
144        .unwrap();
145
146        assert!(matches!(
147            invalid.validate(),
148            Err(ConfigError::InvalidStopPolicy {
149                kind: ProcessKind::Agent,
150                ..
151            })
152        ));
153    }
154}