Skip to main content

llmwiki_tooling/
wiki.rs

1use std::cell::OnceCell;
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4use std::path::PathBuf;
5
6use elsa::FrozenMap;
7use once_cell::unsync::OnceCell as OnceCellTry;
8
9use crate::cmd::is_markdown_file;
10use crate::config::WikiConfig;
11use crate::error::{FrontmatterError, WikiError};
12use crate::frontmatter::{self, Frontmatter};
13use crate::page::{BlockId, Heading, PageId, WikilinkOccurrence};
14use crate::parse::{self, ClassifiedRange};
15
16/// Validated wiki root directory.
17#[derive(Debug, Clone)]
18pub struct WikiRoot(PathBuf);
19
20impl WikiRoot {
21    /// Walk ancestors of `start` looking for the wiki root.
22    /// Tries `wiki.toml` first, then `index.md` + `wiki/`, then `index.md` alone.
23    pub fn discover(start: &Path) -> Result<Self, WikiError> {
24        let mut dir = if start.is_file() {
25            start.parent().unwrap_or(start).to_path_buf()
26        } else {
27            start.to_path_buf()
28        };
29        loop {
30            if dir.join("wiki.toml").is_file() {
31                return Ok(Self(dir));
32            }
33            if dir.join("index.md").is_file() && dir.join("wiki").is_dir() {
34                return Ok(Self(dir));
35            }
36            if dir.join("index.md").is_file() {
37                return Ok(Self(dir));
38            }
39            if !dir.pop() {
40                return Err(WikiError::RootNotFound {
41                    start: start.to_path_buf(),
42                });
43            }
44        }
45    }
46
47    pub fn from_path(path: PathBuf) -> Result<Self, WikiError> {
48        if path.join("wiki.toml").is_file()
49            || path.join("index.md").is_file()
50            || path.join("wiki").is_dir()
51        {
52            Ok(Self(path))
53        } else {
54            Err(WikiError::RootNotFound { start: path })
55        }
56    }
57
58    pub fn path(&self) -> &Path {
59        &self.0
60    }
61}
62
63/// A single page's catalog entry.
64#[derive(Debug, Clone)]
65pub struct PageEntry {
66    pub rel_path: PathBuf,
67}
68
69/// Cached file with lazy-parsed components.
70pub struct CachedFile {
71    source: String,
72    frontmatter: OnceCell<Result<Option<Frontmatter>, FrontmatterError>>,
73    headings: OnceCell<Vec<Heading>>,
74    wikilinks: OnceCell<Vec<WikilinkOccurrence>>,
75    classified_ranges: OnceCell<Vec<ClassifiedRange>>,
76    block_ids: OnceCell<Vec<BlockId>>,
77}
78
79impl CachedFile {
80    fn new(source: String) -> Self {
81        Self {
82            source,
83            frontmatter: OnceCell::new(),
84            headings: OnceCell::new(),
85            wikilinks: OnceCell::new(),
86            classified_ranges: OnceCell::new(),
87            block_ids: OnceCell::new(),
88        }
89    }
90
91    pub fn source(&self) -> &str {
92        &self.source
93    }
94
95    pub fn frontmatter(&self) -> &Result<Option<Frontmatter>, FrontmatterError> {
96        self.frontmatter
97            .get_or_init(|| frontmatter::parse_frontmatter(&self.source))
98    }
99
100    pub fn headings(&self) -> &[Heading] {
101        self.headings
102            .get_or_init(|| parse::extract_headings(&self.source))
103    }
104
105    pub fn wikilinks(&self) -> &[WikilinkOccurrence] {
106        self.wikilinks
107            .get_or_init(|| parse::extract_wikilinks(&self.source))
108    }
109
110    pub fn classified_ranges(&self) -> &[ClassifiedRange] {
111        self.classified_ranges
112            .get_or_init(|| parse::classify_ranges(&self.source))
113    }
114
115    pub fn block_ids(&self) -> &[BlockId] {
116        self.block_ids
117            .get_or_init(|| parse::extract_block_ids(&self.source))
118    }
119}
120
121/// Unified wiki structure with lazy-loaded content caching.
122pub struct Wiki {
123    root: WikiRoot,
124    config: WikiConfig,
125    pages: HashMap<PageId, PageEntry>,
126    autolink_candidates: HashSet<PageId>,
127    autolink_pages: OnceCellTry<HashSet<PageId>>,
128    content: FrozenMap<PathBuf, Box<CachedFile>>,
129}
130
131impl Wiki {
132    /// Build wiki from root — discovers paths only, no file reads.
133    pub fn build(root: WikiRoot, config: WikiConfig) -> Result<Self, WikiError> {
134        let (pages, autolink_candidates) = Self::discover_pages(&root, &config)?;
135
136        Ok(Self {
137            root,
138            config,
139            pages,
140            autolink_candidates,
141            autolink_pages: OnceCellTry::new(),
142            content: FrozenMap::new(),
143        })
144    }
145
146    fn discover_pages(
147        root: &WikiRoot,
148        config: &WikiConfig,
149    ) -> Result<(HashMap<PageId, PageEntry>, HashSet<PageId>), WikiError> {
150        let mut pages: HashMap<PageId, PageEntry> = HashMap::new();
151        let mut autolink_candidates = HashSet::new();
152
153        for dir_config in &config.directories {
154            let dir_path = root.path().join(&dir_config.path);
155            if !dir_path.is_dir() {
156                continue;
157            }
158
159            for entry in ignore::WalkBuilder::new(&dir_path).hidden(false).build() {
160                let entry = entry.map_err(|e| WikiError::Walk {
161                    path: dir_path.clone(),
162                    source: e,
163                })?;
164                let path = entry.path();
165                if !is_markdown_file(path) {
166                    continue;
167                }
168                let Some(page_id) = PageId::from_path(path) else {
169                    continue;
170                };
171                let rel_path = path.strip_prefix(root.path()).unwrap_or(path).to_path_buf();
172
173                // Skip index file
174                if let Some(index) = &config.index
175                    && rel_path.to_str().is_some_and(|s| s == index)
176                {
177                    continue;
178                }
179
180                // Skip files owned by a more-specific directory config
181                let owning_dir = config.directory_for(&rel_path);
182                if owning_dir.map(|d| d.path.as_str()) != Some(dir_config.path.as_str()) {
183                    continue;
184                }
185
186                // Check for duplicate page IDs
187                if let Some(existing) = pages.get(&page_id) {
188                    return Err(WikiError::DuplicatePageId {
189                        id: page_id.to_string(),
190                        path1: existing.rel_path.clone(),
191                        path2: rel_path,
192                    });
193                }
194
195                if dir_config.autolink {
196                    autolink_candidates.insert(page_id.clone());
197                }
198
199                pages.insert(page_id, PageEntry { rel_path });
200            }
201        }
202
203        Ok((pages, autolink_candidates))
204    }
205
206    pub fn root(&self) -> &WikiRoot {
207        &self.root
208    }
209
210    pub fn config(&self) -> &WikiConfig {
211        &self.config
212    }
213
214    pub fn pages(&self) -> &HashMap<PageId, PageEntry> {
215        &self.pages
216    }
217
218    pub fn get(&self, id: &PageId) -> Option<&PageEntry> {
219        self.pages.get(id)
220    }
221
222    pub fn contains(&self, id: &PageId) -> bool {
223        self.pages.contains_key(id)
224    }
225
226    /// Find a page by name. Always O(1) since PageIds are normalized to lowercase.
227    pub fn find(&self, name: &str) -> Option<(&PageId, &PageEntry)> {
228        let id = PageId::from(name);
229        self.pages.get_key_value(&id)
230    }
231
232    /// Get the display name for a page (original filename case from rel_path).
233    pub fn display_name(&self, id: &PageId) -> Option<&str> {
234        self.pages
235            .get(id)
236            .and_then(|e| e.rel_path.file_stem())
237            .and_then(|s| s.to_str())
238    }
239
240    pub fn index_path(&self) -> Option<PathBuf> {
241        self.config.index.as_ref().map(|idx| self.root.path().join(idx))
242    }
243
244    /// Get the absolute path for a page entry.
245    pub fn entry_path(&self, entry: &PageEntry) -> PathBuf {
246        self.root.path().join(&entry.rel_path)
247    }
248
249    /// Convert an absolute path to a path relative to the wiki root.
250    pub fn rel_path<'a>(&self, path: &'a Path) -> &'a Path {
251        path.strip_prefix(self.root.path()).unwrap_or(path)
252    }
253
254    /// All wiki page files that should be scanned for wikilink content.
255    pub fn all_scannable_files(&self) -> Vec<PathBuf> {
256        let mut files: Vec<PathBuf> = self
257            .pages
258            .values()
259            .map(|entry| self.root.path().join(&entry.rel_path))
260            .collect();
261        if let Some(index_path) = self.index_path()
262            && index_path.is_file()
263        {
264            files.push(index_path);
265        }
266        files
267    }
268
269    /// Autolink pages — lazily computed on first access.
270    pub fn autolink_pages(&self) -> Result<&HashSet<PageId>, WikiError> {
271        self.autolink_pages
272            .get_or_try_init(|| self.compute_autolink_pages())
273    }
274
275    fn compute_autolink_pages(&self) -> Result<HashSet<PageId>, WikiError> {
276        let mut result = HashSet::new();
277        for page_id in &self.autolink_candidates {
278            if self.config.linking.exclude.contains(page_id.as_str()) {
279                continue;
280            }
281            if let Some(entry) = self.pages.get(page_id) {
282                let file_path = self.entry_path(entry);
283                let cached = self.file(&file_path)?;
284                if let Ok(Some(fm)) = cached.frontmatter()
285                    && let Some(val) = fm.get(&self.config.linking.autolink_field)
286                    && val == &serde_yml::Value::Bool(false)
287                {
288                    continue;
289                }
290            }
291            result.insert(page_id.clone());
292        }
293        Ok(result)
294    }
295
296    pub fn abs_path(&self, path: &Path) -> PathBuf {
297        if path.is_absolute() {
298            path.to_path_buf()
299        } else {
300            self.root.path().join(path)
301        }
302    }
303
304    /// Get cached file, loading on first access.
305    pub fn file(&self, path: &Path) -> Result<&CachedFile, WikiError> {
306        let abs_path = self.abs_path(path);
307
308        if let Some(cached) = self.content.get(&abs_path) {
309            return Ok(cached);
310        }
311
312        let source = std::fs::read_to_string(&abs_path).map_err(|e| WikiError::ReadFile {
313            path: abs_path.clone(),
314            source: e,
315        })?;
316
317        Ok(self
318            .content
319            .insert(abs_path, Box::new(CachedFile::new(source))))
320    }
321
322    /// Get source content for a file.
323    pub fn source(&self, path: &Path) -> Result<&str, WikiError> {
324        Ok(self.file(path)?.source())
325    }
326
327    /// Get frontmatter for a file.
328    pub fn frontmatter(
329        &self,
330        path: &Path,
331    ) -> Result<&Result<Option<Frontmatter>, FrontmatterError>, WikiError> {
332        Ok(self.file(path)?.frontmatter())
333    }
334
335    /// Get headings for a file.
336    pub fn headings(&self, path: &Path) -> Result<&[Heading], WikiError> {
337        Ok(self.file(path)?.headings())
338    }
339
340    /// Get wikilinks for a file.
341    pub fn wikilinks(&self, path: &Path) -> Result<&[WikilinkOccurrence], WikiError> {
342        Ok(self.file(path)?.wikilinks())
343    }
344
345    /// Get classified ranges for a file.
346    pub fn classified_ranges(&self, path: &Path) -> Result<&[ClassifiedRange], WikiError> {
347        Ok(self.file(path)?.classified_ranges())
348    }
349
350    /// Get block IDs for a file.
351    pub fn block_ids(&self, path: &Path) -> Result<&[BlockId], WikiError> {
352        Ok(self.file(path)?.block_ids())
353    }
354
355    /// Write file content. Takes `&mut self` to ensure no outstanding borrows.
356    pub fn write_file(&mut self, path: &Path, content: &str) -> Result<(), WikiError> {
357        let abs_path = self.abs_path(path);
358        std::fs::write(&abs_path, content).map_err(|e| WikiError::WriteFile {
359            path: abs_path,
360            source: e,
361        })
362    }
363
364    /// Rename a file. Takes `&mut self` to ensure no outstanding borrows.
365    pub fn rename_file(&mut self, old: &Path, new: &Path) -> Result<(), WikiError> {
366        let old_abs = self.abs_path(old);
367        let new_abs = self.abs_path(new);
368        std::fs::rename(&old_abs, &new_abs).map_err(|e| WikiError::WriteFile {
369            path: new_abs,
370            source: e,
371        })
372    }
373}