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