muster/domain/config/
workspace.rs1use 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#[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 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 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 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 #[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}