Skip to main content

wt/config/
mod.rs

1//! Configuration loading and merging (spec ยง11).
2//!
3//! Two file layers are merged over the built-in defaults: the global
4//! `config.toml` (under the platform config dir or `$XDG_CONFIG_HOME/wt`) and
5//! the per-repo `.wt.toml` at the repo root. Per-repo overrides global; both are
6//! parsed and validated on every invocation ([`load`]).
7
8mod parse;
9mod schema;
10pub mod wtconfig;
11
12use std::path::{Path, PathBuf};
13
14use directories::ProjectDirs;
15
16use crate::cx::Env;
17use crate::error::{Error, Result};
18
19pub use parse::parse_layer;
20pub use schema::{Config, ConfigLayer, SubmoduleInit};
21pub use wtconfig::WtMeta;
22
23/// The path to the global `config.toml`, honoring `$XDG_CONFIG_HOME` and falling
24/// back to the platform config directory. `None` only if no home directory can
25/// be determined.
26pub fn global_config_path(env: &Env) -> Option<PathBuf> {
27    if let Some(xdg) = env.get("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {
28        return Some(PathBuf::from(xdg).join("wt").join("config.toml"));
29    }
30    ProjectDirs::from("", "", "wt").map(|dirs| dirs.config_dir().join("config.toml"))
31}
32
33/// The path to the per-repo `.wt.toml` at `repo_root`.
34pub fn repo_config_path(repo_root: &Path) -> PathBuf {
35    repo_root.join(".wt.toml")
36}
37
38/// Reads a file's text, returning `None` if it does not exist.
39fn read_if_exists(path: &Path) -> Result<Option<String>> {
40    match std::fs::read_to_string(path) {
41        Ok(text) => Ok(Some(text)),
42        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
43        Err(e) => Err(Error::Config {
44            file: path.display().to_string(),
45            key: String::new(),
46            reason: format!("cannot read: {e}"),
47        }),
48    }
49}
50
51/// Loads and merges the global and per-repo configuration over the defaults.
52/// `repo_root` is `None` when not inside a repository (only the global layer is
53/// considered).
54pub fn load(repo_root: Option<&Path>, env: &Env) -> Result<Config> {
55    let mut config = Config::default();
56    if let Some(global) = global_config_path(env)
57        && let Some(text) = read_if_exists(&global)?
58    {
59        config.apply(parse_layer(&text, &global.display().to_string())?);
60    }
61    if let Some(root) = repo_root {
62        let per_repo = repo_config_path(root);
63        if let Some(text) = read_if_exists(&per_repo)? {
64            config.apply(parse_layer(&text, &per_repo.display().to_string())?);
65        }
66    }
67    Ok(config)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use std::collections::HashMap;
74
75    fn env(pairs: &[(&str, &str)]) -> Env {
76        Env::from_map(
77            pairs
78                .iter()
79                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
80                .collect::<HashMap<_, _>>(),
81        )
82    }
83
84    #[test]
85    fn global_path_uses_xdg_when_set() {
86        let e = env(&[("XDG_CONFIG_HOME", "/cfg")]);
87        assert_eq!(
88            global_config_path(&e),
89            Some(PathBuf::from("/cfg/wt/config.toml"))
90        );
91    }
92
93    #[test]
94    fn global_path_falls_back_to_platform_dir() {
95        // With XDG unset, the platform config dir is used (present on macOS/Linux).
96        assert!(global_config_path(&env(&[])).is_some());
97    }
98
99    #[test]
100    fn load_without_files_yields_defaults() {
101        let dir = tempfile::tempdir().unwrap();
102        let e = env(&[("XDG_CONFIG_HOME", dir.path().to_str().unwrap())]);
103        let config = load(Some(dir.path()), &e).unwrap();
104        assert_eq!(config, Config::default());
105    }
106
107    #[test]
108    fn per_repo_overrides_global() {
109        let global_dir = tempfile::tempdir().unwrap();
110        let repo_dir = tempfile::tempdir().unwrap();
111        // Global config: set remote + disable mouse.
112        let global_wt = global_dir.path().join("wt");
113        std::fs::create_dir_all(&global_wt).unwrap();
114        std::fs::write(
115            global_wt.join("config.toml"),
116            "pr.default_remote = \"global-remote\"\n[ui]\nmouse = false\n",
117        )
118        .unwrap();
119        // Per-repo: override only the remote.
120        std::fs::write(
121            repo_config_path(repo_dir.path()),
122            "[pr]\ndefault_remote = \"repo-remote\"\n",
123        )
124        .unwrap();
125
126        let e = env(&[("XDG_CONFIG_HOME", global_dir.path().to_str().unwrap())]);
127        let config = load(Some(repo_dir.path()), &e).unwrap();
128        assert_eq!(config.pr_default_remote, "repo-remote"); // per-repo wins
129        assert!(!config.ui_mouse); // inherited from global
130    }
131
132    #[test]
133    fn load_propagates_validation_errors() {
134        let repo_dir = tempfile::tempdir().unwrap();
135        std::fs::write(repo_config_path(repo_dir.path()), "bogus_key = 1\n").unwrap();
136        let e = env(&[("XDG_CONFIG_HOME", "/nonexistent-xyz")]);
137        let err = load(Some(repo_dir.path()), &e).unwrap_err();
138        assert!(matches!(err, Error::Config { .. }));
139    }
140
141    #[test]
142    fn load_without_repo_uses_global_only() {
143        let global_dir = tempfile::tempdir().unwrap();
144        let global_wt = global_dir.path().join("wt");
145        std::fs::create_dir_all(&global_wt).unwrap();
146        std::fs::write(global_wt.join("config.toml"), "default_base = \"trunk\"\n").unwrap();
147        let e = env(&[("XDG_CONFIG_HOME", global_dir.path().to_str().unwrap())]);
148        let config = load(None, &e).unwrap();
149        assert_eq!(config.default_base.as_deref(), Some("trunk"));
150    }
151}