use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::domain::common::{CorpusConfig, HallouminateError, Result, expand_tilde};
use crate::domain::corpus::blake3_bytes;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryConfig {
pub name: String,
pub path: String,
#[serde(default)]
pub corpus_paths: Vec<String>,
#[serde(default)]
pub corpus_globs: Vec<String>,
#[serde(default)]
pub corpus_exclude: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RepoCorpusKind {
Wiki,
Corpus,
}
impl RepoCorpusKind {
fn suffix(self) -> &'static str {
match self {
RepoCorpusKind::Wiki => "wiki",
RepoCorpusKind::Corpus => "corpus",
}
}
}
pub const WIKI_RELATIVE_PATH: &str = ".hallouminate/wiki";
pub fn repo_corpus_name(repo_name: &str, kind: RepoCorpusKind) -> Result<String> {
if repo_name.is_empty() {
return Err(HallouminateError::Config(
"repository name must not be empty".to_string(),
));
}
if repo_name.contains(':') {
return Err(HallouminateError::Config(format!(
"repository name {repo_name:?} must not contain ':' \
(reserved as the repo:{{name}}:{{kind}} separator)"
)));
}
Ok(format!("repo:{repo_name}:{}", kind.suffix()))
}
pub fn repository_wiki_corpus(repo: &RepositoryConfig) -> Result<CorpusConfig> {
let name = repo_corpus_name(&repo.name, RepoCorpusKind::Wiki)?;
let wiki_dir = wiki_directory(repo);
Ok(CorpusConfig {
name,
paths: vec![wiki_dir.to_string_lossy().into_owned()],
globs: vec!["**/*.md".to_string()],
exclude: Vec::new(),
global: false,
})
}
pub fn repository_source_corpus(repo: &RepositoryConfig) -> Result<Option<CorpusConfig>> {
if repo.corpus_paths.is_empty() {
return Ok(None);
}
let name = repo_corpus_name(&repo.name, RepoCorpusKind::Corpus)?;
let repo_root = PathBuf::from(&repo.path);
let mut paths: Vec<String> = Vec::with_capacity(repo.corpus_paths.len());
for raw in &repo.corpus_paths {
paths.push(resolve_under(&repo_root, raw));
}
Ok(Some(CorpusConfig {
name,
paths,
globs: repo.corpus_globs.clone(),
exclude: repo.corpus_exclude.clone(),
global: false,
}))
}
pub fn effective_corpora(
corpora: &[CorpusConfig],
repositories: &[RepositoryConfig],
) -> Result<Vec<CorpusConfig>> {
let mut out: Vec<CorpusConfig> = corpora.to_vec();
for repo in repositories {
out.push(repository_wiki_corpus(repo)?);
if let Some(src) = repository_source_corpus(repo)? {
out.push(src);
}
}
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for corpus in &out {
if !seen.insert(corpus.name.as_str()) {
return Err(HallouminateError::Config(format!(
"duplicate corpus name {:?} after deriving repository corpora",
corpus.name
)));
}
}
Ok(out)
}
pub fn wiki_directory(repo: &RepositoryConfig) -> PathBuf {
PathBuf::from(&repo.path).join(WIKI_RELATIVE_PATH)
}
pub fn default_wiki_for_cwd(repositories: &[RepositoryConfig], cwd: &Path) -> Option<String> {
let cwd_abs = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
let mut best: Option<(usize, String)> = None;
for repo in repositories {
let expanded = expand_tilde(&repo.path);
let repo_abs = std::fs::canonicalize(&expanded).unwrap_or(expanded);
if !cwd_abs.starts_with(&repo_abs) {
continue;
}
let depth = repo_abs.components().count();
let beats_best = best.as_ref().is_none_or(|(d, _)| depth > *d);
if !beats_best {
continue;
}
if let Ok(name) = repo_corpus_name(&repo.name, RepoCorpusKind::Wiki) {
best = Some((depth, name));
}
}
best.map(|(_, name)| name)
}
pub fn repository_for_discovered_wiki(repo_root: &Path) -> Option<RepositoryConfig> {
let name = repo_root.file_name()?.to_str()?.to_string();
if name.trim().is_empty() || name.contains(':') {
return None;
}
Some(RepositoryConfig {
name,
path: repo_root.to_string_lossy().into_owned(),
corpus_paths: Vec::new(),
corpus_globs: Vec::new(),
corpus_exclude: Vec::new(),
})
}
pub fn union_discovered_repositories(
baseline: &[RepositoryConfig],
discovered: Vec<RepositoryConfig>,
) -> (Vec<RepositoryConfig>, Vec<String>) {
let baseline_wiki_paths: std::collections::HashSet<PathBuf> =
baseline.iter().map(canonical_wiki_path).collect();
let mut out: Vec<RepositoryConfig> = baseline.to_vec();
let mut warnings: Vec<String> = Vec::new();
for mut repo in discovered {
if baseline_wiki_paths.contains(&canonical_wiki_path(&repo)) {
continue;
}
let Ok(name) = repo_corpus_name(&repo.name, RepoCorpusKind::Wiki) else {
continue;
};
if let Some(pos) = position_by_derived_name(&out, &name) {
let matched_is_baseline = baseline_wiki_paths.contains(&canonical_wiki_path(&out[pos]));
if matched_is_baseline {
warnings.push(format!(
"discovered sub-repo wiki {name:?} shadows a baseline repository \
of the same name; preferring the discovered local config",
));
out.remove(pos);
} else {
let new_name = disambiguate_discovered_name(&mut repo, &out);
warnings.push(format!(
"discovered sub-repo wiki {name:?} (at {path:?}) collides with another \
discovered sub-repo of the same name; keeping both, renaming this one to \
{new_name:?} so its wiki is not dropped",
path = repo.path,
));
}
}
out.push(repo);
}
(out, warnings)
}
fn position_by_derived_name(repos: &[RepositoryConfig], derived_name: &str) -> Option<usize> {
repos.iter().position(|r| {
repo_corpus_name(&r.name, RepoCorpusKind::Wiki)
.ok()
.as_deref()
== Some(derived_name)
})
}
fn disambiguate_discovered_name(
repo: &mut RepositoryConfig,
existing: &[RepositoryConfig],
) -> String {
let base = repo.name.clone();
let segments: Vec<String> = Path::new(&repo.path)
.parent()
.map(|parent| {
parent
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().replace(':', "-")),
_ => None,
})
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
for take in 1..=segments.len() {
let prefix = segments[segments.len() - take..].join("-");
let candidate = format!("{prefix}-{base}");
if let Some(derived) = unique_derived_name(&candidate, existing) {
repo.name = candidate;
return derived;
}
}
let digest = blake3_bytes(repo.path.as_bytes());
for len in 8..=digest.len() {
let candidate = format!("{base}-{}", &digest[..len]);
if let Some(derived) = unique_derived_name(&candidate, existing) {
repo.name = candidate;
return derived;
}
}
let candidate = format!("{base}-{digest}");
let derived = repo_corpus_name(&candidate, RepoCorpusKind::Wiki).unwrap_or(base);
repo.name = candidate;
derived
}
fn unique_derived_name(candidate: &str, existing: &[RepositoryConfig]) -> Option<String> {
let derived = repo_corpus_name(candidate, RepoCorpusKind::Wiki).ok()?;
position_by_derived_name(existing, &derived)
.is_none()
.then_some(derived)
}
fn canonical_wiki_path(repo: &RepositoryConfig) -> PathBuf {
let wiki = wiki_directory(repo);
let expanded = expand_tilde(&wiki.to_string_lossy());
std::fs::canonicalize(&expanded).unwrap_or(expanded)
}
fn resolve_under(base: &Path, raw: &str) -> String {
let candidate = Path::new(raw);
if candidate.is_absolute() {
raw.to_string()
} else {
base.join(candidate).to_string_lossy().into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn repo(name: &str, path: &str) -> RepositoryConfig {
RepositoryConfig {
name: name.into(),
path: path.into(),
corpus_paths: Vec::new(),
corpus_globs: Vec::new(),
corpus_exclude: Vec::new(),
}
}
#[test]
fn repo_corpus_name_emits_canonical_wiki_and_corpus_suffixes() {
assert_eq!(
repo_corpus_name("tern", RepoCorpusKind::Wiki).unwrap(),
"repo:tern:wiki",
);
assert_eq!(
repo_corpus_name("tern", RepoCorpusKind::Corpus).unwrap(),
"repo:tern:corpus",
);
}
#[test]
fn repo_corpus_name_rejects_empty_name() {
let err = repo_corpus_name("", RepoCorpusKind::Wiki).expect_err("empty must fail");
match err {
HallouminateError::Config(msg) => assert!(msg.contains("empty"), "got: {msg}"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn repo_corpus_name_rejects_names_containing_colon() {
let err = repo_corpus_name("a:b", RepoCorpusKind::Wiki).expect_err("colon must fail");
match err {
HallouminateError::Config(msg) => {
assert!(msg.contains("':'") || msg.contains("colon"), "got: {msg}");
assert!(msg.contains("a:b"), "got: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn repository_wiki_corpus_anchors_under_dot_hallouminate_wiki() {
let cfg = repository_wiki_corpus(&repo("tern", "/repos/tern")).unwrap();
assert_eq!(cfg.name, "repo:tern:wiki");
assert_eq!(
cfg.paths,
vec!["/repos/tern/.hallouminate/wiki".to_string()],
);
assert_eq!(cfg.globs, vec!["**/*.md".to_string()]);
assert!(cfg.exclude.is_empty());
}
#[test]
fn repository_source_corpus_returns_none_when_corpus_paths_empty() {
let cfg = repository_source_corpus(&repo("tern", "/r")).unwrap();
assert!(cfg.is_none(), "no corpus_paths => no source corpus");
}
#[test]
fn repository_source_corpus_resolves_relative_paths_against_repo_path() {
let mut r = repo("tern", "/repos/tern");
r.corpus_paths = vec!["docs".into(), "/abs/elsewhere".into()];
r.corpus_globs = vec!["**/*.md".into()];
r.corpus_exclude = vec!["**/drafts/**".into()];
let cfg = repository_source_corpus(&r).unwrap().expect("present");
assert_eq!(cfg.name, "repo:tern:corpus");
assert_eq!(
cfg.paths,
vec!["/repos/tern/docs".to_string(), "/abs/elsewhere".to_string(),],
);
assert_eq!(cfg.globs, vec!["**/*.md".to_string()]);
assert_eq!(cfg.exclude, vec!["**/drafts/**".to_string()]);
}
#[test]
fn effective_corpora_appends_derived_repository_corpora() {
let user = CorpusConfig {
name: "docs".into(),
paths: vec!["/docs".into()],
globs: vec!["**/*.md".into()],
exclude: Vec::new(),
global: false,
};
let mut r = repo("tern", "/r");
r.corpus_paths = vec!["src/docs".into()];
let all = effective_corpora(std::slice::from_ref(&user), &[r]).unwrap();
let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["docs", "repo:tern:wiki", "repo:tern:corpus"]);
}
#[test]
fn effective_corpora_rejects_user_corpus_colliding_with_derived_name() {
let shadow = CorpusConfig {
name: "repo:tern:wiki".into(),
paths: vec!["/x".into()],
..Default::default()
};
let r = repo("tern", "/r");
let err = effective_corpora(&[shadow], &[r]).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 effective_corpora_omits_source_corpus_when_repo_declares_no_paths() {
let r = repo("tern", "/r");
let all = effective_corpora(&[], &[r]).unwrap();
let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["repo:tern:wiki"]);
}
#[test]
fn wiki_directory_is_repo_path_joined_with_dot_hallouminate_wiki() {
let r = repo("tern", "/repos/tern");
assert_eq!(
wiki_directory(&r),
PathBuf::from("/repos/tern/.hallouminate/wiki"),
);
}
#[test]
fn default_wiki_for_cwd_returns_none_with_no_repositories() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(default_wiki_for_cwd(&[], tmp.path()).is_none());
}
#[test]
fn default_wiki_for_cwd_returns_none_when_cwd_outside_every_repo() {
let outer = tempfile::tempdir().expect("tempdir");
let repo_root = outer.path().join("inside");
std::fs::create_dir(&repo_root).expect("mkdir");
let elsewhere = outer.path().join("elsewhere");
std::fs::create_dir(&elsewhere).expect("mkdir");
let r = repo("tern", repo_root.to_str().unwrap());
assert!(default_wiki_for_cwd(&[r], &elsewhere).is_none());
}
#[test]
fn default_wiki_for_cwd_picks_repo_containing_cwd() {
let outer = tempfile::tempdir().expect("tempdir");
let repo_root = outer.path().join("tern");
let nested = repo_root.join("src");
std::fs::create_dir_all(&nested).expect("mkdir");
let r = repo("tern", repo_root.to_str().unwrap());
let got = default_wiki_for_cwd(&[r], &nested).expect("matched");
assert_eq!(got, "repo:tern:wiki");
}
#[test]
fn default_wiki_for_cwd_prefers_deepest_repo_when_nested() {
let outer = tempfile::tempdir().expect("tempdir");
let parent_repo = outer.path().join("parent");
let inner_repo = parent_repo.join("vendor").join("inner");
let cwd = inner_repo.join("src");
std::fs::create_dir_all(&cwd).expect("mkdir");
let parent = repo("parent", parent_repo.to_str().unwrap());
let inner = repo("inner", inner_repo.to_str().unwrap());
let got = default_wiki_for_cwd(&[parent, inner], &cwd).expect("matched");
assert_eq!(got, "repo:inner:wiki");
}
#[test]
fn repository_for_discovered_wiki_names_repo_after_directory_basename() {
let r = repository_for_discovered_wiki(Path::new("/home/dev/tern")).expect("derived");
assert_eq!(r.name, "tern");
assert_eq!(r.path, "/home/dev/tern");
assert_eq!(
repository_wiki_corpus(&r).unwrap().name,
"repo:tern:wiki",
"discovered repo derives the standard repo:{{name}}:wiki corpus"
);
}
#[test]
fn repository_for_discovered_wiki_skips_invalid_basename() {
assert!(
repository_for_discovered_wiki(Path::new("/home/dev/a:b")).is_none(),
"':'-bearing basename must yield None"
);
}
#[test]
fn repository_for_discovered_wiki_skips_whitespace_only_basename() {
let mut p = std::path::PathBuf::from("/home/dev");
p.push(" "); assert!(
repository_for_discovered_wiki(&p).is_none(),
"whitespace-only basename must yield None"
);
}
#[test]
fn union_appends_discovered_repos_after_baseline_with_no_collision() {
let baseline = vec![repo("alpha", "/dev/alpha")];
let discovered = vec![repo("beta", "/dev/beta"), repo("gamma", "/dev/gamma")];
let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
let names: Vec<&str> = merged.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
vec!["alpha", "beta", "gamma"],
"baseline first, discovered appended"
);
assert!(warnings.is_empty(), "no collision => no warnings");
}
#[test]
fn union_dedupes_discovered_repo_sharing_a_baseline_wiki_path() {
let baseline = vec![repo("registered", "/dev/shared")];
let discovered = vec![repo("shared", "/dev/shared")];
let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
let names: Vec<&str> = merged.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
vec!["registered"],
"same wiki path => discovered duplicate dropped"
);
assert!(
warnings.is_empty(),
"path-dedupe is silent (not a shadowing collision)"
);
}
#[test]
fn union_prefers_discovered_on_name_collision_at_different_path_and_warns() {
let baseline = vec![repo("tern", "/baseline/tern")];
let discovered = vec![repo("tern", "/local/tern")];
let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
assert_eq!(merged.len(), 1, "collision collapses to one entry");
assert_eq!(
merged[0].path, "/local/tern",
"discovered local config is preferred over baseline"
);
assert_eq!(warnings.len(), 1, "shadowing must warn exactly once");
assert!(
warnings[0].contains("repo:tern:wiki") && warnings[0].contains("shadows"),
"warning must name the shadowed corpus: {warnings:?}"
);
}
#[test]
fn union_keeps_both_same_basename_discovered_siblings_with_distinct_corpora() {
let baseline: Vec<RepositoryConfig> = Vec::new();
let discovered = vec![
repo("tern", "/dev/work/tern"),
repo("tern", "/dev/personal/tern"),
];
let (merged, _warnings) = union_discovered_repositories(&baseline, discovered);
assert_eq!(
merged.len(),
2,
"both same-basename siblings must survive the union, not one"
);
let paths: Vec<&str> = merged.iter().map(|r| r.path.as_str()).collect();
assert!(
paths.contains(&"/dev/work/tern") && paths.contains(&"/dev/personal/tern"),
"both wiki roots must be present: {paths:?}"
);
let mut corpora: Vec<String> = merged
.iter()
.map(|r| repo_corpus_name(&r.name, RepoCorpusKind::Wiki).unwrap())
.collect();
corpora.sort();
corpora.dedup();
assert_eq!(
corpora.len(),
2,
"the two siblings must derive distinct corpus names: {corpora:?}"
);
assert!(
corpora.iter().any(|c| c == "repo:tern:wiki"),
"first sibling keeps the canonical name: {corpora:?}"
);
assert!(
corpora
.iter()
.any(|c| c.contains("tern") && c != "repo:tern:wiki"),
"second sibling is parent-qualified: {corpora:?}"
);
}
#[test]
fn union_warning_for_discovered_sibling_collision_names_sibling_not_baseline() {
let baseline: Vec<RepositoryConfig> = Vec::new();
let discovered = vec![
repo("tern", "/dev/work/tern"),
repo("tern", "/dev/personal/tern"),
];
let (_merged, warnings) = union_discovered_repositories(&baseline, discovered);
assert_eq!(warnings.len(), 1, "one collision => one warning");
let w = &warnings[0];
assert!(
w.contains("another discovered sub-repo"),
"warning must name the colliding discovered sibling: {w:?}"
);
assert!(
!w.contains("baseline"),
"all-discovered collision must NOT misdescribe the cause as a baseline shadow: {w:?}"
);
assert!(
w.contains("keeping both"),
"warning must state that both wikis are kept, not that one was dropped: {w:?}"
);
}
#[test]
fn union_baseline_tern_plus_two_discovered_tern_siblings_keeps_all_three_wikis() {
let baseline = vec![repo("tern", "/baseline/tern")];
let discovered = vec![
repo("tern", "/dev/work/tern"),
repo("tern", "/dev/personal/tern"),
];
let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
let paths: Vec<&str> = merged.iter().map(|r| r.path.as_str()).collect();
assert!(
paths.contains(&"/dev/work/tern") && paths.contains(&"/dev/personal/tern"),
"both discovered sibling wikis must survive, not be dropped: {paths:?}"
);
assert!(
!paths.contains(&"/baseline/tern"),
"the baseline `tern` is the entry shadowed by the discovered configs: {paths:?}"
);
effective_corpora(&[], &merged)
.expect("merged repos must derive a dup-free corpus set, not error-abort");
let shadow_warnings = warnings.iter().filter(|w| w.contains("shadows")).count();
assert_eq!(
shadow_warnings, 1,
"only the genuine baseline shadow warns about shadowing: {warnings:?}"
);
assert!(
warnings.iter().any(|w| w.contains("keeping both")),
"the discovered sibling collision must warn it kept both wikis: {warnings:?}"
);
}
#[test]
fn union_fallback_when_parent_segments_exhausted_still_keeps_both_with_unique_name() {
let baseline: Vec<RepositoryConfig> = Vec::new();
let discovered = vec![repo("tern", "tern"), repo("tern", "tern")];
let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
assert_eq!(merged.len(), 2, "both bare-path siblings must be kept");
let corpora =
effective_corpora(&[], &merged).expect("fallback must not produce a duplicate name");
let mut names: Vec<&str> = corpora.iter().map(|c| c.name.as_str()).collect();
names.sort_unstable();
let unique = names.len();
names.dedup();
assert_eq!(
names.len(),
unique,
"derived corpus names must be unique: {names:?}"
);
let renamed = &warnings[0];
assert!(
renamed.contains("keeping both") && !renamed.contains("repo:tern:wiki\" so"),
"warning must name the actual unique new name, not the original: {renamed:?}"
);
}
#[test]
fn union_fallback_when_qualified_candidate_recollides_lands_a_unique_name() {
let baseline: Vec<RepositoryConfig> = Vec::new();
let discovered = vec![
repo("tern", "/x/work/tern"),
repo("personal-tern", "/x/elsewhere/personal-tern"),
repo("tern", "/x/personal/tern"),
];
let (merged, _warnings) = union_discovered_repositories(&baseline, discovered);
assert_eq!(merged.len(), 3, "all three distinct wikis must be kept");
let corpora = effective_corpora(&[], &merged)
.expect("a re-colliding qualified candidate must not abort the union");
let mut names: Vec<&str> = corpora.iter().map(|c| c.name.as_str()).collect();
let total = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(
names.len(),
total,
"every derived corpus name must be unique: {names:?}"
);
}
}