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#[derive(Debug, Clone)]
18pub struct WikiRoot(PathBuf);
19
20impl WikiRoot {
21 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#[derive(Debug, Clone)]
71pub struct PageEntry {
72 pub rel_path: PathBuf,
73}
74
75pub 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
127pub 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 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 if let Some(index) = &config.index
181 && rel_path.to_str().is_some_and(|s| s == index)
182 {
183 continue;
184 }
185
186 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 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 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 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 pub fn entry_path(&self, entry: &PageEntry) -> PathBuf {
255 self.root.path().join(&entry.rel_path)
256 }
257
258 pub fn rel_path<'a>(&self, path: &'a Path) -> &'a Path {
260 path.strip_prefix(self.root.path()).unwrap_or(path)
261 }
262
263 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 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 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 pub fn source(&self, path: &Path) -> Result<&str, WikiError> {
333 Ok(self.file(path)?.source())
334 }
335
336 pub fn frontmatter(
338 &self,
339 path: &Path,
340 ) -> Result<&Result<Option<Frontmatter>, FrontmatterError>, WikiError> {
341 Ok(self.file(path)?.frontmatter())
342 }
343
344 pub fn headings(&self, path: &Path) -> Result<&[Heading], WikiError> {
346 Ok(self.file(path)?.headings())
347 }
348
349 pub fn wikilinks(&self, path: &Path) -> Result<&[WikilinkOccurrence], WikiError> {
351 Ok(self.file(path)?.wikilinks())
352 }
353
354 pub fn classified_ranges(&self, path: &Path) -> Result<&[ClassifiedRange], WikiError> {
356 Ok(self.file(path)?.classified_ranges())
357 }
358
359 pub fn block_ids(&self, path: &Path) -> Result<&[BlockId], WikiError> {
361 Ok(self.file(path)?.block_ids())
362 }
363
364 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 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}