Skip to main content

oneharness_core/io/
config.rs

1//! Locating and reading config files. This is an I/O boundary: it reads the
2//! environment, the platform config directory, and the filesystem. Parsing,
3//! validation, and layering stay pure in `src/domain/config.rs`.
4
5use std::path::{Path, PathBuf};
6
7use crate::domain::config::{self, FileConfig};
8use crate::errors::OneharnessError;
9
10/// Project-level file names, checked in this order in each directory.
11const PROJECT_FILE_NAMES: &[&str] = &["oneharness.toml", ".oneharness.toml"];
12
13/// Set to `1`/`true` to ignore all config files (same as `--no-config`); lets
14/// wrappers and hermetic test suites pin the binary's behavior regardless of
15/// what is configured on the machine.
16const NO_CONFIG_ENV: &str = "ONEHARNESS_NO_CONFIG";
17
18/// Points the user-level config at an explicit file (which must then exist),
19/// instead of the platform default `<config dir>/oneharness/config.toml`.
20const USER_CONFIG_ENV: &str = "ONEHARNESS_CONFIG";
21
22/// The fully layered configuration plus the files it actually came from.
23#[derive(Debug, Default)]
24pub struct LoadedConfig {
25    /// User and project files merged (project wins per field).
26    pub config: FileConfig,
27    /// Paths loaded, in layering order (user first, project last). Surfaced in
28    /// the run report so a consumer can see which files shaped a run.
29    pub files: Vec<String>,
30}
31
32/// Load the effective config for an invocation: [`load_layers`], folded with
33/// the domain's per-field merge.
34pub 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
47/// Locate and parse the config layers for an invocation, in layering order
48/// (user first, project last). `oneharness config` consumes the layers
49/// directly to attribute each value to its file; `run`/`detect` use [`load`].
50///
51/// - `no_config` (or `ONEHARNESS_NO_CONFIG=1`) loads nothing — neither files
52///   nor the `ONEHARNESS_*` environment overrides — so a hermetic run sees only
53///   CLI flags and built-in defaults.
54/// - `explicit` (`--config <path>`) loads exactly that file — no discovery —
55///   and a missing file is an error, since the user named it.
56/// - Otherwise: the user-level file (`$ONEHARNESS_CONFIG`, else the platform
57///   config dir) layered under the project-level file (`oneharness.toml` /
58///   `.oneharness.toml`, walking up from `project_start`). A missing
59///   discovered file is simply an absent layer, never an error.
60///
61/// The `ONEHARNESS_*` environment overrides ([`config::from_env`]) are appended
62/// as a final layer in every non-`no_config` case, so they beat every config
63/// file (an explicit `--config` included). CLI flags, applied by each command
64/// after this, still beat them — giving CLI > env > files > defaults.
65pub 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
97/// Truthy env flag: set and not `""`/`0`/`false`.
98fn 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
105/// The user-level config path: `$ONEHARNESS_CONFIG` if set (the file must
106/// exist — an explicitly named config that is missing is a configuration
107/// error, not a silent no-op), else the platform config directory.
108fn 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
124/// The per-user configuration directory, resolved like `gh` and friends:
125/// `%APPDATA%` on Windows; `$XDG_CONFIG_HOME` (else `~/.config`) everywhere
126/// else, macOS included — a dotfile-style path suits a developer CLI better
127/// than `Library/Application Support`.
128fn 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
142/// Walk up from `start` looking for a project config; the first (deepest)
143/// match wins, so a nested project shadows its parent.
144fn 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
158/// Read and parse a discovered file; `Ok(None)` when it does not exist.
159fn 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
170/// Read and parse an explicitly named file; missing is an error.
171fn 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        // A deeper file shadows the outer one, and the dotted name is honored.
207        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}