Skip to main content

agent_procs/
config.rs

1//! YAML configuration file parsing and dependency-ordered startup.
2//!
3//! An `agent-procs.yaml` file declares a set of [`ProcessDef`]s, optional
4//! session name, and proxy settings.  [`ProjectConfig::startup_order`]
5//! topologically sorts processes by `depends_on` edges so they can be
6//! launched in the correct order.
7//!
8//! Use [`load_config`] to read and parse the file, or [`discover_config`]
9//! to walk up from the current directory until one is found.
10
11use 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    /// Compute the startup order by topologically sorting processes.
53    ///
54    /// Returns groups of process names; processes within a group are
55    /// independent and can start concurrently.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use agent_procs::config::{ProjectConfig, ProcessDef};
61    /// use std::collections::HashMap;
62    ///
63    /// let config = ProjectConfig {
64    ///     session: None,
65    ///     processes: HashMap::from([
66    ///         ("db".into(), ProcessDef {
67    ///             cmd: "pg".into(), cwd: None, env: HashMap::new(),
68    ///             ready: None, depends_on: vec![], port: None,
69    ///             autorestart: None, max_restarts: None, restart_delay: None,
70    ///             watch: None, watch_ignore: None,
71    ///         }),
72    ///         ("api".into(), ProcessDef {
73    ///             cmd: "node".into(), cwd: None, env: HashMap::new(),
74    ///             ready: None, depends_on: vec!["db".into()], port: None,
75    ///             autorestart: None, max_restarts: None, restart_delay: None,
76    ///             watch: None, watch_ignore: None,
77    ///         }),
78    ///     ]),
79    ///     proxy: None,
80    ///     proxy_port: None,
81    /// };
82    /// let groups = config.startup_order().unwrap();
83    /// assert_eq!(groups.len(), 2);
84    /// assert_eq!(groups[0], vec!["db"]);
85    /// assert_eq!(groups[1], vec!["api"]);
86    /// ```
87    #[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        // a depends on nothing, b depends on a, c depends on b
225        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        // d depends on b and c; b and c depend on a
236        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        // cli_session takes highest priority
300        assert_eq!(resolve_session(Some("cli"), Some("config")), "cli");
301        // config_session if no cli
302        assert_eq!(resolve_session(None, Some("config")), "config");
303        // DEFAULT_SESSION as fallback
304        assert_eq!(resolve_session(None, None), DEFAULT_SESSION);
305    }
306}