use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::domain::common::{CorpusConfig, HallouminateError, Result, expand_tilde};
#[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)
}
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");
}
}