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#[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 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#[derive(Debug, Clone)]
65pub struct PageEntry {
66 pub rel_path: PathBuf,
67}
68
69pub 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
121pub 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 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 if let Some(index) = &config.index
175 && rel_path.to_str().is_some_and(|s| s == index)
176 {
177 continue;
178 }
179
180 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 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 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 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 pub fn entry_path(&self, entry: &PageEntry) -> PathBuf {
246 self.root.path().join(&entry.rel_path)
247 }
248
249 pub fn rel_path<'a>(&self, path: &'a Path) -> &'a Path {
251 path.strip_prefix(self.root.path()).unwrap_or(path)
252 }
253
254 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 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 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 pub fn source(&self, path: &Path) -> Result<&str, WikiError> {
324 Ok(self.file(path)?.source())
325 }
326
327 pub fn frontmatter(
329 &self,
330 path: &Path,
331 ) -> Result<&Result<Option<Frontmatter>, FrontmatterError>, WikiError> {
332 Ok(self.file(path)?.frontmatter())
333 }
334
335 pub fn headings(&self, path: &Path) -> Result<&[Heading], WikiError> {
337 Ok(self.file(path)?.headings())
338 }
339
340 pub fn wikilinks(&self, path: &Path) -> Result<&[WikilinkOccurrence], WikiError> {
342 Ok(self.file(path)?.wikilinks())
343 }
344
345 pub fn classified_ranges(&self, path: &Path) -> Result<&[ClassifiedRange], WikiError> {
347 Ok(self.file(path)?.classified_ranges())
348 }
349
350 pub fn block_ids(&self, path: &Path) -> Result<&[BlockId], WikiError> {
352 Ok(self.file(path)?.block_ids())
353 }
354
355 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 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}