use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::domain::common::{CorpusConfig, HallouminateError, Result};
use crate::domain::embeddings::{DEFAULT_EMBED_MODEL, canonical_model_name};
use crate::domain::repository::{RepositoryConfig, effective_corpora};
const DEFAULT_TOP_FILES: usize = 10;
const DEFAULT_CHUNKS_PER_FILE: usize = 3;
const DEFAULT_DEBOUNCE_MS: u64 = 500;
const DEFAULT_EMBED_CACHE: &str = "~/.cache/hallouminate/fastembed";
const DEFAULT_GROUND_DIR: &str = "~/.local/share/hallouminate/ground";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SearchConfig {
#[serde(default = "default_top_files")]
pub top_files_default: usize,
#[serde(default = "default_chunks_per_file")]
pub chunks_per_file_default: usize,
#[serde(default)]
pub crossencoder: Option<String>,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
top_files_default: DEFAULT_TOP_FILES,
chunks_per_file_default: DEFAULT_CHUNKS_PER_FILE,
crossencoder: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmbeddingsConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_model")]
pub model: String,
#[serde(default)]
pub quantized: bool,
#[serde(default = "default_embed_cache")]
pub cache_dir: String,
}
impl Default for EmbeddingsConfig {
fn default() -> Self {
Self {
enabled: true,
model: DEFAULT_EMBED_MODEL.into(),
quantized: false,
cache_dir: DEFAULT_EMBED_CACHE.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WatchConfig {
#[serde(default = "default_debounce_ms")]
pub debounce_ms: u64,
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
debounce_ms: DEFAULT_DEBOUNCE_MS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageConfig {
#[serde(default = "default_ground_dir")]
pub ground_dir: String,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
ground_dir: DEFAULT_GROUND_DIR.into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Config {
#[serde(rename = "corpus", default)]
pub corpora: Vec<CorpusConfig>,
#[serde(rename = "repository", alias = "code_repo", default)]
pub repositories: Vec<RepositoryConfig>,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub embeddings: EmbeddingsConfig,
#[serde(default)]
pub watch: WatchConfig,
#[serde(default)]
pub storage: StorageConfig,
}
impl Config {
pub fn effective_corpora(&self) -> Result<Vec<CorpusConfig>> {
effective_corpora(&self.corpora, &self.repositories)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLayers {
pub xdg_path: Option<PathBuf>,
pub repo_path: PathBuf,
}
pub fn load_xdg(path: Option<&Path>) -> Result<Config> {
let resolved = match path {
Some(p) => p.to_path_buf(),
None => xdg_config_path(),
};
let text = match std::fs::read_to_string(&resolved) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Config::default());
}
Err(e) => return Err(HallouminateError::from(e)),
};
parse(&text, Some(&resolved))
}
pub fn load(path: Option<&Path>) -> Result<Config> {
load_xdg(path)
}
pub fn discover_repo_config(cwd: &Path) -> Result<PathBuf> {
let absolute_cwd = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
let here = std::env::current_dir().map_err(HallouminateError::from)?;
absolutize_cwd(cwd, &here)
};
let mut current: Option<&Path> = Some(&absolute_cwd);
while let Some(level) = current {
let candidate = level.join(".hallouminate").join("config.toml");
if candidate.is_file() {
return Ok(candidate);
}
let git_marker = level.join(".git");
if git_marker.exists() {
return Err(HallouminateError::Config(format!(
"no .hallouminate/config.toml found walking up from {} \
(stopped at repo root {})",
cwd.display(),
level.display(),
)));
}
current = level.parent();
}
Err(HallouminateError::Config(format!(
"no .hallouminate/config.toml found walking up from {} \
(reached filesystem root without hitting a .git boundary)",
cwd.display(),
)))
}
fn absolutize_cwd(cwd: &Path, base: &Path) -> PathBuf {
base.join(cwd)
}
pub fn load_repo_layer(config_path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(config_path).map_err(HallouminateError::from)?;
let mut cfg: Config = toml::from_str(&text).map_err(|e| {
HallouminateError::Config(format!("parsing config at {}: {e}", config_path.display()))
})?;
let hallouminate_dir = config_path.parent().ok_or_else(|| {
HallouminateError::Config(format!(
"repo config path has no parent directory: {}",
config_path.display(),
))
})?;
let repo_root = hallouminate_dir.parent().unwrap_or(hallouminate_dir);
resolve_repo_layer_paths(&mut cfg, repo_root);
normalize(&mut cfg)?;
validate(&cfg)?;
Ok(cfg)
}
pub fn merge_layers(baseline: &Config, repo: &Config) -> Result<Config> {
merge_layers_with_sources(baseline, repo, None, None)
}
fn merge_layers_with_sources(
baseline: &Config,
repo: &Config,
baseline_path: Option<&Path>,
repo_path: Option<&Path>,
) -> Result<Config> {
let defaults = Config::default();
let mut corpora = baseline.corpora.clone();
corpora.extend(repo.corpora.iter().cloned());
let mut repositories = baseline.repositories.clone();
repositories.extend(repo.repositories.iter().cloned());
let search = SearchConfig {
top_files_default: merge_scalar(
"search.top_files_default",
baseline.search.top_files_default,
repo.search.top_files_default,
defaults.search.top_files_default,
baseline_path,
repo_path,
)?,
chunks_per_file_default: merge_scalar(
"search.chunks_per_file_default",
baseline.search.chunks_per_file_default,
repo.search.chunks_per_file_default,
defaults.search.chunks_per_file_default,
baseline_path,
repo_path,
)?,
crossencoder: merge_scalar(
"search.crossencoder",
baseline.search.crossencoder.clone(),
repo.search.crossencoder.clone(),
defaults.search.crossencoder.clone(),
baseline_path,
repo_path,
)?,
};
let embeddings = EmbeddingsConfig {
enabled: merge_scalar(
"embeddings.enabled",
baseline.embeddings.enabled,
repo.embeddings.enabled,
defaults.embeddings.enabled,
baseline_path,
repo_path,
)?,
model: merge_scalar(
"embeddings.model",
baseline.embeddings.model.clone(),
repo.embeddings.model.clone(),
defaults.embeddings.model.clone(),
baseline_path,
repo_path,
)?,
quantized: merge_scalar(
"embeddings.quantized",
baseline.embeddings.quantized,
repo.embeddings.quantized,
defaults.embeddings.quantized,
baseline_path,
repo_path,
)?,
cache_dir: merge_scalar(
"embeddings.cache_dir",
baseline.embeddings.cache_dir.clone(),
repo.embeddings.cache_dir.clone(),
defaults.embeddings.cache_dir.clone(),
baseline_path,
repo_path,
)?,
};
let watch = WatchConfig {
debounce_ms: merge_scalar(
"watch.debounce_ms",
baseline.watch.debounce_ms,
repo.watch.debounce_ms,
defaults.watch.debounce_ms,
baseline_path,
repo_path,
)?,
};
let storage = StorageConfig {
ground_dir: merge_scalar(
"storage.ground_dir",
baseline.storage.ground_dir.clone(),
repo.storage.ground_dir.clone(),
defaults.storage.ground_dir.clone(),
baseline_path,
repo_path,
)?,
};
let merged = Config {
corpora,
repositories,
search,
embeddings,
watch,
storage,
};
validate(&merged)?;
Ok(merged)
}
pub fn resolve_for_cwd(
baseline: &Config,
cwd: &Path,
xdg_path: Option<&Path>,
) -> Result<(Config, ResolvedLayers)> {
let repo_path = discover_repo_config(cwd)?;
let repo = load_repo_layer(&repo_path)?;
let effective = merge_layers_with_sources(baseline, &repo, xdg_path, Some(&repo_path))?;
Ok((
effective,
ResolvedLayers {
xdg_path: xdg_path.map(Path::to_path_buf),
repo_path,
},
))
}
fn merge_scalar<T>(
field: &str,
baseline: T,
repo: T,
default: T,
baseline_path: Option<&Path>,
repo_path: Option<&Path>,
) -> Result<T>
where
T: PartialEq + std::fmt::Debug,
{
let baseline_set = baseline != default;
let repo_set = repo != default;
match (baseline_set, repo_set) {
(false, false) => Ok(default),
(true, false) => Ok(baseline),
(false, true) => Ok(repo),
(true, true) => {
if baseline == repo {
Ok(baseline)
} else {
let baseline_src = baseline_path
.map(|p| format!(" (baseline at {})", p.display()))
.unwrap_or_else(|| " (baseline)".into());
let repo_src = repo_path
.map(|p| format!(" (repo at {})", p.display()))
.unwrap_or_else(|| " (repo layer)".into());
Err(HallouminateError::Config(format!(
"scalar conflict on {field}: baseline = {baseline:?}{baseline_src}, \
repo = {repo:?}{repo_src}"
)))
}
}
}
}
fn resolve_repo_layer_paths(cfg: &mut Config, base: &Path) {
for corpus in cfg.corpora.iter_mut() {
for p in corpus.paths.iter_mut() {
*p = resolve_repo_path(p, base);
}
}
for repo in cfg.repositories.iter_mut() {
repo.path = resolve_repo_path(&repo.path, base);
for p in repo.corpus_paths.iter_mut() {
*p = resolve_repo_path(p, base);
}
}
cfg.storage.ground_dir = resolve_repo_path(&cfg.storage.ground_dir, base);
cfg.embeddings.cache_dir = resolve_repo_path(&cfg.embeddings.cache_dir, base);
}
fn resolve_repo_path(raw: &str, base: &Path) -> String {
if raw.is_empty() {
return raw.to_string();
}
if raw.starts_with('~') {
return raw.to_string();
}
let candidate = Path::new(raw);
if candidate.is_absolute() {
return raw.to_string();
}
base.join(candidate).to_string_lossy().into_owned()
}
pub fn xdg_config_path() -> PathBuf {
crate::app::xdg::xdg_path(
"XDG_CONFIG_HOME",
"~/.config",
&["hallouminate", "config.toml"],
)
}
#[cfg(test)]
fn xdg_config_path_from(xdg_config_home: Option<&std::ffi::OsStr>) -> PathBuf {
crate::app::xdg::xdg_path_from(
xdg_config_home,
"~/.config",
&["hallouminate", "config.toml"],
)
}
fn parse(text: &str, source: Option<&Path>) -> Result<Config> {
let mut cfg: Config = toml::from_str(text).map_err(|e| {
let where_ = source
.map(|p| format!(" at {}", p.display()))
.unwrap_or_default();
HallouminateError::Config(format!("parsing config{where_}: {e}"))
})?;
normalize(&mut cfg)?;
validate(&cfg)?;
Ok(cfg)
}
fn normalize(cfg: &mut Config) -> Result<()> {
cfg.embeddings.model = canonical_model_name(&cfg.embeddings.model)?.to_string();
Ok(())
}
fn validate(cfg: &Config) -> Result<()> {
for (idx, c) in cfg.corpora.iter().enumerate() {
if c.name.trim().is_empty() {
return Err(HallouminateError::Config(format!(
"corpus #{idx} has empty name"
)));
}
if c.paths.is_empty() {
return Err(HallouminateError::Config(format!(
"corpus '{}' has no paths",
c.name
)));
}
}
for (idx, r) in cfg.repositories.iter().enumerate() {
if r.name.trim().is_empty() {
return Err(HallouminateError::Config(format!(
"repository #{idx} has empty name"
)));
}
if r.path.trim().is_empty() {
return Err(HallouminateError::Config(format!(
"repository '{}' has empty path",
r.name
)));
}
}
let globals: Vec<&str> = cfg
.corpora
.iter()
.filter(|c| c.global)
.map(|c| c.name.as_str())
.collect();
if globals.len() > 1 {
return Err(HallouminateError::Config(format!(
"more than one corpus marked global = true: {}; exactly one is allowed",
globals.join(", "),
)));
}
cfg.effective_corpora()?;
Ok(())
}
fn default_top_files() -> usize {
DEFAULT_TOP_FILES
}
fn default_chunks_per_file() -> usize {
DEFAULT_CHUNKS_PER_FILE
}
fn default_debounce_ms() -> u64 {
DEFAULT_DEBOUNCE_MS
}
fn default_enabled() -> bool {
true
}
fn default_model() -> String {
DEFAULT_EMBED_MODEL.into()
}
fn default_embed_cache() -> String {
DEFAULT_EMBED_CACHE.into()
}
fn default_ground_dir() -> String {
DEFAULT_GROUND_DIR.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_rejects_two_global_corpora() {
let err = parse(
r#"
[[corpus]]
name = "a"
paths = ["/a"]
global = true
[[corpus]]
name = "b"
paths = ["/b"]
global = true
"#,
None,
)
.expect_err("two global corpora must fail validation");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("global"), "got: {msg}");
assert!(
msg.contains('a') && msg.contains('b'),
"must name both: {msg}"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_accepts_single_global_corpus() {
let cfg = parse(
r#"
[[corpus]]
name = "global"
paths = ["/g"]
global = true
[[corpus]]
name = "local"
paths = ["/l"]
"#,
None,
)
.expect("one global corpus is valid");
let global = cfg
.corpora
.iter()
.find(|c| c.global)
.expect("global corpus present");
assert_eq!(global.name, "global");
assert!(
!cfg.corpora
.iter()
.find(|c| c.name == "local")
.unwrap()
.global
);
}
#[test]
fn parse_defaults_corpus_global_to_false() {
let cfg = parse("[[corpus]]\nname = \"docs\"\npaths = [\"/x\"]\n", None).expect("parse");
assert!(!cfg.corpora[0].global, "global must default to false");
}
const SPEC_EXAMPLE: &str = r#"
[[corpus]]
name = "claude-config"
paths = ["~/.claude/skills", "~/.claude/agents", "~/.claude/CLAUDE.md"]
globs = ["**/*.md"]
exclude = ["**/.git/**", "**/node_modules/**"]
[[repository]]
name = "tern"
path = "~/Dev/tern"
[search]
top_files_default = 10
chunks_per_file_default = 3
[embeddings]
enabled = true
model = "BAAI/bge-small-en-v1.5"
quantized = false
cache_dir = "~/.cache/hallouminate/fastembed"
[watch]
debounce_ms = 500
[storage]
ground_dir = "~/.local/share/hallouminate/ground"
"#;
#[test]
fn parse_spec_example_decodes_every_field() {
let cfg = parse(SPEC_EXAMPLE, None).expect("spec example parses");
assert_eq!(cfg.corpora.len(), 1);
let corpus = &cfg.corpora[0];
assert_eq!(corpus.name, "claude-config");
assert_eq!(
corpus.paths,
vec![
"~/.claude/skills".to_string(),
"~/.claude/agents".into(),
"~/.claude/CLAUDE.md".into(),
]
);
assert_eq!(corpus.globs, vec!["**/*.md".to_string()]);
assert_eq!(
corpus.exclude,
vec!["**/.git/**".to_string(), "**/node_modules/**".into()]
);
assert_eq!(cfg.repositories.len(), 1);
assert_eq!(cfg.repositories[0].name, "tern");
assert_eq!(cfg.repositories[0].path, "~/Dev/tern");
assert_eq!(cfg.search.top_files_default, 10);
assert_eq!(cfg.search.chunks_per_file_default, 3);
assert!(cfg.embeddings.enabled);
assert_eq!(cfg.embeddings.model, "BAAI/bge-small-en-v1.5");
assert!(!cfg.embeddings.quantized);
assert_eq!(cfg.embeddings.cache_dir, "~/.cache/hallouminate/fastembed");
assert_eq!(cfg.watch.debounce_ms, 500);
assert_eq!(cfg.storage.ground_dir, "~/.local/share/hallouminate/ground");
}
#[test]
fn parse_empty_string_yields_full_defaults() {
let cfg = parse("", None).expect("empty toml parses");
assert!(cfg.corpora.is_empty());
assert!(cfg.repositories.is_empty());
assert_eq!(cfg.search, SearchConfig::default());
assert_eq!(cfg.embeddings, EmbeddingsConfig::default());
assert_eq!(cfg.watch, WatchConfig::default());
assert_eq!(cfg.storage, StorageConfig::default());
}
#[test]
fn embeddings_default_is_on_snowflake_and_full_precision() {
let cfg = EmbeddingsConfig::default();
assert!(cfg.enabled, "embeddings must default to enabled");
assert!(!cfg.quantized, "quantization must default to off");
assert_eq!(cfg.model, "snowflake/snowflake-arctic-embed-s");
}
#[test]
fn parse_embeddings_section_omitting_enabled_defaults_to_enabled() {
let cfg = parse("[embeddings]\nmodel = \"BAAI/bge-small-en-v1.5\"\n", None)
.expect("partial embeddings section parses");
assert!(
cfg.embeddings.enabled,
"absent `enabled` must default to true, not false"
);
}
#[test]
fn parse_rejects_dropped_bare_bge_alias() {
let err = parse("[embeddings]\nmodel = \"bge-small-en-v1.5\"\n", None)
.expect_err("bare bge alias must no longer parse");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("unsupported embedding model"), "got: {msg}");
assert!(msg.contains("BAAI/bge-small-en-v1.5"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn default_embeddings_model_is_the_honest_snowflake_default() {
assert_eq!(
EmbeddingsConfig::default().model,
crate::domain::embeddings::ARCTIC_S_MODEL
);
assert_eq!(
DEFAULT_EMBED_MODEL,
crate::domain::embeddings::ARCTIC_S_MODEL
);
let cfg = parse("[embeddings]\nenabled = true\n", None).expect("partial parses");
assert_eq!(
cfg.embeddings.model,
crate::domain::embeddings::ARCTIC_S_MODEL
);
}
#[test]
fn parse_rejects_unknown_embedding_model_before_runtime_downloads() {
let err = parse("[embeddings]\nmodel = \"clip-vit-b32\"\n", None)
.expect_err("unsupported model must fail during config parse");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("unsupported embedding model"), "got: {msg}");
assert!(msg.contains("BAAI/bge-small-en-v1.5"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_partial_search_section_uses_defaults_for_missing_fields() {
let cfg = parse("[search]\ntop_files_default = 5\n", None).expect("partial search parses");
assert_eq!(cfg.search.top_files_default, 5);
assert_eq!(cfg.search.chunks_per_file_default, DEFAULT_CHUNKS_PER_FILE);
}
#[test]
fn parse_rejects_corpus_with_empty_name() {
let err = parse("[[corpus]]\nname = \"\"\npaths = [\"/x\"]\n", None)
.expect_err("empty corpus name");
match err {
HallouminateError::Config(msg) => assert!(msg.contains("empty name"), "got: {msg}"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_rejects_corpus_with_no_paths() {
let err = parse("[[corpus]]\nname = \"docs\"\n", None).expect_err("no paths");
match err {
HallouminateError::Config(msg) => assert!(msg.contains("no paths"), "got: {msg}"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_rejects_repository_with_empty_name() {
let err = parse("[[repository]]\nname = \"\"\npath = \"/r\"\n", None)
.expect_err("empty repository name");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("empty name"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_rejects_repository_with_empty_path() {
let err = parse("[[repository]]\nname = \"tern\"\npath = \"\"\n", None)
.expect_err("empty repository path");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("empty path"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_rejects_repository_name_containing_colon() {
let err = parse("[[repository]]\nname = \"bad:name\"\npath = \"/r\"\n", None)
.expect_err("colon in repo name must surface during validate");
match err {
HallouminateError::Config(msg) => assert!(msg.contains("bad:name"), "got: {msg}"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn effective_corpora_includes_repository_wiki_after_user_corpora() {
let cfg = parse(
r#"
[[corpus]]
name = "docs"
paths = ["/docs"]
[[repository]]
name = "tern"
path = "/repos/tern"
"#,
None,
)
.expect("parses");
let all = cfg.effective_corpora().expect("derive");
let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["docs", "repo:tern:wiki"]);
}
#[test]
fn effective_corpora_includes_repository_source_corpus_when_paths_set() {
let cfg = parse(
r#"
[[repository]]
name = "tern"
path = "/repos/tern"
corpus_paths = ["docs"]
"#,
None,
)
.expect("parses");
let all = cfg.effective_corpora().expect("derive");
let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["repo:tern:wiki", "repo:tern:corpus"]);
let source = &all[1];
assert_eq!(source.paths, vec!["/repos/tern/docs".to_string()]);
}
#[test]
fn parse_rejects_duplicate_user_corpus_shadowing_repository_wiki() {
let err = parse(
r#"
[[corpus]]
name = "repo:tern:wiki"
paths = ["/x"]
[[repository]]
name = "tern"
path = "/r"
"#,
None,
)
.expect_err("duplicate must fail");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("duplicate"), "got: {msg}");
assert!(msg.contains("repo:tern:wiki"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_rejects_two_repositories_with_same_name_via_derived_corpus_collision() {
let err = parse(
r#"
[[repository]]
name = "tern"
path = "/r1"
[[repository]]
name = "tern"
path = "/r2"
"#,
None,
)
.expect_err("two repos with the same name must fail");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("duplicate"), "got: {msg}");
assert!(msg.contains("repo:tern:wiki"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn load_xdg_with_explicit_missing_path_returns_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("does-not-exist.toml");
let cfg = load_xdg(Some(&missing)).expect("missing file → defaults");
assert_eq!(cfg, Config::default());
}
#[test]
fn load_xdg_reads_file_from_explicit_path() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, SPEC_EXAMPLE).expect("write");
let cfg = load_xdg(Some(&cfg_path)).expect("load");
assert_eq!(cfg.corpora[0].name, "claude-config");
}
#[test]
fn load_is_alias_for_load_xdg() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, SPEC_EXAMPLE).expect("write");
assert_eq!(
load(Some(&cfg_path)).expect("load"),
load_xdg(Some(&cfg_path)).expect("load_xdg"),
);
}
#[test]
fn parse_silently_ignores_legacy_fusion_fields() {
let legacy = r#"
[search]
top_files_default = 7
chunks_per_file_default = 2
fusion = "convex"
convex_alpha = 0.65
rrf_k = 60
"#;
let cfg = parse(legacy, None).expect("legacy config must still parse");
assert_eq!(cfg.search.top_files_default, 7);
assert_eq!(cfg.search.chunks_per_file_default, 2);
assert_eq!(
cfg.search,
SearchConfig {
top_files_default: 7,
chunks_per_file_default: 2,
crossencoder: None,
},
"SearchConfig must hold only the surviving fields"
);
}
#[test]
fn xdg_config_path_falls_back_to_dot_config_when_xdg_env_absent() {
let path = xdg_config_path_from(None);
assert!(
path.ends_with(".config/hallouminate/config.toml"),
"got {}",
path.display()
);
assert!(path.is_absolute(), "tilde must expand: {}", path.display());
}
#[test]
fn xdg_config_path_falls_back_when_xdg_env_is_empty_string() {
let path = xdg_config_path_from(Some(std::ffi::OsStr::new("")));
assert!(
path.ends_with(".config/hallouminate/config.toml"),
"empty XDG_CONFIG_HOME must fall back; got {}",
path.display()
);
}
#[test]
fn xdg_config_path_honors_custom_xdg_config_home() {
let custom = std::path::PathBuf::from("/var/tmp/custom-xdg");
let path = xdg_config_path_from(Some(custom.as_os_str()));
assert_eq!(path, custom.join("hallouminate").join("config.toml"));
}
#[test]
fn load_xdg_missing_path_returns_defaults_without_error() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("nope.toml");
let cfg = load_xdg(Some(&missing)).expect("missing -> defaults");
assert_eq!(cfg, Config::default());
}
#[cfg(unix)]
#[test]
fn load_xdg_propagates_non_notfound_io_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let unreadable = dir.path().join("locked");
std::fs::create_dir(&unreadable).expect("mkdir");
let cfg_path = unreadable.join("config.toml");
std::fs::write(&cfg_path, "").expect("write");
let is_root = nix_getuid_is_zero();
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000))
.expect("chmod");
let result = load_xdg(Some(&cfg_path));
let _ = std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755));
if is_root {
return; }
let err = result.expect_err("unreadable parent must surface an io error");
match err {
HallouminateError::Io(io) => {
assert_ne!(
io.kind(),
std::io::ErrorKind::NotFound,
"must NOT classify as NotFound: {io}"
);
}
other => panic!("expected HallouminateError::Io, got {other:?}"),
}
}
#[cfg(unix)]
fn nix_getuid_is_zero() -> bool {
if let Ok(s) = std::fs::read_to_string("/proc/self/status")
&& let Some(line) = s.lines().find(|l| l.starts_with("Uid:"))
{
return line.split_whitespace().nth(1) == Some("0");
}
std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim() == "0")
.unwrap_or(false)
}
fn canon(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn write_repo_config(dir: &Path, body: &str) -> PathBuf {
let cfg_dir = dir.join(".hallouminate");
std::fs::create_dir_all(&cfg_dir).expect("mkdir .hallouminate");
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(&cfg_path, body).expect("write repo config");
cfg_path
}
#[test]
fn discover_repo_config_finds_at_cwd_itself() {
let dir = tempfile::tempdir().expect("tempdir");
let root = canon(dir.path());
let expected = write_repo_config(&root, "");
let found = discover_repo_config(&root).expect("found at cwd");
assert_eq!(canon(&found), canon(&expected));
}
#[test]
fn discover_repo_config_finds_at_ancestor() {
let dir = tempfile::tempdir().expect("tempdir");
let root = canon(dir.path());
let expected = write_repo_config(&root, "");
let nested = root.join("a").join("b").join("c");
std::fs::create_dir_all(&nested).expect("mkdir nested");
let found = discover_repo_config(&nested).expect("walked up to ancestor");
assert_eq!(canon(&found), canon(&expected));
}
#[test]
fn discover_repo_config_stops_at_git_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let root = canon(dir.path());
std::fs::create_dir(root.join(".git")).expect("mkdir .git");
let nested = root.join("src");
std::fs::create_dir_all(&nested).expect("mkdir nested");
let err = discover_repo_config(&nested).expect_err("stop at repo root");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("stopped at repo root"), "got: {msg}");
assert!(msg.contains(&nested.display().to_string()), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn discover_repo_config_treats_git_file_as_repo_boundary() {
let dir = tempfile::tempdir().expect("tempdir");
let root = canon(dir.path());
std::fs::write(root.join(".git"), "gitdir: /elsewhere\n").expect("write .git file");
let err = discover_repo_config(&root).expect_err("git file stops walk");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("stopped at repo root"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn discover_repo_config_relative_cwd_resolves_against_current_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let base = canon(dir.path());
let rel = Path::new("nested/leaf");
let normalized = absolutize_cwd(rel, &base);
assert_eq!(
normalized,
base.join(rel),
"a relative cwd must resolve by joining against the base dir",
);
assert!(
normalized.is_absolute(),
"normalization against an absolute base must yield an absolute path \
so the walk reaches the real filesystem root, not the empty component",
);
match discover_repo_config(&normalized) {
Err(HallouminateError::Config(m)) => {
assert!(
m.contains("reached filesystem root") || m.contains("stopped at repo root"),
"the normalized path must walk a real parent chain to a \
boundary, not bottom out at an empty component; got: {m}",
);
assert!(
m.contains(&normalized.display().to_string()),
"the error must name the normalized cwd it walked from; got: {m}",
);
}
other => panic!("normalized relative input must error: {other:?}"),
}
}
#[test]
fn discover_repo_config_errors_walking_past_no_git_no_config() {
let dir = tempfile::tempdir().expect("tempdir");
let cwd = canon(dir.path());
match discover_repo_config(&cwd) {
Err(HallouminateError::Config(msg)) => {
assert!(
msg.contains("filesystem root") || msg.contains("stopped at repo root"),
"got: {msg}"
);
}
Ok(p) => panic!("did not expect to find a config; got {}", p.display()),
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn load_repo_layer_resolves_relative_paths_against_repo_root() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_root = canon(dir.path());
let cfg = r#"
[[corpus]]
name = "docs"
paths = ["docs", "specs/cur"]
[[repository]]
name = "self"
path = "."
corpus_paths = ["sub/docs"]
[storage]
ground_dir = "var/ground"
[embeddings]
cache_dir = "var/fastembed"
"#;
let cfg_path = write_repo_config(&repo_root, cfg);
let parsed = load_repo_layer(&cfg_path).expect("load_repo_layer");
let base = &repo_root;
assert_eq!(
parsed.corpora[0].paths,
vec![
base.join("docs").to_string_lossy().into_owned(),
base.join("specs/cur").to_string_lossy().into_owned(),
]
);
assert_eq!(
parsed.repositories[0].path,
base.join(".").to_string_lossy().into_owned(),
);
assert_eq!(
parsed.repositories[0].corpus_paths,
vec![base.join("sub/docs").to_string_lossy().into_owned()],
);
assert_eq!(
parsed.storage.ground_dir,
base.join("var/ground").to_string_lossy().into_owned(),
);
assert_eq!(
parsed.embeddings.cache_dir,
base.join("var/fastembed").to_string_lossy().into_owned(),
);
}
#[test]
fn load_repo_layer_preserves_absolute_paths_untouched() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_root = canon(dir.path());
let cfg = r#"
[[corpus]]
name = "abs"
paths = ["/abs/docs"]
[[repository]]
name = "absrepo"
path = "/abs/repo"
corpus_paths = ["/abs/repo/docs"]
[storage]
ground_dir = "/abs/ground"
[embeddings]
cache_dir = "/abs/cache"
"#;
let cfg_path = write_repo_config(&repo_root, cfg);
let parsed = load_repo_layer(&cfg_path).expect("load_repo_layer");
assert_eq!(parsed.corpora[0].paths, vec!["/abs/docs".to_string()]);
assert_eq!(parsed.repositories[0].path, "/abs/repo");
assert_eq!(
parsed.repositories[0].corpus_paths,
vec!["/abs/repo/docs".to_string()],
);
assert_eq!(parsed.storage.ground_dir, "/abs/ground");
assert_eq!(parsed.embeddings.cache_dir, "/abs/cache");
}
#[test]
fn load_repo_layer_preserves_tilde_paths_untouched() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_root = canon(dir.path());
let cfg = r#"
[[corpus]]
name = "home"
paths = ["~/docs"]
[[repository]]
name = "homerepo"
path = "~/repo"
corpus_paths = ["~/repo/docs"]
[storage]
ground_dir = "~/ground"
[embeddings]
cache_dir = "~/cache"
"#;
let cfg_path = write_repo_config(&repo_root, cfg);
let parsed = load_repo_layer(&cfg_path).expect("load_repo_layer");
assert_eq!(parsed.corpora[0].paths, vec!["~/docs".to_string()]);
assert_eq!(parsed.repositories[0].path, "~/repo");
assert_eq!(
parsed.repositories[0].corpus_paths,
vec!["~/repo/docs".to_string()],
);
assert_eq!(parsed.storage.ground_dir, "~/ground");
assert_eq!(parsed.embeddings.cache_dir, "~/cache");
}
#[test]
fn merge_layers_appends_repo_corpora_after_baseline() {
let baseline = parse(
r#"
[[corpus]]
name = "global"
paths = ["/global"]
"#,
None,
)
.expect("baseline parses");
let repo = parse(
r#"
[[corpus]]
name = "local"
paths = ["/local"]
"#,
None,
)
.expect("repo parses");
let merged = merge_layers(&baseline, &repo).expect("merge");
let names: Vec<&str> = merged.corpora.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["global", "local"]);
}
#[test]
fn merge_layers_appends_repo_repositories_after_baseline() {
let baseline = parse(
r#"
[[repository]]
name = "a"
path = "/a"
"#,
None,
)
.expect("baseline parses");
let repo = parse(
r#"
[[repository]]
name = "b"
path = "/b"
"#,
None,
)
.expect("repo parses");
let merged = merge_layers(&baseline, &repo).expect("merge");
let names: Vec<&str> = merged
.repositories
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn merge_layers_uses_baseline_scalar_when_repo_left_default() {
let baseline = parse("[search]\ntop_files_default = 20\n", None).expect("baseline");
let repo = parse("", None).expect("repo default");
let merged = merge_layers(&baseline, &repo).expect("merge");
assert_eq!(merged.search.top_files_default, 20);
}
#[test]
fn merge_layers_uses_repo_scalar_when_baseline_left_default() {
let baseline = parse("", None).expect("baseline default");
let repo = parse("[search]\ntop_files_default = 30\n", None).expect("repo");
let merged = merge_layers(&baseline, &repo).expect("merge");
assert_eq!(merged.search.top_files_default, 30);
}
#[test]
fn merge_layers_accepts_both_sides_explicit_equal() {
let cfg = "[embeddings]\nmodel = \"BAAI/bge-small-en-v1.5\"\ncache_dir = \"/shared\"\n";
let baseline = parse(cfg, None).expect("baseline");
let repo = parse(cfg, None).expect("repo");
let merged = merge_layers(&baseline, &repo).expect("merge");
assert_eq!(merged.embeddings.cache_dir, "/shared");
assert_eq!(merged.embeddings.model, "BAAI/bge-small-en-v1.5");
}
#[test]
fn merge_layers_fails_on_scalar_conflict_with_field_name_in_message() {
let baseline = parse("[embeddings]\ncache_dir = \"/a\"\n", None).expect("baseline");
let repo = parse("[embeddings]\ncache_dir = \"/b\"\n", None).expect("repo");
let err = merge_layers(&baseline, &repo).expect_err("conflict must fail");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("embeddings.cache_dir"), "got: {msg}");
assert!(msg.contains("\"/a\""), "got: {msg}");
assert!(msg.contains("\"/b\""), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn merge_layers_conflict_names_both_source_paths_when_supplied() {
let baseline = parse("[embeddings]\ncache_dir = \"/a\"\n", None).expect("baseline");
let repo = parse("[embeddings]\ncache_dir = \"/b\"\n", None).expect("repo");
let xdg = Path::new("/etc/hallouminate/config.toml");
let repo_p = Path::new("/work/.hallouminate/config.toml");
let err = merge_layers_with_sources(&baseline, &repo, Some(xdg), Some(repo_p))
.expect_err("conflict must fail");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("/etc/hallouminate/config.toml"), "got: {msg}");
assert!(
msg.contains("/work/.hallouminate/config.toml"),
"got: {msg}"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn resolve_for_cwd_walks_finds_and_merges_repo_layer() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_root = canon(dir.path());
write_repo_config(
&repo_root,
r#"
[[corpus]]
name = "repo-docs"
paths = ["docs"]
"#,
);
let baseline = parse(
r#"
[[corpus]]
name = "global"
paths = ["/g"]
"#,
None,
)
.expect("baseline");
let nested = repo_root.join("src").join("inner");
std::fs::create_dir_all(&nested).expect("mkdir nested");
let xdg = PathBuf::from("/etc/hallouminate/config.toml");
let (effective, layers) = resolve_for_cwd(&baseline, &nested, Some(&xdg)).expect("resolve");
let names: Vec<&str> = effective.corpora.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["global", "repo-docs"]);
assert_eq!(layers.xdg_path, Some(xdg));
assert_eq!(
canon(&layers.repo_path),
canon(&repo_root.join(".hallouminate").join("config.toml")),
);
}
#[test]
fn resolve_for_cwd_with_repository_dot_path_derives_corpora_against_repo_root() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_root = canon(dir.path());
write_repo_config(
&repo_root,
r#"
[[repository]]
name = "X"
path = "."
corpus_paths = ["docs"]
"#,
);
let baseline = Config::default();
let (effective, _layers) = resolve_for_cwd(&baseline, &repo_root, None).expect("resolve");
let all = effective.effective_corpora().expect("derive corpora");
let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["repo:X:wiki", "repo:X:corpus"]);
let wiki_expected = repo_root
.join(".")
.join(".hallouminate")
.join("wiki")
.to_string_lossy()
.into_owned();
assert_eq!(all[0].paths, vec![wiki_expected]);
let docs_expected = repo_root.join("docs").to_string_lossy().into_owned();
assert_eq!(all[1].paths, vec![docs_expected]);
}
#[test]
fn resolve_for_cwd_returns_hard_error_when_no_repo_config_found() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_root = canon(dir.path());
std::fs::create_dir(repo_root.join(".git")).expect("mkdir .git");
let nested = repo_root.join("src");
std::fs::create_dir_all(&nested).expect("mkdir nested");
let baseline = Config::default();
let err = resolve_for_cwd(&baseline, &nested, None).expect_err("must error");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("stopped at repo root"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn resolve_for_cwd_passes_xdg_path_into_conflict_messages() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_root = canon(dir.path());
let cfg_path = write_repo_config(&repo_root, "[embeddings]\ncache_dir = \"/repo-cache\"\n");
let baseline =
parse("[embeddings]\ncache_dir = \"/xdg-cache\"\n", None).expect("baseline parse");
let xdg = PathBuf::from("/etc/hallouminate/config.toml");
let err =
resolve_for_cwd(&baseline, &repo_root, Some(&xdg)).expect_err("conflict must fail");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("embeddings.cache_dir"), "got: {msg}");
assert!(msg.contains("/etc/hallouminate/config.toml"), "got: {msg}");
assert!(msg.contains(&cfg_path.display().to_string()), "got: {msg}");
assert!(msg.contains("/xdg-cache"), "got: {msg}");
assert!(msg.contains("/repo-cache"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
}