1use crate::error::ConfigError;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16pub const DEFAULT_SESSION: &str = "default";
17
18#[derive(Debug, Deserialize)]
19pub struct ProjectConfig {
20 pub session: Option<String>,
21 pub processes: HashMap<String, ProcessDef>,
22 #[serde(default)]
23 pub proxy: Option<bool>,
24 #[serde(default)]
25 pub proxy_port: Option<u16>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct ProcessDef {
30 pub cmd: String,
31 pub cwd: Option<String>,
32 #[serde(default)]
33 pub env: HashMap<String, String>,
34 pub ready: Option<String>,
35 #[serde(default)]
36 pub depends_on: Vec<String>,
37 #[serde(default)]
38 pub port: Option<u16>,
39 #[serde(default)]
40 pub autorestart: Option<String>,
41 #[serde(default)]
42 pub max_restarts: Option<u32>,
43 #[serde(default)]
44 pub restart_delay: Option<u64>,
45 #[serde(default)]
46 pub watch: Option<Vec<String>>,
47 #[serde(default)]
48 pub watch_ignore: Option<Vec<String>>,
49}
50
51impl ProjectConfig {
52 #[must_use = "startup order should be used to launch processes"]
88 pub fn startup_order(&self) -> Result<Vec<Vec<String>>, ConfigError> {
89 let mut in_degree: HashMap<&str, usize> = HashMap::new();
90 let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
91
92 for name in self.processes.keys() {
93 in_degree.entry(name.as_str()).or_insert(0);
94 }
95 for (name, def) in &self.processes {
96 for dep in &def.depends_on {
97 if !self.processes.contains_key(dep) {
98 return Err(ConfigError::UnknownDep {
99 from: name.clone(),
100 to: dep.clone(),
101 });
102 }
103 dependents
104 .entry(dep.as_str())
105 .or_default()
106 .push(name.as_str());
107 *in_degree.entry(name.as_str()).or_insert(0) += 1;
108 }
109 }
110
111 let mut groups = Vec::new();
112 let mut remaining = in_degree.clone();
113
114 loop {
115 let mut ready: Vec<String> = remaining
116 .iter()
117 .filter(|(_, deg)| **deg == 0)
118 .map(|(name, _)| (*name).to_string())
119 .collect();
120
121 if ready.is_empty() {
122 if remaining.is_empty() {
123 break;
124 }
125 return Err(ConfigError::CycleDetected);
126 }
127
128 for name in &ready {
129 remaining.remove(name.as_str());
130 if let Some(deps) = dependents.get(name.as_str()) {
131 for dep in deps {
132 if let Some(deg) = remaining.get_mut(dep) {
133 *deg -= 1;
134 }
135 }
136 }
137 }
138 ready.sort();
139 groups.push(ready);
140 }
141 Ok(groups)
142 }
143}
144
145#[must_use]
146pub fn discover_config(start: &Path) -> Option<PathBuf> {
147 let mut dir = start.to_path_buf();
148 loop {
149 let candidate = dir.join("agent-procs.yaml");
150 if candidate.exists() {
151 return Some(candidate);
152 }
153 if !dir.pop() {
154 return None;
155 }
156 }
157}
158
159#[must_use = "config should be used after loading"]
160pub fn load_config(config_path: Option<&str>) -> Result<(PathBuf, ProjectConfig), ConfigError> {
161 let path = match config_path {
162 Some(p) => PathBuf::from(p),
163 None => discover_config(&std::env::current_dir().map_err(ConfigError::Cwd)?)
164 .ok_or(ConfigError::NotFound)?,
165 };
166 let content = std::fs::read_to_string(&path).map_err(ConfigError::Read)?;
167 let config: ProjectConfig = serde_yaml::from_str(&content).map_err(ConfigError::Parse)?;
168 Ok((path, config))
169}
170
171pub fn resolve_session<'a>(
172 cli_session: Option<&'a str>,
173 config_session: Option<&'a str>,
174) -> &'a str {
175 cli_session.or(config_session).unwrap_or(DEFAULT_SESSION)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::error::ConfigError;
182 use std::collections::HashMap;
183
184 fn proc_def(cmd: &str, depends_on: Vec<&str>) -> ProcessDef {
185 ProcessDef {
186 cmd: cmd.into(),
187 cwd: None,
188 env: HashMap::new(),
189 ready: None,
190 depends_on: depends_on.into_iter().map(String::from).collect(),
191 port: None,
192 autorestart: None,
193 max_restarts: None,
194 restart_delay: None,
195 watch: None,
196 watch_ignore: None,
197 }
198 }
199
200 fn config_with(procs: Vec<(&str, Vec<&str>)>) -> ProjectConfig {
201 ProjectConfig {
202 session: None,
203 processes: procs
204 .into_iter()
205 .map(|(name, deps)| (name.into(), proc_def("true", deps)))
206 .collect(),
207 proxy: None,
208 proxy_port: None,
209 }
210 }
211
212 #[test]
213 fn test_startup_order_no_deps() {
214 let cfg = config_with(vec![("a", vec![]), ("b", vec![]), ("c", vec![])]);
215 let groups = cfg.startup_order().unwrap();
216 assert_eq!(groups.len(), 1);
217 let mut names = groups[0].clone();
218 names.sort();
219 assert_eq!(names, vec!["a", "b", "c"]);
220 }
221
222 #[test]
223 fn test_startup_order_linear_chain() {
224 let cfg = config_with(vec![("a", vec![]), ("b", vec!["a"]), ("c", vec!["b"])]);
226 let groups = cfg.startup_order().unwrap();
227 assert_eq!(groups.len(), 3);
228 assert_eq!(groups[0], vec!["a"]);
229 assert_eq!(groups[1], vec!["b"]);
230 assert_eq!(groups[2], vec!["c"]);
231 }
232
233 #[test]
234 fn test_startup_order_diamond() {
235 let cfg = config_with(vec![
237 ("a", vec![]),
238 ("b", vec!["a"]),
239 ("c", vec!["a"]),
240 ("d", vec!["b", "c"]),
241 ]);
242 let groups = cfg.startup_order().unwrap();
243 assert_eq!(groups.len(), 3);
244 assert_eq!(groups[0], vec!["a"]);
245 let mut g1 = groups[1].clone();
246 g1.sort();
247 assert_eq!(g1, vec!["b", "c"]);
248 assert_eq!(groups[2], vec!["d"]);
249 }
250
251 #[test]
252 fn test_startup_order_cycle_detected() {
253 let cfg = config_with(vec![("a", vec!["b"]), ("b", vec!["a"])]);
254 let err = cfg.startup_order().unwrap_err();
255 assert!(matches!(err, ConfigError::CycleDetected));
256 }
257
258 #[test]
259 fn test_startup_order_unknown_dep() {
260 let cfg = config_with(vec![("a", vec!["nonexistent"])]);
261 let err = cfg.startup_order().unwrap_err();
262 assert!(matches!(err, ConfigError::UnknownDep { .. }));
263 }
264
265 #[test]
266 fn test_startup_order_empty() {
267 let cfg = config_with(vec![]);
268 let groups = cfg.startup_order().unwrap();
269 assert!(groups.is_empty());
270 }
271
272 #[test]
273 fn test_discover_config_finds_file() {
274 let tmp = tempfile::tempdir().unwrap();
275 let config_path = tmp.path().join("agent-procs.yaml");
276 std::fs::write(&config_path, "processes: {}").unwrap();
277 let found = discover_config(tmp.path());
278 assert_eq!(found, Some(config_path));
279 }
280
281 #[test]
282 fn test_discover_config_not_found() {
283 let tmp = tempfile::tempdir().unwrap();
284 let found = discover_config(tmp.path());
285 assert!(found.is_none());
286 }
287
288 #[test]
289 fn test_load_config_parse_error() {
290 let tmp = tempfile::tempdir().unwrap();
291 let config_path = tmp.path().join("bad.yaml");
292 std::fs::write(&config_path, "{{{{not valid yaml at all").unwrap();
293 let err = load_config(Some(config_path.to_str().unwrap())).unwrap_err();
294 assert!(matches!(err, ConfigError::Parse(_)));
295 }
296
297 #[test]
298 fn test_resolve_session_priority() {
299 assert_eq!(resolve_session(Some("cli"), Some("config")), "cli");
301 assert_eq!(resolve_session(None, Some("config")), "config");
303 assert_eq!(resolve_session(None, None), DEFAULT_SESSION);
305 }
306}