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>,
30 pub index_issues: Vec<ParseIssue>,
32 backlinks: HashMap<PageId, Vec<PageId>>,
34 forward_links: HashMap<PageId, HashSet<PageId>>,
38}
39
40pub struct KnowledgeBase;
42
43impl KnowledgeBase {
44 pub fn open(path: impl AsRef<Path>) -> Result<KnowledgeBaseIndex> {
49 let root = path.as_ref().to_path_buf();
50 let mut pages = HashMap::new();
51 let mut issues = Vec::new();
52
53 for entry in WalkDir::new(&root)
54 .min_depth(1)
55 .max_depth(1)
56 .into_iter()
57 .filter_map(|e| e.ok())
58 {
59 let file_type = entry.file_type();
60 let file_path = entry.path();
61 if !file_type.is_file()
62 || file_path.extension().and_then(|e| e.to_str()) != Some("lepiter")
63 {
64 continue;
65 }
66
67 match parse_page_meta(file_path) {
68 Ok(mut meta) => {
69 if meta.id.is_empty()
70 && let Some(stem) = file_path.file_stem().and_then(|s| s.to_str())
71 {
72 meta.id = stem.to_string();
73 meta.id_lower = meta.id.to_lowercase();
74 }
75 if meta.title.is_empty() {
76 meta.title = meta.id.clone();
77 meta.title_lower = meta.title.to_lowercase();
78 }
79 pages.insert(meta.id.clone(), meta);
80 }
81 Err(err) => issues.push(ParseIssue {
82 path: file_path.to_path_buf(),
83 message: format!("{err:#}"),
84 }),
85 }
86 }
87
88 let sorted_ids = compute_sorted_ids(&pages);
89
90 Ok(KnowledgeBaseIndex {
91 root,
92 pages,
93 sorted_ids,
94 index_issues: issues,
95 backlinks: HashMap::new(),
96 forward_links: HashMap::new(),
97 })
98 }
99}
100
101impl KnowledgeBaseIndex {
102 pub fn register_page(&mut self, meta: PageMeta) {
108 let id = meta.id.clone();
109 if self.pages.contains_key(&id)
110 && let Some(pos) = self.sorted_ids.iter().position(|i| i == &id)
111 {
112 self.sorted_ids.remove(pos);
113 }
114 self.pages.insert(id.clone(), meta);
115 insert_sorted_by_title(&mut self.sorted_ids, &self.pages, id);
116 }
117
118 pub fn load_page(&self, id: &str) -> Result<Page> {
122 let meta = self
123 .pages
124 .get(id)
125 .with_context(|| format!("page id not found: {id}"))?;
126
127 let file = File::open(&meta.path)
128 .with_context(|| format!("failed to open page file {}", meta.path.display()))?;
129 let reader = BufReader::new(file);
130 let raw: Value =
131 serde_json::from_reader(reader).with_context(|| "failed to decode page JSON")?;
132
133 let mut content = Vec::new();
134 if let Some(items) = raw
135 .get("children")
136 .and_then(|v| v.get("items"))
137 .and_then(Value::as_array)
138 {
139 for item in items {
140 parse_item_recursive(item, &mut content);
141 }
142 }
143
144 Ok(Page {
145 id: meta.id.clone(),
146 title: meta.title.clone(),
147 updated_at: meta.updated_at,
148 tags: meta.tags.clone(),
149 content,
150 })
151 }
152
153 pub fn sorted_pages(&self) -> Vec<&PageMeta> {
155 self.sorted_ids
156 .iter()
157 .filter_map(|id| self.pages.get(id))
158 .collect()
159 }
160
161 pub fn filter_page_ids(&self, query: &str) -> Vec<PageId> {
163 let needle = query.trim().to_lowercase();
164 let mut metas = self.sorted_pages();
165 if !needle.is_empty() {
166 metas.retain(|m| page_meta_match_kind(m, &needle).is_some());
167 }
168 metas.into_iter().map(|m| m.id.clone()).collect()
169 }
170
171 pub fn filter_page_ids_scored(&self, query: &str) -> Vec<(PageId, SearchMatchKind)> {
173 let needle = query.trim().to_lowercase();
174 if needle.is_empty() {
175 return Vec::new();
176 }
177 let metas = self.sorted_pages();
178 metas
179 .into_iter()
180 .filter_map(|m| page_meta_match_kind(m, &needle).map(|kind| (m.id.clone(), kind)))
181 .collect()
182 }
183
184 pub fn search_hits(&self, query: &str, include_content: bool) -> Vec<SearchHit> {
188 let needle = query.trim().to_lowercase();
189 if needle.is_empty() {
190 return Vec::new();
191 }
192
193 let mut by_id: HashMap<PageId, SearchMatchKind> = HashMap::new();
194 let metas = self.sorted_pages();
195
196 for meta in &metas {
197 if let Some(kind) = page_meta_match_kind(meta, &needle) {
198 by_id.insert(meta.id.clone(), kind);
199 }
200 }
201
202 if include_content {
203 for meta in &metas {
204 if by_id.contains_key(&meta.id) {
205 continue;
206 }
207 let Ok(page) = self.load_page(&meta.id) else {
208 continue;
209 };
210 if page_content_contains(&page, &needle) {
211 by_id.insert(meta.id.clone(), SearchMatchKind::Content);
212 }
213 }
214 }
215
216 let mut hits: Vec<SearchHit> = metas
217 .iter()
218 .filter_map(|meta| {
219 by_id.get(&meta.id).map(|kind| SearchHit {
220 id: meta.id.clone(),
221 kind: *kind,
222 })
223 })
224 .collect();
225 hits.sort_by_cached_key(|h| {
226 let title = self
227 .pages
228 .get(&h.id)
229 .map(|m| m.title_lower.clone())
230 .unwrap_or_default();
231 (std::cmp::Reverse(h.kind.score()), title)
232 });
233 hits
234 }
235
236 pub fn resolve_page_id_by_title(&self, title: &str) -> TitleResolution {
238 let needle = title.trim().to_lowercase();
239 if needle.is_empty() {
240 return TitleResolution::NotFound;
241 }
242
243 let sorted = self.sorted_pages();
244
245 let exact = sorted
246 .iter()
247 .filter(|m| m.title_lower == needle)
248 .map(|m| m.id.clone())
249 .collect::<Vec<_>>();
250 match exact.len() {
251 1 => return TitleResolution::Unique(exact[0].clone()),
252 n if n > 1 => return TitleResolution::Ambiguous(exact),
253 _ => {}
254 }
255
256 let partial = sorted
257 .iter()
258 .filter(|m| m.title_lower.contains(&needle))
259 .map(|m| m.id.clone())
260 .collect::<Vec<_>>();
261 match partial.len() {
262 1 => TitleResolution::Unique(partial[0].clone()),
263 0 => TitleResolution::NotFound,
264 _ => TitleResolution::Ambiguous(partial),
265 }
266 }
267
268 pub fn classify_link_target(&self, raw: &str) -> LinkTargetKind {
270 let target = raw.trim();
271 if target.is_empty() {
272 return LinkTargetKind::Unknown(raw.to_string());
273 }
274
275 if self.pages.contains_key(target) {
276 return LinkTargetKind::InternalPage(target.to_string());
277 }
278
279 if let Some(rest) = target.strip_prefix("page:") {
280 let id = rest.trim();
281 if self.pages.contains_key(id) {
282 return LinkTargetKind::InternalPage(id.to_string());
283 }
284 if let TitleResolution::Unique(resolved) = self.resolve_page_id_by_title(id) {
285 return LinkTargetKind::InternalPage(resolved);
286 }
287 }
288 if let Some(rest) = target.strip_prefix("title:") {
289 return match self.resolve_page_id_by_title(rest.trim()) {
290 TitleResolution::Unique(id) => LinkTargetKind::InternalPage(id),
291 _ => LinkTargetKind::Unknown(target.to_string()),
292 };
293 }
294
295 if let Some(uuid) = extract_uuid_like(target)
296 && self.pages.contains_key(uuid)
297 {
298 return LinkTargetKind::InternalPage(uuid.to_string());
299 }
300
301 if is_external_target(target) {
302 return LinkTargetKind::ExternalUrl(target.to_string());
303 }
304
305 if let Some(path) = self.attachment_resolver().resolve_path(target) {
306 return LinkTargetKind::AttachmentPath(path);
307 }
308
309 match self.resolve_page_id_by_title(target) {
310 TitleResolution::Unique(id) => LinkTargetKind::InternalPage(id),
311 _ => LinkTargetKind::Unknown(target.to_string()),
312 }
313 }
314
315 pub fn build_backlinks(&mut self) {
320 let mut back: HashMap<PageId, HashSet<PageId>> = HashMap::new();
321 let mut forward: HashMap<PageId, HashSet<PageId>> = HashMap::new();
322 for source_id in &self.sorted_ids {
323 let Ok(page) = self.load_page(source_id) else {
324 continue;
325 };
326 let mut source_targets = HashSet::new();
327 for target in extract_link_targets(&page.content) {
328 if let LinkTargetKind::InternalPage(target_id) = self.classify_link_target(&target)
329 && target_id != *source_id
330 && source_targets.insert(target_id.clone())
331 {
332 back.entry(target_id).or_default().insert(source_id.clone());
333 }
334 }
335 if !source_targets.is_empty() {
336 forward.insert(source_id.clone(), source_targets);
337 }
338 }
339 self.forward_links = forward;
340 self.backlinks = back
341 .into_iter()
342 .map(|(target, sources)| {
343 let mut sorted: Vec<PageId> = sources.into_iter().collect();
344 sorted.sort_by(|a, b| {
345 title_sort_key(&self.pages, a).cmp(title_sort_key(&self.pages, b))
346 });
347 (target, sorted)
348 })
349 .collect();
350 }
351
352 pub fn update_backlinks_for(&mut self, page_id: &str) {
358 if let Some(old_targets) = self.forward_links.remove(page_id) {
360 for target_id in &old_targets {
361 if let Some(sources) = self.backlinks.get_mut(target_id) {
362 sources.retain(|s| s != page_id);
363 if sources.is_empty() {
364 self.backlinks.remove(target_id);
365 }
366 }
367 }
368 }
369
370 let page = match self.load_page(page_id) {
372 Ok(p) => p,
373 Err(e) => {
374 log::warn!("update_backlinks_for: failed to load page {page_id}: {e:#}");
375 return;
376 }
377 };
378 let mut new_targets = HashSet::new();
379 for target in extract_link_targets(&page.content) {
380 if let LinkTargetKind::InternalPage(target_id) = self.classify_link_target(&target)
381 && target_id != page_id
382 && new_targets.insert(target_id.clone())
383 {
384 insert_sorted_by_title(
385 self.backlinks.entry(target_id).or_default(),
386 &self.pages,
387 page_id.to_string(),
388 );
389 }
390 }
391 if !new_targets.is_empty() {
392 self.forward_links.insert(page_id.to_string(), new_targets);
393 }
394 }
395
396 pub fn backlinks_for(&self, id: &str) -> &[PageId] {
398 self.backlinks
399 .get(id)
400 .map(Vec::as_slice)
401 .unwrap_or_default()
402 }
403
404 pub fn build_link_graph(&self) -> LinkGraph {
409 LinkGraph {
410 edges: self.scan_all_pages().edges,
411 }
412 }
413
414 pub fn root(&self) -> &Path {
416 &self.root
417 }
418
419 pub fn attachment_resolver(&self) -> AttachmentResolver {
421 AttachmentResolver::new(&self.root)
422 }
423 pub fn scan_all_pages(&self) -> LinkAnalysisResult {
426 let resolver = self.attachment_resolver();
427 let mut broken_links = Vec::new();
428 let mut linked_pages: HashSet<PageId> = HashSet::new();
429 let mut load_errors = Vec::new();
430 let mut missing_attachments = Vec::new();
431 let mut seen_attachments: HashSet<PathBuf> = HashSet::new();
432 let mut edges = Vec::new();
433 let mut seen_edges = HashSet::new();
434
435 for id in &self.sorted_ids {
436 let meta = match self.pages.get(id) {
437 Some(m) => m,
438 None => continue,
439 };
440 let page = match self.load_page(id) {
441 Ok(p) => p,
442 Err(e) => {
443 load_errors.push(PageLoadError {
444 page_id: id.clone(),
445 title: meta.title.clone(),
446 error: format!("{e:#}"),
447 });
448 continue;
449 }
450 };
451 seen_edges.clear();
452 for target in extract_link_targets(&page.content) {
453 match self.classify_link_target(&target) {
454 LinkTargetKind::InternalPage(target_id) if target_id != *id => {
455 linked_pages.insert(target_id.clone());
456 if seen_edges.insert(target_id.clone()) {
457 edges.push(LinkEdge {
458 source: id.clone(),
459 target: target_id,
460 });
461 }
462 }
463 LinkTargetKind::Unknown(_) => {
464 broken_links.push(BrokenLink {
465 source_title: meta.title.clone(),
466 source_id: id.clone(),
467 target: target.clone(),
468 });
469 }
470 _ => {}
471 }
472
473 if extract_attachment_relative(&target).is_some()
474 && let Ok(resolved) = resolver.resolve(&target)
475 && !resolved.exists
476 && seen_attachments.insert(resolved.path.clone())
477 {
478 missing_attachments.push(MissingAttachment {
479 source_title: meta.title.clone(),
480 source_id: id.clone(),
481 target,
482 resolved_path: resolved.path,
483 });
484 }
485 }
486 }
487
488 LinkAnalysisResult {
489 broken_links,
490 linked_pages,
491 load_errors,
492 missing_attachments,
493 edges,
494 }
495 }
496
497 pub fn analyze_all(&self) -> LinkAnalysisResult {
500 self.scan_all_pages()
501 }
502
503 pub fn analyze_links(&self) -> LinkAnalysisResult {
507 self.scan_all_pages()
508 }
509
510 pub fn orphan_ids(&self, linked_pages: &HashSet<PageId>, toc_page_id: &str) -> Vec<PageId> {
515 self.sorted_ids
516 .iter()
517 .filter(|id| !linked_pages.contains(*id) && id.as_str() != toc_page_id)
518 .cloned()
519 .collect()
520 }
521
522 pub fn find_duplicate_titles(&self) -> Vec<DuplicateTitle> {
524 let mut by_title: HashMap<&str, Vec<&PageMeta>> = HashMap::new();
525 for meta in self.pages.values() {
526 by_title.entry(&meta.title_lower).or_default().push(meta);
527 }
528 let mut dupes: Vec<DuplicateTitle> = by_title
529 .into_iter()
530 .filter(|(_, metas)| metas.len() > 1)
531 .map(|(_, metas)| {
532 let title = metas[0].title.clone();
533 let mut page_ids: Vec<PageId> = metas.iter().map(|m| m.id.clone()).collect();
534 page_ids.sort();
535 DuplicateTitle { title, page_ids }
536 })
537 .collect();
538 dupes.sort_by_key(|a| a.title.to_lowercase());
539 dupes
540 }
541
542 pub fn find_missing_attachments(&self) -> Vec<MissingAttachment> {
544 self.scan_all_pages().missing_attachments
545 }
546}
547
548#[derive(Debug, Clone)]
550pub struct BrokenLink {
551 pub source_title: String,
553 pub source_id: PageId,
555 pub target: String,
557}
558
559#[derive(Debug, Clone)]
561pub struct PageLoadError {
562 pub page_id: PageId,
564 pub title: String,
566 pub error: String,
568}
569
570#[derive(Debug, Clone)]
572pub struct LinkAnalysisResult {
573 pub broken_links: Vec<BrokenLink>,
575 pub linked_pages: HashSet<PageId>,
577 pub load_errors: Vec<PageLoadError>,
579 pub missing_attachments: Vec<MissingAttachment>,
581 pub edges: Vec<LinkEdge>,
583}
584
585#[derive(Debug, Clone, Serialize)]
587pub struct LinkEdge {
588 pub source: PageId,
590 pub target: PageId,
592}
593
594#[derive(Debug, Clone)]
596pub struct LinkGraph {
597 pub edges: Vec<LinkEdge>,
599}
600
601#[derive(Debug, Clone)]
603pub struct DuplicateTitle {
604 pub title: String,
606 pub page_ids: Vec<PageId>,
608}
609
610#[derive(Debug, Clone)]
612pub struct MissingAttachment {
613 pub source_title: String,
615 pub source_id: PageId,
617 pub target: String,
619 pub resolved_path: PathBuf,
621}
622
623impl LinkGraph {
624 pub fn ego(&self, page_id: &str) -> Vec<&LinkEdge> {
626 self.edges
627 .iter()
628 .filter(|e| e.source == page_id || e.target == page_id)
629 .collect()
630 }
631}
632
633fn title_sort_key<'a>(pages: &'a HashMap<PageId, PageMeta>, id: &str) -> &'a str {
638 pages.get(id).map(|m| m.title_lower.as_str()).unwrap_or("")
639}
640
641fn insert_sorted_by_title(
646 sources: &mut Vec<PageId>,
647 pages: &HashMap<PageId, PageMeta>,
648 source_id: String,
649) {
650 let key = title_sort_key(pages, &source_id);
651 let pos = sources.partition_point(|id| title_sort_key(pages, id) < key);
652 sources.insert(pos, source_id);
653}
654
655fn compute_sorted_ids(pages: &HashMap<PageId, PageMeta>) -> Vec<PageId> {
656 let mut entries: Vec<_> = pages.values().collect();
657 entries.sort_by(|a, b| a.title_lower.cmp(&b.title_lower));
658 entries.into_iter().map(|m| m.id.clone()).collect()
659}
660
661fn page_meta_match_kind(meta: &PageMeta, needle: &str) -> Option<SearchMatchKind> {
662 if meta.title_lower.contains(needle) || meta.id_lower.contains(needle) {
663 Some(SearchMatchKind::Title)
664 } else if meta.tags_lower.iter().any(|t| t.contains(needle)) {
665 Some(SearchMatchKind::Tag)
666 } else {
667 None
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use serde_json::json;
675 use std::fs;
676 use std::time::{SystemTime, UNIX_EPOCH};
677
678 fn temp_dir_path(name: &str) -> PathBuf {
679 let ts = SystemTime::now()
680 .duration_since(UNIX_EPOCH)
681 .expect("time")
682 .as_nanos();
683 std::env::temp_dir().join(format!("lepiter-core-{name}-{ts}"))
684 }
685
686 fn make_kb_on_disk(pages: &[(&str, &str, &[&str], &str)]) -> (PathBuf, KnowledgeBaseIndex) {
687 let dir = temp_dir_path("kb");
688 fs::create_dir_all(&dir).unwrap();
689 for (id, title, tags, body_text) in pages {
690 let tags_json: Vec<Value> = tags.iter().map(|t| json!(t)).collect();
691 let content = json!({
692 "uid": {"uuid": id},
693 "pageType": {"title": title},
694 "tags": tags_json,
695 "children": {"items": [
696 {"__type": "textSnippet", "string": body_text}
697 ]}
698 });
699 let file_path = dir.join(format!("{id}.lepiter"));
700 fs::write(&file_path, serde_json::to_vec(&content).unwrap()).unwrap();
701 }
702 let index = KnowledgeBase::open(&dir).unwrap();
703 (dir, index)
704 }
705
706 #[test]
707 fn filter_page_ids_matches_title_id_and_tags() {
708 let mut pages = HashMap::new();
709 pages.insert(
710 "id-1".to_string(),
711 PageMeta {
712 id: "id-1".to_string(),
713 id_lower: "id-1".to_string(),
714 title: "Alpha".to_string(),
715 title_lower: "alpha".to_string(),
716 path: PathBuf::from("/tmp/a"),
717 updated_at: None,
718 tags: vec!["rust".to_string()],
719 tags_lower: vec!["rust".to_string()],
720 },
721 );
722 pages.insert(
723 "id-2".to_string(),
724 PageMeta {
725 id: "id-2".to_string(),
726 id_lower: "id-2".to_string(),
727 title: "Beta".to_string(),
728 title_lower: "beta".to_string(),
729 path: PathBuf::from("/tmp/b"),
730 updated_at: None,
731 tags: vec!["pharo".to_string()],
732 tags_lower: vec!["pharo".to_string()],
733 },
734 );
735 let sorted_ids = compute_sorted_ids(&pages);
736 let index = KnowledgeBaseIndex {
737 root: PathBuf::from("/tmp"),
738 pages,
739 sorted_ids,
740 index_issues: Vec::new(),
741 backlinks: HashMap::new(),
742 forward_links: HashMap::new(),
743 };
744
745 assert_eq!(index.filter_page_ids("alpha"), vec!["id-1".to_string()]);
746 assert_eq!(index.filter_page_ids("id-2"), vec!["id-2".to_string()]);
747 assert_eq!(index.filter_page_ids("pharo"), vec!["id-2".to_string()]);
748 assert_eq!(
749 index.filter_page_ids(""),
750 vec!["id-1".to_string(), "id-2".to_string()]
751 );
752 }
753
754 #[test]
755 fn resolve_page_id_by_title_handles_unique_ambiguous_and_missing() {
756 let mut pages = HashMap::new();
757 pages.insert(
758 "id-1".to_string(),
759 PageMeta {
760 id: "id-1".to_string(),
761 id_lower: "id-1".to_string(),
762 title: "Alpha".to_string(),
763 title_lower: "alpha".to_string(),
764 path: PathBuf::from("/tmp/a"),
765 updated_at: None,
766 tags: Vec::new(),
767 tags_lower: Vec::new(),
768 },
769 );
770 pages.insert(
771 "id-2".to_string(),
772 PageMeta {
773 id: "id-2".to_string(),
774 id_lower: "id-2".to_string(),
775 title: "Alphabet".to_string(),
776 title_lower: "alphabet".to_string(),
777 path: PathBuf::from("/tmp/b"),
778 updated_at: None,
779 tags: Vec::new(),
780 tags_lower: Vec::new(),
781 },
782 );
783 let sorted_ids = compute_sorted_ids(&pages);
784 let index = KnowledgeBaseIndex {
785 root: PathBuf::from("/tmp"),
786 pages,
787 sorted_ids,
788 index_issues: Vec::new(),
789 backlinks: HashMap::new(),
790 forward_links: HashMap::new(),
791 };
792
793 assert_eq!(
794 index.resolve_page_id_by_title("Alpha"),
795 TitleResolution::Unique("id-1".to_string())
796 );
797 assert!(matches!(
798 index.resolve_page_id_by_title("alp"),
799 TitleResolution::Ambiguous(_)
800 ));
801 assert_eq!(
802 index.resolve_page_id_by_title("zzz"),
803 TitleResolution::NotFound
804 );
805 }
806
807 #[test]
808 fn classify_link_target_covers_internal_attachment_external_unknown() {
809 let mut pages = HashMap::new();
810 pages.insert(
811 "8a505fa0-2222-3333-4444-555555555555".to_string(),
812 PageMeta {
813 id: "8a505fa0-2222-3333-4444-555555555555".to_string(),
814 id_lower: "8a505fa0-2222-3333-4444-555555555555".to_string(),
815 title: "Alpha".to_string(),
816 title_lower: "alpha".to_string(),
817 path: PathBuf::from("/tmp/a"),
818 updated_at: None,
819 tags: Vec::new(),
820 tags_lower: Vec::new(),
821 },
822 );
823 let sorted_ids = compute_sorted_ids(&pages);
824 let index = KnowledgeBaseIndex {
825 root: PathBuf::from("/kb"),
826 pages,
827 sorted_ids,
828 index_issues: Vec::new(),
829 backlinks: HashMap::new(),
830 forward_links: HashMap::new(),
831 };
832
833 assert!(matches!(
834 index.classify_link_target("8a505fa0-2222-3333-4444-555555555555"),
835 LinkTargetKind::InternalPage(_)
836 ));
837 assert!(matches!(
838 index.classify_link_target("title:alpha"),
839 LinkTargetKind::InternalPage(_)
840 ));
841 assert!(matches!(
842 index.classify_link_target("go to 8a505fa0-2222-3333-4444-555555555555 now"),
843 LinkTargetKind::InternalPage(_)
844 ));
845 assert!(matches!(
846 index.classify_link_target("attachments/image.png"),
847 LinkTargetKind::AttachmentPath(_)
848 ));
849 assert!(matches!(
850 index.classify_link_target("https://example.com"),
851 LinkTargetKind::ExternalUrl(_)
852 ));
853 assert!(matches!(
854 index.classify_link_target("not a thing"),
855 LinkTargetKind::Unknown(_)
856 ));
857 assert!(matches!(
859 index.classify_link_target("page:Alpha"),
860 LinkTargetKind::InternalPage(_)
861 ));
862 assert!(matches!(
864 index.classify_link_target("page:Nonexistent"),
865 LinkTargetKind::Unknown(_)
866 ));
867 }
868
869 #[test]
870 fn search_hits_empty_query_returns_nothing() {
871 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "hello world")]);
872 assert!(index.search_hits("", false).is_empty());
873 assert!(index.search_hits(" ", true).is_empty());
874 fs::remove_dir_all(&dir).unwrap();
875 }
876
877 #[test]
878 fn search_hits_matches_title_case_insensitively() {
879 let (dir, index) = make_kb_on_disk(&[
880 ("p1", "Alpha Guide", &[], "nothing special"),
881 ("p2", "Beta Notes", &[], "nothing special"),
882 ]);
883 let hits = index.search_hits("alpha", false);
884 assert_eq!(hits.len(), 1);
885 assert_eq!(hits[0].id, "p1");
886 assert_eq!(hits[0].kind, SearchMatchKind::Title);
887 fs::remove_dir_all(&dir).unwrap();
888 }
889
890 #[test]
891 fn search_hits_matches_tags() {
892 let (dir, index) = make_kb_on_disk(&[
893 ("p1", "Page One", &["rust", "cli"], "body"),
894 ("p2", "Page Two", &["pharo"], "body"),
895 ]);
896 let hits = index.search_hits("rust", false);
897 assert_eq!(hits.len(), 1);
898 assert_eq!(hits[0].id, "p1");
899 assert_eq!(hits[0].kind, SearchMatchKind::Tag);
900 fs::remove_dir_all(&dir).unwrap();
901 }
902
903 #[test]
904 fn search_hits_content_flag_searches_page_body() {
905 let (dir, index) = make_kb_on_disk(&[
906 ("p1", "Alpha", &[], "the quick brown fox"),
907 ("p2", "Beta", &[], "lazy dog sleeps"),
908 ]);
909
910 let no_content = index.search_hits("fox", false);
911 assert!(no_content.is_empty());
912
913 let with_content = index.search_hits("fox", true);
914 assert_eq!(with_content.len(), 1);
915 assert_eq!(with_content[0].id, "p1");
916 assert_eq!(with_content[0].kind, SearchMatchKind::Content);
917 fs::remove_dir_all(&dir).unwrap();
918 }
919
920 #[test]
921 fn search_hits_title_match_takes_priority_over_content() {
922 let (dir, index) = make_kb_on_disk(&[("p1", "Fox Guide", &[], "the fox jumps")]);
923 let hits = index.search_hits("fox", true);
924 assert_eq!(hits.len(), 1);
925 assert_eq!(hits[0].kind, SearchMatchKind::Title);
926 fs::remove_dir_all(&dir).unwrap();
927 }
928
929 #[test]
930 fn search_hits_same_score_sorted_alphabetically() {
931 let (dir, index) = make_kb_on_disk(&[
932 ("p1", "Zebra", &["common"], "body"),
933 ("p2", "Alpha", &["common"], "body"),
934 ("p3", "Middle", &["common"], "body"),
935 ]);
936 let hits = index.search_hits("common", false);
937 let ids: Vec<&str> = hits.iter().map(|h| h.id.as_str()).collect();
938 assert_eq!(ids, vec!["p2", "p3", "p1"]);
940 fs::remove_dir_all(&dir).unwrap();
941 }
942
943 #[test]
944 fn search_hits_title_ranked_above_tag() {
945 let (dir, index) = make_kb_on_disk(&[
946 ("p1", "Page One", &["rust"], "body"),
947 ("p2", "Rust Guide", &[], "body"),
948 ]);
949 let hits = index.search_hits("rust", false);
950 assert_eq!(hits.len(), 2);
951 assert_eq!(hits[0].id, "p2");
953 assert_eq!(hits[0].kind, SearchMatchKind::Title);
954 assert_eq!(hits[1].id, "p1");
956 assert_eq!(hits[1].kind, SearchMatchKind::Tag);
957 fs::remove_dir_all(&dir).unwrap();
958 }
959
960 #[test]
961 fn search_hits_title_ranked_above_content() {
962 let (dir, index) = make_kb_on_disk(&[
963 ("p1", "Alpha", &[], "the word rust appears here"),
964 ("p2", "Rust Guide", &[], "no match in body"),
965 ]);
966 let hits = index.search_hits("rust", true);
967 assert_eq!(hits.len(), 2);
968 assert_eq!(hits[0].id, "p2");
969 assert_eq!(hits[0].kind, SearchMatchKind::Title);
970 assert_eq!(hits[1].id, "p1");
971 assert_eq!(hits[1].kind, SearchMatchKind::Content);
972 fs::remove_dir_all(&dir).unwrap();
973 }
974
975 #[test]
976 fn search_hits_tag_ranked_above_content() {
977 let (dir, index) = make_kb_on_disk(&[
978 ("p1", "Alpha", &[], "the word rust appears here"),
979 ("p2", "Beta", &["rust"], "no match in body"),
980 ]);
981 let hits = index.search_hits("rust", true);
982 assert_eq!(hits.len(), 2);
983 assert_eq!(hits[0].id, "p2");
984 assert_eq!(hits[0].kind, SearchMatchKind::Tag);
985 assert_eq!(hits[1].id, "p1");
986 assert_eq!(hits[1].kind, SearchMatchKind::Content);
987 fs::remove_dir_all(&dir).unwrap();
988 }
989
990 #[test]
991 fn search_hits_mixed_kinds_ranked_correctly() {
992 let (dir, index) = make_kb_on_disk(&[
993 ("p1", "Alpha", &[], "cli tools are great"),
994 ("p2", "Beta", &["cli"], "no match"),
995 ("p3", "CLI Reference", &[], "no match"),
996 ("p4", "Delta", &[], "no match"),
997 ]);
998 let hits = index.search_hits("cli", true);
999 assert_eq!(hits.len(), 3);
1000 assert_eq!(hits[0].id, "p3");
1002 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1003 assert_eq!(hits[1].id, "p2");
1005 assert_eq!(hits[1].kind, SearchMatchKind::Tag);
1006 assert_eq!(hits[2].id, "p1");
1008 assert_eq!(hits[2].kind, SearchMatchKind::Content);
1009 fs::remove_dir_all(&dir).unwrap();
1010 }
1011
1012 #[test]
1013 fn search_hits_title_with_tag_stays_title() {
1014 let (dir, index) =
1016 make_kb_on_disk(&[("p1", "Rust Guide", &["rust"], "also mentions rust")]);
1017 let hits = index.search_hits("rust", true);
1018 assert_eq!(hits.len(), 1);
1019 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1020 fs::remove_dir_all(&dir).unwrap();
1021 }
1022
1023 #[test]
1024 fn search_hits_tag_match_takes_priority_over_content() {
1025 let (dir, index) = make_kb_on_disk(&[("p1", "Some Page", &["fox"], "the fox jumps")]);
1026 let hits = index.search_hits("fox", true);
1027 assert_eq!(hits.len(), 1);
1028 assert_eq!(hits[0].kind, SearchMatchKind::Tag);
1029 fs::remove_dir_all(&dir).unwrap();
1030 }
1031
1032 #[test]
1033 fn search_hits_id_match_counts_as_title() {
1034 let (dir, index) = make_kb_on_disk(&[("rustacean", "Some Page", &[], "body")]);
1035 let hits = index.search_hits("rustacean", false);
1036 assert_eq!(hits.len(), 1);
1037 assert_eq!(hits[0].kind, SearchMatchKind::Title);
1038 fs::remove_dir_all(&dir).unwrap();
1039 }
1040
1041 #[test]
1042 fn filter_page_ids_scored_returns_kinds() {
1043 let (dir, index) = make_kb_on_disk(&[
1044 ("p1", "Rust Intro", &[], "body"),
1045 ("p2", "Beta", &["rust"], "body"),
1046 ("p3", "Gamma", &[], "body"),
1047 ]);
1048 let scored = index.filter_page_ids_scored("rust");
1049 assert_eq!(scored.len(), 2);
1050 let map: HashMap<&str, SearchMatchKind> =
1051 scored.iter().map(|(id, k)| (id.as_str(), *k)).collect();
1052 assert_eq!(map["p1"], SearchMatchKind::Title);
1053 assert_eq!(map["p2"], SearchMatchKind::Tag);
1054 fs::remove_dir_all(&dir).unwrap();
1055 }
1056
1057 #[test]
1058 fn classify_link_target_page_prefix() {
1059 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1060 assert!(matches!(
1061 index.classify_link_target("page:p1"),
1062 LinkTargetKind::InternalPage(id) if id == "p1"
1063 ));
1064 assert!(matches!(
1065 index.classify_link_target("page:nonexistent"),
1066 LinkTargetKind::Unknown(_)
1067 ));
1068 fs::remove_dir_all(&dir).unwrap();
1069 }
1070
1071 #[test]
1072 fn classify_link_target_empty_is_unknown() {
1073 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1074 assert!(matches!(
1075 index.classify_link_target(""),
1076 LinkTargetKind::Unknown(_)
1077 ));
1078 fs::remove_dir_all(&dir).unwrap();
1079 }
1080
1081 #[test]
1082 fn classify_link_target_title_fallback() {
1083 let (dir, index) = make_kb_on_disk(&[("p1", "My Special Page", &[], "body")]);
1084 assert!(matches!(
1085 index.classify_link_target("My Special Page"),
1086 LinkTargetKind::InternalPage(id) if id == "p1"
1087 ));
1088 fs::remove_dir_all(&dir).unwrap();
1089 }
1090
1091 #[test]
1092 fn classify_link_target_mixed_case_urls() {
1093 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1094 assert!(matches!(
1095 index.classify_link_target("HTTPS://EXAMPLE.COM"),
1096 LinkTargetKind::ExternalUrl(_)
1097 ));
1098 assert!(matches!(
1099 index.classify_link_target("Http://Example.Com"),
1100 LinkTargetKind::ExternalUrl(_)
1101 ));
1102 assert!(matches!(
1103 index.classify_link_target("MAILTO:user@host.com"),
1104 LinkTargetKind::ExternalUrl(_)
1105 ));
1106 fs::remove_dir_all(&dir).unwrap();
1107 }
1108
1109 #[test]
1110 fn classify_link_target_whitespace_only_is_unknown() {
1111 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1112 assert!(matches!(
1113 index.classify_link_target(" "),
1114 LinkTargetKind::Unknown(_)
1115 ));
1116 assert!(matches!(
1117 index.classify_link_target("\t\n"),
1118 LinkTargetKind::Unknown(_)
1119 ));
1120 fs::remove_dir_all(&dir).unwrap();
1121 }
1122
1123 #[test]
1124 fn classify_link_target_trims_whitespace_around_url() {
1125 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1126 assert!(matches!(
1127 index.classify_link_target(" https://example.com "),
1128 LinkTargetKind::ExternalUrl(_)
1129 ));
1130 fs::remove_dir_all(&dir).unwrap();
1131 }
1132
1133 #[test]
1134 fn classify_link_target_unusual_schemes() {
1135 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1136 assert!(matches!(
1137 index.classify_link_target("ftp://files.example.com"),
1138 LinkTargetKind::ExternalUrl(_)
1139 ));
1140 assert!(matches!(
1141 index.classify_link_target("ssh://git.example.com"),
1142 LinkTargetKind::ExternalUrl(_)
1143 ));
1144 fs::remove_dir_all(&dir).unwrap();
1145 }
1146
1147 #[test]
1148 fn resolve_page_id_by_title_empty_and_whitespace() {
1149 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1150 assert_eq!(
1151 index.resolve_page_id_by_title(""),
1152 TitleResolution::NotFound
1153 );
1154 assert_eq!(
1155 index.resolve_page_id_by_title(" "),
1156 TitleResolution::NotFound
1157 );
1158 fs::remove_dir_all(&dir).unwrap();
1159 }
1160
1161 #[test]
1162 fn resolve_page_id_by_title_case_insensitive_exact() {
1163 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1164 assert_eq!(
1165 index.resolve_page_id_by_title("ALPHA"),
1166 TitleResolution::Unique("p1".to_string())
1167 );
1168 fs::remove_dir_all(&dir).unwrap();
1169 }
1170
1171 #[test]
1172 fn filter_page_ids_no_match_returns_empty() {
1173 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "body")]);
1174 assert!(index.filter_page_ids("zzzzz").is_empty());
1175 fs::remove_dir_all(&dir).unwrap();
1176 }
1177
1178 #[test]
1179 fn open_empty_directory_returns_empty_index() -> anyhow::Result<()> {
1180 let dir = temp_dir_path("empty-kb");
1181 fs::create_dir_all(&dir)?;
1182 let index = KnowledgeBase::open(&dir)?;
1183 fs::remove_dir_all(&dir)?;
1184
1185 assert!(index.pages.is_empty());
1186 assert!(index.index_issues.is_empty());
1187 Ok(())
1188 }
1189
1190 #[test]
1191 fn open_skips_non_lepiter_files() -> anyhow::Result<()> {
1192 let dir = temp_dir_path("non-lepiter");
1193 fs::create_dir_all(&dir)?;
1194 fs::write(dir.join("readme.txt"), b"hello")?;
1195 fs::write(dir.join("data.json"), b"{}")?;
1196 let index = KnowledgeBase::open(&dir)?;
1197 fs::remove_dir_all(&dir)?;
1198
1199 assert!(index.pages.is_empty());
1200 assert!(index.index_issues.is_empty());
1201 Ok(())
1202 }
1203
1204 #[test]
1205 fn open_reports_invalid_json_as_issue() -> anyhow::Result<()> {
1206 let dir = temp_dir_path("bad-json");
1207 fs::create_dir_all(&dir)?;
1208 fs::write(dir.join("broken.lepiter"), b"not json at all")?;
1209 let index = KnowledgeBase::open(&dir)?;
1210 fs::remove_dir_all(&dir)?;
1211
1212 assert!(index.pages.is_empty());
1213 assert_eq!(index.index_issues.len(), 1);
1214 assert!(index.index_issues[0].message.contains("failed to decode"));
1215 Ok(())
1216 }
1217
1218 #[test]
1219 fn open_reports_wrong_json_structure_as_issue() -> anyhow::Result<()> {
1220 let dir = temp_dir_path("wrong-shape");
1221 fs::create_dir_all(&dir)?;
1222 fs::write(dir.join("array.lepiter"), b"[1, 2, 3]")?;
1223 let index = KnowledgeBase::open(&dir)?;
1224 fs::remove_dir_all(&dir)?;
1225
1226 assert!(index.pages.is_empty());
1227 assert_eq!(index.index_issues.len(), 1);
1228 Ok(())
1229 }
1230
1231 #[test]
1232 fn open_fills_in_defaults_for_minimal_page() -> anyhow::Result<()> {
1233 let dir = temp_dir_path("minimal");
1234 fs::create_dir_all(&dir)?;
1235 fs::write(dir.join("mypage.lepiter"), b"{}")?;
1236 let index = KnowledgeBase::open(&dir)?;
1237 fs::remove_dir_all(&dir)?;
1238
1239 assert_eq!(index.pages.len(), 1);
1240 let meta = index.pages.values().next().unwrap();
1241 assert_eq!(meta.id, "mypage");
1242 assert_eq!(meta.title, "mypage");
1243 Ok(())
1244 }
1245
1246 #[test]
1247 fn load_page_nonexistent_id_errors() -> anyhow::Result<()> {
1248 let dir = temp_dir_path("no-such-id");
1249 fs::create_dir_all(&dir)?;
1250 let index = KnowledgeBase::open(&dir)?;
1251 fs::remove_dir_all(&dir)?;
1252
1253 let err = index.load_page("does-not-exist");
1254 assert!(err.is_err());
1255 assert!(format!("{:#}", err.unwrap_err()).contains("page id not found"));
1256 Ok(())
1257 }
1258
1259 #[test]
1260 fn load_page_missing_children_yields_empty_content() -> anyhow::Result<()> {
1261 let dir = temp_dir_path("no-children");
1262 fs::create_dir_all(&dir)?;
1263 let content = json!({"uid": {"uuid": "pg-1"}, "pageType": {"title": "T"}});
1264 fs::write(dir.join("pg-1.lepiter"), serde_json::to_vec(&content)?)?;
1265 let index = KnowledgeBase::open(&dir)?;
1266 let page = index.load_page("pg-1")?;
1267 fs::remove_dir_all(&dir)?;
1268
1269 assert!(page.content.is_empty());
1270 Ok(())
1271 }
1272
1273 #[test]
1274 fn build_backlinks_computes_reverse_index() {
1275 let (dir, mut index) = make_kb_on_disk(&[
1276 ("p1", "Alpha", &[], "see [[Beta]]"),
1277 ("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
1278 ("p3", "Gamma", &[], "no links here"),
1279 ]);
1280 index.build_backlinks();
1281
1282 assert_eq!(index.backlinks_for("p1"), &["p2"]);
1284 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1286 assert_eq!(index.backlinks_for("p3"), &["p2"]);
1288
1289 fs::remove_dir_all(&dir).unwrap();
1290 }
1291
1292 #[test]
1293 fn build_backlinks_excludes_self_links() {
1294 let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
1295 index.build_backlinks();
1296 assert!(index.backlinks_for("p1").is_empty());
1297 fs::remove_dir_all(&dir).unwrap();
1298 }
1299
1300 #[test]
1301 fn backlinks_for_unknown_page_returns_empty() {
1302 let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "text")]);
1303 index.build_backlinks();
1304 assert!(index.backlinks_for("nonexistent").is_empty());
1305 fs::remove_dir_all(&dir).unwrap();
1306 }
1307
1308 #[test]
1309 fn build_backlinks_deduplicates_multiple_links() {
1310 let (dir, mut index) = make_kb_on_disk(&[
1311 ("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
1312 ("p2", "Beta", &[], "nothing"),
1313 ]);
1314 index.build_backlinks();
1315 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1317 fs::remove_dir_all(&dir).unwrap();
1318 }
1319
1320 #[test]
1321 fn build_backlinks_sorted_by_title() {
1322 let (dir, mut index) = make_kb_on_disk(&[
1323 ("p1", "Zebra", &[], "links to [[Target]]"),
1324 ("p2", "Alpha", &[], "links to [[Target]]"),
1325 ("p3", "Target", &[], "nothing"),
1326 ]);
1327 index.build_backlinks();
1328 assert_eq!(index.backlinks_for("p3"), &["p2", "p1"]);
1329 fs::remove_dir_all(&dir).unwrap();
1330 }
1331
1332 #[test]
1333 fn update_backlinks_for_adds_new_links() {
1334 let (dir, mut index) = make_kb_on_disk(&[
1335 ("p1", "Alpha", &[], "no links"),
1336 ("p2", "Beta", &[], "nothing"),
1337 ]);
1338 index.build_backlinks();
1339 assert!(index.backlinks_for("p2").is_empty());
1340
1341 let content = json!({
1343 "uid": {"uuid": "p1"},
1344 "pageType": {"title": "Alpha"},
1345 "children": {"items": [
1346 {"__type": "textSnippet", "string": "now links to [[Beta]]"}
1347 ]}
1348 });
1349 fs::write(
1350 dir.join("p1.lepiter"),
1351 serde_json::to_vec(&content).unwrap(),
1352 )
1353 .unwrap();
1354
1355 index.update_backlinks_for("p1");
1356 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1357
1358 fs::remove_dir_all(&dir).unwrap();
1359 }
1360
1361 #[test]
1362 fn update_backlinks_for_removes_stale_links() {
1363 let (dir, mut index) = make_kb_on_disk(&[
1364 ("p1", "Alpha", &[], "see [[Beta]]"),
1365 ("p2", "Beta", &[], "nothing"),
1366 ]);
1367 index.build_backlinks();
1368 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1369
1370 let content = json!({
1372 "uid": {"uuid": "p1"},
1373 "pageType": {"title": "Alpha"},
1374 "children": {"items": [
1375 {"__type": "textSnippet", "string": "no links anymore"}
1376 ]}
1377 });
1378 fs::write(
1379 dir.join("p1.lepiter"),
1380 serde_json::to_vec(&content).unwrap(),
1381 )
1382 .unwrap();
1383
1384 index.update_backlinks_for("p1");
1385 assert!(index.backlinks_for("p2").is_empty());
1386
1387 fs::remove_dir_all(&dir).unwrap();
1388 }
1389
1390 #[test]
1391 fn update_backlinks_for_replaces_changed_link() {
1392 let (dir, mut index) = make_kb_on_disk(&[
1393 ("p1", "Alpha", &[], "see [[Beta]]"),
1394 ("p2", "Beta", &[], "nothing"),
1395 ("p3", "Gamma", &[], "nothing"),
1396 ]);
1397 index.build_backlinks();
1398 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1399 assert!(index.backlinks_for("p3").is_empty());
1400
1401 let content = json!({
1403 "uid": {"uuid": "p1"},
1404 "pageType": {"title": "Alpha"},
1405 "children": {"items": [
1406 {"__type": "textSnippet", "string": "see [[Gamma]]"}
1407 ]}
1408 });
1409 fs::write(
1410 dir.join("p1.lepiter"),
1411 serde_json::to_vec(&content).unwrap(),
1412 )
1413 .unwrap();
1414
1415 index.update_backlinks_for("p1");
1416 assert!(index.backlinks_for("p2").is_empty());
1417 assert_eq!(index.backlinks_for("p3"), &["p1"]);
1418
1419 fs::remove_dir_all(&dir).unwrap();
1420 }
1421
1422 #[test]
1423 fn update_backlinks_for_preserves_other_sources() {
1424 let (dir, mut index) = make_kb_on_disk(&[
1425 ("p1", "Alpha", &[], "see [[Gamma]]"),
1426 ("p2", "Beta", &[], "see [[Gamma]]"),
1427 ("p3", "Gamma", &[], "nothing"),
1428 ]);
1429 index.build_backlinks();
1430 assert_eq!(index.backlinks_for("p3"), &["p1", "p2"]);
1431
1432 let content = json!({
1434 "uid": {"uuid": "p1"},
1435 "pageType": {"title": "Alpha"},
1436 "children": {"items": [
1437 {"__type": "textSnippet", "string": "no link"}
1438 ]}
1439 });
1440 fs::write(
1441 dir.join("p1.lepiter"),
1442 serde_json::to_vec(&content).unwrap(),
1443 )
1444 .unwrap();
1445
1446 index.update_backlinks_for("p1");
1447 assert_eq!(index.backlinks_for("p3"), &["p2"]);
1448
1449 fs::remove_dir_all(&dir).unwrap();
1450 }
1451
1452 #[test]
1453 fn update_backlinks_for_deduplicates() {
1454 let (dir, mut index) = make_kb_on_disk(&[
1455 ("p1", "Alpha", &[], "nothing"),
1456 ("p2", "Beta", &[], "nothing"),
1457 ]);
1458 index.build_backlinks();
1459
1460 let content = json!({
1462 "uid": {"uuid": "p1"},
1463 "pageType": {"title": "Alpha"},
1464 "children": {"items": [
1465 {"__type": "textSnippet", "string": "[[Beta]] and [[Beta]] again"}
1466 ]}
1467 });
1468 fs::write(
1469 dir.join("p1.lepiter"),
1470 serde_json::to_vec(&content).unwrap(),
1471 )
1472 .unwrap();
1473
1474 index.update_backlinks_for("p1");
1475 assert_eq!(index.backlinks_for("p2"), &["p1"]);
1476
1477 fs::remove_dir_all(&dir).unwrap();
1478 }
1479
1480 #[test]
1481 fn update_backlinks_for_excludes_self_links() {
1482 let (dir, mut index) = make_kb_on_disk(&[("p1", "Alpha", &[], "nothing")]);
1483 index.build_backlinks();
1484
1485 let content = json!({
1487 "uid": {"uuid": "p1"},
1488 "pageType": {"title": "Alpha"},
1489 "children": {"items": [
1490 {"__type": "textSnippet", "string": "see [[Alpha]]"}
1491 ]}
1492 });
1493 fs::write(
1494 dir.join("p1.lepiter"),
1495 serde_json::to_vec(&content).unwrap(),
1496 )
1497 .unwrap();
1498
1499 index.update_backlinks_for("p1");
1500 assert!(index.backlinks_for("p1").is_empty());
1501
1502 fs::remove_dir_all(&dir).unwrap();
1503 }
1504
1505 #[test]
1506 fn update_backlinks_for_maintains_sort_order() {
1507 let (dir, mut index) = make_kb_on_disk(&[
1508 ("p1", "Zebra", &[], "nothing"),
1509 ("p2", "Alpha", &[], "links to [[Target]]"),
1510 ("p3", "Target", &[], "nothing"),
1511 ]);
1512 index.build_backlinks();
1513 assert_eq!(index.backlinks_for("p3"), &["p2"]);
1514
1515 let content = json!({
1517 "uid": {"uuid": "p1"},
1518 "pageType": {"title": "Zebra"},
1519 "children": {"items": [
1520 {"__type": "textSnippet", "string": "links to [[Target]]"}
1521 ]}
1522 });
1523 fs::write(
1524 dir.join("p1.lepiter"),
1525 serde_json::to_vec(&content).unwrap(),
1526 )
1527 .unwrap();
1528
1529 index.update_backlinks_for("p1");
1530 assert_eq!(index.backlinks_for("p3"), &["p2", "p1"]);
1531
1532 fs::remove_dir_all(&dir).unwrap();
1533 }
1534
1535 #[test]
1536 fn register_page_adds_and_resorts() {
1537 let dir = temp_dir_path("register");
1538 fs::create_dir_all(&dir).unwrap();
1539 let mut index = KnowledgeBase::open(&dir).unwrap();
1540 assert!(index.sorted_ids.is_empty());
1541
1542 let meta = PageMeta {
1543 id: "new-page".to_string(),
1544 id_lower: "new-page".to_string(),
1545 title: "My Page".to_string(),
1546 title_lower: "my page".to_string(),
1547 path: dir.join("new-page.lepiter"),
1548 updated_at: None,
1549 tags: Vec::new(),
1550 tags_lower: Vec::new(),
1551 };
1552 index.register_page(meta);
1553 assert_eq!(index.sorted_ids.len(), 1);
1554 assert_eq!(index.sorted_ids[0], "new-page");
1555 assert!(index.pages.contains_key("new-page"));
1556
1557 fs::remove_dir_all(&dir).unwrap();
1558 }
1559
1560 #[test]
1561 fn register_page_reregistration_no_duplicate() {
1562 let dir = temp_dir_path("reregister");
1563 fs::create_dir_all(&dir).unwrap();
1564 let mut index = KnowledgeBase::open(&dir).unwrap();
1565
1566 let meta = PageMeta {
1567 id: "dup".to_string(),
1568 id_lower: "dup".to_string(),
1569 title: "Original".to_string(),
1570 title_lower: "original".to_string(),
1571 path: dir.join("dup.lepiter"),
1572 updated_at: None,
1573 tags: Vec::new(),
1574 tags_lower: Vec::new(),
1575 };
1576 index.register_page(meta);
1577 assert_eq!(index.sorted_ids.len(), 1);
1578
1579 let updated = PageMeta {
1581 id: "dup".to_string(),
1582 id_lower: "dup".to_string(),
1583 title: "Renamed".to_string(),
1584 title_lower: "renamed".to_string(),
1585 path: dir.join("dup.lepiter"),
1586 updated_at: None,
1587 tags: Vec::new(),
1588 tags_lower: Vec::new(),
1589 };
1590 index.register_page(updated);
1591
1592 assert_eq!(index.sorted_ids.len(), 1);
1594 assert_eq!(index.sorted_ids[0], "dup");
1595 assert_eq!(index.pages["dup"].title, "Renamed");
1597
1598 fs::remove_dir_all(&dir).unwrap();
1599 }
1600
1601 #[test]
1602 fn build_link_graph_collects_edges() {
1603 let (dir, index) = make_kb_on_disk(&[
1604 ("p1", "Alpha", &[], "see [[Beta]]"),
1605 ("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
1606 ("p3", "Gamma", &[], "no links here"),
1607 ]);
1608 let graph = index.build_link_graph();
1609 assert_eq!(graph.edges.len(), 3);
1610 let pairs: Vec<(&str, &str)> = graph
1611 .edges
1612 .iter()
1613 .map(|e| (e.source.as_str(), e.target.as_str()))
1614 .collect();
1615 assert!(pairs.contains(&("p1", "p2")));
1616 assert!(pairs.contains(&("p2", "p1")));
1617 assert!(pairs.contains(&("p2", "p3")));
1618 fs::remove_dir_all(&dir).unwrap();
1619 }
1620
1621 #[test]
1622 fn build_link_graph_excludes_self_links() {
1623 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
1624 let graph = index.build_link_graph();
1625 assert!(graph.edges.is_empty());
1626 fs::remove_dir_all(&dir).unwrap();
1627 }
1628
1629 #[test]
1630 fn build_link_graph_deduplicates() {
1631 let (dir, index) = make_kb_on_disk(&[
1632 ("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
1633 ("p2", "Beta", &[], "nothing"),
1634 ]);
1635 let graph = index.build_link_graph();
1636 assert_eq!(graph.edges.len(), 1);
1637 fs::remove_dir_all(&dir).unwrap();
1638 }
1639
1640 #[test]
1641 fn link_graph_ego_filters_by_page() {
1642 let (dir, index) = make_kb_on_disk(&[
1643 ("p1", "Alpha", &[], "see [[Beta]]"),
1644 ("p2", "Beta", &[], "see [[Gamma]]"),
1645 ("p3", "Gamma", &[], "nothing"),
1646 ]);
1647 let graph = index.build_link_graph();
1648 let ego = graph.ego("p2");
1649 assert_eq!(ego.len(), 2);
1650 let pairs: Vec<(&str, &str)> = ego
1651 .iter()
1652 .map(|e| (e.source.as_str(), e.target.as_str()))
1653 .collect();
1654 assert!(pairs.contains(&("p1", "p2")));
1655 assert!(pairs.contains(&("p2", "p3")));
1656 fs::remove_dir_all(&dir).unwrap();
1657 }
1658
1659 #[test]
1660 fn link_graph_ego_unconnected_page() {
1661 let (dir, index) = make_kb_on_disk(&[
1662 ("p1", "Alpha", &[], "see [[Beta]]"),
1663 ("p2", "Beta", &[], "nothing"),
1664 ("p3", "Gamma", &[], "nothing"),
1665 ]);
1666 let graph = index.build_link_graph();
1667 assert!(graph.ego("p3").is_empty());
1668 fs::remove_dir_all(&dir).unwrap();
1669 }
1670
1671 #[test]
1676 fn analyze_links_detects_broken_links() {
1677 let (dir, index) = make_kb_on_disk(&[
1678 ("p1", "Page One", &[], "see [link](page:nonexistent) here"),
1679 ("p2", "Page Two", &[], "hello"),
1680 ]);
1681 let result = index.analyze_links();
1682 assert_eq!(result.broken_links.len(), 1);
1683 assert_eq!(result.broken_links[0].source_id, "p1");
1684 assert_eq!(result.broken_links[0].target, "page:nonexistent");
1685 assert!(result.load_errors.is_empty());
1686 fs::remove_dir_all(&dir).unwrap();
1687 }
1688
1689 #[test]
1690 fn analyze_links_tracks_linked_pages() {
1691 let (dir, index) = make_kb_on_disk(&[
1692 ("p1", "Page One", &[], "see [link](page:p2) for more"),
1693 ("p2", "Page Two", &[], "target page"),
1694 ]);
1695 let result = index.analyze_links();
1696 assert!(result.linked_pages.contains("p2"));
1697 assert!(!result.linked_pages.contains("p1"));
1698 assert!(result.broken_links.is_empty());
1699 fs::remove_dir_all(&dir).unwrap();
1700 }
1701
1702 #[test]
1703 fn analyze_links_empty_kb() {
1704 let (dir, index) = make_kb_on_disk(&[]);
1705 let result = index.analyze_links();
1706 assert!(result.broken_links.is_empty());
1707 assert!(result.linked_pages.is_empty());
1708 assert!(result.load_errors.is_empty());
1709 fs::remove_dir_all(&dir).unwrap();
1710 }
1711
1712 #[test]
1713 fn analyze_links_captures_load_errors() {
1714 let (dir, index) = make_kb_on_disk(&[("p1", "Page One", &[], "hello")]);
1716 let page_path = dir.join("p1.lepiter");
1717 fs::write(&page_path, b"NOT VALID JSON").unwrap();
1718 let result = index.analyze_links();
1719 assert_eq!(result.load_errors.len(), 1);
1720 assert_eq!(result.load_errors[0].page_id, "p1");
1721 assert_eq!(result.load_errors[0].title, "Page One");
1722 fs::remove_dir_all(&dir).unwrap();
1723 }
1724
1725 #[test]
1730 fn orphan_ids_excludes_linked_pages() {
1731 let (dir, index) = make_kb_on_disk(&[
1732 ("p1", "Page One", &[], "see [link](page:p2) for more"),
1733 ("p2", "Page Two", &[], "target page"),
1734 ]);
1735 let result = index.analyze_links();
1736 let orphans = index.orphan_ids(&result.linked_pages, "");
1737 assert_eq!(orphans, vec!["p1"]);
1739 fs::remove_dir_all(&dir).unwrap();
1740 }
1741
1742 #[test]
1743 fn orphan_ids_excludes_toc_page() {
1744 let (dir, index) = make_kb_on_disk(&[
1745 ("toc", "Table of Contents", &[], "hello"),
1746 ("p1", "Page One", &[], "world"),
1747 ]);
1748 let result = index.analyze_links();
1749 let orphans = index.orphan_ids(&result.linked_pages, "toc");
1750 assert_eq!(orphans, vec!["p1"]);
1752 fs::remove_dir_all(&dir).unwrap();
1753 }
1754
1755 #[test]
1760 fn find_duplicate_titles_none_when_unique() {
1761 let (dir, index) =
1762 make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "Beta", &[], "body")]);
1763 assert!(index.find_duplicate_titles().is_empty());
1764 fs::remove_dir_all(&dir).unwrap();
1765 }
1766
1767 #[test]
1768 fn find_duplicate_titles_detects_exact_match() {
1769 let (dir, index) =
1770 make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "Alpha", &[], "body")]);
1771 let dupes = index.find_duplicate_titles();
1772 assert_eq!(dupes.len(), 1);
1773 assert_eq!(dupes[0].title, "Alpha");
1774 assert_eq!(dupes[0].page_ids.len(), 2);
1775 assert!(dupes[0].page_ids.contains(&"p1".to_string()));
1776 assert!(dupes[0].page_ids.contains(&"p2".to_string()));
1777 fs::remove_dir_all(&dir).unwrap();
1778 }
1779
1780 #[test]
1781 fn find_duplicate_titles_case_insensitive() {
1782 let (dir, index) =
1783 make_kb_on_disk(&[("p1", "Alpha", &[], "body"), ("p2", "ALPHA", &[], "body")]);
1784 let dupes = index.find_duplicate_titles();
1785 assert_eq!(dupes.len(), 1);
1786 assert_eq!(dupes[0].page_ids.len(), 2);
1787 fs::remove_dir_all(&dir).unwrap();
1788 }
1789
1790 #[test]
1791 fn find_duplicate_titles_multiple_groups() {
1792 let (dir, index) = make_kb_on_disk(&[
1793 ("p1", "Alpha", &[], "body"),
1794 ("p2", "Alpha", &[], "body"),
1795 ("p3", "Beta", &[], "body"),
1796 ("p4", "Beta", &[], "body"),
1797 ("p5", "Gamma", &[], "body"),
1798 ]);
1799 let dupes = index.find_duplicate_titles();
1800 assert_eq!(dupes.len(), 2);
1801 assert_eq!(dupes[0].title, "Alpha");
1803 assert_eq!(dupes[1].title, "Beta");
1804 fs::remove_dir_all(&dir).unwrap();
1805 }
1806
1807 #[test]
1808 fn find_duplicate_titles_empty_kb() {
1809 let (dir, index) = make_kb_on_disk(&[]);
1810 assert!(index.find_duplicate_titles().is_empty());
1811 fs::remove_dir_all(&dir).unwrap();
1812 }
1813
1814 #[test]
1819 fn find_missing_attachments_none_when_no_refs() {
1820 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "no attachment refs")]);
1821 assert!(index.find_missing_attachments().is_empty());
1822 fs::remove_dir_all(&dir).unwrap();
1823 }
1824
1825 #[test]
1826 fn find_missing_attachments_detects_missing_file() {
1827 let (dir, index) =
1828 make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/missing.png)")]);
1829 let missing = index.find_missing_attachments();
1830 assert_eq!(missing.len(), 1);
1831 assert_eq!(missing[0].source_id, "p1");
1832 assert_eq!(missing[0].target, "attachments/missing.png");
1833 fs::remove_dir_all(&dir).unwrap();
1834 }
1835
1836 #[test]
1837 fn find_missing_attachments_ignores_existing_file() {
1838 let (dir, index) =
1839 make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/present.png)")]);
1840 let att_dir = dir.join("attachments");
1841 fs::create_dir_all(&att_dir).unwrap();
1842 fs::write(att_dir.join("present.png"), b"data").unwrap();
1843 let missing = index.find_missing_attachments();
1844 assert!(missing.is_empty());
1845 fs::remove_dir_all(&dir).unwrap();
1846 }
1847
1848 #[test]
1849 fn find_missing_attachments_mixed() {
1850 let (dir, index) = make_kb_on_disk(&[(
1851 "p1",
1852 "Alpha",
1853 &[],
1854 "see [a](attachments/ok.png) and [b](attachments/gone.png)",
1855 )]);
1856 let att_dir = dir.join("attachments");
1857 fs::create_dir_all(&att_dir).unwrap();
1858 fs::write(att_dir.join("ok.png"), b"data").unwrap();
1859 let missing = index.find_missing_attachments();
1860 assert_eq!(missing.len(), 1);
1861 assert_eq!(missing[0].target, "attachments/gone.png");
1862 fs::remove_dir_all(&dir).unwrap();
1863 }
1864
1865 #[test]
1866 fn find_missing_attachments_deduplicates_across_pages() {
1867 let (dir, index) = make_kb_on_disk(&[
1868 ("p1", "Alpha", &[], "see [img](attachments/missing.png)"),
1869 ("p2", "Beta", &[], "see [img](attachments/missing.png)"),
1870 ]);
1871 let missing = index.find_missing_attachments();
1872 assert_eq!(missing.len(), 1);
1874 fs::remove_dir_all(&dir).unwrap();
1875 }
1876
1877 #[test]
1878 fn find_missing_attachments_ignores_non_attachment_links() {
1879 let (dir, index) = make_kb_on_disk(&[(
1880 "p1",
1881 "Alpha",
1882 &[],
1883 "see [link](page:p2) and [ext](https://example.com)",
1884 )]);
1885 assert!(index.find_missing_attachments().is_empty());
1886 fs::remove_dir_all(&dir).unwrap();
1887 }
1888
1889 #[test]
1894 fn analyze_all_combines_broken_links_and_missing_attachments() {
1895 let (dir, index) = make_kb_on_disk(&[(
1896 "p1",
1897 "Alpha",
1898 &[],
1899 "see [link](page:nonexistent) and [img](attachments/gone.png)",
1900 )]);
1901 let result = index.analyze_all();
1902 assert_eq!(result.broken_links.len(), 1);
1903 assert_eq!(result.broken_links[0].target, "page:nonexistent");
1904 assert_eq!(result.missing_attachments.len(), 1);
1905 assert_eq!(result.missing_attachments[0].target, "attachments/gone.png");
1906 fs::remove_dir_all(&dir).unwrap();
1907 }
1908
1909 #[test]
1910 fn analyze_all_tracks_linked_pages() {
1911 let (dir, index) = make_kb_on_disk(&[
1912 ("p1", "Alpha", &[], "see [link](page:p2) here"),
1913 ("p2", "Beta", &[], "nothing"),
1914 ]);
1915 let result = index.analyze_all();
1916 assert!(result.linked_pages.contains("p2"));
1917 assert!(!result.linked_pages.contains("p1"));
1918 assert!(result.broken_links.is_empty());
1919 assert!(result.missing_attachments.is_empty());
1920 fs::remove_dir_all(&dir).unwrap();
1921 }
1922
1923 #[test]
1924 fn analyze_all_empty_kb() {
1925 let (dir, index) = make_kb_on_disk(&[]);
1926 let result = index.analyze_all();
1927 assert!(result.broken_links.is_empty());
1928 assert!(result.linked_pages.is_empty());
1929 assert!(result.load_errors.is_empty());
1930 assert!(result.missing_attachments.is_empty());
1931 fs::remove_dir_all(&dir).unwrap();
1932 }
1933
1934 #[test]
1935 fn analyze_all_captures_load_errors() {
1936 let (dir, index) = make_kb_on_disk(&[("p1", "Page One", &[], "hello")]);
1937 fs::write(dir.join("p1.lepiter"), b"NOT VALID JSON").unwrap();
1938 let result = index.analyze_all();
1939 assert_eq!(result.load_errors.len(), 1);
1940 assert_eq!(result.load_errors[0].page_id, "p1");
1941 fs::remove_dir_all(&dir).unwrap();
1942 }
1943
1944 #[test]
1945 fn analyze_all_skips_existing_attachments() {
1946 let (dir, index) =
1947 make_kb_on_disk(&[("p1", "Alpha", &[], "see [img](attachments/present.png)")]);
1948 let att_dir = dir.join("attachments");
1949 fs::create_dir_all(&att_dir).unwrap();
1950 fs::write(att_dir.join("present.png"), b"data").unwrap();
1951 let result = index.analyze_all();
1952 assert!(result.missing_attachments.is_empty());
1953 fs::remove_dir_all(&dir).unwrap();
1954 }
1955
1956 #[test]
1957 fn analyze_all_deduplicates_missing_attachments() {
1958 let (dir, index) = make_kb_on_disk(&[
1959 ("p1", "Alpha", &[], "see [img](attachments/missing.png)"),
1960 ("p2", "Beta", &[], "see [img](attachments/missing.png)"),
1961 ]);
1962 let result = index.analyze_all();
1963 assert_eq!(result.missing_attachments.len(), 1);
1964 fs::remove_dir_all(&dir).unwrap();
1965 }
1966
1967 #[test]
1968 fn analyze_all_mixed_links_and_attachments() {
1969 let (dir, index) = make_kb_on_disk(&[
1970 (
1971 "p1",
1972 "Alpha",
1973 &[],
1974 "see [link](page:p2) and [img](attachments/gone.png)",
1975 ),
1976 (
1977 "p2",
1978 "Beta",
1979 &[],
1980 "see [link](page:nonexistent) and [ext](https://example.com)",
1981 ),
1982 ]);
1983 let result = index.analyze_all();
1984 assert!(result.linked_pages.contains("p2"));
1986 assert_eq!(result.broken_links.len(), 1);
1987 assert_eq!(result.broken_links[0].source_id, "p2");
1988 assert_eq!(result.missing_attachments.len(), 1);
1990 assert_eq!(result.missing_attachments[0].source_id, "p1");
1991 assert!(result.load_errors.is_empty());
1992 fs::remove_dir_all(&dir).unwrap();
1993 }
1994
1995 #[test]
2000 fn scan_all_pages_collects_edges_and_analysis() {
2001 let (dir, index) = make_kb_on_disk(&[
2002 ("p1", "Alpha", &[], "see [[Beta]]"),
2003 ("p2", "Beta", &[], "links to [a](page:p1) and [[Gamma]]"),
2004 ("p3", "Gamma", &[], "no links here"),
2005 ]);
2006 let result = index.scan_all_pages();
2007 assert_eq!(result.edges.len(), 3);
2009 let pairs: Vec<(&str, &str)> = result
2010 .edges
2011 .iter()
2012 .map(|e| (e.source.as_str(), e.target.as_str()))
2013 .collect();
2014 assert!(pairs.contains(&("p1", "p2")));
2015 assert!(pairs.contains(&("p2", "p1")));
2016 assert!(pairs.contains(&("p2", "p3")));
2017 assert!(result.linked_pages.contains("p1"));
2019 assert!(result.linked_pages.contains("p2"));
2020 assert!(result.linked_pages.contains("p3"));
2021 assert!(result.broken_links.is_empty());
2022 fs::remove_dir_all(&dir).unwrap();
2023 }
2024
2025 #[test]
2026 fn scan_all_pages_edges_exclude_self_links() {
2027 let (dir, index) = make_kb_on_disk(&[("p1", "Alpha", &[], "see [[Alpha]]")]);
2028 let result = index.scan_all_pages();
2029 assert!(result.edges.is_empty());
2030 fs::remove_dir_all(&dir).unwrap();
2031 }
2032
2033 #[test]
2034 fn scan_all_pages_edges_deduplicated() {
2035 let (dir, index) = make_kb_on_disk(&[
2036 ("p1", "Alpha", &[], "[[Beta]] and [[Beta]] again"),
2037 ("p2", "Beta", &[], "nothing"),
2038 ]);
2039 let result = index.scan_all_pages();
2040 assert_eq!(result.edges.len(), 1);
2041 fs::remove_dir_all(&dir).unwrap();
2042 }
2043
2044 #[test]
2045 fn scan_all_pages_mixed_edges_and_broken() {
2046 let (dir, index) = make_kb_on_disk(&[
2047 (
2048 "p1",
2049 "Alpha",
2050 &[],
2051 "see [link](page:p2) and [bad](page:nonexistent)",
2052 ),
2053 ("p2", "Beta", &[], "nothing"),
2054 ]);
2055 let result = index.scan_all_pages();
2056 assert_eq!(result.edges.len(), 1);
2057 assert_eq!(result.edges[0].source, "p1");
2058 assert_eq!(result.edges[0].target, "p2");
2059 assert_eq!(result.broken_links.len(), 1);
2060 assert_eq!(result.broken_links[0].target, "page:nonexistent");
2061 fs::remove_dir_all(&dir).unwrap();
2062 }
2063
2064 #[test]
2065 fn scan_all_pages_empty_kb() {
2066 let (dir, index) = make_kb_on_disk(&[]);
2067 let result = index.scan_all_pages();
2068 assert!(result.edges.is_empty());
2069 assert!(result.broken_links.is_empty());
2070 assert!(result.linked_pages.is_empty());
2071 assert!(result.load_errors.is_empty());
2072 assert!(result.missing_attachments.is_empty());
2073 fs::remove_dir_all(&dir).unwrap();
2074 }
2075
2076 #[test]
2077 fn scan_all_pages_edges_match_build_link_graph() {
2078 let (dir, index) = make_kb_on_disk(&[
2079 ("p1", "Alpha", &[], "see [[Beta]]"),
2080 ("p2", "Beta", &[], "see [[Gamma]]"),
2081 ("p3", "Gamma", &[], "nothing"),
2082 ]);
2083 let scan = index.scan_all_pages();
2084 let graph = index.build_link_graph();
2085 assert_eq!(scan.edges.len(), graph.edges.len());
2086 for (a, b) in scan.edges.iter().zip(graph.edges.iter()) {
2087 assert_eq!(a.source, b.source);
2088 assert_eq!(a.target, b.target);
2089 }
2090 fs::remove_dir_all(&dir).unwrap();
2091 }
2092}