Skip to main content

hyalo_cli/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context as _;
5use serde::Deserialize;
6
7use hyalo_core::case_index::CaseInsensitiveMode;
8use hyalo_core::schema::{RawSchemaConfig, SchemaConfig};
9
10/// Search-specific configuration from `[search]` in `.hyalo.toml`.
11#[derive(Debug, Deserialize)]
12#[serde(deny_unknown_fields)]
13struct SearchConfig {
14    language: Option<String>,
15}
16
17/// Link-extraction configuration from `[links]` in `.hyalo.toml`.
18#[derive(Debug, Deserialize)]
19#[serde(deny_unknown_fields)]
20struct LinksConfig {
21    /// Frontmatter property names whose values are scanned for `[[wikilink]]`
22    /// strings and included in the link graph. Overrides the built-in defaults
23    /// (`related`, `depends-on`, `supersedes`, `superseded-by`).
24    frontmatter_properties: Option<Vec<String>>,
25    /// Case-insensitive link resolution mode.
26    ///
27    /// Accepted values: `"auto"` (default), `"true"`, `"false"`.
28    /// - `"auto"` — enables fallback only on case-insensitive filesystems.
29    /// - `"true"` — always enable case-insensitive fallback.
30    /// - `"false"` — always disable; exact-match only.
31    #[serde(default)]
32    case_insensitive: Option<String>,
33}
34
35/// Lint configuration from `[lint]` in `.hyalo.toml`.
36#[derive(Debug, Deserialize)]
37#[serde(deny_unknown_fields)]
38struct LintConfig {
39    /// Vault-relative paths or glob patterns to skip during `hyalo lint`.
40    /// Files matching any entry are excluded from lint output. Entries without
41    /// glob meta-characters are matched literally against the normalized
42    /// vault-relative path (`/` separators); other entries use the standard
43    /// globset semantics (`**/*.md`, `dir/*.md`, etc.). This only affects the
44    /// `lint` command — read-only commands still surface their own frontmatter
45    /// parse-error warnings for these files.
46    #[serde(default)]
47    ignore: Vec<String>,
48}
49
50/// Raw deserialized representation of `.hyalo.toml`.
51///
52/// All fields are optional so that a partial config file is valid.
53/// Unknown fields are rejected via `deny_unknown_fields` so that typos
54/// are caught early rather than silently ignored.
55#[derive(Debug, Deserialize)]
56#[serde(deny_unknown_fields)]
57struct ConfigFile {
58    dir: Option<String>,
59    format: Option<String>,
60    hints: Option<bool>,
61    /// Explicit override for the site prefix used when resolving absolute links
62    /// (e.g. `/docs/page.md`).  When set, this takes precedence over the
63    /// auto-derived value (last component of the resolved `dir`).
64    site_prefix: Option<String>,
65    /// Named find-filter sets. Stored so `deny_unknown_fields` does not reject
66    /// configs that contain `[views.*]` tables. The views module reads these
67    /// directly from the TOML file; they are not propagated to `ResolvedDefaults`.
68    #[allow(dead_code)]
69    views: Option<HashMap<String, toml::Value>>,
70    /// Search configuration (BM25 stemming language, etc.)
71    search: Option<SearchConfig>,
72    /// Link extraction configuration (frontmatter property names to scan).
73    links: Option<LinksConfig>,
74    /// When `true`, schema validation runs automatically on every `set`/`append`.
75    /// Accepted as a top-level key for backwards compatibility; the documented
76    /// location is `[schema] validate_on_write`.
77    validate_on_write: Option<bool>,
78    /// Lint-specific configuration (`[lint]` section).
79    lint: Option<LintConfig>,
80    /// Schema configuration for document type validation.
81    /// Stored as raw TOML value to avoid `deny_unknown_fields` issues with
82    /// the deeply nested schema structure. Also hosts `validate_on_write` —
83    /// see `extract_schema_validate_on_write`.
84    #[serde(default)]
85    schema: Option<toml::Value>,
86    /// Default output limit for list commands (0 = unlimited).
87    default_limit: Option<usize>,
88}
89
90/// Resolved configuration with all defaults applied.
91#[derive(Debug)]
92pub(crate) struct ResolvedDefaults {
93    pub(crate) dir: PathBuf,
94    /// The directory where `.hyalo.toml` was found.  Views and types are stored
95    /// in this file, so mutations must target `config_dir/.hyalo.toml` — not the
96    /// vault directory (which may be a subdirectory specified via `dir = "…"`).
97    pub(crate) config_dir: PathBuf,
98    pub(crate) format: String,
99    pub(crate) hints: bool,
100    /// Explicit site-prefix override from `.hyalo.toml`, if any.
101    pub(crate) site_prefix: Option<String>,
102    /// Default stemming language for BM25 search from `[search] language` in `.hyalo.toml`.
103    pub(crate) search_language: Option<String>,
104    /// Frontmatter property names scanned for `[[wikilink]]` values in the link graph.
105    /// `None` = use built-in defaults (`related`, `depends-on`, etc.).
106    pub(crate) frontmatter_link_props: Option<Vec<String>>,
107    /// When `true`, schema validation is applied on every `set`/`append` operation.
108    /// From `validate_on_write = true` in `.hyalo.toml`.
109    pub(crate) validate_on_write: bool,
110    /// Vault-relative paths excluded from `hyalo lint`. From `[lint] ignore`.
111    pub(crate) lint_ignore: Vec<String>,
112    /// Parsed schema configuration from `[schema.*]` sections.
113    pub(crate) schema: SchemaConfig,
114    /// Default output limit for list commands.
115    /// `None` = use hardcoded default (50).
116    /// `Some(0)` = unlimited.
117    /// `Some(n)` = limit to n.
118    pub(crate) default_limit: Option<usize>,
119    /// Case-insensitive link resolution mode from `[links] case_insensitive`.
120    pub(crate) case_insensitive_mode: CaseInsensitiveMode,
121}
122
123impl PartialEq for ResolvedDefaults {
124    fn eq(&self, other: &Self) -> bool {
125        // SchemaConfig doesn't implement PartialEq, so compare the other fields only.
126        // Tests that care about schema equality check it separately.
127        self.dir == other.dir
128            && self.config_dir == other.config_dir
129            && self.format == other.format
130            && self.hints == other.hints
131            && self.site_prefix == other.site_prefix
132            && self.search_language == other.search_language
133            && self.frontmatter_link_props == other.frontmatter_link_props
134            && self.validate_on_write == other.validate_on_write
135            && self.lint_ignore == other.lint_ignore
136            && self.default_limit == other.default_limit
137            && self.case_insensitive_mode == other.case_insensitive_mode
138    }
139}
140
141impl ResolvedDefaults {
142    fn hardcoded() -> Self {
143        Self {
144            dir: PathBuf::from("."),
145            config_dir: PathBuf::from("."),
146            format: "json".to_owned(),
147            hints: true,
148            site_prefix: None,
149            search_language: None,
150            frontmatter_link_props: None,
151            validate_on_write: false,
152            lint_ignore: Vec::new(),
153            schema: SchemaConfig::default(),
154            default_limit: None,
155            case_insensitive_mode: CaseInsensitiveMode::Auto,
156        }
157    }
158
159    /// Hardcoded defaults with `config_dir` set to the given directory.
160    fn defaults_for(dir: &Path) -> Self {
161        Self {
162            config_dir: dir.to_path_buf(),
163            ..Self::hardcoded()
164        }
165    }
166}
167
168/// Load configuration from `.hyalo.toml` in the current working directory.
169///
170/// Missing file → silent, returns hardcoded defaults.
171/// I/O error (not NotFound) → prints a warning, returns defaults.
172/// Malformed TOML or unknown fields → prints a warning, returns defaults.
173/// Valid config → merges with defaults (config values take precedence).
174pub(crate) fn load_config() -> ResolvedDefaults {
175    match std::env::current_dir() {
176        Ok(cwd) => load_config_from(&cwd),
177        Err(e) => {
178            crate::warn::warn(format!(
179                "could not determine current directory to locate .hyalo.toml: {e}"
180            ));
181            ResolvedDefaults::hardcoded()
182        }
183    }
184}
185
186/// Parse the `[links] case_insensitive` value into a [`CaseInsensitiveMode`].
187///
188/// Returns `Ok(None)` when the key is absent, `Ok(Some(mode))` on success,
189/// and `Err(...)` when the value is not one of `"auto"`, `"true"`, or `"false"`.
190fn parse_case_insensitive_mode(raw: Option<&str>) -> anyhow::Result<CaseInsensitiveMode> {
191    match raw {
192        None => Ok(CaseInsensitiveMode::Auto),
193        Some(s) => CaseInsensitiveMode::parse(s)
194            .with_context(|| format!("[links] case_insensitive = {s:?}")),
195    }
196}
197
198/// Load configuration from `.hyalo.toml` inside `dir`.
199///
200/// This variant accepts an explicit directory to make it testable without
201/// relying on the process working directory.
202pub(crate) fn load_config_from(dir: &Path) -> ResolvedDefaults {
203    let path = dir.join(".hyalo.toml");
204
205    let contents = match std::fs::read_to_string(&path) {
206        Ok(s) => s,
207        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
208            return ResolvedDefaults::defaults_for(dir);
209        }
210        Err(e) => {
211            crate::warn::warn(format!("could not read .hyalo.toml: {e}"));
212            return ResolvedDefaults::defaults_for(dir);
213        }
214    };
215
216    let cfg: ConfigFile = match toml::from_str(&contents) {
217        Ok(c) => c,
218        Err(e) => {
219            crate::warn::warn(format!("malformed .hyalo.toml: {e}"));
220            return ResolvedDefaults::defaults_for(dir);
221        }
222    };
223
224    // Warn when the resolved config points its `dir` at a subdirectory that
225    // itself contains a `.hyalo.toml`. The inner file is shadowed by this
226    // parent config, and `hyalo` currently doesn't merge nested configs —
227    // surfacing the shadow at least makes the silent shadowing visible.
228    //
229    // Routed through `warn::warn`, so `--quiet` suppresses it and the dedup
230    // tracker prevents multiple prints per run. It's a warning (not a hint),
231    // so `--no-hints` intentionally does *not* gate it.
232    if let Some(ref sub) = cfg.dir {
233        let nested = dir.join(sub).join(".hyalo.toml");
234        if nested.is_file() {
235            // Skip warning when dir points back at itself (e.g. dir = ".") —
236            // the nested path resolves to the same file as the root config.
237            let is_self = nested
238                .canonicalize()
239                .and_then(|n| dir.join(".hyalo.toml").canonicalize().map(|r| n == r))
240                .unwrap_or(false);
241            if !is_self {
242                crate::warn::warn(format!(
243                    "ignoring nested config {}/.hyalo.toml (shadowed by {}/.hyalo.toml)",
244                    sub.trim_end_matches('/'),
245                    dir.display()
246                ));
247            }
248        }
249    }
250
251    let defaults = ResolvedDefaults::hardcoded();
252    // Resolve `validate_on_write` from either `[schema] validate_on_write`
253    // (documented location) or the top-level `validate_on_write` key
254    // (backwards-compatible alternate). The `[schema]` table wins if both set.
255    let schema_validate_on_write = extract_schema_validate_on_write(cfg.schema.as_ref());
256    let validate_on_write = schema_validate_on_write
257        .or(cfg.validate_on_write)
258        .unwrap_or(false);
259    let schema = parse_schema_from_toml(cfg.schema.as_ref());
260
261    // Parse [links] fields — borrow before moving.
262    let case_insensitive_mode = match parse_case_insensitive_mode(
263        cfg.links
264            .as_ref()
265            .and_then(|l| l.case_insensitive.as_deref()),
266    ) {
267        Ok(m) => m,
268        Err(e) => {
269            crate::warn::warn(format!(
270                "invalid [links] case_insensitive in .hyalo.toml: {e}"
271            ));
272            CaseInsensitiveMode::Auto
273        }
274    };
275
276    ResolvedDefaults {
277        dir: cfg.dir.map(PathBuf::from).unwrap_or(defaults.dir),
278        config_dir: dir.to_path_buf(),
279        format: cfg.format.unwrap_or(defaults.format),
280        hints: cfg.hints.unwrap_or(defaults.hints),
281        site_prefix: cfg.site_prefix,
282        search_language: cfg.search.and_then(|s| s.language),
283        frontmatter_link_props: cfg.links.and_then(|l| l.frontmatter_properties),
284        validate_on_write,
285        lint_ignore: cfg.lint.map(|l| l.ignore).unwrap_or_default(),
286        schema,
287        default_limit: cfg.default_limit,
288        case_insensitive_mode,
289    }
290}
291
292/// Extract `[schema] validate_on_write` from the raw TOML if present. Returns
293/// `None` if the key is absent or not a boolean (in which case the caller falls
294/// back to the top-level `validate_on_write` key, then to the default `false`).
295fn extract_schema_validate_on_write(raw: Option<&toml::Value>) -> Option<bool> {
296    raw?.get("validate_on_write")?.as_bool()
297}
298
299/// Parse a `SchemaConfig` from the raw `[schema]` TOML value.
300///
301/// On malformed schema TOML, emits a warning and returns an empty schema
302/// (no validation), consistent with how malformed `.hyalo.toml` is handled
303/// throughout the rest of the config loading pipeline.
304fn parse_schema_from_toml(raw: Option<&toml::Value>) -> SchemaConfig {
305    let Some(val) = raw else {
306        return SchemaConfig::default();
307    };
308    match val.clone().try_into::<RawSchemaConfig>() {
309        Ok(raw_cfg) => SchemaConfig::from(raw_cfg),
310        Err(e) => {
311            crate::warn::warn(format!("malformed [schema] in .hyalo.toml: {e}"));
312            SchemaConfig::default()
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use std::fs;
320
321    use tempfile::TempDir;
322
323    use super::*;
324
325    fn make_temp() -> TempDir {
326        tempfile::tempdir().expect("failed to create temp dir")
327    }
328
329    #[test]
330    fn missing_config_returns_defaults() {
331        let dir = make_temp();
332        let resolved = load_config_from(dir.path());
333        assert_eq!(resolved, ResolvedDefaults::defaults_for(dir.path()));
334    }
335
336    #[test]
337    fn valid_full_config() {
338        let dir = make_temp();
339        fs::write(
340            dir.path().join(".hyalo.toml"),
341            r#"
342dir = "notes"
343format = "text"
344hints = true
345"#,
346        )
347        .unwrap();
348
349        let resolved = load_config_from(dir.path());
350        assert_eq!(resolved.dir, PathBuf::from("notes"));
351        assert_eq!(resolved.format, "text");
352        assert!(resolved.hints);
353        assert_eq!(resolved.site_prefix, None);
354    }
355
356    #[test]
357    fn site_prefix_config() {
358        let dir = make_temp();
359        fs::write(
360            dir.path().join(".hyalo.toml"),
361            r#"dir = "docs"
362site_prefix = "docs"
363"#,
364        )
365        .unwrap();
366
367        let resolved = load_config_from(dir.path());
368        assert_eq!(resolved.dir, PathBuf::from("docs"));
369        assert_eq!(resolved.site_prefix, Some("docs".to_owned()));
370    }
371
372    #[test]
373    fn partial_config_merges_with_defaults() {
374        let dir = make_temp();
375        fs::write(dir.path().join(".hyalo.toml"), "hints = false\n").unwrap();
376
377        let resolved = load_config_from(dir.path());
378        // Only hints overridden; dir and format stay at defaults.
379        assert_eq!(resolved.dir, PathBuf::from("."));
380        assert_eq!(resolved.format, "json");
381        assert!(
382            !resolved.hints,
383            "config should override the default (true) to false"
384        );
385    }
386
387    #[test]
388    fn malformed_toml_returns_defaults() {
389        let dir = make_temp();
390        fs::write(dir.path().join(".hyalo.toml"), "this is not { valid toml").unwrap();
391
392        let resolved = load_config_from(dir.path());
393        assert_eq!(resolved, ResolvedDefaults::defaults_for(dir.path()));
394    }
395
396    #[test]
397    fn unknown_fields_returns_defaults() {
398        let dir = make_temp();
399        fs::write(dir.path().join(".hyalo.toml"), "unknown_key = \"value\"\n").unwrap();
400
401        let resolved = load_config_from(dir.path());
402        assert_eq!(resolved, ResolvedDefaults::defaults_for(dir.path()));
403    }
404
405    #[test]
406    fn invalid_format_value_passed_through() {
407        let dir = make_temp();
408        fs::write(dir.path().join(".hyalo.toml"), "format = \"xml\"\n").unwrap();
409
410        // config.rs does not validate the format string — that is the caller's job.
411        let resolved = load_config_from(dir.path());
412        assert_eq!(resolved.format, "xml");
413        assert_eq!(resolved.dir, PathBuf::from("."));
414        assert!(resolved.hints);
415    }
416
417    #[test]
418    fn search_language_config() {
419        let dir = make_temp();
420        fs::write(
421            dir.path().join(".hyalo.toml"),
422            "[search]\nlanguage = \"french\"\n",
423        )
424        .unwrap();
425
426        let resolved = load_config_from(dir.path());
427        assert_eq!(resolved.search_language, Some("french".to_owned()));
428    }
429
430    #[test]
431    fn search_language_absent() {
432        let dir = make_temp();
433        fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
434
435        let resolved = load_config_from(dir.path());
436        assert_eq!(resolved.search_language, None);
437    }
438
439    #[test]
440    fn search_language_empty_section() {
441        let dir = make_temp();
442        fs::write(dir.path().join(".hyalo.toml"), "[search]\n").unwrap();
443
444        let resolved = load_config_from(dir.path());
445        assert_eq!(resolved.search_language, None);
446    }
447
448    #[test]
449    fn nested_config_emits_shadow_warning() {
450        // Parent `.hyalo.toml` sets dir = "subkb" and `subkb/` contains its own
451        // `.hyalo.toml`. The nested file is shadowed, so a warning must fire.
452        let _guard = crate::warn::WARN_TEST_LOCK.lock().unwrap();
453        crate::warn::reset_for_test();
454        crate::warn::init(false);
455        let dir = make_temp();
456        fs::create_dir_all(dir.path().join("subkb")).unwrap();
457        fs::write(dir.path().join(".hyalo.toml"), "dir = \"subkb\"\n").unwrap();
458        fs::write(dir.path().join("subkb").join(".hyalo.toml"), "# nested\n").unwrap();
459        let _ = load_config_from(dir.path());
460        // The warning message is built with dir.display() which is a tempdir path,
461        // so we verify the "ignoring nested config" fragment got tracked by
462        // walking all recorded keys.
463        let tracked =
464            crate::warn::any_tracked_starts_with("ignoring nested config subkb/.hyalo.toml");
465        assert!(tracked, "expected nested-config warning to fire");
466    }
467
468    #[test]
469    fn nested_config_dir_dot_no_warning() {
470        // When dir = ".", the nested path resolves to the same .hyalo.toml —
471        // this should NOT trigger a shadow warning.
472        let _guard = crate::warn::WARN_TEST_LOCK.lock().unwrap();
473        crate::warn::reset_for_test();
474        crate::warn::init(false);
475        let dir = make_temp();
476        fs::write(dir.path().join(".hyalo.toml"), "dir = \".\"\n").unwrap();
477        let _ = load_config_from(dir.path());
478        let tracked = crate::warn::any_tracked_starts_with("ignoring nested config");
479        assert!(
480            !tracked,
481            "dir = '.' should not trigger nested-config warning"
482        );
483    }
484
485    #[test]
486    fn config_dir_points_to_toml_location_not_vault_dir() {
487        let dir = make_temp();
488        fs::create_dir_all(dir.path().join("subdir")).unwrap();
489        fs::write(dir.path().join(".hyalo.toml"), "dir = \"subdir\"\n").unwrap();
490
491        let resolved = load_config_from(dir.path());
492        assert_eq!(resolved.dir, PathBuf::from("subdir"));
493        assert_eq!(
494            resolved.config_dir,
495            dir.path().to_path_buf(),
496            "config_dir should be where .hyalo.toml lives, not the vault subdir"
497        );
498    }
499
500    // ---------------------------------------------------------------------------
501    // UX-5: [lint] ignore list
502    // ---------------------------------------------------------------------------
503
504    #[test]
505    fn lint_ignore_list_loaded() {
506        let dir = make_temp();
507        fs::write(
508            dir.path().join(".hyalo.toml"),
509            "[lint]\nignore = [\"templates/template.md\", \"_drafts/draft.md\"]\n",
510        )
511        .unwrap();
512
513        let resolved = load_config_from(dir.path());
514        assert_eq!(
515            resolved.lint_ignore,
516            vec![
517                "templates/template.md".to_owned(),
518                "_drafts/draft.md".to_owned()
519            ]
520        );
521    }
522
523    #[test]
524    fn lint_ignore_empty_by_default() {
525        let dir = make_temp();
526        fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
527
528        let resolved = load_config_from(dir.path());
529        assert!(resolved.lint_ignore.is_empty());
530    }
531
532    // ---------------------------------------------------------------------------
533    // [links] frontmatter_properties config
534    // ---------------------------------------------------------------------------
535
536    #[test]
537    fn links_frontmatter_properties_loaded() {
538        let dir = make_temp();
539        fs::write(
540            dir.path().join(".hyalo.toml"),
541            "[links]\nfrontmatter_properties = [\"related\", \"custom-ref\"]\n",
542        )
543        .unwrap();
544
545        let resolved = load_config_from(dir.path());
546        assert_eq!(
547            resolved.frontmatter_link_props,
548            Some(vec!["related".to_owned(), "custom-ref".to_owned()])
549        );
550    }
551
552    // ---------------------------------------------------------------------------
553    // validate_on_write config
554    // ---------------------------------------------------------------------------
555
556    #[test]
557    fn validate_on_write_config() {
558        let dir = make_temp();
559        fs::write(dir.path().join(".hyalo.toml"), "validate_on_write = true\n").unwrap();
560
561        let resolved = load_config_from(dir.path());
562        assert!(resolved.validate_on_write);
563    }
564
565    #[test]
566    fn validate_on_write_under_schema_table() {
567        // The documented location is `[schema] validate_on_write = true`.
568        let dir = make_temp();
569        fs::write(
570            dir.path().join(".hyalo.toml"),
571            "[schema]\nvalidate_on_write = true\n",
572        )
573        .unwrap();
574
575        let resolved = load_config_from(dir.path());
576        assert!(
577            resolved.validate_on_write,
578            "`[schema] validate_on_write` should enable write-time validation"
579        );
580    }
581
582    #[test]
583    fn validate_on_write_schema_table_wins_over_top_level() {
584        // If both are set, `[schema] validate_on_write` wins.
585        let dir = make_temp();
586        fs::write(
587            dir.path().join(".hyalo.toml"),
588            "validate_on_write = false\n[schema]\nvalidate_on_write = true\n",
589        )
590        .unwrap();
591
592        let resolved = load_config_from(dir.path());
593        assert!(resolved.validate_on_write);
594    }
595
596    #[test]
597    fn validate_on_write_default_false() {
598        let dir = make_temp();
599        fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
600
601        let resolved = load_config_from(dir.path());
602        assert!(!resolved.validate_on_write);
603    }
604
605    // ---------------------------------------------------------------------------
606    // [links] case_insensitive config
607    // ---------------------------------------------------------------------------
608
609    #[test]
610    fn case_insensitive_missing_key_defaults_to_auto() {
611        let dir = make_temp();
612        fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
613
614        let resolved = load_config_from(dir.path());
615        assert_eq!(
616            resolved.case_insensitive_mode,
617            CaseInsensitiveMode::Auto,
618            "missing key should default to Auto"
619        );
620    }
621
622    #[test]
623    fn case_insensitive_auto_value() {
624        let dir = make_temp();
625        fs::write(
626            dir.path().join(".hyalo.toml"),
627            "[links]\ncase_insensitive = \"auto\"\n",
628        )
629        .unwrap();
630
631        let resolved = load_config_from(dir.path());
632        assert_eq!(resolved.case_insensitive_mode, CaseInsensitiveMode::Auto);
633    }
634
635    #[test]
636    fn case_insensitive_true_value() {
637        let dir = make_temp();
638        fs::write(
639            dir.path().join(".hyalo.toml"),
640            "[links]\ncase_insensitive = \"true\"\n",
641        )
642        .unwrap();
643
644        let resolved = load_config_from(dir.path());
645        assert_eq!(resolved.case_insensitive_mode, CaseInsensitiveMode::On);
646    }
647
648    #[test]
649    fn case_insensitive_false_value() {
650        let dir = make_temp();
651        fs::write(
652            dir.path().join(".hyalo.toml"),
653            "[links]\ncase_insensitive = \"false\"\n",
654        )
655        .unwrap();
656
657        let resolved = load_config_from(dir.path());
658        assert_eq!(resolved.case_insensitive_mode, CaseInsensitiveMode::Off);
659    }
660
661    #[test]
662    fn case_insensitive_invalid_value_falls_back_to_auto() {
663        // Invalid values emit a warning and fall back to Auto.
664        let _guard = crate::warn::WARN_TEST_LOCK.lock().unwrap();
665        crate::warn::reset_for_test();
666        crate::warn::init(false);
667        let dir = make_temp();
668        fs::write(
669            dir.path().join(".hyalo.toml"),
670            "[links]\ncase_insensitive = \"maybe\"\n",
671        )
672        .unwrap();
673
674        let resolved = load_config_from(dir.path());
675        assert_eq!(
676            resolved.case_insensitive_mode,
677            CaseInsensitiveMode::Auto,
678            "invalid value should fall back to Auto"
679        );
680        let warned =
681            crate::warn::any_tracked_starts_with("invalid [links] case_insensitive in .hyalo.toml");
682        assert!(
683            warned,
684            "expected a warning for invalid case_insensitive value"
685        );
686    }
687}