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#[derive(Debug, Deserialize)]
12#[serde(deny_unknown_fields)]
13struct SearchConfig {
14 language: Option<String>,
15}
16
17#[derive(Debug, Deserialize)]
19#[serde(deny_unknown_fields)]
20struct LinksConfig {
21 frontmatter_properties: Option<Vec<String>>,
25 #[serde(default)]
32 case_insensitive: Option<String>,
33}
34
35#[derive(Debug, Deserialize)]
37#[serde(deny_unknown_fields)]
38struct LintConfig {
39 #[serde(default)]
47 ignore: Vec<String>,
48}
49
50#[derive(Debug, Deserialize)]
56#[serde(deny_unknown_fields)]
57struct ConfigFile {
58 dir: Option<String>,
59 format: Option<String>,
60 hints: Option<bool>,
61 site_prefix: Option<String>,
65 #[allow(dead_code)]
69 views: Option<HashMap<String, toml::Value>>,
70 search: Option<SearchConfig>,
72 links: Option<LinksConfig>,
74 validate_on_write: Option<bool>,
78 lint: Option<LintConfig>,
80 #[serde(default)]
85 schema: Option<toml::Value>,
86 default_limit: Option<usize>,
88}
89
90#[derive(Debug)]
92pub(crate) struct ResolvedDefaults {
93 pub(crate) dir: PathBuf,
94 pub(crate) config_dir: PathBuf,
98 pub(crate) format: String,
99 pub(crate) hints: bool,
100 pub(crate) site_prefix: Option<String>,
102 pub(crate) search_language: Option<String>,
104 pub(crate) frontmatter_link_props: Option<Vec<String>>,
107 pub(crate) validate_on_write: bool,
110 pub(crate) lint_ignore: Vec<String>,
112 pub(crate) schema: SchemaConfig,
114 pub(crate) default_limit: Option<usize>,
119 pub(crate) case_insensitive_mode: CaseInsensitiveMode,
121}
122
123impl PartialEq for ResolvedDefaults {
124 fn eq(&self, other: &Self) -> bool {
125 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 fn defaults_for(dir: &Path) -> Self {
161 Self {
162 config_dir: dir.to_path_buf(),
163 ..Self::hardcoded()
164 }
165 }
166}
167
168pub(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
186fn 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
198pub(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 if let Some(ref sub) = cfg.dir {
233 let nested = dir.join(sub).join(".hyalo.toml");
234 if nested.is_file() {
235 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 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 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
292fn extract_schema_validate_on_write(raw: Option<&toml::Value>) -> Option<bool> {
296 raw?.get("validate_on_write")?.as_bool()
297}
298
299fn 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 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 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 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 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 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 #[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 #[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 #[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 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 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 #[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 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}