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