use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Deserialize;
use hyalo_core::case_index::CaseInsensitiveMode;
use hyalo_core::schema::{RawSchemaConfig, SchemaConfig};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SearchConfig {
language: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LinksConfig {
frontmatter_properties: Option<Vec<String>>,
#[serde(default)]
case_insensitive: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LintConfig {
#[serde(default)]
ignore: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
dir: Option<String>,
format: Option<String>,
hints: Option<bool>,
site_prefix: Option<String>,
#[allow(dead_code)]
views: Option<HashMap<String, toml::Value>>,
search: Option<SearchConfig>,
links: Option<LinksConfig>,
validate_on_write: Option<bool>,
lint: Option<LintConfig>,
#[serde(default)]
schema: Option<toml::Value>,
default_limit: Option<usize>,
}
#[derive(Debug)]
pub(crate) struct ResolvedDefaults {
pub(crate) dir: PathBuf,
pub(crate) config_dir: PathBuf,
pub(crate) format: String,
pub(crate) hints: bool,
pub(crate) site_prefix: Option<String>,
pub(crate) search_language: Option<String>,
pub(crate) frontmatter_link_props: Option<Vec<String>>,
pub(crate) validate_on_write: bool,
pub(crate) lint_ignore: Vec<String>,
pub(crate) schema: SchemaConfig,
pub(crate) default_limit: Option<usize>,
pub(crate) case_insensitive_mode: CaseInsensitiveMode,
}
impl PartialEq for ResolvedDefaults {
fn eq(&self, other: &Self) -> bool {
self.dir == other.dir
&& self.config_dir == other.config_dir
&& self.format == other.format
&& self.hints == other.hints
&& self.site_prefix == other.site_prefix
&& self.search_language == other.search_language
&& self.frontmatter_link_props == other.frontmatter_link_props
&& self.validate_on_write == other.validate_on_write
&& self.lint_ignore == other.lint_ignore
&& self.default_limit == other.default_limit
&& self.case_insensitive_mode == other.case_insensitive_mode
}
}
impl ResolvedDefaults {
fn hardcoded() -> Self {
Self {
dir: PathBuf::from("."),
config_dir: PathBuf::from("."),
format: "json".to_owned(),
hints: true,
site_prefix: None,
search_language: None,
frontmatter_link_props: None,
validate_on_write: false,
lint_ignore: Vec::new(),
schema: SchemaConfig::default(),
default_limit: None,
case_insensitive_mode: CaseInsensitiveMode::Auto,
}
}
fn defaults_for(dir: &Path) -> Self {
Self {
config_dir: dir.to_path_buf(),
..Self::hardcoded()
}
}
}
pub(crate) fn load_config() -> ResolvedDefaults {
match std::env::current_dir() {
Ok(cwd) => load_config_from(&cwd),
Err(e) => {
crate::warn::warn(format!(
"could not determine current directory to locate .hyalo.toml: {e}"
));
ResolvedDefaults::hardcoded()
}
}
}
fn parse_case_insensitive_mode(raw: Option<&str>) -> anyhow::Result<CaseInsensitiveMode> {
match raw {
None => Ok(CaseInsensitiveMode::Auto),
Some(s) => CaseInsensitiveMode::parse(s)
.with_context(|| format!("[links] case_insensitive = {s:?}")),
}
}
pub(crate) fn load_config_from(dir: &Path) -> ResolvedDefaults {
let path = dir.join(".hyalo.toml");
let contents = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return ResolvedDefaults::defaults_for(dir);
}
Err(e) => {
crate::warn::warn(format!("could not read .hyalo.toml: {e}"));
return ResolvedDefaults::defaults_for(dir);
}
};
let cfg: ConfigFile = match toml::from_str(&contents) {
Ok(c) => c,
Err(e) => {
crate::warn::warn(format!("malformed .hyalo.toml: {e}"));
return ResolvedDefaults::defaults_for(dir);
}
};
if let Some(ref sub) = cfg.dir {
let nested = dir.join(sub).join(".hyalo.toml");
if nested.is_file() {
crate::warn::warn(format!(
"ignoring nested config {}/.hyalo.toml (shadowed by {}/.hyalo.toml)",
sub.trim_end_matches('/'),
dir.display()
));
}
}
let defaults = ResolvedDefaults::hardcoded();
let schema_validate_on_write = extract_schema_validate_on_write(cfg.schema.as_ref());
let validate_on_write = schema_validate_on_write
.or(cfg.validate_on_write)
.unwrap_or(false);
let schema = parse_schema_from_toml(cfg.schema.as_ref());
let case_insensitive_mode = match parse_case_insensitive_mode(
cfg.links
.as_ref()
.and_then(|l| l.case_insensitive.as_deref()),
) {
Ok(m) => m,
Err(e) => {
crate::warn::warn(format!(
"invalid [links] case_insensitive in .hyalo.toml: {e}"
));
CaseInsensitiveMode::Auto
}
};
ResolvedDefaults {
dir: cfg.dir.map(PathBuf::from).unwrap_or(defaults.dir),
config_dir: dir.to_path_buf(),
format: cfg.format.unwrap_or(defaults.format),
hints: cfg.hints.unwrap_or(defaults.hints),
site_prefix: cfg.site_prefix,
search_language: cfg.search.and_then(|s| s.language),
frontmatter_link_props: cfg.links.and_then(|l| l.frontmatter_properties),
validate_on_write,
lint_ignore: cfg.lint.map(|l| l.ignore).unwrap_or_default(),
schema,
default_limit: cfg.default_limit,
case_insensitive_mode,
}
}
fn extract_schema_validate_on_write(raw: Option<&toml::Value>) -> Option<bool> {
raw?.get("validate_on_write")?.as_bool()
}
fn parse_schema_from_toml(raw: Option<&toml::Value>) -> SchemaConfig {
let Some(val) = raw else {
return SchemaConfig::default();
};
match val.clone().try_into::<RawSchemaConfig>() {
Ok(raw_cfg) => SchemaConfig::from(raw_cfg),
Err(e) => {
crate::warn::warn(format!("malformed [schema] in .hyalo.toml: {e}"));
SchemaConfig::default()
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn make_temp() -> TempDir {
tempfile::tempdir().expect("failed to create temp dir")
}
#[test]
fn missing_config_returns_defaults() {
let dir = make_temp();
let resolved = load_config_from(dir.path());
assert_eq!(resolved, ResolvedDefaults::defaults_for(dir.path()));
}
#[test]
fn valid_full_config() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
r#"
dir = "notes"
format = "text"
hints = true
"#,
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.dir, PathBuf::from("notes"));
assert_eq!(resolved.format, "text");
assert!(resolved.hints);
assert_eq!(resolved.site_prefix, None);
}
#[test]
fn site_prefix_config() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
r#"dir = "docs"
site_prefix = "docs"
"#,
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.dir, PathBuf::from("docs"));
assert_eq!(resolved.site_prefix, Some("docs".to_owned()));
}
#[test]
fn partial_config_merges_with_defaults() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "hints = false\n").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.dir, PathBuf::from("."));
assert_eq!(resolved.format, "json");
assert!(
!resolved.hints,
"config should override the default (true) to false"
);
}
#[test]
fn malformed_toml_returns_defaults() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "this is not { valid toml").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved, ResolvedDefaults::defaults_for(dir.path()));
}
#[test]
fn unknown_fields_returns_defaults() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "unknown_key = \"value\"\n").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved, ResolvedDefaults::defaults_for(dir.path()));
}
#[test]
fn invalid_format_value_passed_through() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "format = \"xml\"\n").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.format, "xml");
assert_eq!(resolved.dir, PathBuf::from("."));
assert!(resolved.hints);
}
#[test]
fn search_language_config() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[search]\nlanguage = \"french\"\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.search_language, Some("french".to_owned()));
}
#[test]
fn search_language_absent() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.search_language, None);
}
#[test]
fn search_language_empty_section() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "[search]\n").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.search_language, None);
}
#[test]
fn nested_config_emits_shadow_warning() {
let _guard = crate::warn::WARN_TEST_LOCK.lock().unwrap();
crate::warn::reset_for_test();
crate::warn::init(false);
let dir = make_temp();
fs::create_dir_all(dir.path().join("subkb")).unwrap();
fs::write(dir.path().join(".hyalo.toml"), "dir = \"subkb\"\n").unwrap();
fs::write(dir.path().join("subkb").join(".hyalo.toml"), "# nested\n").unwrap();
let _ = load_config_from(dir.path());
let tracked =
crate::warn::any_tracked_starts_with("ignoring nested config subkb/.hyalo.toml");
assert!(tracked, "expected nested-config warning to fire");
}
#[test]
fn config_dir_points_to_toml_location_not_vault_dir() {
let dir = make_temp();
fs::create_dir_all(dir.path().join("subdir")).unwrap();
fs::write(dir.path().join(".hyalo.toml"), "dir = \"subdir\"\n").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.dir, PathBuf::from("subdir"));
assert_eq!(
resolved.config_dir,
dir.path().to_path_buf(),
"config_dir should be where .hyalo.toml lives, not the vault subdir"
);
}
#[test]
fn lint_ignore_list_loaded() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[lint]\nignore = [\"templates/template.md\", \"_drafts/draft.md\"]\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(
resolved.lint_ignore,
vec![
"templates/template.md".to_owned(),
"_drafts/draft.md".to_owned()
]
);
}
#[test]
fn lint_ignore_empty_by_default() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
let resolved = load_config_from(dir.path());
assert!(resolved.lint_ignore.is_empty());
}
#[test]
fn links_frontmatter_properties_loaded() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[links]\nfrontmatter_properties = [\"related\", \"custom-ref\"]\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(
resolved.frontmatter_link_props,
Some(vec!["related".to_owned(), "custom-ref".to_owned()])
);
}
#[test]
fn validate_on_write_config() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "validate_on_write = true\n").unwrap();
let resolved = load_config_from(dir.path());
assert!(resolved.validate_on_write);
}
#[test]
fn validate_on_write_under_schema_table() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[schema]\nvalidate_on_write = true\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert!(
resolved.validate_on_write,
"`[schema] validate_on_write` should enable write-time validation"
);
}
#[test]
fn validate_on_write_schema_table_wins_over_top_level() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"validate_on_write = false\n[schema]\nvalidate_on_write = true\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert!(resolved.validate_on_write);
}
#[test]
fn validate_on_write_default_false() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
let resolved = load_config_from(dir.path());
assert!(!resolved.validate_on_write);
}
#[test]
fn case_insensitive_missing_key_defaults_to_auto() {
let dir = make_temp();
fs::write(dir.path().join(".hyalo.toml"), "dir = \"notes\"\n").unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(
resolved.case_insensitive_mode,
CaseInsensitiveMode::Auto,
"missing key should default to Auto"
);
}
#[test]
fn case_insensitive_auto_value() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[links]\ncase_insensitive = \"auto\"\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.case_insensitive_mode, CaseInsensitiveMode::Auto);
}
#[test]
fn case_insensitive_true_value() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[links]\ncase_insensitive = \"true\"\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.case_insensitive_mode, CaseInsensitiveMode::On);
}
#[test]
fn case_insensitive_false_value() {
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[links]\ncase_insensitive = \"false\"\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(resolved.case_insensitive_mode, CaseInsensitiveMode::Off);
}
#[test]
fn case_insensitive_invalid_value_falls_back_to_auto() {
let _guard = crate::warn::WARN_TEST_LOCK.lock().unwrap();
crate::warn::reset_for_test();
crate::warn::init(false);
let dir = make_temp();
fs::write(
dir.path().join(".hyalo.toml"),
"[links]\ncase_insensitive = \"maybe\"\n",
)
.unwrap();
let resolved = load_config_from(dir.path());
assert_eq!(
resolved.case_insensitive_mode,
CaseInsensitiveMode::Auto,
"invalid value should fall back to Auto"
);
let warned =
crate::warn::any_tracked_starts_with("invalid [links] case_insensitive in .hyalo.toml");
assert!(
warned,
"expected a warning for invalid case_insensitive value"
);
}
}