Skip to main content

hyalo_cli/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6/// Raw deserialized representation of `.hyalo.toml`.
7///
8/// All fields are optional so that a partial config file is valid.
9/// Unknown fields are rejected via `deny_unknown_fields` so that typos
10/// are caught early rather than silently ignored.
11#[derive(Debug, Deserialize)]
12#[serde(deny_unknown_fields)]
13struct ConfigFile {
14    dir: Option<String>,
15    format: Option<String>,
16    hints: Option<bool>,
17    /// Explicit override for the site prefix used when resolving absolute links
18    /// (e.g. `/docs/page.md`).  When set, this takes precedence over the
19    /// auto-derived value (last component of the resolved `dir`).
20    site_prefix: Option<String>,
21    /// Named find-filter sets. Stored so `deny_unknown_fields` does not reject
22    /// configs that contain `[views.*]` tables. The views module reads these
23    /// directly from the TOML file; they are not propagated to `ResolvedDefaults`.
24    #[allow(dead_code)]
25    views: Option<HashMap<String, toml::Value>>,
26}
27
28/// Resolved configuration with all defaults applied.
29#[derive(Debug, PartialEq)]
30pub struct ResolvedDefaults {
31    pub dir: PathBuf,
32    pub format: String,
33    pub hints: bool,
34    /// Explicit site-prefix override from `.hyalo.toml`, if any.
35    pub site_prefix: Option<String>,
36}
37
38impl ResolvedDefaults {
39    fn hardcoded() -> Self {
40        Self {
41            dir: PathBuf::from("."),
42            format: "json".to_owned(),
43            hints: true,
44            site_prefix: None,
45        }
46    }
47}
48
49/// Load configuration from `.hyalo.toml` in the current working directory.
50///
51/// Missing file → silent, returns hardcoded defaults.
52/// I/O error (not NotFound) → prints a warning, returns defaults.
53/// Malformed TOML or unknown fields → prints a warning, returns defaults.
54/// Valid config → merges with defaults (config values take precedence).
55pub fn load_config() -> ResolvedDefaults {
56    match std::env::current_dir() {
57        Ok(cwd) => load_config_from(&cwd),
58        Err(e) => {
59            crate::warn::warn(format!(
60                "could not determine current directory to locate .hyalo.toml: {e}"
61            ));
62            ResolvedDefaults::hardcoded()
63        }
64    }
65}
66
67/// Load configuration from `.hyalo.toml` inside `dir`.
68///
69/// This variant accepts an explicit directory to make it testable without
70/// relying on the process working directory.
71pub fn load_config_from(dir: &Path) -> ResolvedDefaults {
72    let path = dir.join(".hyalo.toml");
73
74    let contents = match std::fs::read_to_string(&path) {
75        Ok(s) => s,
76        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
77            return ResolvedDefaults::hardcoded();
78        }
79        Err(e) => {
80            crate::warn::warn(format!("could not read .hyalo.toml: {e}"));
81            return ResolvedDefaults::hardcoded();
82        }
83    };
84
85    let cfg: ConfigFile = match toml::from_str(&contents) {
86        Ok(c) => c,
87        Err(e) => {
88            crate::warn::warn(format!("malformed .hyalo.toml: {e}"));
89            return ResolvedDefaults::hardcoded();
90        }
91    };
92
93    let defaults = ResolvedDefaults::hardcoded();
94    ResolvedDefaults {
95        dir: cfg.dir.map(PathBuf::from).unwrap_or(defaults.dir),
96        format: cfg.format.unwrap_or(defaults.format),
97        hints: cfg.hints.unwrap_or(defaults.hints),
98        site_prefix: cfg.site_prefix,
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::fs;
105
106    use tempfile::TempDir;
107
108    use super::*;
109
110    fn make_temp() -> TempDir {
111        tempfile::tempdir().expect("failed to create temp dir")
112    }
113
114    #[test]
115    fn missing_config_returns_defaults() {
116        let dir = make_temp();
117        let resolved = load_config_from(dir.path());
118        assert_eq!(resolved, ResolvedDefaults::hardcoded());
119    }
120
121    #[test]
122    fn valid_full_config() {
123        let dir = make_temp();
124        fs::write(
125            dir.path().join(".hyalo.toml"),
126            r#"
127dir = "notes"
128format = "text"
129hints = true
130"#,
131        )
132        .unwrap();
133
134        let resolved = load_config_from(dir.path());
135        assert_eq!(resolved.dir, PathBuf::from("notes"));
136        assert_eq!(resolved.format, "text");
137        assert!(resolved.hints);
138        assert_eq!(resolved.site_prefix, None);
139    }
140
141    #[test]
142    fn site_prefix_config() {
143        let dir = make_temp();
144        fs::write(
145            dir.path().join(".hyalo.toml"),
146            r#"dir = "docs"
147site_prefix = "docs"
148"#,
149        )
150        .unwrap();
151
152        let resolved = load_config_from(dir.path());
153        assert_eq!(resolved.dir, PathBuf::from("docs"));
154        assert_eq!(resolved.site_prefix, Some("docs".to_owned()));
155    }
156
157    #[test]
158    fn partial_config_merges_with_defaults() {
159        let dir = make_temp();
160        fs::write(dir.path().join(".hyalo.toml"), "hints = false\n").unwrap();
161
162        let resolved = load_config_from(dir.path());
163        // Only hints overridden; dir and format stay at defaults.
164        assert_eq!(resolved.dir, PathBuf::from("."));
165        assert_eq!(resolved.format, "json");
166        assert!(
167            !resolved.hints,
168            "config should override the default (true) to false"
169        );
170    }
171
172    #[test]
173    fn malformed_toml_returns_defaults() {
174        let dir = make_temp();
175        fs::write(dir.path().join(".hyalo.toml"), "this is not { valid toml").unwrap();
176
177        let resolved = load_config_from(dir.path());
178        assert_eq!(resolved, ResolvedDefaults::hardcoded());
179    }
180
181    #[test]
182    fn unknown_fields_returns_defaults() {
183        let dir = make_temp();
184        fs::write(dir.path().join(".hyalo.toml"), "unknown_key = \"value\"\n").unwrap();
185
186        let resolved = load_config_from(dir.path());
187        assert_eq!(resolved, ResolvedDefaults::hardcoded());
188    }
189
190    #[test]
191    fn invalid_format_value_passed_through() {
192        let dir = make_temp();
193        fs::write(dir.path().join(".hyalo.toml"), "format = \"xml\"\n").unwrap();
194
195        // config.rs does not validate the format string — that is the caller's job.
196        let resolved = load_config_from(dir.path());
197        assert_eq!(resolved.format, "xml");
198        assert_eq!(resolved.dir, PathBuf::from("."));
199        assert!(resolved.hints);
200    }
201}