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