1use std::collections::{HashMap, HashSet};
2use std::fs::File;
3use std::io::BufReader;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use serde_json::Value;
8use walkdir::WalkDir;
9
10use serde::Serialize;
11
12use crate::model::{
13 AttachmentResolver, LinkTargetKind, Page, PageId, PageMeta, ParseIssue, SearchHit,
14 SearchMatchKind, TitleResolution,
15};
16use crate::parse::{parse_item_recursive, parse_page_meta};
17use crate::render::page_content_contains;
18use crate::util::{
19 extract_attachment_relative, extract_link_targets, extract_uuid_like, is_external_target,
20};
21
22#[derive(Debug, Clone)]
24pub struct KnowledgeBaseIndex {
25 root: PathBuf,
26 pub pages: HashMap<PageId, PageMeta>,
28 pub sorted_ids: Vec<PageId>,
31 title_index: HashMap<String, Vec<PageId>>,
34 pub index_issues: Vec<ParseIssue>,
36 backlinks: HashMap<PageId, Vec<PageId>>,
38 forward_links: HashMap<PageId, HashSet<PageId>>,
41 pub duplicate_ids: Vec<DuplicateId>,
44}
45
46pub struct KnowledgeBase;
48
49impl KnowledgeBase {
50 pub fn open(path: impl AsRef<Path>) -> Result<KnowledgeBaseIndex> {
55 let root = path.as_ref().to_path_buf();
56 let mut pages = HashMap::new();
57 let mut issues = Vec::new();
58 let mut id_paths: HashMap<PageId, Vec<PathBuf>> = HashMap::new();
59
60 for entry in WalkDir::new(&root)
61 .min_depth(1)
62 .max_depth(1)
63 .into_iter()
64 .filter_map(|e| e.ok())
65 {
66 let file_type = entry.file_type();
67 let file_path = entry.path();
68 if !file_type.is_file()
69 || file_path.extension().and_then(|e| e.to_str()) != Some("lepiter")
70 {
71 continue;
72 }
73
74 match parse_page_meta(file_path) {
75 Ok(mut meta) => {
76 if meta.id.is_empty()
77 && let Some(stem) = file_path.file_stem().and_then(|s| s.to_str())
78 {
79 meta.id = stem.to_string();
80 meta.id_lower = meta.id.to_lowercase();
81 }
82 if meta.title.is_empty() {
83 meta.title = meta.id.clone();
84 meta.title_lower = meta.title.to_lowercase();
85 }
86 id_paths
87 .entry(meta.id.clone())
88 .or_default()
89 .push(file_path.to_path_buf());
90 pages.insert(meta.id.clone(), meta);
91 }
92 Err(err) => issues.push(ParseIssue {
93 path: file_path.to_path_buf(),
94 message: format!("{err:#}"),
95 }),
96 }
97 }
98
99 let sorted_ids = compute_sorted_ids(&pages);
100 let title_index = compute_title_index(&pages, &sorted_ids);
101 let duplicate_ids = collect_duplicate_ids(id_paths);
102
103 Ok(KnowledgeBaseIndex {
104 root,
105 pages,
106 sorted_ids,
107 title_index,
108 index_issues: issues,
109 backlinks: HashMap::new(),
110 forward_links: HashMap::new(),
111 duplicate_ids,
112 })
113 }
114}
115
116impl KnowledgeBaseIndex {
117 pub fn register_page(&mut self, meta: PageMeta) {
126 let id = meta.id.clone();
127 let new_title_lower = meta.title_lower.clone();
128 let old_title_lower = self.pages.get(&id).map(|m| m.title_lower.clone());
129
130 if old_title_lower.is_some()
131 && let Some(pos) = self.sorted_ids.iter().position(|i| i == &id)
132 {
133 self.sorted_ids.remove(pos);
134 }
135 self.pages.insert(id.clone(), meta);
136 insert_sorted_by_title(&mut self.sorted_ids, &self.pages, id.clone());
137
138 if old_title_lower.as_deref() != Some(new_title_lower.as_str()) {
139 if let Some(old) = &old_title_lower {
140 self.remove_from_title_index(old, &id);
141 }
142 self.title_index
143 .entry(new_title_lower)
144 .or_default()
145 .push(id);
146 }
147 }
148
149 fn remove_from_title_index(&mut self, title_lower: &str, id: &str) {
152 if let Some(bucket) = self.title_index.get_mut(title_lower) {
153 bucket.retain(|i| i != id);
154 if bucket.is_empty() {
155 self.title_index.remove(title_lower);
156 }
157 }
158 }
159
160 pub fn load_page(&self, id: &str) -> Result<Page> {
164 let meta = self
165 .pages
166 .get(id)
167 .with_context(|| format!("page id not found: {id}"))?;
168
169 let file = File::open(&meta.path)
170 .with_context(|| format!("failed to open page file {}", meta.path.display()))?;
171 let reader = BufReader::new(file);
172 let raw: Value =
173 serde_json::from_reader(reader).with_context(|| "failed to decode page JSON")?;
174
175 let mut content = Vec::new();
176 if let Some(items) = raw
177 .get("children")
178 .and_then(|v| v.get("items"))
179 .and_then(Value::as_array)
180 {
181 for item in items {
182 parse_item_recursive(item, &mut content);
183 }
184 }
185
186 Ok(Page {
187 id: meta.id.clone(),
188 title: meta.title.clone(),
189 updated_at: meta.updated_at,
190 tags: meta.tags.clone(),
191 content,
192 })
193 }
194
195 pub fn sorted_pages(&self) -> Vec<&PageMeta> {
197 self.sorted_ids
198 .iter()
199 .filter_map(|id| self.pages.get(id))
200 .collect()
201 }
202
203 pub fn filter_page_ids(&self, query: &str) -> Vec<PageId> {
205 let needle = query.trim().to_lowercase();
206 let mut metas = self.sorted_pages();
207 if !needle.is_empty() {
208 metas.retain(|m| page_meta_match_kind(m, &needle).is_some());
209 }
210 metas.into_iter().map(|m| m.id.clone()).collect()
211 }
212
213 pub fn filter_page_ids_scored(&self, query: &str) -> Vec<(PageId, SearchMatchKind)> {
215 let needle = query.trim().to_lowercase();
216 if needle.is_empty() {
217 return Vec::new();
218 }
219 let metas = self.sorted_pages();
220 metas
221 .into_iter()
222 .filter_map(|m| page_meta_match_kind(m, &needle).map(|kind| (m.id.clone(), kind)))
223 .collect()
224 }
225
226 pub fn search_hits(&self, query: &str, include_content: bool) -> Vec<SearchHit> {
230 let needle = query.trim().to_lowercase();
231 if needle.is_empty() {
232 return Vec::new();
233 }
234
235 let mut by_id: HashMap<PageId, SearchMatchKind> = HashMap::new();
236 let metas = self.sorted_pages();
237
238 for meta in &metas {
239 if let Some(kind) = page_meta_match_kind(meta, &needle) {
240 by_id.insert(meta.id.clone(), kind);
241 }
242 }
243
244 if include_content {
245 for meta in &metas {
246 if by_id.contains_key(&meta.id) {
247 continue;
248 }
249 let Ok(page) = self.load_page(&meta.id) else {
250 continue;
251 };
252 if page_content_contains(&page, &needle) {
253 by_id.insert(meta.id.clone(), SearchMatchKind::Content);
254 }
255 }
256 }
257
258 let mut hits: Vec<SearchHit> = metas
259 .iter()
260 .filter_map(|meta| {
261 by_id.get(&meta.id).map(|kind| SearchHit {
262 id: meta.id.clone(),
263 kind: *kind,
264 })
265 })
266 .collect();
267 hits.sort_by_cached_key(|h| {
268 let title = self
269 .pages
270 .get(&h.id)
271 .map(|m| m.title_lower.clone())
272 .unwrap_or_default();
273 (std::cmp::Reverse(h.kind.score()), title)
274 });
275 hits
276 }
277
278 pub fn resolve_page_id_by_title(&self, title: &str) -> TitleResolution {
280 let needle = title.trim().to_lowercase();
281 if needle.is_empty() {
282 return TitleResolution::NotFound;
283 }
284
285 if let Some(exact) = self.resolve_exact_from_index(&needle) {
286 return exact;
287 }
288
289 let partial = self
290 .sorted_pages()
291 .iter()
292 .filter(|m| m.title_lower.contains(&needle))
293 .map(|m| m.id.clone())
294 .collect::<Vec<_>>();
295 match partial.len() {
296 1 => TitleResolution::Unique(partial[0].clone()),
297 0 => TitleResolution::NotFound,
298 _ => TitleResolution::Ambiguous(partial),
299 }
300 }
301
302 fn resolve_exact_from_index(&self, needle: &str) -> Option<TitleResolution> {
307 match self.title_index.get(needle).map(Vec::as_slice) {
308 Some([id]) => Some(TitleResolution::Unique(id.clone())),
309 Some(ids) if ids.len() > 1 => Some(TitleResolution::Ambiguous(ids.to_vec())),
310 _ => None,
311 }
312 }
313
314 pub fn resolve_page_id_by_title_exact(&self, title: &str) -> TitleResolution {
319 let needle = title.trim().to_lowercase();
320 if needle.is_empty() {
321 return TitleResolution::NotFound;
322 }
323
324 self.resolve_exact_from_index(&needle)
325 .unwrap_or(TitleResolution::NotFound)
326 }
327
328 pub fn classify_link_target(&self, raw: &str) -> LinkTargetKind {
330 let target = raw.trim();
331 if target.is_empty() {
332 return LinkTargetKind::Unknown(raw.to_string());
333 }
334
335 if self.pages.contains_key(target) {
336 return LinkTargetKind::InternalPage(target.to_string());
337 }
338
339 if let Some(rest) = target.strip_prefix("page:") {
340 let id = rest.trim();
341 if self.pages.contains_key(id) {
342 return LinkTargetKind::InternalPage(id.to_string());
343 }
344 if let TitleResolution::Unique(resolved) = self.resolve_page_id_by_title_exact(id) {
345 return LinkTargetKind::InternalPage(resolved);
346 }
347 }
348 if let Some(rest) = target.strip_prefix("title:") {
349 return match self.resolve_page_id_by_title_exact(rest.trim()) {
350 TitleResolution::Unique(id) => LinkTargetKind::InternalPage(id),
351 _ => LinkTargetKind::Unknown(target.to_string()),
352 };
353 }
354
355 if let Some(uuid) = extract_uuid_like(target)
356 && self.pages.contains_key(uuid)
357 {
358 return LinkTargetKind::InternalPage(uuid.to_string());
359 }
360
361 if is_external_target(target) {
362 return LinkTargetKind::ExternalUrl(target.to_string());
363 }
364
365 if let Some(path) = self.attachment_resolver().resolve_path(target) {
366 return LinkTargetKind::AttachmentPath(path);
367 }
368
369 match self.resolve_page_id_by_title_exact(target) {
370 TitleResolution::Unique(id) => LinkTargetKind::InternalPage(id),
371 _ => LinkTargetKind::Unknown(target.to_string()),
372 }
373 }
374
375 pub fn build_backlinks(&mut self) {
380 let mut back: HashMap<PageId, HashSet<PageId>> = HashMap::new();
381 let mut forward: HashMap<PageId, HashSet<PageId>> = HashMap::new();
382 for source_id in &self.sorted_ids {
383 let Ok(page) = self.load_page(source_id) else {
384 continue;
385 };
386 let mut source_targets = HashSet::new();
387 for target in extract_link_targets(&page.content) {
388 if let LinkTargetKind::InternalPage(target_id) = self.classify_link_target(&target)
389 && target_id != *source_id
390 && source_targets.insert(target_id.clone())
391 {
392 back.entry(target_id).or_default().insert(source_id.clone());
393 }
394 }
395 if !source_targets.is_empty() {
396 forward.insert(source_id.clone(), source_targets);
397 }
398 }
399 self.forward_links = forward;
400 self.backlinks = back
401 .into_iter()
402 .map(|(target, sources)| {
403 let mut sorted: Vec<PageId> = sources.into_iter().collect();
404 sorted.sort_by(|a, b| {
405 title_sort_key(&self.pages, a).cmp(title_sort_key(&self.pages, b))
406 });
407 (target, sorted)
408 })
409 .collect();
410 }
411
412 pub fn update_backlinks_for(&mut self, page_id: &str) {
418 if let Some(old_targets) = self.forward_links.remove(page_id) {
420 for target_id in &old_targets {
421 if let Some(sources) = self.backlinks.get_mut(target_id) {
422 sources.retain(|s| s != page_id);
423 if sources.is_empty() {
424 self.backlinks.remove(target_id);
425 }
426 }
427 }
428 }
429
430 let page = match self.load_page(page_id) {
432 Ok(p) => p,
433 Err(e) => {
434 log::warn!("update_backlinks_for: failed to load page {page_id}: {e:#}");
435 return;
436 }
437 };
438 let mut new_targets = HashSet::new();
439 for target in extract_link_targets(&page.content) {
440 if let LinkTargetKind::InternalPage(target_id) = self.classify_link_target(&target)
441 && target_id != page_id
442 && new_targets.insert(target_id.clone())
443 {
444 insert_sorted_by_title(
445 self.backlinks.entry(target_id).or_default(),
446 &self.pages,
447 page_id.to_string(),
448 );
449 }
450 }
451 if !new_targets.is_empty() {
452 self.forward_links.insert(page_id.to_string(), new_targets);
453 }
454 }
455
456 pub fn backlinks_for(&self, id: &str) -> &[PageId] {
458 self.backlinks
459 .get(id)
460 .map(Vec::as_slice)
461 .unwrap_or_default()
462 }
463
464 pub fn build_link_graph(&self) -> LinkGraph {
469 LinkGraph {
470 edges: self.scan_all_pages().edges,
471 }
472 }
473
474 pub fn root(&self) -> &Path {
476 &self.root
477 }
478
479 pub fn attachment_resolver(&self) -> AttachmentResolver {
481 AttachmentResolver::new(&self.root)
482 }
483 pub fn scan_all_pages(&self) -> LinkAnalysisResult {
486 let resolver = self.attachment_resolver();
487 let mut broken_links = Vec::new();
488 let mut linked_pages: HashSet<PageId> = HashSet::new();
489 let mut load_errors = Vec::new();
490 let mut missing_attachments = Vec::new();
491 let mut seen_attachments: HashSet<PathBuf> = HashSet::new();
492 let mut edges = Vec::new();
493 let mut seen_edges = HashSet::new();
494
495 for id in &self.sorted_ids {
496 let meta = match self.pages.get(id) {
497 Some(m) => m,
498 None => continue,
499 };
500 let page = match self.load_page(id) {
501 Ok(p) => p,
502 Err(e) => {
503 load_errors.push(PageLoadError {
504 page_id: id.clone(),
505 title: meta.title.clone(),
506 error: format!("{e:#}"),
507 });
508 continue;
509 }
510 };
511 seen_edges.clear();
512 seen_attachments.clear();
513 for target in extract_link_targets(&page.content) {
514 match self.classify_link_target(&target) {
515 LinkTargetKind::InternalPage(target_id) if target_id != *id => {
516 linked_pages.insert(target_id.clone());
517 if seen_edges.insert(target_id.clone()) {
518 edges.push(LinkEdge {
519 source: id.clone(),
520 target: target_id,
521 });
522 }
523 }
524 LinkTargetKind::Unknown(_) => {
525 broken_links.push(BrokenLink {
526 source_title: meta.title.clone(),
527 source_id: id.clone(),
528 target: target.clone(),
529 });
530 }
531 _ => {}
532 }
533
534 if extract_attachment_relative(&target).is_some()
535 && let Ok(resolved) = resolver.resolve(&target)
536 && !resolved.exists
537 && seen_attachments.insert(resolved.path.clone())
538 {
539 missing_attachments.push(MissingAttachment {
540 source_title: meta.title.clone(),
541 source_id: id.clone(),
542 target,
543 resolved_path: resolved.path,
544 });
545 }
546 }
547 }
548
549 LinkAnalysisResult {
550 broken_links,
551 linked_pages,
552 load_errors,
553 missing_attachments,
554 edges,
555 }
556 }
557
558 pub fn analyze_all(&self) -> LinkAnalysisResult {
560 self.scan_all_pages()
561 }
562
563 pub fn analyze_links(&self) -> LinkAnalysisResult {
565 self.scan_all_pages()
566 }
567
568 pub fn orphan_ids(
574 &self,
575 linked_pages: &HashSet<PageId>,
576 toc_page_id: Option<&str>,
577 ) -> Vec<PageId> {
578 self.sorted_ids
579 .iter()
580 .filter(|id| !linked_pages.contains(*id) && Some(id.as_str()) != toc_page_id)
581 .cloned()
582 .collect()
583 }
584
585 pub fn find_duplicate_titles(&self) -> Vec<DuplicateTitle> {
587 let mut by_title: HashMap<&str, Vec<&PageMeta>> = HashMap::new();
588 for meta in self.pages.values() {
589 by_title.entry(&meta.title_lower).or_default().push(meta);
590 }
591 let mut dupes: Vec<DuplicateTitle> = by_title
592 .into_iter()
593 .filter(|(_, metas)| metas.len() > 1)
594 .map(|(_, metas)| {
595 let title = metas[0].title.clone();
596 let mut page_ids: Vec<PageId> = metas.iter().map(|m| m.id.clone()).collect();
597 page_ids.sort();
598 DuplicateTitle { title, page_ids }
599 })
600 .collect();
601 dupes.sort_by_key(|a| a.title.to_lowercase());
602 dupes
603 }
604
605 pub fn find_duplicate_ids(&self) -> Vec<DuplicateId> {
607 self.duplicate_ids.clone()
608 }
609
610 pub fn find_missing_attachments(&self) -> Vec<MissingAttachment> {
613 self.scan_all_pages().missing_attachments
614 }
615}
616
617#[derive(Debug, Clone)]
619pub struct BrokenLink {
620 pub source_title: String,
622 pub source_id: PageId,
624 pub target: String,
626}
627
628#[derive(Debug, Clone)]
630pub struct PageLoadError {
631 pub page_id: PageId,
633 pub title: String,
635 pub error: String,
637}
638
639#[derive(Debug, Clone)]
641pub struct LinkAnalysisResult {
642 pub broken_links: Vec<BrokenLink>,
644 pub linked_pages: HashSet<PageId>,
646 pub load_errors: Vec<PageLoadError>,
648 pub missing_attachments: Vec<MissingAttachment>,
651 pub edges: Vec<LinkEdge>,
653}
654
655#[derive(Debug, Clone, Serialize)]
657pub struct LinkEdge {
658 pub source: PageId,
660 pub target: PageId,
662}
663
664#[derive(Debug, Clone)]
666pub struct LinkGraph {
667 pub edges: Vec<LinkEdge>,
669}
670
671#[derive(Debug, Clone)]
673pub struct DuplicateTitle {
674 pub title: String,
676 pub page_ids: Vec<PageId>,
678}
679
680#[derive(Debug, Clone)]
682pub struct DuplicateId {
683 pub id: PageId,
685 pub paths: Vec<PathBuf>,
687}
688
689#[derive(Debug, Clone)]
691pub struct MissingAttachment {
692 pub source_title: String,
694 pub source_id: PageId,
696 pub target: String,
698 pub resolved_path: PathBuf,
700}
701
702impl LinkGraph {
703 pub fn ego(&self, page_id: &str) -> Vec<&LinkEdge> {
705 self.edges
706 .iter()
707 .filter(|e| e.source == page_id || e.target == page_id)
708 .collect()
709 }
710}
711
712fn title_sort_key<'a>(pages: &'a HashMap<PageId, PageMeta>, id: &str) -> &'a str {
717 pages.get(id).map(|m| m.title_lower.as_str()).unwrap_or("")
718}
719
720fn insert_sorted_by_title(
723 sources: &mut Vec<PageId>,
724 pages: &HashMap<PageId, PageMeta>,
725 source_id: String,
726) {
727 let key = title_sort_key(pages, &source_id);
728 let pos = sources.partition_point(|id| title_sort_key(pages, id) < key);
729 sources.insert(pos, source_id);
730}
731
732fn compute_sorted_ids(pages: &HashMap<PageId, PageMeta>) -> Vec<PageId> {
733 let mut entries: Vec<_> = pages.values().collect();
734 entries.sort_by(|a, b| a.title_lower.cmp(&b.title_lower));
735 entries.into_iter().map(|m| m.id.clone()).collect()
736}
737
738fn collect_duplicate_ids(id_paths: HashMap<PageId, Vec<PathBuf>>) -> Vec<DuplicateId> {
741 let mut dupes: Vec<DuplicateId> = id_paths
742 .into_iter()
743 .filter(|(_, paths)| paths.len() > 1)
744 .map(|(id, mut paths)| {
745 paths.sort();
746 DuplicateId { id, paths }
747 })
748 .collect();
749 dupes.sort_by(|a, b| a.id.cmp(&b.id));
750 dupes
751}
752
753fn compute_title_index(
757 pages: &HashMap<PageId, PageMeta>,
758 sorted_ids: &[PageId],
759) -> HashMap<String, Vec<PageId>> {
760 let mut index: HashMap<String, Vec<PageId>> = HashMap::new();
761 for id in sorted_ids {
762 if let Some(meta) = pages.get(id) {
763 index
764 .entry(meta.title_lower.clone())
765 .or_default()
766 .push(id.clone());
767 }
768 }
769 index
770}
771
772fn page_meta_match_kind(meta: &PageMeta, needle: &str) -> Option<SearchMatchKind> {
773 if meta.title_lower.contains(needle) || meta.id_lower.contains(needle) {
774 Some(SearchMatchKind::Title)
775 } else if meta.tags_lower.iter().any(|t| t.contains(needle)) {
776 Some(SearchMatchKind::Tag)
777 } else {
778 None
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use serde_json::json;
786 use std::fs;
787
788 fn temp_dir_path(name: &str) -> PathBuf {
792 tempfile::Builder::new()
793 .prefix(&format!("lepiter-core-{name}-"))
794 .tempdir()
795 .expect("temp dir")
796 .keep()
797 }
798
799 fn make_kb_on_disk(pages: &[(&str, &str, &[&str], &str)]) -> (PathBuf, KnowledgeBaseIndex) {
800 let dir = temp_dir_path("kb");
801 fs::create_dir_all(&dir).unwrap();
802 for (id, title, tags, body_text) in pages {
803 let tags_json: Vec<Value> = tags.iter().map(|t| json!(t)).collect();
804 let content = json!({
805 "uid": {"uuid": id},
806 "pageType": {"title": title},
807 "tags": tags_json,
808 "children": {"items": [
809 {"__type": "textSnippet", "string": body_text}
810 ]}
811 });
812 let file_path = dir.join(format!("{id}.lepiter"));
813 fs::write(&file_path, serde_json::to_vec(&content).unwrap()).unwrap();
814 }
815 let index = KnowledgeBase::open(&dir).unwrap();
816 (dir, index)
817 }
818
819 #[test]
820 fn filter_page_ids_matches_title_id_and_tags() {
821 let mut pages = HashMap::new();
822 pages.insert(
823 "id-1".to_string(),
824 PageMeta {
825 id: "id-1".to_string(),
826 id_lower: "id-1".to_string(),
827 title: "Alpha".to_string(),
828 title_lower: "alpha".to_string(),
829 path: PathBuf::from("/tmp/a"),
830 updated_at: None,
831 tags: vec!["rust".to_string()],
832 tags_lower: vec!["rust".to_string()],
833 },
834 );
835 pages.insert(
836 "id-2".to_string(),
837 PageMeta {
838 id: "id-2".to_string(),
839 id_lower: "id-2".to_string(),
840 title: "Beta".to_string(),
841 title_lower: "beta".to_string(),
842 path: PathBuf::from("/tmp/b"),
843 updated_at: None,
844 tags: vec!["pharo".to_string()],
845 tags_lower: vec!["pharo".to_string()],
846 },
847 );
848 let sorted_ids = compute_sorted_ids(&pages);
849 let title_index = compute_title_index(&pages, &sorted_ids);
850 let index = KnowledgeBaseIndex {
851 root: PathBuf::from("/tmp"),
852 pages,
853 sorted_ids,
854 title_index,
855 index_issues: Vec::new(),
856 backlinks: HashMap::new(),
857 forward_links: HashMap::new(),
858 duplicate_ids: Vec::new(),
859 };
860
861 assert_eq!(index.filter_page_ids("alpha"), vec!["id-1".to_string()]);
862 assert_eq!(index.filter_page_ids("id-2"), vec!["id-2".to_string()]);
863 assert_eq!(index.filter_page_ids("pharo"), vec!["id-2".to_string()]);
864 assert_eq!(
865 index.filter_page_ids(""),
866 vec!["id-1".to_string(), "id-2".to_string()]
867 );
868 }
869
870 #[test]
871 fn resolve_page_id_by_title_handles_unique_ambiguous_and_missing() {
872 let mut pages = HashMap::new();
873 pages.insert(
874 "id-1".to_string(),
875 PageMeta {
876 id: "id-1".to_string(),
877 id_lower: "id-1".to_string(),
878 title: "Alpha".to_string(),
879 title_lower: "alpha".to_string(),
880 path: PathBuf::from("/tmp/a"),
881 updated_at: None,
882 tags: Vec::new(),
883 tags_lower: Vec::new(),
884 },
885 );
886 pages.insert(
887 "id-2".to_string(),
888 PageMeta {
889 id: "id-2".to_string(),
890 id_lower: "id-2".to_string(),
891 title: "Alphabet".to_string(),
892 title_lower: "alphabet".to_string(),
893 path: PathBuf::from("/tmp/b"),
894 updated_at: None,
895 tags: Vec::new(),
896 tags_lower: Vec::new(),
897 },
898 );
899 let sorted_ids = compute_sorted_ids(&pages);
900 let title_index = compute_title_index(&pages, &sorted_ids);
901 let index = KnowledgeBaseIndex {
902 root: PathBuf::from("/tmp"),
903 pages,
904 sorted_ids,
905 title_index,
906 index_issues: Vec::new(),
907 backlinks: HashMap::new(),
908 forward_links: HashMap::new(),
909 duplicate_ids: Vec::new(),
910 };
911
912 assert_eq!(
913 index.resolve_page_id_by_title("Alpha"),
914 TitleResolution::Unique("id-1".to_string())
915 );
916 assert!(matches!(
917 index.resolve_page_id_by_title("alp"),
918 TitleResolution::Ambiguous(_)
919 ));
920 assert_eq!(
921 index.resolve_page_id_by_title("zzz"),
922 TitleResolution::NotFound
923 );
924
925 assert_eq!(
927 index.resolve_page_id_by_title_exact("ALPHA"),
928 TitleResolution::Unique("id-1".to_string())
929 );
930 assert_eq!(
933 index.resolve_page_id_by_title_exact("alp"),
934 TitleResolution::NotFound
935 );
936 assert_eq!(
937 index.resolve_page_id_by_title_exact("alpha"),
938 TitleResolution::Unique("id-1".to_string())
939 );
940 }
941
942 #[test]
943 fn resolve_page_id_by_title_exact_reports_duplicate_titles_as_ambiguous() {
944 let (dir, index) = make_kb_on_disk(&[
945 ("p1", "Rust", &[], "one"),
946 ("p2", "Rust", &[], "two"),
947 ("p3", "Rust Programming", &[], "three"),
948 ]);
949 assert!(matches!(
950 index.resolve_page_id_by_title_exact("Rust"),
951 TitleResolution::Ambiguous(ids) if ids.len() == 2
952 ));
953 fs::remove_dir_all(&dir).unwrap();
954 }
955
956 #[test]
957 fn classify_link_target_covers_internal_attachment_external_unknown() {
958 let mut pages = HashMap::new();
959 pages.insert(
960 "8a505fa0-2222-3333-4444-555555555555".to_string(),
961 PageMeta {
962 id: "8a505fa0-2222-3333-4444-555555555555".to_string(),
963 id_lower: "8a505fa0-2222-3333-4444-555555555555".to_string(),
964 title: "Alpha".to_string(),
965 title_lower: "alpha".to_string(),
966 path: PathBuf::from("/tmp/a"),
967 updated_at: None,
968 tags: Vec::new(),
969 tags_lower: Vec::new(),
970 },
971 );
972 let sorted_ids = compute_sorted_ids(&pages);
973 let title_index = compute_title_index(&pages, &sorted_ids);
974 let index = KnowledgeBaseIndex {
975 root: PathBuf::from("/kb"),
976 pages,
977 sorted_ids,
978 title_index,
979 index_issues: Vec::new(),
980 backlinks: HashMap::new(),
981 forward_links: HashMap::new(),
982 duplicate_ids: Vec::new(),
983 };
984
985 assert!(matches!(
986 index.classify_link_target("8a505fa0-2222-3333-4444-555555555555"),
987 LinkTargetKind::InternalPage(_)
988 ));
989 assert!(matches!(
990 index.classify_link_target("title:alpha"),
991 LinkTargetKind::InternalPage(_)
992 ));
993 assert!(matches!(
994 index.classify_link_target("go to 8a505fa0-2222-3333-4444-555555555555 now"),
995 LinkTargetKind::InternalPage(_)
996 ));
997 assert!(matches!(
998 index.classify_link_target("attachments/image.png"),
999 LinkTargetKind::AttachmentPath(_)
1000 ));
1001 assert!(matches!(
1002 index.classify_link_target("https://example.com"),
1003 LinkTargetKind::ExternalUrl(_)
1004 ));
1005 assert!(matches!(
1006 index.classify_link_target("not a thing"),
1007 LinkTargetKind::Unknown(_)
1008 ));
1009 assert!(matches!(
1011 index.classify_link_target("page:Alpha"),
1012 LinkTargetKind::InternalPage(_)
1013 ));
1014 assert!(matches!(
1016 index.classify_link_target("page:Nonexistent"),
1017 LinkTargetKind::Unknown(_)
1018 ));
1019 }
1020
1021 #[test]
1022 fn search_hits_empty_query_returns_nothing() {
1023 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "hello world")]);
1024 assert!(index.search_hits("", false).is_empty());
1025 assert!(index.search_hits(" ", true).is_empty());
1026 fs::remove_dir_all(&dir).unwrap();
1027 }
1028
1029 #[test]
1030 fn search_hits_matches_title_case_insensitively() {
1031 let (dir, index) = make_kb_on_disk(&[
1032 ("p1", "Alpha Guide", &[], "nothing special"),
1033 ("p2", "Beta Notes", &[], "nothing special"),
1034 ]);
1035 let hits = index.search_hits("alpha", false);
1036 assert_eq!(hits.len(), 1);
1037 assert_eq!(hits[0].id, "p1");
1038 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1039 fs::remove_dir_all(&dir).unwrap();
1040 }
1041
1042 #[test]
1043 fn search_hits_matches_tags() {
1044 let (dir, index) = make_kb_on_disk(&[
1045 ("p1", "Page One", &["rust", "cli"], "body"),
1046 ("p2", "Page Two", &["pharo"], "body"),
1047 ]);
1048 let hits = index.search_hits("rust", false);
1049 assert_eq!(hits.len(), 1);
1050 assert_eq!(hits[0].id, "p1");
1051 assert_eq!(hits[0].kind, SearchMatchKind::Tag);
1052 fs::remove_dir_all(&dir).unwrap();
1053 }
1054
1055 #[test]
1056 fn search_hits_content_flag_searches_page_body() {
1057 let (dir, index) = make_kb_on_disk(&[
1058 ("p1", "Alpha", &[], "the quick brown fox"),
1059 ("p2", "Beta", &[], "lazy dog sleeps"),
1060 ]);
1061
1062 let no_content = index.search_hits("fox", false);
1063 assert!(no_content.is_empty());
1064
1065 let with_content = index.search_hits("fox", true);
1066 assert_eq!(with_content.len(), 1);
1067 assert_eq!(with_content[0].id, "p1");
1068 assert_eq!(with_content[0].kind, SearchMatchKind::Content);
1069 fs::remove_dir_all(&dir).unwrap();
1070 }
1071
1072 #[test]
1073 fn search_hits_title_match_takes_priority_over_content() {
1074 let (dir, index) = make_kb_on_disk(&[("p1", "Fox Guide", &[], "the fox jumps")]);
1075 let hits = index.search_hits("fox", true);
1076 assert_eq!(hits.len(), 1);
1077 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1078 fs::remove_dir_all(&dir).unwrap();
1079 }
1080
1081 #[test]
1082 fn search_hits_same_score_sorted_alphabetically() {
1083 let (dir, index) = make_kb_on_disk(&[
1084 ("p1", "Zebra", &["common"], "body"),
1085 ("p2", "Alpha", &["common"], "body"),
1086 ("p3", "Middle", &["common"], "body"),
1087 ]);
1088 let hits = index.search_hits("common", false);
1089 let ids: Vec<&str> = hits.iter().map(|h| h.id.as_str()).collect();
1090 assert_eq!(ids, vec!["p2", "p3", "p1"]);
1092 fs::remove_dir_all(&dir).unwrap();
1093 }
1094
1095 #[test]
1096 fn search_hits_title_ranked_above_tag() {
1097 let (dir, index) = make_kb_on_disk(&[
1098 ("p1", "Page One", &["rust"], "body"),
1099 ("p2", "Rust Guide", &[], "body"),
1100 ]);
1101 let hits = index.search_hits("rust", false);
1102 assert_eq!(hits.len(), 2);
1103 assert_eq!(hits[0].id, "p2");
1105 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1106 assert_eq!(hits[1].id, "p1");
1108 assert_eq!(hits[1].kind, SearchMatchKind::Tag);
1109 fs::remove_dir_all(&dir).unwrap();
1110 }
1111
1112 #[test]
1113 fn search_hits_title_ranked_above_content() {
1114 let (dir, index) = make_kb_on_disk(&[
1115 ("p1", "Alpha", &[], "the word rust appears here"),
1116 ("p2", "Rust Guide", &[], "no match in body"),
1117 ]);
1118 let hits = index.search_hits("rust", true);
1119 assert_eq!(hits.len(), 2);
1120 assert_eq!(hits[0].id, "p2");
1121 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1122 assert_eq!(hits[1].id, "p1");
1123 assert_eq!(hits[1].kind, SearchMatchKind::Content);
1124 fs::remove_dir_all(&dir).unwrap();
1125 }
1126
1127 #[test]
1128 fn search_hits_tag_ranked_above_content() {
1129 let (dir, index) = make_kb_on_disk(&[
1130 ("p1", "Alpha", &[], "the word rust appears here"),
1131 ("p2", "Beta", &["rust"], "no match in body"),
1132 ]);
1133 let hits = index.search_hits("rust", true);
1134 assert_eq!(hits.len(), 2);
1135 assert_eq!(hits[0].id, "p2");
1136 assert_eq!(hits[0].kind, SearchMatchKind::Tag);
1137 assert_eq!(hits[1].id, "p1");
1138 assert_eq!(hits[1].kind, SearchMatchKind::Content);
1139 fs::remove_dir_all(&dir).unwrap();
1140 }
1141
1142 #[test]
1143 fn search_hits_mixed_kinds_ranked_correctly() {
1144 let (dir, index) = make_kb_on_disk(&[
1145 ("p1", "Alpha", &[], "cli tools are great"),
1146 ("p2", "Beta", &["cli"], "no match"),
1147 ("p3", "CLI Reference", &[], "no match"),
1148 ("p4", "Delta", &[], "no match"),
1149 ]);
1150 let hits = index.search_hits("cli", true);
1151 assert_eq!(hits.len(), 3);
1152 assert_eq!(hits[0].id, "p3");
1154 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1155 assert_eq!(hits[1].id, "p2");
1157 assert_eq!(hits[1].kind, SearchMatchKind::Tag);
1158 assert_eq!(hits[2].id, "p1");
1160 assert_eq!(hits[2].kind, SearchMatchKind::Content);
1161 fs::remove_dir_all(&dir).unwrap();
1162 }
1163
1164 #[test]
1165 fn search_hits_title_with_tag_stays_title() {
1166 let (dir, index) =
1168 make_kb_on_disk(&[("p1", "Rust Guide", &["rust"], "also mentions rust")]);
1169 let hits = index.search_hits("rust", true);
1170 assert_eq!(hits.len(), 1);
1171 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1172 fs::remove_dir_all(&dir).unwrap();
1173 }
1174
1175 #[test]
1176 fn search_hits_tag_match_takes_priority_over_content() {
1177 let (dir, index) = make_kb_on_disk(&[("p1", "Some Page", &["fox"], "the fox jumps")]);
1178 let hits = index.search_hits("fox", true);
1179 assert_eq!(hits.len(), 1);
1180 assert_eq!(hits[0].kind, SearchMatchKind::Tag);
1181 fs::remove_dir_all(&dir).unwrap();
1182 }
1183
1184 #[test]
1185 fn search_hits_id_match_counts_as_title() {
1186 let (dir, index) = make_kb_on_disk(&[("rustacean", "Some Page", &[], "body")]);
1187 let hits = index.search_hits("rustacean", false);
1188 assert_eq!(hits.len(), 1);
1189 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1190 fs::remove_dir_all(&dir).unwrap();
1191 }
1192
1193 #[test]
1194 fn filter_page_ids_scored_returns_kinds() {
1195 let (dir, index) = make_kb_on_disk(&[
1196 ("p1", "Rust Intro", &[], "body"),
1197 ("p2", "Beta", &["rust"], "body"),
1198 ("p3", "Gamma", &[], "body"),
1199 ]);
1200 let scored = index.filter_page_ids_scored("rust");
1201 assert_eq!(scored.len(), 2);
1202 let map: HashMap<&str, SearchMatchKind> =
1203 scored.iter().map(|(id, k)| (id.as_str(), *k)).collect();
1204 assert_eq!(map["p1"], SearchMatchKind::Title);
1205 assert_eq!(map["p2"], SearchMatchKind::Tag);
1206 fs::remove_dir_all(&dir).unwrap();
1207 }
1208
1209 #[test]
1210 fn classify_link_target_page_prefix() {
1211 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1212 assert!(matches!(
1213 index.classify_link_target("page:p1"),
1214 LinkTargetKind::InternalPage(id) if id == "p1"
1215 ));
1216 assert!(matches!(
1217 index.classify_link_target("page:nonexistent"),
1218 LinkTargetKind::Unknown(_)
1219 ));
1220 fs::remove_dir_all(&dir).unwrap();
1221 }
1222
1223 #[test]
1224 fn classify_link_target_empty_is_unknown() {
1225 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1226 assert!(matches!(
1227 index.classify_link_target(""),
1228 LinkTargetKind::Unknown(_)
1229 ));
1230 fs::remove_dir_all(&dir).unwrap();
1231 }
1232
1233 #[test]
1234 fn classify_link_target_title_fallback() {
1235 let (dir, index) = make_kb_on_disk(&[("p1", "My Special Page", &[], "body")]);
1236 assert!(matches!(
1237 index.classify_link_target("My Special Page"),
1238 LinkTargetKind::InternalPage(id) if id == "p1"
1239 ));
1240 fs::remove_dir_all(&dir).unwrap();
1241 }
1242
1243 #[test]
1244 fn classify_link_target_mixed_case_urls() {
1245 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1246 assert!(matches!(
1247 index.classify_link_target("HTTPS://EXAMPLE.COM"),
1248 LinkTargetKind::ExternalUrl(_)
1249 ));
1250 assert!(matches!(
1251 index.classify_link_target("Http://Example.Com"),
1252 LinkTargetKind::ExternalUrl(_)
1253 ));
1254 assert!(matches!(
1255 index.classify_link_target("MAILTO:user@host.com"),
1256 LinkTargetKind::ExternalUrl(_)
1257 ));
1258 fs::remove_dir_all(&dir).unwrap();
1259 }
1260
1261 #[test]
1262 fn classify_link_target_whitespace_only_is_unknown() {
1263 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1264 assert!(matches!(
1265 index.classify_link_target(" "),
1266 LinkTargetKind::Unknown(_)
1267 ));
1268 assert!(matches!(
1269 index.classify_link_target("\t\n"),
1270 LinkTargetKind::Unknown(_)
1271 ));
1272 fs::remove_dir_all(&dir).unwrap();
1273 }
1274
1275 #[test]
1276 fn classify_link_target_trims_whitespace_around_url() {
1277 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1278 assert!(matches!(
1279 index.classify_link_target(" https://example.com "),
1280 LinkTargetKind::ExternalUrl(_)
1281 ));
1282 fs::remove_dir_all(&dir).unwrap();
1283 }
1284
1285 #[test]
1286 fn classify_link_target_unusual_schemes() {
1287 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1288 assert!(matches!(
1289 index.classify_link_target("ftp://files.example.com"),
1290 LinkTargetKind::ExternalUrl(_)
1291 ));
1292 assert!(matches!(
1293 index.classify_link_target("ssh://git.example.com"),
1294 LinkTargetKind::ExternalUrl(_)
1295 ));
1296 fs::remove_dir_all(&dir).unwrap();
1297 }
1298
1299 #[test]
1300 fn resolve_page_id_by_title_empty_and_whitespace() {
1301 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1302 assert_eq!(
1303 index.resolve_page_id_by_title(""),
1304 TitleResolution::NotFound
1305 );
1306 assert_eq!(
1307 index.resolve_page_id_by_title(" "),
1308 TitleResolution::NotFound
1309 );
1310 fs::remove_dir_all(&dir).unwrap();
1311 }
1312
1313 #[test]
1314 fn resolve_page_id_by_title_case_insensitive_exact() {
1315 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1316 assert_eq!(
1317 index.resolve_page_id_by_title("ALPHA"),
1318 TitleResolution::Unique("p1".to_string())
1319 );
1320 fs::remove_dir_all(&dir).unwrap();
1321 }
1322
1323 #[test]
1324 fn filter_page_ids_no_match_returns_empty() {
1325 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1326 assert!(index.filter_page_ids("zzzzz").is_empty());
1327 fs::remove_dir_all(&dir).unwrap();
1328 }
1329
1330 #[test]
1331 fn open_empty_directory_returns_empty_index() -> anyhow::Result<()> {
1332 let dir = temp_dir_path("empty-kb");
1333 fs::create_dir_all(&dir)?;
1334 let index = KnowledgeBase::open(&dir)?;
1335 fs::remove_dir_all(&dir)?;
1336
1337 assert!(index.pages.is_empty());
1338 assert!(index.index_issues.is_empty());
1339 Ok(())
1340 }
1341
1342 #[test]
1343 fn open_skips_non_lepiter_files() -> anyhow::Result<()> {
1344 let dir = temp_dir_path("non-lepiter");
1345 fs::create_dir_all(&dir)?;
1346 fs::write(dir.join("readme.txt"), b"hello")?;
1347 fs::write(dir.join("data.json"), b"{}")?;
1348 let index = KnowledgeBase::open(&dir)?;
1349 fs::remove_dir_all(&dir)?;
1350
1351 assert!(index.pages.is_empty());
1352 assert!(index.index_issues.is_empty());
1353 Ok(())
1354 }
1355
1356 #[test]
1357 fn open_reports_invalid_json_as_issue() -> anyhow::Result<()> {
1358 let dir = temp_dir_path("bad-json");
1359 fs::create_dir_all(&dir)?;
1360 fs::write(dir.join("broken.lepiter"), b"not json at all")?;
1361 let index = KnowledgeBase::open(&dir)?;
1362 fs::remove_dir_all(&dir)?;
1363
1364 assert!(index.pages.is_empty());
1365 assert_eq!(index.index_issues.len(), 1);
1366 assert!(index.index_issues[0].message.contains("failed to decode"));
1367 Ok(())
1368 }
1369
1370 #[test]
1371 fn open_reports_wrong_json_structure_as_issue() -> anyhow::Result<()> {
1372 let dir = temp_dir_path("wrong-shape");
1373 fs::create_dir_all(&dir)?;
1374 fs::write(dir.join("array.lepiter"), b"[1, 2, 3]")?;
1375 let index = KnowledgeBase::open(&dir)?;
1376 fs::remove_dir_all(&dir)?;
1377
1378 assert!(index.pages.is_empty());
1379 assert_eq!(index.index_issues.len(), 1);
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn open_fills_in_defaults_for_minimal_page() -> anyhow::Result<()> {
1385 let dir = temp_dir_path("minimal");
1386 fs::create_dir_all(&dir)?;
1387 fs::write(dir.join("mypage.lepiter"), b"{}")?;
1388 let index = KnowledgeBase::open(&dir)?;
1389 fs::remove_dir_all(&dir)?;
1390
1391 assert_eq!(index.pages.len(), 1);
1392 let meta = index.pages.values().next().unwrap();
1393 assert_eq!(meta.id, "mypage");
1394 assert_eq!(meta.title, "mypage");
1395 Ok(())
1396 }
1397
1398 #[test]
1399 fn open_reports_duplicate_ids_across_files() -> anyhow::Result<()> {
1400 let dir = temp_dir_path("dup-ids");
1401 fs::create_dir_all(&dir)?;
1402 let page = |body: &str| {
1403 json!({
1404 "uid": {"uuid": "shared"},
1405 "pageType": {"title": "Whatever"},
1406 "tags": [],
1407 "children": {"items": [{"__type": "textSnippet", "string": body}]}
1408 })
1409 };
1410 let a = dir.join("a.lepiter");
1411 let b = dir.join("b.lepiter");
1412 fs::write(&a, serde_json::to_vec(&page("first"))?)?;
1413 fs::write(&b, serde_json::to_vec(&page("second"))?)?;
1414
1415 let index = KnowledgeBase::open(&dir)?;
1416 fs::remove_dir_all(&dir)?;
1417
1418 assert_eq!(index.pages.len(), 1);
1419 let dupes = index.find_duplicate_ids();
1420 assert_eq!(dupes.len(), 1);
1421 assert_eq!(dupes[0].id, "shared");
1422 assert_eq!(dupes[0].paths, vec![a, b]);
1423 Ok(())
1424 }
1425
1426 #[test]
1427 fn open_reports_no_duplicate_ids_for_unique_kb() -> anyhow::Result<()> {
1428 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "a"), ("p2", "Beta", &[], "b")]);
1429 assert!(index.find_duplicate_ids().is_empty());
1430 fs::remove_dir_all(&dir).unwrap();
1431 Ok(())
1432 }
1433
1434 #[test]
1435 fn load_page_nonexistent_id_errors() -> anyhow::Result<()> {
1436 let dir = temp_dir_path("no-such-id");
1437 fs::create_dir_all(&dir)?;
1438 let index = KnowledgeBase::open(&dir)?;
1439 fs::remove_dir_all(&dir)?;
1440
1441 let err = index.load_page("does-not-exist");
1442 assert!(err.is_err());
1443 assert!(format!("{:#}", err.unwrap_err()).contains("page id not found"));
1444 Ok(())
1445 }
1446
1447 #[test]
1448 fn load_page_missing_children_yields_empty_content() -> anyhow::Result<()> {
1449 let dir = temp_dir_path("no-children");
1450 fs::create_dir_all(&dir)?;
1451 let content = json!({"uid": {"uuid": "pg-1"}, "pageType": {"title": "T"}});
1452 fs::write(dir.join("pg-1.lepiter"), serde_json::to_vec(&content)?)?;
1453 let index = KnowledgeBase::open(&dir)?;
1454 let page = index.load_page("pg-1")?;
1455 fs::remove_dir_all(&dir)?;
1456
1457 assert!(page.content.is_empty());
1458 Ok(())
1459 }
1460
1461 #[test]
1462 fn build_backlinks_computes_reverse_index() {
1463 let (dir, mut index) = make_kb_on_disk(&[
1464 ("p1", "Alpha", &[], "see [[Beta]]"),
1465 ("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
1466 ("p3", "Gamma", &[], "no links here"),
1467 ]);
1468 index.build_backlinks();
1469
1470 assert_eq!(index.backlinks_for("p1"), &["p2"]);
1472 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1474 assert_eq!(index.backlinks_for("p3"), &["p2"]);
1476
1477 fs::remove_dir_all(&dir).unwrap();
1478 }
1479
1480 #[test]
1481 fn build_backlinks_excludes_self_links() {
1482 let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
1483 index.build_backlinks();
1484 assert!(index.backlinks_for("p1").is_empty());
1485 fs::remove_dir_all(&dir).unwrap();
1486 }
1487
1488 #[test]
1489 fn backlinks_for_unknown_page_returns_empty() {
1490 let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "text")]);
1491 index.build_backlinks();
1492 assert!(index.backlinks_for("nonexistent").is_empty());
1493 fs::remove_dir_all(&dir).unwrap();
1494 }
1495
1496 #[test]
1497 fn build_backlinks_deduplicates_multiple_links() {
1498 let (dir, mut index) = make_kb_on_disk(&[
1499 ("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
1500 ("p2", "Beta", &[], "nothing"),
1501 ]);
1502 index.build_backlinks();
1503 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1505 fs::remove_dir_all(&dir).unwrap();
1506 }
1507
1508 #[test]
1509 fn build_backlinks_sorted_by_title() {
1510 let (dir, mut index) = make_kb_on_disk(&[
1511 ("p1", "Zebra", &[], "links to [[Target]]"),
1512 ("p2", "Alpha", &[], "links to [[Target]]"),
1513 ("p3", "Target", &[], "nothing"),
1514 ]);
1515 index.build_backlinks();
1516 assert_eq!(index.backlinks_for("p3"), &["p2", "p1"]);
1517 fs::remove_dir_all(&dir).unwrap();
1518 }
1519
1520 #[test]
1521 fn update_backlinks_for_adds_new_links() {
1522 let (dir, mut index) = make_kb_on_disk(&[
1523 ("p1", "Alpha", &[], "no links"),
1524 ("p2", "Beta", &[], "nothing"),
1525 ]);
1526 index.build_backlinks();
1527 assert!(index.backlinks_for("p2").is_empty());
1528
1529 let content = json!({
1531 "uid": {"uuid": "p1"},
1532 "pageType": {"title": "Alpha"},
1533 "children": {"items": [
1534 {"__type": "textSnippet", "string": "now links to [[Beta]]"}
1535 ]}
1536 });
1537 fs::write(
1538 dir.join("p1.lepiter"),
1539 serde_json::to_vec(&content).unwrap(),
1540 )
1541 .unwrap();
1542
1543 index.update_backlinks_for("p1");
1544 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1545
1546 fs::remove_dir_all(&dir).unwrap();
1547 }
1548
1549 #[test]
1550 fn update_backlinks_for_removes_stale_links() {
1551 let (dir, mut index) = make_kb_on_disk(&[
1552 ("p1", "Alpha", &[], "see [[Beta]]"),
1553 ("p2", "Beta", &[], "nothing"),
1554 ]);
1555 index.build_backlinks();
1556 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1557
1558 let content = json!({
1560 "uid": {"uuid": "p1"},
1561 "pageType": {"title": "Alpha"},
1562 "children": {"items": [
1563 {"__type": "textSnippet", "string": "no links anymore"}
1564 ]}
1565 });
1566 fs::write(
1567 dir.join("p1.lepiter"),
1568 serde_json::to_vec(&content).unwrap(),
1569 )
1570 .unwrap();
1571
1572 index.update_backlinks_for("p1");
1573 assert!(index.backlinks_for("p2").is_empty());
1574
1575 fs::remove_dir_all(&dir).unwrap();
1576 }
1577
1578 #[test]
1579 fn update_backlinks_for_replaces_changed_link() {
1580 let (dir, mut index) = make_kb_on_disk(&[
1581 ("p1", "Alpha", &[], "see [[Beta]]"),
1582 ("p2", "Beta", &[], "nothing"),
1583 ("p3", "Gamma", &[], "nothing"),
1584 ]);
1585 index.build_backlinks();
1586 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1587 assert!(index.backlinks_for("p3").is_empty());
1588
1589 let content = json!({
1591 "uid": {"uuid": "p1"},
1592 "pageType": {"title": "Alpha"},
1593 "children": {"items": [
1594 {"__type": "textSnippet", "string": "see [[Gamma]]"}
1595 ]}
1596 });
1597 fs::write(
1598 dir.join("p1.lepiter"),
1599 serde_json::to_vec(&content).unwrap(),
1600 )
1601 .unwrap();
1602
1603 index.update_backlinks_for("p1");
1604 assert!(index.backlinks_for("p2").is_empty());
1605 assert_eq!(index.backlinks_for("p3"), &["p1"]);
1606
1607 fs::remove_dir_all(&dir).unwrap();
1608 }
1609
1610 #[test]
1611 fn update_backlinks_for_preserves_other_sources() {
1612 let (dir, mut index) = make_kb_on_disk(&[
1613 ("p1", "Alpha", &[], "see [[Gamma]]"),
1614 ("p2", "Beta", &[], "see [[Gamma]]"),
1615 ("p3", "Gamma", &[], "nothing"),
1616 ]);
1617 index.build_backlinks();
1618 assert_eq!(index.backlinks_for("p3"), &["p1", "p2"]);
1619
1620 let content = json!({
1622 "uid": {"uuid": "p1"},
1623 "pageType": {"title": "Alpha"},
1624 "children": {"items": [
1625 {"__type": "textSnippet", "string": "no link"}
1626 ]}
1627 });
1628 fs::write(
1629 dir.join("p1.lepiter"),
1630 serde_json::to_vec(&content).unwrap(),
1631 )
1632 .unwrap();
1633
1634 index.update_backlinks_for("p1");
1635 assert_eq!(index.backlinks_for("p3"), &["p2"]);
1636
1637 fs::remove_dir_all(&dir).unwrap();
1638 }
1639
1640 #[test]
1641 fn update_backlinks_for_deduplicates() {
1642 let (dir, mut index) = make_kb_on_disk(&[
1643 ("p1", "Alpha", &[], "nothing"),
1644 ("p2", "Beta", &[], "nothing"),
1645 ]);
1646 index.build_backlinks();
1647
1648 let content = json!({
1650 "uid": {"uuid": "p1"},
1651 "pageType": {"title": "Alpha"},
1652 "children": {"items": [
1653 {"__type": "textSnippet", "string": "[[Beta]] and [[Beta]] again"}
1654 ]}
1655 });
1656 fs::write(
1657 dir.join("p1.lepiter"),
1658 serde_json::to_vec(&content).unwrap(),
1659 )
1660 .unwrap();
1661
1662 index.update_backlinks_for("p1");
1663 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1664
1665 fs::remove_dir_all(&dir).unwrap();
1666 }
1667
1668 #[test]
1669 fn update_backlinks_for_excludes_self_links() {
1670 let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "nothing")]);
1671 index.build_backlinks();
1672
1673 let content = json!({
1675 "uid": {"uuid": "p1"},
1676 "pageType": {"title": "Alpha"},
1677 "children": {"items": [
1678 {"__type": "textSnippet", "string": "see [[Alpha]]"}
1679 ]}
1680 });
1681 fs::write(
1682 dir.join("p1.lepiter"),
1683 serde_json::to_vec(&content).unwrap(),
1684 )
1685 .unwrap();
1686
1687 index.update_backlinks_for("p1");
1688 assert!(index.backlinks_for("p1").is_empty());
1689
1690 fs::remove_dir_all(&dir).unwrap();
1691 }
1692
1693 #[test]
1694 fn update_backlinks_for_maintains_sort_order() {
1695 let (dir, mut index) = make_kb_on_disk(&[
1696 ("p1", "Zebra", &[], "nothing"),
1697 ("p2", "Alpha", &[], "links to [[Target]]"),
1698 ("p3", "Target", &[], "nothing"),
1699 ]);
1700 index.build_backlinks();
1701 assert_eq!(index.backlinks_for("p3"), &["p2"]);
1702
1703 let content = json!({
1705 "uid": {"uuid": "p1"},
1706 "pageType": {"title": "Zebra"},
1707 "children": {"items": [
1708 {"__type": "textSnippet", "string": "links to [[Target]]"}
1709 ]}
1710 });
1711 fs::write(
1712 dir.join("p1.lepiter"),
1713 serde_json::to_vec(&content).unwrap(),
1714 )
1715 .unwrap();
1716
1717 index.update_backlinks_for("p1");
1718 assert_eq!(index.backlinks_for("p3"), &["p2", "p1"]);
1719
1720 fs::remove_dir_all(&dir).unwrap();
1721 }
1722
1723 #[test]
1724 fn register_page_adds_and_resorts() {
1725 let dir = temp_dir_path("register");
1726 fs::create_dir_all(&dir).unwrap();
1727 let mut index = KnowledgeBase::open(&dir).unwrap();
1728 assert!(index.sorted_ids.is_empty());
1729
1730 let meta = PageMeta {
1731 id: "new-page".to_string(),
1732 id_lower: "new-page".to_string(),
1733 title: "My Page".to_string(),
1734 title_lower: "my page".to_string(),
1735 path: dir.join("new-page.lepiter"),
1736 updated_at: None,
1737 tags: Vec::new(),
1738 tags_lower: Vec::new(),
1739 };
1740 index.register_page(meta);
1741 assert_eq!(index.sorted_ids.len(), 1);
1742 assert_eq!(index.sorted_ids[0], "new-page");
1743 assert!(index.pages.contains_key("new-page"));
1744
1745 fs::remove_dir_all(&dir).unwrap();
1746 }
1747
1748 #[test]
1749 fn register_page_reregistration_no_duplicate() {
1750 let dir = temp_dir_path("reregister");
1751 fs::create_dir_all(&dir).unwrap();
1752 let mut index = KnowledgeBase::open(&dir).unwrap();
1753
1754 let meta = PageMeta {
1755 id: "dup".to_string(),
1756 id_lower: "dup".to_string(),
1757 title: "Original".to_string(),
1758 title_lower: "original".to_string(),
1759 path: dir.join("dup.lepiter"),
1760 updated_at: None,
1761 tags: Vec::new(),
1762 tags_lower: Vec::new(),
1763 };
1764 index.register_page(meta);
1765 assert_eq!(index.sorted_ids.len(), 1);
1766
1767 let updated = PageMeta {
1769 id: "dup".to_string(),
1770 id_lower: "dup".to_string(),
1771 title: "Renamed".to_string(),
1772 title_lower: "renamed".to_string(),
1773 path: dir.join("dup.lepiter"),
1774 updated_at: None,
1775 tags: Vec::new(),
1776 tags_lower: Vec::new(),
1777 };
1778 index.register_page(updated);
1779
1780 assert_eq!(index.sorted_ids.len(), 1);
1782 assert_eq!(index.sorted_ids[0], "dup");
1783 assert_eq!(index.pages["dup"].title, "Renamed");
1785
1786 fs::remove_dir_all(&dir).unwrap();
1787 }
1788
1789 fn meta_with_title(id: &str, title: &str) -> PageMeta {
1790 PageMeta {
1791 id: id.to_string(),
1792 id_lower: id.to_lowercase(),
1793 title: title.to_string(),
1794 title_lower: title.to_lowercase(),
1795 path: PathBuf::from(format!("/tmp/{id}.lepiter")),
1796 updated_at: None,
1797 tags: Vec::new(),
1798 tags_lower: Vec::new(),
1799 }
1800 }
1801
1802 #[test]
1803 fn register_page_title_change_updates_exact_title_index() {
1804 let dir = temp_dir_path("title-index-rename");
1805 fs::create_dir_all(&dir).unwrap();
1806 let mut index = KnowledgeBase::open(&dir).unwrap();
1807
1808 index.register_page(meta_with_title("pg", "Old Title"));
1809 assert_eq!(
1810 index.resolve_page_id_by_title_exact("Old Title"),
1811 TitleResolution::Unique("pg".to_string())
1812 );
1813
1814 index.register_page(meta_with_title("pg", "New Title"));
1816 assert_eq!(
1817 index.resolve_page_id_by_title_exact("new title"),
1818 TitleResolution::Unique("pg".to_string())
1819 );
1820 assert_eq!(
1821 index.resolve_page_id_by_title_exact("Old Title"),
1822 TitleResolution::NotFound
1823 );
1824
1825 fs::remove_dir_all(&dir).unwrap();
1826 }
1827
1828 #[test]
1829 fn register_page_duplicate_titles_resolve_ambiguous_via_index() {
1830 let dir = temp_dir_path("title-index-dup");
1831 fs::create_dir_all(&dir).unwrap();
1832 let mut index = KnowledgeBase::open(&dir).unwrap();
1833
1834 index.register_page(meta_with_title("p1", "Shared"));
1835 index.register_page(meta_with_title("p2", "Shared"));
1836
1837 match index.resolve_page_id_by_title_exact("shared") {
1838 TitleResolution::Ambiguous(ids) => {
1839 assert_eq!(ids.len(), 2);
1840 assert!(ids.contains(&"p1".to_string()));
1841 assert!(ids.contains(&"p2".to_string()));
1842 }
1843 other => panic!("expected Ambiguous, got {other:?}"),
1844 }
1845
1846 fs::remove_dir_all(&dir).unwrap();
1847 }
1848
1849 #[test]
1850 fn register_page_rename_out_of_shared_bucket_leaves_other_unique() {
1851 let dir = temp_dir_path("title-index-shrink");
1852 fs::create_dir_all(&dir).unwrap();
1853 let mut index = KnowledgeBase::open(&dir).unwrap();
1854
1855 index.register_page(meta_with_title("p1", "Shared"));
1856 index.register_page(meta_with_title("p2", "Shared"));
1857
1858 index.register_page(meta_with_title("p1", "Renamed"));
1861 assert_eq!(
1862 index.resolve_page_id_by_title_exact("Shared"),
1863 TitleResolution::Unique("p2".to_string())
1864 );
1865 assert_eq!(
1866 index.resolve_page_id_by_title_exact("Renamed"),
1867 TitleResolution::Unique("p1".to_string())
1868 );
1869
1870 fs::remove_dir_all(&dir).unwrap();
1871 }
1872
1873 #[test]
1874 fn build_link_graph_collects_edges() {
1875 let (dir, index) = make_kb_on_disk(&[
1876 ("p1", "Alpha", &[], "see [[Beta]]"),
1877 ("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
1878 ("p3", "Gamma", &[], "no links here"),
1879 ]);
1880 let graph = index.build_link_graph();
1881 assert_eq!(graph.edges.len(), 3);
1882 let pairs: Vec<(&str, &str)> = graph
1883 .edges
1884 .iter()
1885 .map(|e| (e.source.as_str(), e.target.as_str()))
1886 .collect();
1887 assert!(pairs.contains(&("p1", "p2")));
1888 assert!(pairs.contains(&("p2", "p1")));
1889 assert!(pairs.contains(&("p2", "p3")));
1890 fs::remove_dir_all(&dir).unwrap();
1891 }
1892
1893 #[test]
1894 fn build_link_graph_excludes_self_links() {
1895 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
1896 let graph = index.build_link_graph();
1897 assert!(graph.edges.is_empty());
1898 fs::remove_dir_all(&dir).unwrap();
1899 }
1900
1901 #[test]
1902 fn build_link_graph_deduplicates() {
1903 let (dir, index) = make_kb_on_disk(&[
1904 ("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
1905 ("p2", "Beta", &[], "nothing"),
1906 ]);
1907 let graph = index.build_link_graph();
1908 assert_eq!(graph.edges.len(), 1);
1909 fs::remove_dir_all(&dir).unwrap();
1910 }
1911
1912 #[test]
1913 fn link_graph_ego_filters_by_page() {
1914 let (dir, index) = make_kb_on_disk(&[
1915 ("p1", "Alpha", &[], "see [[Beta]]"),
1916 ("p2", "Beta", &[], "see [[Gamma]]"),
1917 ("p3", "Gamma", &[], "nothing"),
1918 ]);
1919 let graph = index.build_link_graph();
1920 let ego = graph.ego("p2");
1921 assert_eq!(ego.len(), 2);
1922 let pairs: Vec<(&str, &str)> = ego
1923 .iter()
1924 .map(|e| (e.source.as_str(), e.target.as_str()))
1925 .collect();
1926 assert!(pairs.contains(&("p1", "p2")));
1927 assert!(pairs.contains(&("p2", "p3")));
1928 fs::remove_dir_all(&dir).unwrap();
1929 }
1930
1931 #[test]
1932 fn link_graph_ego_unconnected_page() {
1933 let (dir, index) = make_kb_on_disk(&[
1934 ("p1", "Alpha", &[], "see [[Beta]]"),
1935 ("p2", "Beta", &[], "nothing"),
1936 ("p3", "Gamma", &[], "nothing"),
1937 ]);
1938 let graph = index.build_link_graph();
1939 assert!(graph.ego("p3").is_empty());
1940 fs::remove_dir_all(&dir).unwrap();
1941 }
1942
1943 #[test]
1948 fn analyze_links_detects_broken_links() {
1949 let (dir, index) = make_kb_on_disk(&[
1950 ("p1", "Page One", &[], "see [link](page:nonexistent) here"),
1951 ("p2", "Page Two", &[], "hello"),
1952 ]);
1953 let result = index.analyze_links();
1954 assert_eq!(result.broken_links.len(), 1);
1955 assert_eq!(result.broken_links[0].source_id, "p1");
1956 assert_eq!(result.broken_links[0].target, "page:nonexistent");
1957 assert!(result.load_errors.is_empty());
1958 fs::remove_dir_all(&dir).unwrap();
1959 }
1960
1961 #[test]
1962 fn analyze_links_tracks_linked_pages() {
1963 let (dir, index) = make_kb_on_disk(&[
1964 ("p1", "Page One", &[], "see [link](page:p2) for more"),
1965 ("p2", "Page Two", &[], "target page"),
1966 ]);
1967 let result = index.analyze_links();
1968 assert!(result.linked_pages.contains("p2"));
1969 assert!(!result.linked_pages.contains("p1"));
1970 assert!(result.broken_links.is_empty());
1971 fs::remove_dir_all(&dir).unwrap();
1972 }
1973
1974 #[test]
1975 fn analyze_links_empty_kb() {
1976 let (dir, index) = make_kb_on_disk(&[]);
1977 let result = index.analyze_links();
1978 assert!(result.broken_links.is_empty());
1979 assert!(result.linked_pages.is_empty());
1980 assert!(result.load_errors.is_empty());
1981 fs::remove_dir_all(&dir).unwrap();
1982 }
1983
1984 #[test]
1985 fn analyze_links_captures_load_errors() {
1986 let (dir, index) = make_kb_on_disk(&[("p1", "Page One", &[], "hello")]);
1988 let page_path = dir.join("p1.lepiter");
1989 fs::write(&page_path, b"NOT VALID JSON").unwrap();
1990 let result = index.analyze_links();
1991 assert_eq!(result.load_errors.len(), 1);
1992 assert_eq!(result.load_errors[0].page_id, "p1");
1993 assert_eq!(result.load_errors[0].title, "Page One");
1994 fs::remove_dir_all(&dir).unwrap();
1995 }
1996
1997 #[test]
2002 fn orphan_ids_excludes_linked_pages() {
2003 let (dir, index) = make_kb_on_disk(&[
2004 ("p1", "Page One", &[], "see [link](page:p2) for more"),
2005 ("p2", "Page Two", &[], "target page"),
2006 ]);
2007 let result = index.analyze_links();
2008 let orphans = index.orphan_ids(&result.linked_pages, None);
2009 assert_eq!(orphans, vec!["p1"]);
2011 fs::remove_dir_all(&dir).unwrap();
2012 }
2013
2014 #[test]
2015 fn orphan_ids_excludes_toc_page() {
2016 let (dir, index) = make_kb_on_disk(&[
2017 ("toc", "Table of Contents", &[], "hello"),
2018 ("p1", "Page One", &[], "world"),
2019 ]);
2020 let result = index.analyze_links();
2021 let orphans = index.orphan_ids(&result.linked_pages, Some("toc"));
2022 assert_eq!(orphans, vec!["p1"]);
2024 fs::remove_dir_all(&dir).unwrap();
2025 }
2026
2027 #[test]
2032 fn find_duplicate_titles_none_when_unique() {
2033 let (dir, index) =
2034 make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "Beta", &[], "body")]);
2035 assert!(index.find_duplicate_titles().is_empty());
2036 fs::remove_dir_all(&dir).unwrap();
2037 }
2038
2039 #[test]
2040 fn find_duplicate_titles_detects_exact_match() {
2041 let (dir, index) =
2042 make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "Alpha", &[], "body")]);
2043 let dupes = index.find_duplicate_titles();
2044 assert_eq!(dupes.len(), 1);
2045 assert_eq!(dupes[0].title, "Alpha");
2046 assert_eq!(dupes[0].page_ids.len(), 2);
2047 assert!(dupes[0].page_ids.contains(&"p1".to_string()));
2048 assert!(dupes[0].page_ids.contains(&"p2".to_string()));
2049 fs::remove_dir_all(&dir).unwrap();
2050 }
2051
2052 #[test]
2053 fn find_duplicate_titles_case_insensitive() {
2054 let (dir, index) =
2055 make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "ALPHA", &[], "body")]);
2056 let dupes = index.find_duplicate_titles();
2057 assert_eq!(dupes.len(), 1);
2058 assert_eq!(dupes[0].page_ids.len(), 2);
2059 fs::remove_dir_all(&dir).unwrap();
2060 }
2061
2062 #[test]
2063 fn find_duplicate_titles_multiple_groups() {
2064 let (dir, index) = make_kb_on_disk(&[
2065 ("p1", "Alpha", &[], "body"),
2066 ("p2", "Alpha", &[], "body"),
2067 ("p3", "Beta", &[], "body"),
2068 ("p4", "Beta", &[], "body"),
2069 ("p5", "Gamma", &[], "body"),
2070 ]);
2071 let dupes = index.find_duplicate_titles();
2072 assert_eq!(dupes.len(), 2);
2073 assert_eq!(dupes[0].title, "Alpha");
2075 assert_eq!(dupes[1].title, "Beta");
2076 fs::remove_dir_all(&dir).unwrap();
2077 }
2078
2079 #[test]
2080 fn find_duplicate_titles_empty_kb() {
2081 let (dir, index) = make_kb_on_disk(&[]);
2082 assert!(index.find_duplicate_titles().is_empty());
2083 fs::remove_dir_all(&dir).unwrap();
2084 }
2085
2086 #[test]
2091 fn find_missing_attachments_none_when_no_refs() {
2092 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "no attachment refs")]);
2093 assert!(index.find_missing_attachments().is_empty());
2094 fs::remove_dir_all(&dir).unwrap();
2095 }
2096
2097 #[test]
2098 fn find_missing_attachments_detects_missing_file() {
2099 let (dir, index) =
2100 make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/missing.png)")]);
2101 let missing = index.find_missing_attachments();
2102 assert_eq!(missing.len(), 1);
2103 assert_eq!(missing[0].source_id, "p1");
2104 assert_eq!(missing[0].target, "attachments/missing.png");
2105 fs::remove_dir_all(&dir).unwrap();
2106 }
2107
2108 #[test]
2109 fn find_missing_attachments_ignores_existing_file() {
2110 let (dir, index) =
2111 make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/present.png)")]);
2112 let att_dir = dir.join("attachments");
2113 fs::create_dir_all(&att_dir).unwrap();
2114 fs::write(att_dir.join("present.png"), b"data").unwrap();
2115 let missing = index.find_missing_attachments();
2116 assert!(missing.is_empty());
2117 fs::remove_dir_all(&dir).unwrap();
2118 }
2119
2120 #[test]
2121 fn find_missing_attachments_mixed() {
2122 let (dir, index) = make_kb_on_disk(&[(
2123 "p1",
2124 "Alpha",
2125 &[],
2126 "see [a](attachments/ok.png) and [b](attachments/gone.png)",
2127 )]);
2128 let att_dir = dir.join("attachments");
2129 fs::create_dir_all(&att_dir).unwrap();
2130 fs::write(att_dir.join("ok.png"), b"data").unwrap();
2131 let missing = index.find_missing_attachments();
2132 assert_eq!(missing.len(), 1);
2133 assert_eq!(missing[0].target, "attachments/gone.png");
2134 fs::remove_dir_all(&dir).unwrap();
2135 }
2136
2137 #[test]
2138 fn find_missing_attachments_reports_every_referencing_page() {
2139 let (dir, index) = make_kb_on_disk(&[
2140 ("p1", "Alpha", &[], "see [img](attachments/missing.png)"),
2141 ("p2", "Beta", &[], "see [img](attachments/missing.png)"),
2142 ]);
2143 let missing = index.find_missing_attachments();
2144 assert_eq!(missing.len(), 2);
2145 let sources: Vec<&str> = missing.iter().map(|m| m.source_id.as_str()).collect();
2146 assert_eq!(sources, vec!["p1", "p2"]);
2147 fs::remove_dir_all(&dir).unwrap();
2148 }
2149
2150 #[test]
2151 fn find_missing_attachments_deduplicates_within_a_page() {
2152 let (dir, index) = make_kb_on_disk(&[(
2153 "p1",
2154 "Alpha",
2155 &[],
2156 "see [a](attachments/missing.png) and [b](attachments/missing.png)",
2157 )]);
2158 let missing = index.find_missing_attachments();
2159 assert_eq!(missing.len(), 1);
2160 assert_eq!(missing[0].source_id, "p1");
2161 fs::remove_dir_all(&dir).unwrap();
2162 }
2163
2164 #[test]
2165 fn find_missing_attachments_ignores_non_attachment_links() {
2166 let (dir, index) = make_kb_on_disk(&[(
2167 "p1",
2168 "Alpha",
2169 &[],
2170 "see [link](page:p2) and [ext](https://example.com)",
2171 )]);
2172 assert!(index.find_missing_attachments().is_empty());
2173 fs::remove_dir_all(&dir).unwrap();
2174 }
2175
2176 #[test]
2181 fn analyze_all_combines_broken_links_and_missing_attachments() {
2182 let (dir, index) = make_kb_on_disk(&[(
2183 "p1",
2184 "Alpha",
2185 &[],
2186 "see [link](page:nonexistent) and [img](attachments/gone.png)",
2187 )]);
2188 let result = index.analyze_all();
2189 assert_eq!(result.broken_links.len(), 1);
2190 assert_eq!(result.broken_links[0].target, "page:nonexistent");
2191 assert_eq!(result.missing_attachments.len(), 1);
2192 assert_eq!(result.missing_attachments[0].target, "attachments/gone.png");
2193 fs::remove_dir_all(&dir).unwrap();
2194 }
2195
2196 #[test]
2197 fn analyze_all_tracks_linked_pages() {
2198 let (dir, index) = make_kb_on_disk(&[
2199 ("p1", "Alpha", &[], "see [link](page:p2) here"),
2200 ("p2", "Beta", &[], "nothing"),
2201 ]);
2202 let result = index.analyze_all();
2203 assert!(result.linked_pages.contains("p2"));
2204 assert!(!result.linked_pages.contains("p1"));
2205 assert!(result.broken_links.is_empty());
2206 assert!(result.missing_attachments.is_empty());
2207 fs::remove_dir_all(&dir).unwrap();
2208 }
2209
2210 #[test]
2211 fn analyze_all_empty_kb() {
2212 let (dir, index) = make_kb_on_disk(&[]);
2213 let result = index.analyze_all();
2214 assert!(result.broken_links.is_empty());
2215 assert!(result.linked_pages.is_empty());
2216 assert!(result.load_errors.is_empty());
2217 assert!(result.missing_attachments.is_empty());
2218 fs::remove_dir_all(&dir).unwrap();
2219 }
2220
2221 #[test]
2222 fn analyze_all_captures_load_errors() {
2223 let (dir, index) = make_kb_on_disk(&[("p1", "Page One", &[], "hello")]);
2224 fs::write(dir.join("p1.lepiter"), b"NOT VALID JSON").unwrap();
2225 let result = index.analyze_all();
2226 assert_eq!(result.load_errors.len(), 1);
2227 assert_eq!(result.load_errors[0].page_id, "p1");
2228 fs::remove_dir_all(&dir).unwrap();
2229 }
2230
2231 #[test]
2232 fn analyze_all_skips_existing_attachments() {
2233 let (dir, index) =
2234 make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/present.png)")]);
2235 let att_dir = dir.join("attachments");
2236 fs::create_dir_all(&att_dir).unwrap();
2237 fs::write(att_dir.join("present.png"), b"data").unwrap();
2238 let result = index.analyze_all();
2239 assert!(result.missing_attachments.is_empty());
2240 fs::remove_dir_all(&dir).unwrap();
2241 }
2242
2243 #[test]
2244 fn analyze_all_reports_missing_attachment_once_per_page() {
2245 let (dir, index) = make_kb_on_disk(&[
2246 ("p1", "Alpha", &[], "see [img](attachments/missing.png)"),
2247 (
2248 "p2",
2249 "Beta",
2250 &[],
2251 "see [a](attachments/missing.png) and [b](attachments/missing.png)",
2252 ),
2253 ("p3", "Gamma", &[], "see [img](attachments/missing.png)"),
2254 ]);
2255 let result = index.analyze_all();
2256 let reported: Vec<(&str, &str)> = result
2257 .missing_attachments
2258 .iter()
2259 .map(|m| (m.source_id.as_str(), m.source_title.as_str()))
2260 .collect();
2261 assert_eq!(
2262 reported,
2263 vec![("p1", "Alpha"), ("p2", "Beta"), ("p3", "Gamma")]
2264 );
2265 fs::remove_dir_all(&dir).unwrap();
2266 }
2267
2268 #[test]
2269 fn analyze_all_mixed_links_and_attachments() {
2270 let (dir, index) = make_kb_on_disk(&[
2271 (
2272 "p1",
2273 "Alpha",
2274 &[],
2275 "see [link](page:p2) and [img](attachments/gone.png)",
2276 ),
2277 (
2278 "p2",
2279 "Beta",
2280 &[],
2281 "see [link](page:nonexistent) and [ext](https://example.com)",
2282 ),
2283 ]);
2284 let result = index.analyze_all();
2285 assert!(result.linked_pages.contains("p2"));
2287 assert_eq!(result.broken_links.len(), 1);
2288 assert_eq!(result.broken_links[0].source_id, "p2");
2289 assert_eq!(result.missing_attachments.len(), 1);
2291 assert_eq!(result.missing_attachments[0].source_id, "p1");
2292 assert!(result.load_errors.is_empty());
2293 fs::remove_dir_all(&dir).unwrap();
2294 }
2295
2296 #[test]
2301 fn scan_all_pages_collects_edges_and_analysis() {
2302 let (dir, index) = make_kb_on_disk(&[
2303 ("p1", "Alpha", &[], "see [[Beta]]"),
2304 ("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
2305 ("p3", "Gamma", &[], "no links here"),
2306 ]);
2307 let result = index.scan_all_pages();
2308 assert_eq!(result.edges.len(), 3);
2310 let pairs: Vec<(&str, &str)> = result
2311 .edges
2312 .iter()
2313 .map(|e| (e.source.as_str(), e.target.as_str()))
2314 .collect();
2315 assert!(pairs.contains(&("p1", "p2")));
2316 assert!(pairs.contains(&("p2", "p1")));
2317 assert!(pairs.contains(&("p2", "p3")));
2318 assert!(result.linked_pages.contains("p1"));
2320 assert!(result.linked_pages.contains("p2"));
2321 assert!(result.linked_pages.contains("p3"));
2322 assert!(result.broken_links.is_empty());
2323 fs::remove_dir_all(&dir).unwrap();
2324 }
2325
2326 #[test]
2327 fn scan_all_pages_edges_exclude_self_links() {
2328 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
2329 let result = index.scan_all_pages();
2330 assert!(result.edges.is_empty());
2331 fs::remove_dir_all(&dir).unwrap();
2332 }
2333
2334 #[test]
2335 fn scan_all_pages_edges_deduplicated() {
2336 let (dir, index) = make_kb_on_disk(&[
2337 ("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
2338 ("p2", "Beta", &[], "nothing"),
2339 ]);
2340 let result = index.scan_all_pages();
2341 assert_eq!(result.edges.len(), 1);
2342 fs::remove_dir_all(&dir).unwrap();
2343 }
2344
2345 #[test]
2346 fn scan_all_pages_mixed_edges_and_broken() {
2347 let (dir, index) = make_kb_on_disk(&[
2348 (
2349 "p1",
2350 "Alpha",
2351 &[],
2352 "see [link](page:p2) and [bad](page:nonexistent)",
2353 ),
2354 ("p2", "Beta", &[], "nothing"),
2355 ]);
2356 let result = index.scan_all_pages();
2357 assert_eq!(result.edges.len(), 1);
2358 assert_eq!(result.edges[0].source, "p1");
2359 assert_eq!(result.edges[0].target, "p2");
2360 assert_eq!(result.broken_links.len(), 1);
2361 assert_eq!(result.broken_links[0].target, "page:nonexistent");
2362 fs::remove_dir_all(&dir).unwrap();
2363 }
2364
2365 #[test]
2366 fn scan_all_pages_wikilink_requires_exact_title_not_substring() {
2367 let (dir, index) = make_kb_on_disk(&[
2368 ("p1", "Guide", &[], "see [[Rust]]"),
2369 ("p2", "Rust Programming", &[], "nothing"),
2370 ]);
2371 let result = index.scan_all_pages();
2372 assert!(
2374 result.edges.is_empty(),
2375 "substring title match fabricated a graph edge: {:?}",
2376 result.edges
2377 );
2378 assert_eq!(result.broken_links.len(), 1);
2379 assert_eq!(result.broken_links[0].source_id, "p1");
2380 assert_eq!(result.broken_links[0].target, "Rust");
2381 fs::remove_dir_all(&dir).unwrap();
2382 }
2383
2384 #[test]
2385 fn scan_all_pages_empty_kb() {
2386 let (dir, index) = make_kb_on_disk(&[]);
2387 let result = index.scan_all_pages();
2388 assert!(result.edges.is_empty());
2389 assert!(result.broken_links.is_empty());
2390 assert!(result.linked_pages.is_empty());
2391 assert!(result.load_errors.is_empty());
2392 assert!(result.missing_attachments.is_empty());
2393 fs::remove_dir_all(&dir).unwrap();
2394 }
2395
2396 #[test]
2397 fn scan_all_pages_edges_match_build_link_graph() {
2398 let (dir, index) = make_kb_on_disk(&[
2399 ("p1", "Alpha", &[], "see [[Beta]]"),
2400 ("p2", "Beta", &[], "see [[Gamma]]"),
2401 ("p3", "Gamma", &[], "nothing"),
2402 ]);
2403 let scan = index.scan_all_pages();
2404 let graph = index.build_link_graph();
2405 assert_eq!(scan.edges.len(), graph.edges.len());
2406 for (a, b) in scan.edges.iter().zip(graph.edges.iter()) {
2407 assert_eq!(a.source, b.source);
2408 assert_eq!(a.target, b.target);
2409 }
2410 fs::remove_dir_all(&dir).unwrap();
2411 }
2412}