oneharness_core/io/
config.rs1use std::path::{Path, PathBuf};
6
7use crate::domain::config::{self, FileConfig};
8use crate::errors::OneharnessError;
9
10const PROJECT_FILE_NAMES: &[&str] = &["oneharness.toml", ".oneharness.toml"];
12
13const NO_CONFIG_ENV: &str = "ONEHARNESS_NO_CONFIG";
17
18const USER_CONFIG_ENV: &str = "ONEHARNESS_CONFIG";
21
22#[derive(Debug, Default)]
24pub struct LoadedConfig {
25 pub config: FileConfig,
27 pub files: Vec<String>,
30}
31
32pub fn load(
35 explicit: Option<&Path>,
36 no_config: bool,
37 project_start: &Path,
38) -> Result<LoadedConfig, OneharnessError> {
39 let mut loaded = LoadedConfig::default();
40 for (path, layer) in load_layers(explicit, no_config, project_start)? {
41 loaded.config = config::merge(loaded.config, layer);
42 loaded.files.push(path);
43 }
44 Ok(loaded)
45}
46
47pub fn load_layers(
66 explicit: Option<&Path>,
67 no_config: bool,
68 project_start: &Path,
69) -> Result<Vec<(String, FileConfig)>, OneharnessError> {
70 if no_config || env_flag(NO_CONFIG_ENV) {
71 return Ok(Vec::new());
72 }
73
74 let mut layers = Vec::new();
75 if let Some(path) = explicit {
76 layers.push((path.display().to_string(), read_required(path)?));
77 } else {
78 if let Some(path) = user_config_path()? {
79 if let Some(user) = read_optional(&path)? {
80 layers.push((path.display().to_string(), user));
81 }
82 }
83 if let Some(path) = find_project_file(project_start) {
84 if let Some(project) = read_optional(&path)? {
85 layers.push((path.display().to_string(), project));
86 }
87 }
88 }
89 if let Some(env) = config::from_env(|name| std::env::var(name).ok())
90 .map_err(OneharnessError::EnvConfigInvalid)?
91 {
92 layers.push((config::ENV_SOURCE.to_string(), env));
93 }
94 Ok(layers)
95}
96
97fn env_flag(key: &str) -> bool {
99 match std::env::var(key) {
100 Ok(v) => !matches!(v.as_str(), "" | "0" | "false"),
101 Err(_) => false,
102 }
103}
104
105fn user_config_path() -> Result<Option<PathBuf>, OneharnessError> {
109 if let Ok(value) = std::env::var(USER_CONFIG_ENV) {
110 if !value.is_empty() {
111 let path = PathBuf::from(&value);
112 if !path.is_file() {
113 return Err(OneharnessError::ConfigInvalid {
114 path: value,
115 message: format!("{USER_CONFIG_ENV} points at a file that does not exist"),
116 });
117 }
118 return Ok(Some(path));
119 }
120 }
121 Ok(platform_config_dir().map(|d| d.join("oneharness").join("config.toml")))
122}
123
124fn platform_config_dir() -> Option<PathBuf> {
129 if cfg!(windows) {
130 return std::env::var_os("APPDATA")
131 .filter(|v| !v.is_empty())
132 .map(PathBuf::from);
133 }
134 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
135 return Some(PathBuf::from(xdg));
136 }
137 std::env::var_os("HOME")
138 .filter(|v| !v.is_empty())
139 .map(|home| PathBuf::from(home).join(".config"))
140}
141
142fn find_project_file(start: &Path) -> Option<PathBuf> {
145 let mut dir = Some(start);
146 while let Some(d) = dir {
147 for name in PROJECT_FILE_NAMES {
148 let candidate = d.join(name);
149 if candidate.is_file() {
150 return Some(candidate);
151 }
152 }
153 dir = d.parent();
154 }
155 None
156}
157
158fn read_optional(path: &Path) -> Result<Option<FileConfig>, OneharnessError> {
160 match std::fs::read_to_string(path) {
161 Ok(text) => parse_at(path, &text).map(Some),
162 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
163 Err(source) => Err(OneharnessError::ConfigRead {
164 path: path.display().to_string(),
165 source,
166 }),
167 }
168}
169
170fn read_required(path: &Path) -> Result<FileConfig, OneharnessError> {
172 let text = std::fs::read_to_string(path).map_err(|source| OneharnessError::ConfigRead {
173 path: path.display().to_string(),
174 source,
175 })?;
176 parse_at(path, &text)
177}
178
179fn parse_at(path: &Path, text: &str) -> Result<FileConfig, OneharnessError> {
180 config::parse(text).map_err(|message| OneharnessError::ConfigInvalid {
181 path: path.display().to_string(),
182 message,
183 })
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn temp_dir(tag: &str) -> PathBuf {
191 let dir = std::env::temp_dir().join(format!("oneharness-cfg-{tag}-{}", std::process::id()));
192 let _ = std::fs::remove_dir_all(&dir);
193 std::fs::create_dir_all(&dir).unwrap();
194 dir
195 }
196
197 #[test]
198 fn project_file_is_found_walking_up() {
199 let root = temp_dir("walk");
200 let nested = root.join("a").join("b");
201 std::fs::create_dir_all(&nested).unwrap();
202 std::fs::write(root.join("oneharness.toml"), "model = \"outer\"").unwrap();
203 let found = find_project_file(&nested).unwrap();
204 assert_eq!(found, root.join("oneharness.toml"));
205
206 std::fs::write(nested.join(".oneharness.toml"), "model = \"inner\"").unwrap();
208 let found = find_project_file(&nested).unwrap();
209 assert_eq!(found, nested.join(".oneharness.toml"));
210 let _ = std::fs::remove_dir_all(&root);
211 }
212
213 #[test]
214 fn missing_discovered_file_is_an_absent_layer() {
215 let dir = temp_dir("missing");
216 assert!(read_optional(&dir.join("oneharness.toml"))
217 .unwrap()
218 .is_none());
219 let _ = std::fs::remove_dir_all(&dir);
220 }
221
222 #[test]
223 fn invalid_file_carries_its_path_in_the_error() {
224 let dir = temp_dir("invalid");
225 let path = dir.join("oneharness.toml");
226 std::fs::write(&path, "not = valid = toml").unwrap();
227 let err = read_optional(&path).unwrap_err();
228 assert!(err.to_string().contains("oneharness.toml"), "{err}");
229 let _ = std::fs::remove_dir_all(&dir);
230 }
231}