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
30pub 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 unsafe { std::env::set_var(key, value) };
208 }
209
210 fn remove_env_var(key: &str) {
211 unsafe { std::env::remove_var(key) };
214 }
215}