Skip to main content

lepiter_core/
index.rs

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/// Indexed knowledge base metadata with lazy page loading.
23#[derive(Debug, Clone)]
24pub struct KnowledgeBaseIndex {
25    root: PathBuf,
26    /// Metadata map keyed by canonical page id.
27    pub pages: HashMap<PageId, PageMeta>,
28    /// Page ids in case-insensitive title sort order, computed at open time,
29    /// maintained by [`Self::register_page`].
30    pub sorted_ids: Vec<PageId>,
31    /// Exact-title lookup index: lowercased title -> page ids sharing that title.
32    /// Kept in sync with `pages` by [`Self::register_page`].
33    title_index: HashMap<String, Vec<PageId>>,
34    /// Non-fatal issues encountered while scanning metadata.
35    pub index_issues: Vec<ParseIssue>,
36    /// Reverse link index: target page id -> sorted list of source page ids that link to it.
37    backlinks: HashMap<PageId, Vec<PageId>>,
38    /// Forward link index: source page id -> set of target page ids it links to.
39    /// Kept in sync with `backlinks`.
40    forward_links: HashMap<PageId, HashSet<PageId>>,
41    /// Ids claimed by more than one `.lepiter` file, captured during [`KnowledgeBase::open`]
42    /// before the collision is lost to `pages`.
43    pub duplicate_ids: Vec<DuplicateId>,
44}
45
46/// Entry point for opening a Lepiter knowledge base directory.
47pub struct KnowledgeBase;
48
49impl KnowledgeBase {
50    /// Scans a knowledge base directory and builds a page metadata index.
51    ///
52    /// This operation only reads metadata and does not parse full page content.
53    /// Full parsing is done lazily via [`KnowledgeBaseIndex::load_page`].
54    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    /// Registers a page in the index, inserting it at the correct
118    /// sorted position via binary search.  If the page already exists, its old sort position
119    /// is removed first so re-registration never creates duplicates
120    /// (and handles title changes correctly).
121    ///
122    /// The exact-title index is kept in sync as well: on a title change the
123    /// page id is moved from its stale title bucket to the new one, and
124    /// now-empty buckets are dropped.
125    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    /// Removes `id` from the `title_lower` bucket of the exact-title index,
150    /// dropping the bucket entirely once it is empty.
151    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    /// Loads and parses a single page by canonical id.
161    ///
162    /// Returns an error if the id is missing from the index or if JSON parsing fails.
163    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    /// Returns metadata entries in cached title-sorted order.
196    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    /// Returns page ids filtered by metadata query (title/id/tags), sorted by title.
204    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    /// Returns page ids with their match kinds, filtered by metadata query.
214    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    /// Searches pages by metadata and optionally content, returning hits
227    /// ranked by relevance (title > tag > content), with ties broken
228    /// alphabetically by title.
229    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    /// Resolves a page id from title using case-insensitive exact match, then partial match.
279    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    /// Looks up exact (case-insensitive) title matches via the precomputed
303    /// index.  `needle` must already be trimmed and lowercased.  Returns
304    /// `None` when no page has that exact title, so callers can decide whether
305    /// to report `NotFound` or fall back to a substring search.
306    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    /// Resolves a page id from title using a case-insensitive *exact* match
315    /// only, never falling back to a substring like [`Self::resolve_page_id_by_title`].
316    /// Used for wikilink/`page:`/`title:` resolution, where a substring hit would
317    /// fabricate a graph edge and hide a genuinely broken link.
318    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    /// Classifies a raw link target for navigation/open behavior.
329    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    /// Builds the reverse link index by loading every page, extracting link
376    /// targets, classifying them, and recording which pages link to which.
377    ///
378    /// Call this once after [`KnowledgeBase::open`] when backlink data is needed.
379    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    /// Incrementally updates the backlinks index for a single page.
413    ///
414    /// Removes any existing outgoing links from `page_id`, then re-extracts
415    /// and classifies its current links.  Much cheaper than a full
416    /// [`Self::build_backlinks`] call when only one page changed.
417    pub fn update_backlinks_for(&mut self, page_id: &str) {
418        // 1. Remove page_id from only the targets it previously linked to.
419        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        // 2. Re-extract outgoing links from the (possibly updated) page.
431        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    /// Returns the page ids that link to the given page, sorted by title.
457    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    /// Builds the full directed link graph across all pages.
465    ///
466    /// Each edge represents one internal page-to-page link (deduplicated per
467    /// source/target pair).  Self-links are excluded.
468    pub fn build_link_graph(&self) -> LinkGraph {
469        LinkGraph {
470            edges: self.scan_all_pages().edges,
471        }
472    }
473
474    /// Returns the root path used to build this index.
475    pub fn root(&self) -> &Path {
476        &self.root
477    }
478
479    /// Returns an attachment resolver rooted at this knowledge base.
480    pub fn attachment_resolver(&self) -> AttachmentResolver {
481        AttachmentResolver::new(&self.root)
482    }
483    /// Scans all pages in a single pass, collecting broken links, linked
484    /// pages, link graph edges, missing attachments, and load errors.
485    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    /// alias for [`Self::scan_all_pages`].
559    pub fn analyze_all(&self) -> LinkAnalysisResult {
560        self.scan_all_pages()
561    }
562
563    /// alias for [`Self::scan_all_pages`].
564    pub fn analyze_links(&self) -> LinkAnalysisResult {
565        self.scan_all_pages()
566    }
567
568    /// Returns page ids that are not linked to by any other page.
569    ///
570    /// The `toc_page_id` (table-of-contents), when present, is excluded from the
571    /// result since it serves as the root entry point and is not expected to be
572    /// linked to.
573    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    /// Finds page titles shared by more than one page (case-insensitive).
586    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    /// Returns page ids claimed by more than one file, captured at open time.
606    pub fn find_duplicate_ids(&self) -> Vec<DuplicateId> {
607        self.duplicate_ids.clone()
608    }
609
610    /// Finds attachment references whose files are missing from disk; see
611    /// [`LinkAnalysisResult::missing_attachments`].
612    pub fn find_missing_attachments(&self) -> Vec<MissingAttachment> {
613        self.scan_all_pages().missing_attachments
614    }
615}
616
617/// A link target that could not be resolved to any known page.
618#[derive(Debug, Clone)]
619pub struct BrokenLink {
620    /// Title of the page containing the broken link.
621    pub source_title: String,
622    /// Id of the page containing the broken link.
623    pub source_id: PageId,
624    /// The raw unresolved link target.
625    pub target: String,
626}
627
628/// A page that could not be loaded during link analysis.
629#[derive(Debug, Clone)]
630pub struct PageLoadError {
631    /// Id of the page that failed to load.
632    pub page_id: PageId,
633    /// Title from the metadata index.
634    pub title: String,
635    /// Human-readable error message.
636    pub error: String,
637}
638
639/// Result of [`KnowledgeBaseIndex::scan_all_pages`].
640#[derive(Debug, Clone)]
641pub struct LinkAnalysisResult {
642    /// Links whose targets could not be resolved.
643    pub broken_links: Vec<BrokenLink>,
644    /// Set of page ids that are linked to by at least one other page.
645    pub linked_pages: HashSet<PageId>,
646    /// Pages that could not be loaded (e.g. corrupted JSON).
647    pub load_errors: Vec<PageLoadError>,
648    /// Attachment references whose files are missing from disk, at most one
649    /// per (referencing page, resolved path).
650    pub missing_attachments: Vec<MissingAttachment>,
651    /// Deduplicated directed link graph edges (self-links excluded).
652    pub edges: Vec<LinkEdge>,
653}
654
655/// A directed edge in the page link graph.
656#[derive(Debug, Clone, Serialize)]
657pub struct LinkEdge {
658    /// Page id of the linking page.
659    pub source: PageId,
660    /// Page id of the linked-to page.
661    pub target: PageId,
662}
663
664/// Directed link graph across all pages in a knowledge base.
665#[derive(Debug, Clone)]
666pub struct LinkGraph {
667    /// Deduplicated directed edges (self-links excluded).
668    pub edges: Vec<LinkEdge>,
669}
670
671/// A set of pages that share the same title (case-insensitive).
672#[derive(Debug, Clone)]
673pub struct DuplicateTitle {
674    /// The shared title (original casing from the first match).
675    pub title: String,
676    /// Page ids sharing this title.
677    pub page_ids: Vec<PageId>,
678}
679
680/// A set of `.lepiter` files that resolve to the same page id.
681#[derive(Debug, Clone)]
682pub struct DuplicateId {
683    /// The shared page id.
684    pub id: PageId,
685    /// Paths of the files claiming this id, sorted.
686    pub paths: Vec<PathBuf>,
687}
688
689/// An attachment reference that points to a file not found on disk.
690#[derive(Debug, Clone)]
691pub struct MissingAttachment {
692    /// Title of the page referencing the attachment.
693    pub source_title: String,
694    /// Id of the page referencing the attachment.
695    pub source_id: PageId,
696    /// The raw attachment target string from the page content.
697    pub target: String,
698    /// The resolved path that was not found.
699    pub resolved_path: PathBuf,
700}
701
702impl LinkGraph {
703    /// Returns edges filtered to only those involving `page_id` (as source or target).
704    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
712/// Returns the case-insensitive title for `id`, or `""` if unknown.
713///
714/// Free function so callers can split-borrow `pages` and `backlinks`
715/// without conflicting on `&self`.
716fn 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
720/// Inserts `source_id` into `sources` at the position that keeps the vec
721/// sorted by title.  Uses `partition_point` (binary search).
722fn 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
738/// Collapses the scan-time id -> paths map into sorted `DuplicateId`s for every
739/// id claimed by more than one file.
740fn 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
753/// Builds the exact-title lookup index keyed by lowercased title.  Ids are
754/// collected in `sorted_ids` order so a bucket matches what a title-sorted
755/// full scan would have produced.
756fn 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    /// a directory no other test shares. `tempfile` picks the name: a timestamp
789    /// cannot, because `SystemTime::now` ticks at microsecond granularity here, so
790    /// two tests in the same process stamp the same value and share a directory.
791    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        // The exact resolver agrees on a genuine exact match (case-insensitive)...
926        assert_eq!(
927            index.resolve_page_id_by_title_exact("ALPHA"),
928            TitleResolution::Unique("id-1".to_string())
929        );
930        // ...but never falls back to a substring: "alp" resolves to nothing,
931        // "alpha" only to its exact match.
932        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        // page: prefix falls back to title resolution
1010        assert!(matches!(
1011            index.classify_link_target("page:Alpha"),
1012            LinkTargetKind::InternalPage(_)
1013        ));
1014        // page: prefix with unknown title stays Unknown
1015        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        // all tag matches → same score → alphabetical by title
1091        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        // title match first
1104        assert_eq!(hits[0].id, "p2");
1105        assert_eq!(hits[0].kind, SearchMatchKind::Title);
1106        // tag match second
1107        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        // title match first
1153        assert_eq!(hits[0].id, "p3");
1154        assert_eq!(hits[0].kind, SearchMatchKind::Title);
1155        // tag match second
1156        assert_eq!(hits[1].id, "p2");
1157        assert_eq!(hits[1].kind, SearchMatchKind::Tag);
1158        // content match last
1159        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        // page matches both title and tag — kind should be Title (highest)
1167        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        // p1 is linked to by p2 (via page:p1)
1471        assert_eq!(index.backlinks_for("p1"), &["p2"]);
1472        // p2 ("Beta") is linked to by p1 (via [[Beta]])
1473        assert_eq!(index.backlinks_for("p2"), &["p1"]);
1474        // p3 ("Gamma") is linked to by p2 (via [[Gamma]])
1475        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        // p1 links to p2 twice but should only appear once
1504        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        // Simulate editing p1 to add a link to Beta.
1530        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        // Edit p1 to remove the link.
1559        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        // Edit p1 to link to Gamma instead of Beta.
1590        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        // Edit p1 to remove its link; p2's link should remain.
1621        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        // Edit p1 to link to Beta twice.
1649        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        // Edit p1 to link to itself.
1674        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        // Edit p1 (Zebra) to also link to Target; result should be sorted Alpha, Zebra.
1704        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        // Re-register same id with a different title.
1768        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        // sorted_ids must still contain the id exactly once.
1781        assert_eq!(index.sorted_ids.len(), 1);
1782        assert_eq!(index.sorted_ids[0], "dup");
1783        // The page data should reflect the updated title.
1784        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        // Rename the page; the exact index must follow the new title only.
1815        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        // Rename p1 away from the shared title: the bucket shrinks to one, so
1859        // "Shared" now resolves uniquely to p2 and "Renamed" to p1.
1860        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    // -----------------------------------------------------------------------
1944    // analyze_links
1945    // -----------------------------------------------------------------------
1946
1947    #[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        // Create a valid page, then corrupt its file after indexing.
1987        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    // -----------------------------------------------------------------------
1998    // orphan_ids
1999    // -----------------------------------------------------------------------
2000
2001    #[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        // p2 is linked to by p1, so only p1 should be orphan.
2010        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        // toc excluded, only p1 should be orphan.
2023        assert_eq!(orphans, vec!["p1"]);
2024        fs::remove_dir_all(&dir).unwrap();
2025    }
2026
2027    // -----------------------------------------------------------------------
2028    // find_duplicate_titles
2029    // -----------------------------------------------------------------------
2030
2031    #[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        // sorted alphabetically
2074        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    // -----------------------------------------------------------------------
2087    // find_missing_attachments
2088    // -----------------------------------------------------------------------
2089
2090    #[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    // -----------------------------------------------------------------------
2177    // analyze_all
2178    // -----------------------------------------------------------------------
2179
2180    #[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        // p1 links to p2 (valid), p2 has a broken link
2286        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        // p1 has a missing attachment
2290        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    // -----------------------------------------------------------------------
2297    // scan_all_pages
2298    // -----------------------------------------------------------------------
2299
2300    #[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        // edges match build_link_graph output
2309        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        // linked_pages consistent with edges
2319        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        // `[[Rust]]` has no exact-title match: no edge, one broken link.
2373        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}