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}
40
41impl ProjectConfig {
42    /// Compute the startup order by topologically sorting processes.
43    ///
44    /// Returns groups of process names; processes within a group are
45    /// independent and can start concurrently.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use agent_procs::config::{ProjectConfig, ProcessDef};
51    /// use std::collections::HashMap;
52    ///
53    /// let config = ProjectConfig {
54    ///     session: None,
55    ///     processes: HashMap::from([
56    ///         ("db".into(), ProcessDef {
57    ///             cmd: "pg".into(), cwd: None, env: HashMap::new(),
58    ///             ready: None, depends_on: vec![], port: None,
59    ///         }),
60    ///         ("api".into(), ProcessDef {
61    ///             cmd: "node".into(), cwd: None, env: HashMap::new(),
62    ///             ready: None, depends_on: vec!["db".into()], port: None,
63    ///         }),
64    ///     ]),
65    ///     proxy: None,
66    ///     proxy_port: None,
67    /// };
68    /// let groups = config.startup_order().unwrap();
69    /// assert_eq!(groups.len(), 2);
70    /// assert_eq!(groups[0], vec!["db"]);
71    /// assert_eq!(groups[1], vec!["api"]);
72    /// ```
73    #[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        // a depends on nothing, b depends on a, c depends on b
206        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        // d depends on b and c; b and c depend on a
217        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        // cli_session takes highest priority
281        assert_eq!(resolve_session(Some("cli"), Some("config")), "cli");
282        // config_session if no cli
283        assert_eq!(resolve_session(None, Some("config")), "config");
284        // DEFAULT_SESSION as fallback
285        assert_eq!(resolve_session(None, None), DEFAULT_SESSION);
286    }
287}