use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::Value;
use walkdir::WalkDir;
use serde::Serialize;
use crate::model::{
AttachmentResolver, LinkTargetKind, Page, PageId, PageMeta, ParseIssue, SearchHit,
SearchMatchKind, TitleResolution,
};
use crate::parse::{parse_item_recursive, parse_page_meta};
use crate::render::page_content_contains;
use crate::util::{
extract_attachment_relative, extract_link_targets, extract_uuid_like, is_external_target,
};
#[derive(Debug, Clone)]
pub struct KnowledgeBaseIndex {
root: PathBuf,
pub pages: HashMap<PageId, PageMeta>,
pub sorted_ids: Vec<PageId>,
pub index_issues: Vec<ParseIssue>,
backlinks: HashMap<PageId, Vec<PageId>>,
forward_links: HashMap<PageId, HashSet<PageId>>,
}
pub struct KnowledgeBase;
impl KnowledgeBase {
pub fn open(path: impl AsRef<Path>) -> Result<KnowledgeBaseIndex> {
let root = path.as_ref().to_path_buf();
let mut pages = HashMap::new();
let mut issues = Vec::new();
for entry in WalkDir::new(&root)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let file_type = entry.file_type();
let file_path = entry.path();
if !file_type.is_file()
|| file_path.extension().and_then(|e| e.to_str()) != Some("lepiter")
{
continue;
}
match parse_page_meta(file_path) {
Ok(mut meta) => {
if meta.id.is_empty()
&& let Some(stem) = file_path.file_stem().and_then(|s| s.to_str())
{
meta.id = stem.to_string();
meta.id_lower = meta.id.to_lowercase();
}
if meta.title.is_empty() {
meta.title = meta.id.clone();
meta.title_lower = meta.title.to_lowercase();
}
pages.insert(meta.id.clone(), meta);
}
Err(err) => issues.push(ParseIssue {
path: file_path.to_path_buf(),
message: format!("{err:#}"),
}),
}
}
let sorted_ids = compute_sorted_ids(&pages);
Ok(KnowledgeBaseIndex {
root,
pages,
sorted_ids,
index_issues: issues,
backlinks: HashMap::new(),
forward_links: HashMap::new(),
})
}
}
impl KnowledgeBaseIndex {
pub fn register_page(&mut self, meta: PageMeta) {
let id = meta.id.clone();
if self.pages.contains_key(&id)
&& let Some(pos) = self.sorted_ids.iter().position(|i| i == &id)
{
self.sorted_ids.remove(pos);
}
self.pages.insert(id.clone(), meta);
insert_sorted_by_title(&mut self.sorted_ids, &self.pages, id);
}
pub fn load_page(&self, id: &str) -> Result<Page> {
let meta = self
.pages
.get(id)
.with_context(|| format!("page id not found: {id}"))?;
let file = File::open(&meta.path)
.with_context(|| format!("failed to open page file {}", meta.path.display()))?;
let reader = BufReader::new(file);
let raw: Value =
serde_json::from_reader(reader).with_context(|| "failed to decode page JSON")?;
let mut content = Vec::new();
if let Some(items) = raw
.get("children")
.and_then(|v| v.get("items"))
.and_then(Value::as_array)
{
for item in items {
parse_item_recursive(item, &mut content);
}
}
Ok(Page {
id: meta.id.clone(),
title: meta.title.clone(),
updated_at: meta.updated_at,
tags: meta.tags.clone(),
content,
})
}
pub fn sorted_pages(&self) -> Vec<&PageMeta> {
self.sorted_ids
.iter()
.filter_map(|id| self.pages.get(id))
.collect()
}
pub fn filter_page_ids(&self, query: &str) -> Vec<PageId> {
let needle = query.trim().to_lowercase();
let mut metas = self.sorted_pages();
if !needle.is_empty() {
metas.retain(|m| page_meta_match_kind(m, &needle).is_some());
}
metas.into_iter().map(|m| m.id.clone()).collect()
}
pub fn filter_page_ids_scored(&self, query: &str) -> Vec<(PageId, SearchMatchKind)> {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
return Vec::new();
}
let metas = self.sorted_pages();
metas
.into_iter()
.filter_map(|m| page_meta_match_kind(m, &needle).map(|kind| (m.id.clone(), kind)))
.collect()
}
pub fn search_hits(&self, query: &str, include_content: bool) -> Vec<SearchHit> {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
return Vec::new();
}
let mut by_id: HashMap<PageId, SearchMatchKind> = HashMap::new();
let metas = self.sorted_pages();
for meta in &metas {
if let Some(kind) = page_meta_match_kind(meta, &needle) {
by_id.insert(meta.id.clone(), kind);
}
}
if include_content {
for meta in &metas {
if by_id.contains_key(&meta.id) {
continue;
}
let Ok(page) = self.load_page(&meta.id) else {
continue;
};
if page_content_contains(&page, &needle) {
by_id.insert(meta.id.clone(), SearchMatchKind::Content);
}
}
}
let mut hits: Vec<SearchHit> = metas
.iter()
.filter_map(|meta| {
by_id.get(&meta.id).map(|kind| SearchHit {
id: meta.id.clone(),
kind: *kind,
})
})
.collect();
hits.sort_by_cached_key(|h| {
let title = self
.pages
.get(&h.id)
.map(|m| m.title_lower.clone())
.unwrap_or_default();
(std::cmp::Reverse(h.kind.score()), title)
});
hits
}
pub fn resolve_page_id_by_title(&self, title: &str) -> TitleResolution {
let needle = title.trim().to_lowercase();
if needle.is_empty() {
return TitleResolution::NotFound;
}
let sorted = self.sorted_pages();
let exact = sorted
.iter()
.filter(|m| m.title_lower == needle)
.map(|m| m.id.clone())
.collect::<Vec<_>>();
match exact.len() {
1 => return TitleResolution::Unique(exact[0].clone()),
n if n > 1 => return TitleResolution::Ambiguous(exact),
_ => {}
}
let partial = sorted
.iter()
.filter(|m| m.title_lower.contains(&needle))
.map(|m| m.id.clone())
.collect::<Vec<_>>();
match partial.len() {
1 => TitleResolution::Unique(partial[0].clone()),
0 => TitleResolution::NotFound,
_ => TitleResolution::Ambiguous(partial),
}
}
pub fn classify_link_target(&self, raw: &str) -> LinkTargetKind {
let target = raw.trim();
if target.is_empty() {
return LinkTargetKind::Unknown(raw.to_string());
}
if self.pages.contains_key(target) {
return LinkTargetKind::InternalPage(target.to_string());
}
if let Some(rest) = target.strip_prefix("page:") {
let id = rest.trim();
if self.pages.contains_key(id) {
return LinkTargetKind::InternalPage(id.to_string());
}
if let TitleResolution::Unique(resolved) = self.resolve_page_id_by_title(id) {
return LinkTargetKind::InternalPage(resolved);
}
}
if let Some(rest) = target.strip_prefix("title:") {
return match self.resolve_page_id_by_title(rest.trim()) {
TitleResolution::Unique(id) => LinkTargetKind::InternalPage(id),
_ => LinkTargetKind::Unknown(target.to_string()),
};
}
if let Some(uuid) = extract_uuid_like(target)
&& self.pages.contains_key(uuid)
{
return LinkTargetKind::InternalPage(uuid.to_string());
}
if is_external_target(target) {
return LinkTargetKind::ExternalUrl(target.to_string());
}
if let Some(path) = self.attachment_resolver().resolve_path(target) {
return LinkTargetKind::AttachmentPath(path);
}
match self.resolve_page_id_by_title(target) {
TitleResolution::Unique(id) => LinkTargetKind::InternalPage(id),
_ => LinkTargetKind::Unknown(target.to_string()),
}
}
pub fn build_backlinks(&mut self) {
let mut back: HashMap<PageId, HashSet<PageId>> = HashMap::new();
let mut forward: HashMap<PageId, HashSet<PageId>> = HashMap::new();
for source_id in &self.sorted_ids {
let Ok(page) = self.load_page(source_id) else {
continue;
};
let mut source_targets = HashSet::new();
for target in extract_link_targets(&page.content) {
if let LinkTargetKind::InternalPage(target_id) = self.classify_link_target(&target)
&& target_id != *source_id
&& source_targets.insert(target_id.clone())
{
back.entry(target_id).or_default().insert(source_id.clone());
}
}
if !source_targets.is_empty() {
forward.insert(source_id.clone(), source_targets);
}
}
self.forward_links = forward;
self.backlinks = back
.into_iter()
.map(|(target, sources)| {
let mut sorted: Vec<PageId> = sources.into_iter().collect();
sorted.sort_by(|a, b| {
title_sort_key(&self.pages, a).cmp(title_sort_key(&self.pages, b))
});
(target, sorted)
})
.collect();
}
pub fn update_backlinks_for(&mut self, page_id: &str) {
if let Some(old_targets) = self.forward_links.remove(page_id) {
for target_id in &old_targets {
if let Some(sources) = self.backlinks.get_mut(target_id) {
sources.retain(|s| s != page_id);
if sources.is_empty() {
self.backlinks.remove(target_id);
}
}
}
}
let page = match self.load_page(page_id) {
Ok(p) => p,
Err(e) => {
log::warn!("update_backlinks_for: failed to load page {page_id}: {e:#}");
return;
}
};
let mut new_targets = HashSet::new();
for target in extract_link_targets(&page.content) {
if let LinkTargetKind::InternalPage(target_id) = self.classify_link_target(&target)
&& target_id != page_id
&& new_targets.insert(target_id.clone())
{
insert_sorted_by_title(
self.backlinks.entry(target_id).or_default(),
&self.pages,
page_id.to_string(),
);
}
}
if !new_targets.is_empty() {
self.forward_links.insert(page_id.to_string(), new_targets);
}
}
pub fn backlinks_for(&self, id: &str) -> &[PageId] {
self.backlinks
.get(id)
.map(Vec::as_slice)
.unwrap_or_default()
}
pub fn build_link_graph(&self) -> LinkGraph {
LinkGraph {
edges: self.scan_all_pages().edges,
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn attachment_resolver(&self) -> AttachmentResolver {
AttachmentResolver::new(&self.root)
}
pub fn scan_all_pages(&self) -> LinkAnalysisResult {
let resolver = self.attachment_resolver();
let mut broken_links = Vec::new();
let mut linked_pages: HashSet<PageId> = HashSet::new();
let mut load_errors = Vec::new();
let mut missing_attachments = Vec::new();
let mut seen_attachments: HashSet<PathBuf> = HashSet::new();
let mut edges = Vec::new();
let mut seen_edges = HashSet::new();
for id in &self.sorted_ids {
let meta = match self.pages.get(id) {
Some(m) => m,
None => continue,
};
let page = match self.load_page(id) {
Ok(p) => p,
Err(e) => {
load_errors.push(PageLoadError {
page_id: id.clone(),
title: meta.title.clone(),
error: format!("{e:#}"),
});
continue;
}
};
seen_edges.clear();
for target in extract_link_targets(&page.content) {
match self.classify_link_target(&target) {
LinkTargetKind::InternalPage(target_id) if target_id != *id => {
linked_pages.insert(target_id.clone());
if seen_edges.insert(target_id.clone()) {
edges.push(LinkEdge {
source: id.clone(),
target: target_id,
});
}
}
LinkTargetKind::Unknown(_) => {
broken_links.push(BrokenLink {
source_title: meta.title.clone(),
source_id: id.clone(),
target: target.clone(),
});
}
_ => {}
}
if extract_attachment_relative(&target).is_some()
&& let Ok(resolved) = resolver.resolve(&target)
&& !resolved.exists
&& seen_attachments.insert(resolved.path.clone())
{
missing_attachments.push(MissingAttachment {
source_title: meta.title.clone(),
source_id: id.clone(),
target,
resolved_path: resolved.path,
});
}
}
}
LinkAnalysisResult {
broken_links,
linked_pages,
load_errors,
missing_attachments,
edges,
}
}
pub fn analyze_all(&self) -> LinkAnalysisResult {
self.scan_all_pages()
}
pub fn analyze_links(&self) -> LinkAnalysisResult {
self.scan_all_pages()
}
pub fn orphan_ids(&self, linked_pages: &HashSet<PageId>, toc_page_id: &str) -> Vec<PageId> {
self.sorted_ids
.iter()
.filter(|id| !linked_pages.contains(*id) && id.as_str() != toc_page_id)
.cloned()
.collect()
}
pub fn find_duplicate_titles(&self) -> Vec<DuplicateTitle> {
let mut by_title: HashMap<&str, Vec<&PageMeta>> = HashMap::new();
for meta in self.pages.values() {
by_title.entry(&meta.title_lower).or_default().push(meta);
}
let mut dupes: Vec<DuplicateTitle> = by_title
.into_iter()
.filter(|(_, metas)| metas.len() > 1)
.map(|(_, metas)| {
let title = metas[0].title.clone();
let mut page_ids: Vec<PageId> = metas.iter().map(|m| m.id.clone()).collect();
page_ids.sort();
DuplicateTitle { title, page_ids }
})
.collect();
dupes.sort_by_key(|a| a.title.to_lowercase());
dupes
}
pub fn find_missing_attachments(&self) -> Vec<MissingAttachment> {
self.scan_all_pages().missing_attachments
}
}
#[derive(Debug, Clone)]
pub struct BrokenLink {
pub source_title: String,
pub source_id: PageId,
pub target: String,
}
#[derive(Debug, Clone)]
pub struct PageLoadError {
pub page_id: PageId,
pub title: String,
pub error: String,
}
#[derive(Debug, Clone)]
pub struct LinkAnalysisResult {
pub broken_links: Vec<BrokenLink>,
pub linked_pages: HashSet<PageId>,
pub load_errors: Vec<PageLoadError>,
pub missing_attachments: Vec<MissingAttachment>,
pub edges: Vec<LinkEdge>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LinkEdge {
pub source: PageId,
pub target: PageId,
}
#[derive(Debug, Clone)]
pub struct LinkGraph {
pub edges: Vec<LinkEdge>,
}
#[derive(Debug, Clone)]
pub struct DuplicateTitle {
pub title: String,
pub page_ids: Vec<PageId>,
}
#[derive(Debug, Clone)]
pub struct MissingAttachment {
pub source_title: String,
pub source_id: PageId,
pub target: String,
pub resolved_path: PathBuf,
}
impl LinkGraph {
pub fn ego(&self, page_id: &str) -> Vec<&LinkEdge> {
self.edges
.iter()
.filter(|e| e.source == page_id || e.target == page_id)
.collect()
}
}
fn title_sort_key<'a>(pages: &'a HashMap<PageId, PageMeta>, id: &str) -> &'a str {
pages.get(id).map(|m| m.title_lower.as_str()).unwrap_or("")
}
fn insert_sorted_by_title(
sources: &mut Vec<PageId>,
pages: &HashMap<PageId, PageMeta>,
source_id: String,
) {
let key = title_sort_key(pages, &source_id);
let pos = sources.partition_point(|id| title_sort_key(pages, id) < key);
sources.insert(pos, source_id);
}
fn compute_sorted_ids(pages: &HashMap<PageId, PageMeta>) -> Vec<PageId> {
let mut entries: Vec<_> = pages.values().collect();
entries.sort_by(|a, b| a.title_lower.cmp(&b.title_lower));
entries.into_iter().map(|m| m.id.clone()).collect()
}
fn page_meta_match_kind(meta: &PageMeta, needle: &str) -> Option<SearchMatchKind> {
if meta.title_lower.contains(needle) || meta.id_lower.contains(needle) {
Some(SearchMatchKind::Title)
} else if meta.tags_lower.iter().any(|t| t.contains(needle)) {
Some(SearchMatchKind::Tag)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir_path(name: &str) -> PathBuf {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
std::env::temp_dir().join(format!("lepiter-core-{name}-{ts}"))
}
fn make_kb_on_disk(pages: &[(&str, &str, &[&str], &str)]) -> (PathBuf, KnowledgeBaseIndex) {
let dir = temp_dir_path("kb");
fs::create_dir_all(&dir).unwrap();
for (id, title, tags, body_text) in pages {
let tags_json: Vec<Value> = tags.iter().map(|t| json!(t)).collect();
let content = json!({
"uid": {"uuid": id},
"pageType": {"title": title},
"tags": tags_json,
"children": {"items": [
{"__type": "textSnippet", "string": body_text}
]}
});
let file_path = dir.join(format!("{id}.lepiter"));
fs::write(&file_path, serde_json::to_vec(&content).unwrap()).unwrap();
}
let index = KnowledgeBase::open(&dir).unwrap();
(dir, index)
}
#[test]
fn filter_page_ids_matches_title_id_and_tags() {
let mut pages = HashMap::new();
pages.insert(
"id-1".to_string(),
PageMeta {
id: "id-1".to_string(),
id_lower: "id-1".to_string(),
title: "Alpha".to_string(),
title_lower: "alpha".to_string(),
path: PathBuf::from("/tmp/a"),
updated_at: None,
tags: vec!["rust".to_string()],
tags_lower: vec!["rust".to_string()],
},
);
pages.insert(
"id-2".to_string(),
PageMeta {
id: "id-2".to_string(),
id_lower: "id-2".to_string(),
title: "Beta".to_string(),
title_lower: "beta".to_string(),
path: PathBuf::from("/tmp/b"),
updated_at: None,
tags: vec!["pharo".to_string()],
tags_lower: vec!["pharo".to_string()],
},
);
let sorted_ids = compute_sorted_ids(&pages);
let index = KnowledgeBaseIndex {
root: PathBuf::from("/tmp"),
pages,
sorted_ids,
index_issues: Vec::new(),
backlinks: HashMap::new(),
forward_links: HashMap::new(),
};
assert_eq!(index.filter_page_ids("alpha"), vec!["id-1".to_string()]);
assert_eq!(index.filter_page_ids("id-2"), vec!["id-2".to_string()]);
assert_eq!(index.filter_page_ids("pharo"), vec!["id-2".to_string()]);
assert_eq!(
index.filter_page_ids(""),
vec!["id-1".to_string(), "id-2".to_string()]
);
}
#[test]
fn resolve_page_id_by_title_handles_unique_ambiguous_and_missing() {
let mut pages = HashMap::new();
pages.insert(
"id-1".to_string(),
PageMeta {
id: "id-1".to_string(),
id_lower: "id-1".to_string(),
title: "Alpha".to_string(),
title_lower: "alpha".to_string(),
path: PathBuf::from("/tmp/a"),
updated_at: None,
tags: Vec::new(),
tags_lower: Vec::new(),
},
);
pages.insert(
"id-2".to_string(),
PageMeta {
id: "id-2".to_string(),
id_lower: "id-2".to_string(),
title: "Alphabet".to_string(),
title_lower: "alphabet".to_string(),
path: PathBuf::from("/tmp/b"),
updated_at: None,
tags: Vec::new(),
tags_lower: Vec::new(),
},
);
let sorted_ids = compute_sorted_ids(&pages);
let index = KnowledgeBaseIndex {
root: PathBuf::from("/tmp"),
pages,
sorted_ids,
index_issues: Vec::new(),
backlinks: HashMap::new(),
forward_links: HashMap::new(),
};
assert_eq!(
index.resolve_page_id_by_title("Alpha"),
TitleResolution::Unique("id-1".to_string())
);
assert!(matches!(
index.resolve_page_id_by_title("alp"),
TitleResolution::Ambiguous(_)
));
assert_eq!(
index.resolve_page_id_by_title("zzz"),
TitleResolution::NotFound
);
}
#[test]
fn classify_link_target_covers_internal_attachment_external_unknown() {
let mut pages = HashMap::new();
pages.insert(
"8a505fa0-2222-3333-4444-555555555555".to_string(),
PageMeta {
id: "8a505fa0-2222-3333-4444-555555555555".to_string(),
id_lower: "8a505fa0-2222-3333-4444-555555555555".to_string(),
title: "Alpha".to_string(),
title_lower: "alpha".to_string(),
path: PathBuf::from("/tmp/a"),
updated_at: None,
tags: Vec::new(),
tags_lower: Vec::new(),
},
);
let sorted_ids = compute_sorted_ids(&pages);
let index = KnowledgeBaseIndex {
root: PathBuf::from("/kb"),
pages,
sorted_ids,
index_issues: Vec::new(),
backlinks: HashMap::new(),
forward_links: HashMap::new(),
};
assert!(matches!(
index.classify_link_target("8a505fa0-2222-3333-4444-555555555555"),
LinkTargetKind::InternalPage(_)
));
assert!(matches!(
index.classify_link_target("title:alpha"),
LinkTargetKind::InternalPage(_)
));
assert!(matches!(
index.classify_link_target("go to 8a505fa0-2222-3333-4444-555555555555 now"),
LinkTargetKind::InternalPage(_)
));
assert!(matches!(
index.classify_link_target("attachments/image.png"),
LinkTargetKind::AttachmentPath(_)
));
assert!(matches!(
index.classify_link_target("https://example.com"),
LinkTargetKind::ExternalUrl(_)
));
assert!(matches!(
index.classify_link_target("not a thing"),
LinkTargetKind::Unknown(_)
));
assert!(matches!(
index.classify_link_target("page:Alpha"),
LinkTargetKind::InternalPage(_)
));
assert!(matches!(
index.classify_link_target("page:Nonexistent"),
LinkTargetKind::Unknown(_)
));
}
#[test]
fn search_hits_empty_query_returns_nothing() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "hello world")]);
assert!(index.search_hits("", false).is_empty());
assert!(index.search_hits(" ", true).is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_matches_title_case_insensitively() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha Guide", &[], "nothing special"),
("p2", "Beta Notes", &[], "nothing special"),
]);
let hits = index.search_hits("alpha", false);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "p1");
assert_eq!(hits[0].kind, SearchMatchKind::Title);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_matches_tags() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Page One", &["rust", "cli"], "body"),
("p2", "Page Two", &["pharo"], "body"),
]);
let hits = index.search_hits("rust", false);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "p1");
assert_eq!(hits[0].kind, SearchMatchKind::Tag);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_content_flag_searches_page_body() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "the quick brown fox"),
("p2", "Beta", &[], "lazy dog sleeps"),
]);
let no_content = index.search_hits("fox", false);
assert!(no_content.is_empty());
let with_content = index.search_hits("fox", true);
assert_eq!(with_content.len(), 1);
assert_eq!(with_content[0].id, "p1");
assert_eq!(with_content[0].kind, SearchMatchKind::Content);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_title_match_takes_priority_over_content() {
let (dir, index) = make_kb_on_disk(&[("p1", "Fox Guide", &[], "the fox jumps")]);
let hits = index.search_hits("fox", true);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, SearchMatchKind::Title);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_same_score_sorted_alphabetically() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Zebra", &["common"], "body"),
("p2", "Alpha", &["common"], "body"),
("p3", "Middle", &["common"], "body"),
]);
let hits = index.search_hits("common", false);
let ids: Vec<&str> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, vec!["p2", "p3", "p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_title_ranked_above_tag() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Page One", &["rust"], "body"),
("p2", "Rust Guide", &[], "body"),
]);
let hits = index.search_hits("rust", false);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "p2");
assert_eq!(hits[0].kind, SearchMatchKind::Title);
assert_eq!(hits[1].id, "p1");
assert_eq!(hits[1].kind, SearchMatchKind::Tag);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_title_ranked_above_content() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "the word rust appears here"),
("p2", "Rust Guide", &[], "no match in body"),
]);
let hits = index.search_hits("rust", true);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "p2");
assert_eq!(hits[0].kind, SearchMatchKind::Title);
assert_eq!(hits[1].id, "p1");
assert_eq!(hits[1].kind, SearchMatchKind::Content);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_tag_ranked_above_content() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "the word rust appears here"),
("p2", "Beta", &["rust"], "no match in body"),
]);
let hits = index.search_hits("rust", true);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "p2");
assert_eq!(hits[0].kind, SearchMatchKind::Tag);
assert_eq!(hits[1].id, "p1");
assert_eq!(hits[1].kind, SearchMatchKind::Content);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_mixed_kinds_ranked_correctly() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "cli tools are great"),
("p2", "Beta", &["cli"], "no match"),
("p3", "CLI Reference", &[], "no match"),
("p4", "Delta", &[], "no match"),
]);
let hits = index.search_hits("cli", true);
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].id, "p3");
assert_eq!(hits[0].kind, SearchMatchKind::Title);
assert_eq!(hits[1].id, "p2");
assert_eq!(hits[1].kind, SearchMatchKind::Tag);
assert_eq!(hits[2].id, "p1");
assert_eq!(hits[2].kind, SearchMatchKind::Content);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_title_with_tag_stays_title() {
let (dir, index) =
make_kb_on_disk(&[("p1", "Rust Guide", &["rust"], "also mentions rust")]);
let hits = index.search_hits("rust", true);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, SearchMatchKind::Title);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_tag_match_takes_priority_over_content() {
let (dir, index) = make_kb_on_disk(&[("p1", "Some Page", &["fox"], "the fox jumps")]);
let hits = index.search_hits("fox", true);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, SearchMatchKind::Tag);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_hits_id_match_counts_as_title() {
let (dir, index) = make_kb_on_disk(&[("rustacean", "Some Page", &[], "body")]);
let hits = index.search_hits("rustacean", false);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, SearchMatchKind::Title);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn filter_page_ids_scored_returns_kinds() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Rust Intro", &[], "body"),
("p2", "Beta", &["rust"], "body"),
("p3", "Gamma", &[], "body"),
]);
let scored = index.filter_page_ids_scored("rust");
assert_eq!(scored.len(), 2);
let map: HashMap<&str, SearchMatchKind> =
scored.iter().map(|(id, k)| (id.as_str(), *k)).collect();
assert_eq!(map["p1"], SearchMatchKind::Title);
assert_eq!(map["p2"], SearchMatchKind::Tag);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn classify_link_target_page_prefix() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert!(matches!(
index.classify_link_target("page:p1"),
LinkTargetKind::InternalPage(id) if id == "p1"
));
assert!(matches!(
index.classify_link_target("page:nonexistent"),
LinkTargetKind::Unknown(_)
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn classify_link_target_empty_is_unknown() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert!(matches!(
index.classify_link_target(""),
LinkTargetKind::Unknown(_)
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn classify_link_target_title_fallback() {
let (dir, index) = make_kb_on_disk(&[("p1", "My Special Page", &[], "body")]);
assert!(matches!(
index.classify_link_target("My Special Page"),
LinkTargetKind::InternalPage(id) if id == "p1"
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn classify_link_target_mixed_case_urls() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert!(matches!(
index.classify_link_target("HTTPS://EXAMPLE.COM"),
LinkTargetKind::ExternalUrl(_)
));
assert!(matches!(
index.classify_link_target("Http://Example.Com"),
LinkTargetKind::ExternalUrl(_)
));
assert!(matches!(
index.classify_link_target("MAILTO:user@host.com"),
LinkTargetKind::ExternalUrl(_)
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn classify_link_target_whitespace_only_is_unknown() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert!(matches!(
index.classify_link_target(" "),
LinkTargetKind::Unknown(_)
));
assert!(matches!(
index.classify_link_target("\t\n"),
LinkTargetKind::Unknown(_)
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn classify_link_target_trims_whitespace_around_url() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert!(matches!(
index.classify_link_target(" https://example.com "),
LinkTargetKind::ExternalUrl(_)
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn classify_link_target_unusual_schemes() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert!(matches!(
index.classify_link_target("ftp://files.example.com"),
LinkTargetKind::ExternalUrl(_)
));
assert!(matches!(
index.classify_link_target("ssh://git.example.com"),
LinkTargetKind::ExternalUrl(_)
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resolve_page_id_by_title_empty_and_whitespace() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert_eq!(
index.resolve_page_id_by_title(""),
TitleResolution::NotFound
);
assert_eq!(
index.resolve_page_id_by_title(" "),
TitleResolution::NotFound
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resolve_page_id_by_title_case_insensitive_exact() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert_eq!(
index.resolve_page_id_by_title("ALPHA"),
TitleResolution::Unique("p1".to_string())
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn filter_page_ids_no_match_returns_empty() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
assert!(index.filter_page_ids("zzzzz").is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn open_empty_directory_returns_empty_index() -> anyhow::Result<()> {
let dir = temp_dir_path("empty-kb");
fs::create_dir_all(&dir)?;
let index = KnowledgeBase::open(&dir)?;
fs::remove_dir_all(&dir)?;
assert!(index.pages.is_empty());
assert!(index.index_issues.is_empty());
Ok(())
}
#[test]
fn open_skips_non_lepiter_files() -> anyhow::Result<()> {
let dir = temp_dir_path("non-lepiter");
fs::create_dir_all(&dir)?;
fs::write(dir.join("readme.txt"), b"hello")?;
fs::write(dir.join("data.json"), b"{}")?;
let index = KnowledgeBase::open(&dir)?;
fs::remove_dir_all(&dir)?;
assert!(index.pages.is_empty());
assert!(index.index_issues.is_empty());
Ok(())
}
#[test]
fn open_reports_invalid_json_as_issue() -> anyhow::Result<()> {
let dir = temp_dir_path("bad-json");
fs::create_dir_all(&dir)?;
fs::write(dir.join("broken.lepiter"), b"not json at all")?;
let index = KnowledgeBase::open(&dir)?;
fs::remove_dir_all(&dir)?;
assert!(index.pages.is_empty());
assert_eq!(index.index_issues.len(), 1);
assert!(index.index_issues[0].message.contains("failed to decode"));
Ok(())
}
#[test]
fn open_reports_wrong_json_structure_as_issue() -> anyhow::Result<()> {
let dir = temp_dir_path("wrong-shape");
fs::create_dir_all(&dir)?;
fs::write(dir.join("array.lepiter"), b"[1, 2, 3]")?;
let index = KnowledgeBase::open(&dir)?;
fs::remove_dir_all(&dir)?;
assert!(index.pages.is_empty());
assert_eq!(index.index_issues.len(), 1);
Ok(())
}
#[test]
fn open_fills_in_defaults_for_minimal_page() -> anyhow::Result<()> {
let dir = temp_dir_path("minimal");
fs::create_dir_all(&dir)?;
fs::write(dir.join("mypage.lepiter"), b"{}")?;
let index = KnowledgeBase::open(&dir)?;
fs::remove_dir_all(&dir)?;
assert_eq!(index.pages.len(), 1);
let meta = index.pages.values().next().unwrap();
assert_eq!(meta.id, "mypage");
assert_eq!(meta.title, "mypage");
Ok(())
}
#[test]
fn load_page_nonexistent_id_errors() -> anyhow::Result<()> {
let dir = temp_dir_path("no-such-id");
fs::create_dir_all(&dir)?;
let index = KnowledgeBase::open(&dir)?;
fs::remove_dir_all(&dir)?;
let err = index.load_page("does-not-exist");
assert!(err.is_err());
assert!(format!("{:#}", err.unwrap_err()).contains("page id not found"));
Ok(())
}
#[test]
fn load_page_missing_children_yields_empty_content() -> anyhow::Result<()> {
let dir = temp_dir_path("no-children");
fs::create_dir_all(&dir)?;
let content = json!({"uid": {"uuid": "pg-1"}, "pageType": {"title": "T"}});
fs::write(dir.join("pg-1.lepiter"), serde_json::to_vec(&content)?)?;
let index = KnowledgeBase::open(&dir)?;
let page = index.load_page("pg-1")?;
fs::remove_dir_all(&dir)?;
assert!(page.content.is_empty());
Ok(())
}
#[test]
fn build_backlinks_computes_reverse_index() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
("p3", "Gamma", &[], "no links here"),
]);
index.build_backlinks();
assert_eq!(index.backlinks_for("p1"), &["p2"]);
assert_eq!(index.backlinks_for("p2"), &["p1"]);
assert_eq!(index.backlinks_for("p3"), &["p2"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn build_backlinks_excludes_self_links() {
let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
index.build_backlinks();
assert!(index.backlinks_for("p1").is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn backlinks_for_unknown_page_returns_empty() {
let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "text")]);
index.build_backlinks();
assert!(index.backlinks_for("nonexistent").is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn build_backlinks_deduplicates_multiple_links() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
("p2", "Beta", &[], "nothing"),
]);
index.build_backlinks();
assert_eq!(index.backlinks_for("p2"), &["p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn build_backlinks_sorted_by_title() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Zebra", &[], "links to [[Target]]"),
("p2", "Alpha", &[], "links to [[Target]]"),
("p3", "Target", &[], "nothing"),
]);
index.build_backlinks();
assert_eq!(index.backlinks_for("p3"), &["p2", "p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn update_backlinks_for_adds_new_links() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "no links"),
("p2", "Beta", &[], "nothing"),
]);
index.build_backlinks();
assert!(index.backlinks_for("p2").is_empty());
let content = json!({
"uid": {"uuid": "p1"},
"pageType": {"title": "Alpha"},
"children": {"items": [
{"__type": "textSnippet", "string": "now links to [[Beta]]"}
]}
});
fs::write(
dir.join("p1.lepiter"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
index.update_backlinks_for("p1");
assert_eq!(index.backlinks_for("p2"), &["p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn update_backlinks_for_removes_stale_links() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "nothing"),
]);
index.build_backlinks();
assert_eq!(index.backlinks_for("p2"), &["p1"]);
let content = json!({
"uid": {"uuid": "p1"},
"pageType": {"title": "Alpha"},
"children": {"items": [
{"__type": "textSnippet", "string": "no links anymore"}
]}
});
fs::write(
dir.join("p1.lepiter"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
index.update_backlinks_for("p1");
assert!(index.backlinks_for("p2").is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn update_backlinks_for_replaces_changed_link() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "nothing"),
("p3", "Gamma", &[], "nothing"),
]);
index.build_backlinks();
assert_eq!(index.backlinks_for("p2"), &["p1"]);
assert!(index.backlinks_for("p3").is_empty());
let content = json!({
"uid": {"uuid": "p1"},
"pageType": {"title": "Alpha"},
"children": {"items": [
{"__type": "textSnippet", "string": "see [[Gamma]]"}
]}
});
fs::write(
dir.join("p1.lepiter"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
index.update_backlinks_for("p1");
assert!(index.backlinks_for("p2").is_empty());
assert_eq!(index.backlinks_for("p3"), &["p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn update_backlinks_for_preserves_other_sources() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Gamma]]"),
("p2", "Beta", &[], "see [[Gamma]]"),
("p3", "Gamma", &[], "nothing"),
]);
index.build_backlinks();
assert_eq!(index.backlinks_for("p3"), &["p1", "p2"]);
let content = json!({
"uid": {"uuid": "p1"},
"pageType": {"title": "Alpha"},
"children": {"items": [
{"__type": "textSnippet", "string": "no link"}
]}
});
fs::write(
dir.join("p1.lepiter"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
index.update_backlinks_for("p1");
assert_eq!(index.backlinks_for("p3"), &["p2"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn update_backlinks_for_deduplicates() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "nothing"),
("p2", "Beta", &[], "nothing"),
]);
index.build_backlinks();
let content = json!({
"uid": {"uuid": "p1"},
"pageType": {"title": "Alpha"},
"children": {"items": [
{"__type": "textSnippet", "string": "[[Beta]] and [[Beta]] again"}
]}
});
fs::write(
dir.join("p1.lepiter"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
index.update_backlinks_for("p1");
assert_eq!(index.backlinks_for("p2"), &["p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn update_backlinks_for_excludes_self_links() {
let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "nothing")]);
index.build_backlinks();
let content = json!({
"uid": {"uuid": "p1"},
"pageType": {"title": "Alpha"},
"children": {"items": [
{"__type": "textSnippet", "string": "see [[Alpha]]"}
]}
});
fs::write(
dir.join("p1.lepiter"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
index.update_backlinks_for("p1");
assert!(index.backlinks_for("p1").is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn update_backlinks_for_maintains_sort_order() {
let (dir, mut index) = make_kb_on_disk(&[
("p1", "Zebra", &[], "nothing"),
("p2", "Alpha", &[], "links to [[Target]]"),
("p3", "Target", &[], "nothing"),
]);
index.build_backlinks();
assert_eq!(index.backlinks_for("p3"), &["p2"]);
let content = json!({
"uid": {"uuid": "p1"},
"pageType": {"title": "Zebra"},
"children": {"items": [
{"__type": "textSnippet", "string": "links to [[Target]]"}
]}
});
fs::write(
dir.join("p1.lepiter"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
index.update_backlinks_for("p1");
assert_eq!(index.backlinks_for("p3"), &["p2", "p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn register_page_adds_and_resorts() {
let dir = temp_dir_path("register");
fs::create_dir_all(&dir).unwrap();
let mut index = KnowledgeBase::open(&dir).unwrap();
assert!(index.sorted_ids.is_empty());
let meta = PageMeta {
id: "new-page".to_string(),
id_lower: "new-page".to_string(),
title: "My Page".to_string(),
title_lower: "my page".to_string(),
path: dir.join("new-page.lepiter"),
updated_at: None,
tags: Vec::new(),
tags_lower: Vec::new(),
};
index.register_page(meta);
assert_eq!(index.sorted_ids.len(), 1);
assert_eq!(index.sorted_ids[0], "new-page");
assert!(index.pages.contains_key("new-page"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn register_page_reregistration_no_duplicate() {
let dir = temp_dir_path("reregister");
fs::create_dir_all(&dir).unwrap();
let mut index = KnowledgeBase::open(&dir).unwrap();
let meta = PageMeta {
id: "dup".to_string(),
id_lower: "dup".to_string(),
title: "Original".to_string(),
title_lower: "original".to_string(),
path: dir.join("dup.lepiter"),
updated_at: None,
tags: Vec::new(),
tags_lower: Vec::new(),
};
index.register_page(meta);
assert_eq!(index.sorted_ids.len(), 1);
let updated = PageMeta {
id: "dup".to_string(),
id_lower: "dup".to_string(),
title: "Renamed".to_string(),
title_lower: "renamed".to_string(),
path: dir.join("dup.lepiter"),
updated_at: None,
tags: Vec::new(),
tags_lower: Vec::new(),
};
index.register_page(updated);
assert_eq!(index.sorted_ids.len(), 1);
assert_eq!(index.sorted_ids[0], "dup");
assert_eq!(index.pages["dup"].title, "Renamed");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn build_link_graph_collects_edges() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
("p3", "Gamma", &[], "no links here"),
]);
let graph = index.build_link_graph();
assert_eq!(graph.edges.len(), 3);
let pairs: Vec<(&str, &str)> = graph
.edges
.iter()
.map(|e| (e.source.as_str(), e.target.as_str()))
.collect();
assert!(pairs.contains(&("p1", "p2")));
assert!(pairs.contains(&("p2", "p1")));
assert!(pairs.contains(&("p2", "p3")));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn build_link_graph_excludes_self_links() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
let graph = index.build_link_graph();
assert!(graph.edges.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn build_link_graph_deduplicates() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
("p2", "Beta", &[], "nothing"),
]);
let graph = index.build_link_graph();
assert_eq!(graph.edges.len(), 1);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn link_graph_ego_filters_by_page() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "see [[Gamma]]"),
("p3", "Gamma", &[], "nothing"),
]);
let graph = index.build_link_graph();
let ego = graph.ego("p2");
assert_eq!(ego.len(), 2);
let pairs: Vec<(&str, &str)> = ego
.iter()
.map(|e| (e.source.as_str(), e.target.as_str()))
.collect();
assert!(pairs.contains(&("p1", "p2")));
assert!(pairs.contains(&("p2", "p3")));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn link_graph_ego_unconnected_page() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "nothing"),
("p3", "Gamma", &[], "nothing"),
]);
let graph = index.build_link_graph();
assert!(graph.ego("p3").is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_links_detects_broken_links() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Page One", &[], "see [link](page:nonexistent) here"),
("p2", "Page Two", &[], "hello"),
]);
let result = index.analyze_links();
assert_eq!(result.broken_links.len(), 1);
assert_eq!(result.broken_links[0].source_id, "p1");
assert_eq!(result.broken_links[0].target, "page:nonexistent");
assert!(result.load_errors.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_links_tracks_linked_pages() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Page One", &[], "see [link](page:p2) for more"),
("p2", "Page Two", &[], "target page"),
]);
let result = index.analyze_links();
assert!(result.linked_pages.contains("p2"));
assert!(!result.linked_pages.contains("p1"));
assert!(result.broken_links.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_links_empty_kb() {
let (dir, index) = make_kb_on_disk(&[]);
let result = index.analyze_links();
assert!(result.broken_links.is_empty());
assert!(result.linked_pages.is_empty());
assert!(result.load_errors.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_links_captures_load_errors() {
let (dir, index) = make_kb_on_disk(&[("p1", "Page One", &[], "hello")]);
let page_path = dir.join("p1.lepiter");
fs::write(&page_path, b"NOT VALID JSON").unwrap();
let result = index.analyze_links();
assert_eq!(result.load_errors.len(), 1);
assert_eq!(result.load_errors[0].page_id, "p1");
assert_eq!(result.load_errors[0].title, "Page One");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn orphan_ids_excludes_linked_pages() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Page One", &[], "see [link](page:p2) for more"),
("p2", "Page Two", &[], "target page"),
]);
let result = index.analyze_links();
let orphans = index.orphan_ids(&result.linked_pages, "");
assert_eq!(orphans, vec!["p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn orphan_ids_excludes_toc_page() {
let (dir, index) = make_kb_on_disk(&[
("toc", "Table of Contents", &[], "hello"),
("p1", "Page One", &[], "world"),
]);
let result = index.analyze_links();
let orphans = index.orphan_ids(&result.linked_pages, "toc");
assert_eq!(orphans, vec!["p1"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_duplicate_titles_none_when_unique() {
let (dir, index) =
make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "Beta", &[], "body")]);
assert!(index.find_duplicate_titles().is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_duplicate_titles_detects_exact_match() {
let (dir, index) =
make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "Alpha", &[], "body")]);
let dupes = index.find_duplicate_titles();
assert_eq!(dupes.len(), 1);
assert_eq!(dupes[0].title, "Alpha");
assert_eq!(dupes[0].page_ids.len(), 2);
assert!(dupes[0].page_ids.contains(&"p1".to_string()));
assert!(dupes[0].page_ids.contains(&"p2".to_string()));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_duplicate_titles_case_insensitive() {
let (dir, index) =
make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "ALPHA", &[], "body")]);
let dupes = index.find_duplicate_titles();
assert_eq!(dupes.len(), 1);
assert_eq!(dupes[0].page_ids.len(), 2);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_duplicate_titles_multiple_groups() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "body"),
("p2", "Alpha", &[], "body"),
("p3", "Beta", &[], "body"),
("p4", "Beta", &[], "body"),
("p5", "Gamma", &[], "body"),
]);
let dupes = index.find_duplicate_titles();
assert_eq!(dupes.len(), 2);
assert_eq!(dupes[0].title, "Alpha");
assert_eq!(dupes[1].title, "Beta");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_duplicate_titles_empty_kb() {
let (dir, index) = make_kb_on_disk(&[]);
assert!(index.find_duplicate_titles().is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_missing_attachments_none_when_no_refs() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "no attachment refs")]);
assert!(index.find_missing_attachments().is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_missing_attachments_detects_missing_file() {
let (dir, index) =
make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/missing.png)")]);
let missing = index.find_missing_attachments();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].source_id, "p1");
assert_eq!(missing[0].target, "attachments/missing.png");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_missing_attachments_ignores_existing_file() {
let (dir, index) =
make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/present.png)")]);
let att_dir = dir.join("attachments");
fs::create_dir_all(&att_dir).unwrap();
fs::write(att_dir.join("present.png"), b"data").unwrap();
let missing = index.find_missing_attachments();
assert!(missing.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_missing_attachments_mixed() {
let (dir, index) = make_kb_on_disk(&[(
"p1",
"Alpha",
&[],
"see [a](attachments/ok.png) and [b](attachments/gone.png)",
)]);
let att_dir = dir.join("attachments");
fs::create_dir_all(&att_dir).unwrap();
fs::write(att_dir.join("ok.png"), b"data").unwrap();
let missing = index.find_missing_attachments();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].target, "attachments/gone.png");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_missing_attachments_deduplicates_across_pages() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [img](attachments/missing.png)"),
("p2", "Beta", &[], "see [img](attachments/missing.png)"),
]);
let missing = index.find_missing_attachments();
assert_eq!(missing.len(), 1);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn find_missing_attachments_ignores_non_attachment_links() {
let (dir, index) = make_kb_on_disk(&[(
"p1",
"Alpha",
&[],
"see [link](page:p2) and [ext](https://example.com)",
)]);
assert!(index.find_missing_attachments().is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_all_combines_broken_links_and_missing_attachments() {
let (dir, index) = make_kb_on_disk(&[(
"p1",
"Alpha",
&[],
"see [link](page:nonexistent) and [img](attachments/gone.png)",
)]);
let result = index.analyze_all();
assert_eq!(result.broken_links.len(), 1);
assert_eq!(result.broken_links[0].target, "page:nonexistent");
assert_eq!(result.missing_attachments.len(), 1);
assert_eq!(result.missing_attachments[0].target, "attachments/gone.png");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_all_tracks_linked_pages() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [link](page:p2) here"),
("p2", "Beta", &[], "nothing"),
]);
let result = index.analyze_all();
assert!(result.linked_pages.contains("p2"));
assert!(!result.linked_pages.contains("p1"));
assert!(result.broken_links.is_empty());
assert!(result.missing_attachments.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_all_empty_kb() {
let (dir, index) = make_kb_on_disk(&[]);
let result = index.analyze_all();
assert!(result.broken_links.is_empty());
assert!(result.linked_pages.is_empty());
assert!(result.load_errors.is_empty());
assert!(result.missing_attachments.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_all_captures_load_errors() {
let (dir, index) = make_kb_on_disk(&[("p1", "Page One", &[], "hello")]);
fs::write(dir.join("p1.lepiter"), b"NOT VALID JSON").unwrap();
let result = index.analyze_all();
assert_eq!(result.load_errors.len(), 1);
assert_eq!(result.load_errors[0].page_id, "p1");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_all_skips_existing_attachments() {
let (dir, index) =
make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/present.png)")]);
let att_dir = dir.join("attachments");
fs::create_dir_all(&att_dir).unwrap();
fs::write(att_dir.join("present.png"), b"data").unwrap();
let result = index.analyze_all();
assert!(result.missing_attachments.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_all_deduplicates_missing_attachments() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [img](attachments/missing.png)"),
("p2", "Beta", &[], "see [img](attachments/missing.png)"),
]);
let result = index.analyze_all();
assert_eq!(result.missing_attachments.len(), 1);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_all_mixed_links_and_attachments() {
let (dir, index) = make_kb_on_disk(&[
(
"p1",
"Alpha",
&[],
"see [link](page:p2) and [img](attachments/gone.png)",
),
(
"p2",
"Beta",
&[],
"see [link](page:nonexistent) and [ext](https://example.com)",
),
]);
let result = index.analyze_all();
assert!(result.linked_pages.contains("p2"));
assert_eq!(result.broken_links.len(), 1);
assert_eq!(result.broken_links[0].source_id, "p2");
assert_eq!(result.missing_attachments.len(), 1);
assert_eq!(result.missing_attachments[0].source_id, "p1");
assert!(result.load_errors.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn scan_all_pages_collects_edges_and_analysis() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
("p3", "Gamma", &[], "no links here"),
]);
let result = index.scan_all_pages();
assert_eq!(result.edges.len(), 3);
let pairs: Vec<(&str, &str)> = result
.edges
.iter()
.map(|e| (e.source.as_str(), e.target.as_str()))
.collect();
assert!(pairs.contains(&("p1", "p2")));
assert!(pairs.contains(&("p2", "p1")));
assert!(pairs.contains(&("p2", "p3")));
assert!(result.linked_pages.contains("p1"));
assert!(result.linked_pages.contains("p2"));
assert!(result.linked_pages.contains("p3"));
assert!(result.broken_links.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn scan_all_pages_edges_exclude_self_links() {
let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
let result = index.scan_all_pages();
assert!(result.edges.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn scan_all_pages_edges_deduplicated() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
("p2", "Beta", &[], "nothing"),
]);
let result = index.scan_all_pages();
assert_eq!(result.edges.len(), 1);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn scan_all_pages_mixed_edges_and_broken() {
let (dir, index) = make_kb_on_disk(&[
(
"p1",
"Alpha",
&[],
"see [link](page:p2) and [bad](page:nonexistent)",
),
("p2", "Beta", &[], "nothing"),
]);
let result = index.scan_all_pages();
assert_eq!(result.edges.len(), 1);
assert_eq!(result.edges[0].source, "p1");
assert_eq!(result.edges[0].target, "p2");
assert_eq!(result.broken_links.len(), 1);
assert_eq!(result.broken_links[0].target, "page:nonexistent");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn scan_all_pages_empty_kb() {
let (dir, index) = make_kb_on_disk(&[]);
let result = index.scan_all_pages();
assert!(result.edges.is_empty());
assert!(result.broken_links.is_empty());
assert!(result.linked_pages.is_empty());
assert!(result.load_errors.is_empty());
assert!(result.missing_attachments.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn scan_all_pages_edges_match_build_link_graph() {
let (dir, index) = make_kb_on_disk(&[
("p1", "Alpha", &[], "see [[Beta]]"),
("p2", "Beta", &[], "see [[Gamma]]"),
("p3", "Gamma", &[], "nothing"),
]);
let scan = index.scan_all_pages();
let graph = index.build_link_graph();
assert_eq!(scan.edges.len(), graph.edges.len());
for (a, b) in scan.edges.iter().zip(graph.edges.iter()) {
assert_eq!(a.source, b.source);
assert_eq!(a.target, b.target);
}
fs::remove_dir_all(&dir).unwrap();
}
}