Skip to main content

agent_sim/config/
mod.rs

1pub mod error;
2pub mod recipe;
3
4use crate::config::error::ConfigError;
5use crate::config::recipe::{EnvDef, FileConfig, RecipeDef, parse_config};
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Default)]
9pub struct AppConfig {
10    pub file: FileConfig,
11    pub source_path: Option<PathBuf>,
12}
13
14impl AppConfig {
15    pub fn recipe(&self, name: &str) -> Result<&RecipeDef, ConfigError> {
16        self.file
17            .recipe
18            .get(name)
19            .ok_or_else(|| ConfigError::MissingRecipe(name.to_string()))
20    }
21
22    pub fn env(&self, name: &str) -> Result<&EnvDef, ConfigError> {
23        self.file
24            .env
25            .get(name)
26            .ok_or_else(|| ConfigError::InvalidRecipeStep(format!("missing env: {name}")))
27    }
28}
29
30/// Load config with first-match priority:
31/// 1. Explicit path (`--config`)
32/// 2. `AGENT_SIM_CONFIG` env var
33/// 3. `./agent-sim.toml` in cwd
34/// 4. Empty defaults
35pub fn load_config(explicit_path: Option<&Path>) -> Result<AppConfig, ConfigError> {
36    if let Some(path) = explicit_path {
37        return load_single(path);
38    }
39
40    if let Ok(path) = std::env::var("AGENT_SIM_CONFIG") {
41        return load_single(Path::new(&path));
42    }
43
44    let project_path = std::env::current_dir()?.join("agent-sim.toml");
45    if project_path.exists() {
46        return load_single(&project_path);
47    }
48
49    Ok(AppConfig::default())
50}
51
52fn load_single(path: &Path) -> Result<AppConfig, ConfigError> {
53    let content = std::fs::read_to_string(path)?;
54    let file = parse_config(&content)?;
55    Ok(AppConfig {
56        file,
57        source_path: Some(path.to_path_buf()),
58    })
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use serial_test::serial;
65    use std::ffi::OsString;
66    use std::io::Write;
67
68    #[test]
69    #[serial]
70    fn explicit_path_takes_priority() {
71        let mut temp = tempfile::NamedTempFile::new().expect("temp config should be creatable");
72        let content = r#"
73[defaults]
74json = true
75speed = 2.0
76"#;
77        std::io::Write::write_all(&mut temp, content.as_bytes())
78            .expect("temp config should be writable");
79
80        let config = load_config(Some(temp.path())).expect("explicit config should load");
81        let defaults = config
82            .file
83            .defaults
84            .expect("defaults should be present in explicit config");
85        assert_eq!(defaults.json, Some(true));
86        assert_eq!(defaults.speed, Some(2.0));
87        assert_eq!(config.source_path, Some(temp.path().to_path_buf()));
88    }
89
90    #[test]
91    #[serial]
92    fn env_var_used_when_no_explicit_path() {
93        let sandbox = tempfile::tempdir().expect("sandbox tempdir should be creatable");
94        let env_cfg_path = sandbox.path().join("env.toml");
95        let mut env_cfg =
96            std::fs::File::create(&env_cfg_path).expect("env config file should be creatable");
97        env_cfg
98            .write_all(
99                br#"
100[defaults]
101json = true
102speed = 3.0
103
104[recipe.only_env]
105steps = [{ step = "5ms" }]
106"#,
107            )
108            .expect("env config should be writable");
109
110        let env_guard = TestEnvGuard::new();
111        set_env_var("AGENT_SIM_CONFIG", &env_cfg_path);
112
113        let config = load_config(None).expect("env config should load");
114        let defaults = config
115            .file
116            .defaults
117            .expect("defaults should exist in env config");
118        assert_eq!(defaults.json, Some(true));
119        assert_eq!(defaults.speed, Some(3.0));
120        assert!(config.file.recipe.contains_key("only_env"));
121        assert_eq!(config.source_path, Some(env_cfg_path));
122
123        env_guard.restore();
124    }
125
126    #[test]
127    #[serial]
128    fn project_local_config_loaded_from_cwd() {
129        let sandbox = tempfile::tempdir().expect("sandbox tempdir should be creatable");
130        let project_dir = sandbox.path().join("project");
131        std::fs::create_dir_all(&project_dir).expect("project dir should be creatable");
132        std::fs::write(
133            project_dir.join("agent-sim.toml"),
134            r#"
135[defaults]
136speed = 1.5
137
138[recipe.check]
139steps = [{ print = "*" }]
140"#,
141        )
142        .expect("project config should be writable");
143
144        let env_guard = TestEnvGuard::new();
145        remove_env_var("AGENT_SIM_CONFIG");
146        std::env::set_current_dir(&project_dir).expect("should change current dir to project");
147
148        let config = load_config(None).expect("project config should load");
149        let defaults = config
150            .file
151            .defaults
152            .expect("defaults should exist in project config");
153        assert_eq!(defaults.speed, Some(1.5));
154        assert!(config.file.recipe.contains_key("check"));
155
156        env_guard.restore();
157    }
158
159    #[test]
160    #[serial]
161    fn returns_empty_defaults_when_no_config_found() {
162        let sandbox = tempfile::tempdir().expect("sandbox tempdir should be creatable");
163
164        let env_guard = TestEnvGuard::new();
165        remove_env_var("AGENT_SIM_CONFIG");
166        std::env::set_current_dir(sandbox.path()).expect("should change cwd");
167
168        let config = load_config(None).expect("should return empty defaults");
169        assert!(config.file.defaults.is_none());
170        assert!(config.file.recipe.is_empty());
171        assert!(config.source_path.is_none());
172
173        env_guard.restore();
174    }
175
176    struct TestEnvGuard {
177        cwd: PathBuf,
178        agent_sim_config: Option<OsString>,
179    }
180
181    impl TestEnvGuard {
182        fn new() -> Self {
183            Self {
184                cwd: std::env::current_dir().expect("cwd should be readable"),
185                agent_sim_config: std::env::var_os("AGENT_SIM_CONFIG"),
186            }
187        }
188
189        fn restore(&self) {
190            std::env::set_current_dir(&self.cwd).expect("cwd should restore");
191            match &self.agent_sim_config {
192                Some(v) => set_env_var("AGENT_SIM_CONFIG", v),
193                None => remove_env_var("AGENT_SIM_CONFIG"),
194            }
195        }
196    }
197
198    impl Drop for TestEnvGuard {
199        fn drop(&mut self) {
200            self.restore();
201        }
202    }
203
204    fn set_env_var(key: &str, value: impl AsRef<std::ffi::OsStr>) {
205        // SAFETY: tests in this module are marked `#[serial]` to prevent concurrent
206        // environment mutation across threads/processes while these variables are changed.
207        unsafe { std::env::set_var(key, value) };
208    }
209
210    fn remove_env_var(key: &str) {
211        // SAFETY: tests in this module are marked `#[serial]` to prevent concurrent
212        // environment mutation across threads/processes while these variables are changed.
213        unsafe { std::env::remove_var(key) };
214    }
215}