Skip to main content

hyalo_cli/
config.rs

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