use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::domain::repository::WIKI_RELATIVE_PATH;
pub const DEFAULT_MAX_DEPTH: usize = 4;
#[derive(Debug, Clone, Copy)]
pub struct IgnoreRules {
pub respect_gitignore: bool,
pub skip_hidden: bool,
}
impl Default for IgnoreRules {
fn default() -> Self {
Self {
respect_gitignore: true,
skip_hidden: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredWiki {
pub repo_root: PathBuf,
pub config_path: Option<PathBuf>,
pub wiki_dir: Option<PathBuf>,
}
pub fn discover_wiki_roots(
root: &Path,
max_depth: usize,
ignore: &IgnoreRules,
) -> Vec<DiscoveredWiki> {
let mut builder = WalkBuilder::new(root);
builder
.standard_filters(ignore.respect_gitignore)
.git_ignore(ignore.respect_gitignore)
.git_global(ignore.respect_gitignore)
.git_exclude(ignore.respect_gitignore)
.ignore(ignore.respect_gitignore)
.parents(false)
.hidden(false)
.follow_links(false)
.max_depth(Some(max_depth + 1));
let root_path = root.to_path_buf();
let skip_hidden = ignore.skip_hidden;
builder.filter_entry(move |entry| {
if entry.path() == root_path {
return true;
}
let name = entry.file_name();
if name == ".hallouminate" {
return true;
}
if name == ".git" {
return false;
}
if skip_hidden {
return !name.to_str().map(|n| n.starts_with('.')).unwrap_or(false);
}
true
});
let mut out: Vec<DiscoveredWiki> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for entry in builder.build() {
let entry = match entry {
Ok(e) => e,
Err(err) => {
tracing::warn!(
target: "hallouminate::discovery",
error = %err,
"discovery walk: skipping unreadable entry"
);
continue;
}
};
if entry.file_name() != ".hallouminate" {
continue;
}
let Some(ft) = entry.file_type() else {
continue;
};
if !ft.is_dir() {
continue;
}
let hallou_dir = entry.path();
let Some(repo_root) = hallou_dir.parent() else {
continue;
};
if repo_root == root {
continue;
}
let config = hallou_dir.join("config.toml");
let config_path = config.is_file().then_some(config);
let wiki = repo_root.join(WIKI_RELATIVE_PATH);
let wiki_dir = wiki.is_dir().then_some(wiki);
if config_path.is_none() && wiki_dir.is_none() {
continue;
}
let repo_root = repo_root.to_path_buf();
if seen.insert(repo_root.clone()) {
out.push(DiscoveredWiki {
repo_root,
config_path,
wiki_dir,
});
}
}
tracing::debug!(
target: "hallouminate::discovery",
roots = out.len(),
root = %root.display(),
"discovered sub-repo wikis"
);
out
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn seed_repo_wiki(root: &Path, repo: &str) -> PathBuf {
let repo_root = root.join(repo);
let hallou = repo_root.join(".hallouminate");
fs::create_dir_all(hallou.join("wiki")).expect("mkdir wiki");
fs::write(hallou.join("config.toml"), "").expect("write config");
repo_root
}
fn repo_roots(found: &[DiscoveredWiki]) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = found.iter().map(|w| w.repo_root.clone()).collect();
v.sort();
v
}
#[test]
fn discovers_multiple_sibling_repo_wikis_below_parent_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let alpha = seed_repo_wiki(tmp.path(), "alpha");
let beta = seed_repo_wiki(tmp.path(), "beta");
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &IgnoreRules::default());
assert_eq!(
repo_roots(&found),
{
let mut want = vec![alpha.clone(), beta.clone()];
want.sort();
want
},
"both sibling repo wikis must be discovered below the parent dir"
);
let alpha_hit = found
.iter()
.find(|w| w.repo_root == alpha)
.expect("alpha present");
assert_eq!(
alpha_hit.config_path,
Some(alpha.join(".hallouminate").join("config.toml")),
"config.toml path must be attributed to the discovered repo"
);
assert_eq!(
alpha_hit.wiki_dir,
Some(alpha.join(".hallouminate").join("wiki")),
"wiki dir must be attributed to the discovered repo"
);
}
#[test]
fn does_not_report_the_walk_root_itself() {
let tmp = tempfile::tempdir().expect("tempdir");
let hallou = tmp.path().join(".hallouminate");
fs::create_dir_all(hallou.join("wiki")).expect("mkdir");
fs::write(hallou.join("config.toml"), "").expect("write");
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &IgnoreRules::default());
assert!(
found.is_empty(),
"the walk root's own .hallouminate must not be reported: {found:?}"
);
}
#[test]
fn respects_gitignore_pruning_ignored_subtrees() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join(".git")).expect("mkdir .git");
fs::write(tmp.path().join(".gitignore"), "vendor/\n").expect("write gitignore");
let visible = seed_repo_wiki(tmp.path(), "visible");
seed_repo_wiki(tmp.path(), "vendor");
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &IgnoreRules::default());
assert_eq!(
repo_roots(&found),
vec![visible],
"gitignored vendor/ subtree must be pruned from discovery"
);
}
#[test]
fn finds_gitignored_wiki_when_gitignore_disabled() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join(".git")).expect("mkdir .git");
fs::write(tmp.path().join(".gitignore"), "vendor/\n").expect("write gitignore");
let visible = seed_repo_wiki(tmp.path(), "visible");
let vendored = seed_repo_wiki(tmp.path(), "vendor");
let rules = IgnoreRules {
respect_gitignore: false,
skip_hidden: true,
};
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &rules);
assert_eq!(
repo_roots(&found),
{
let mut want = vec![visible, vendored];
want.sort();
want
},
"with gitignore disabled, the vendored wiki must be discovered too"
);
}
#[test]
fn skips_hidden_directories_except_dot_hallouminate() {
let tmp = tempfile::tempdir().expect("tempdir");
let normal = seed_repo_wiki(tmp.path(), "normal");
seed_repo_wiki(&tmp.path().join(".cache"), "buried");
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &IgnoreRules::default());
assert_eq!(
repo_roots(&found),
vec![normal],
"a wiki under a hidden .cache/ dir must be skipped; the normal repo's \
.hallouminate must still be found"
);
}
#[test]
fn honors_the_depth_cap() {
let tmp = tempfile::tempdir().expect("tempdir");
let shallow = seed_repo_wiki(tmp.path(), "shallow");
let deep_parent = tmp.path().join("a").join("b").join("c").join("d");
let deep = seed_repo_wiki(&deep_parent, "deep");
let found = discover_wiki_roots(tmp.path(), 2, &IgnoreRules::default());
let roots = repo_roots(&found);
assert!(
roots.contains(&shallow),
"shallow repo within the cap must be found: {roots:?}"
);
assert!(
!roots.contains(&deep),
"repo beyond the depth cap must not be found: {roots:?}"
);
}
#[test]
fn skips_bare_hallouminate_with_no_config_or_wiki() {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("bare");
fs::create_dir_all(bare.join(".hallouminate")).expect("mkdir bare .hallouminate");
let real = seed_repo_wiki(tmp.path(), "real");
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &IgnoreRules::default());
assert_eq!(
repo_roots(&found),
vec![real],
"bare .hallouminate with no config/wiki must be skipped"
);
}
#[test]
fn reports_wiki_only_repo_when_config_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo_root = tmp.path().join("wikionly");
fs::create_dir_all(repo_root.join(".hallouminate").join("wiki")).expect("mkdir");
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &IgnoreRules::default());
assert_eq!(
found.len(),
1,
"wiki-only repo must be discovered: {found:?}"
);
assert_eq!(found[0].config_path, None);
assert_eq!(
found[0].wiki_dir,
Some(repo_root.join(".hallouminate").join("wiki"))
);
}
#[test]
fn prunes_git_dirs_unconditionally_even_when_skip_hidden_false() {
let tmp = tempfile::tempdir().expect("tempdir");
let git_buried = tmp.path().join(".git").join("buried");
let hallou_git = git_buried.join(".hallouminate");
fs::create_dir_all(hallou_git.join("wiki")).expect("mkdir .git buried wiki");
fs::write(hallou_git.join("config.toml"), "").expect("write .git buried config");
let cache_buried = tmp.path().join(".cache").join("cached");
let hallou_cache = cache_buried.join(".hallouminate");
fs::create_dir_all(hallou_cache.join("wiki")).expect("mkdir .cache buried wiki");
fs::write(hallou_cache.join("config.toml"), "").expect("write .cache buried config");
let rules = IgnoreRules {
respect_gitignore: false,
skip_hidden: false,
};
let found = discover_wiki_roots(tmp.path(), DEFAULT_MAX_DEPTH, &rules);
let roots: std::collections::HashSet<std::path::PathBuf> =
found.iter().map(|w| w.repo_root.clone()).collect();
assert!(
!roots.contains(&git_buried),
".git-buried wiki must not be discovered (unconditional .git prune): {roots:?}"
);
assert!(
roots.contains(&cache_buried),
".cache-buried wiki must be discovered when skip_hidden=false: {roots:?}"
);
}
}