Skip to main content

ashlar_docs/
lib.rs

1//! A documentation library for agents: folders are categories (nested folders are
2//! subcategories), markdown files are documents, and every page is served verbatim.
3//!
4//! - `_category.md` in a folder describes it (frontmatter `title`, `description`; the body is the
5//!   overview). It is not listed as a document. The root one says what the library is.
6//! - A document's frontmatter has `title`, `description` (when to read it), and optionally
7//!   `order` (lower first) and `tags`.
8//! - [`embed`] (build scripts) writes a `(path, text)` table of a folder for `include!`, so a page
9//!   joins by being added to the folder. [`Library::overlay_dir`] adds a folder at run time on
10//!   top, where the same path replaces the built-in page.
11
12use std::fmt::Write as _;
13use std::path::Path;
14
15pub mod write;
16
17/// The file in a folder that describes its category.
18pub const CATEGORY_FILE: &str = "_category.md";
19/// Folders deeper than this are not read.
20pub const MAX_DEPTH: usize = 8;
21/// A run-time folder contributes at most this many files.
22pub const MAX_FILES: usize = 2000;
23/// A run-time file larger than this is skipped.
24pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
25
26/// Where a page came from.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Source {
29    /// Built into the program.
30    BuiltIn,
31    /// A folder added at run time ([`Library::overlay_dir`]).
32    User,
33}
34
35impl Source {
36    /// `built-in` or `yours`, as listings show it.
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::BuiltIn => "built-in",
40            Self::User => "yours",
41        }
42    }
43}
44
45/// One document.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Doc {
48    /// Path from the library root, e.g. `skills/review.md`.
49    pub path: String,
50    pub title: String,
51    pub description: String,
52    /// The folder it is in (`""` for the root), e.g. `reference/motion`.
53    pub category: String,
54    pub order: Option<i64>,
55    pub tags: Vec<String>,
56    pub source: Source,
57    /// The file, verbatim (frontmatter included).
58    pub text: String,
59}
60
61/// One category (folder). A folder without `_category.md` still is one, titled after its name.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Category {
64    /// Path from the library root (`""` for the root), e.g. `reference/motion`.
65    pub path: String,
66    pub title: String,
67    pub description: String,
68    /// The body of `_category.md` (after the frontmatter), trimmed.
69    pub overview: String,
70    /// `_category.md` verbatim, when there is one.
71    pub text: Option<String>,
72    pub source: Source,
73}
74
75/// A category with what is under it, for [`Library::tree`].
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Node {
78    pub path: String,
79    pub title: String,
80    pub description: String,
81    /// Documents directly in this category.
82    pub docs: usize,
83    /// Documents in this category and all below it.
84    pub total: usize,
85    pub children: Vec<Node>,
86}
87
88/// Documents and categories, sorted by path.
89#[derive(Debug, Clone, Default, PartialEq, Eq)]
90pub struct Library {
91    docs: Vec<Doc>,
92    categories: Vec<Category>,
93}
94
95impl Library {
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// A library of built-in files (`(path, text)`, as [`embed`] writes them).
101    pub fn from_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
102        let mut lib = Self::new();
103        for (path, text) in files {
104            lib.insert(path, text, Source::BuiltIn);
105        }
106        lib
107    }
108
109    /// Add or replace one file: `_category.md` describes its folder, any other `.md` is a document.
110    pub fn insert(&mut self, path: &str, text: &str, source: Source) {
111        let path = normalize(path);
112        let (dir, name) = split(&path);
113        let front = Frontmatter::parse(text);
114        if name == CATEGORY_FILE {
115            let cat = Category {
116                path: dir.to_string(),
117                title: front.title.clone().unwrap_or_else(|| default_title(dir)),
118                description: front.description.clone().unwrap_or_default(),
119                overview: front.body.trim().to_string(),
120                text: Some(text.to_string()),
121                source,
122            };
123            self.categories.retain(|c| c.path != cat.path);
124            self.categories.push(cat);
125            self.categories.sort_by(|a, b| a.path.cmp(&b.path));
126            return;
127        }
128        let stem = name.strip_suffix(".md").unwrap_or(name);
129        let doc = Doc {
130            title: front.title.clone().unwrap_or_else(|| first_heading(front.body).unwrap_or_else(|| humanize(stem))),
131            description: front.description.clone().unwrap_or_default(),
132            category: dir.to_string(),
133            order: front.order,
134            tags: front.tags.clone(),
135            source,
136            text: text.to_string(),
137            path,
138        };
139        self.docs.retain(|d| d.path != doc.path);
140        self.docs.push(doc);
141        self.docs.sort_by(|a, b| a.path.cmp(&b.path));
142    }
143
144    /// Add the markdown files under `dir` on top (source [`Source::User`]; the same path replaces
145    /// a page). Symbolic links, dotfiles and non-markdown files are skipped, as are files over
146    /// [`MAX_FILE_BYTES`], folders deeper than [`MAX_DEPTH`] and files past [`MAX_FILES`]. A
147    /// missing folder adds nothing.
148    pub fn overlay_dir(mut self, dir: &Path) -> Self {
149        let mut files = Vec::new();
150        walk(dir, "", 0, &mut files);
151        for (rel, file) in files.into_iter().take(MAX_FILES) {
152            let small = std::fs::metadata(&file).is_ok_and(|m| m.len() <= MAX_FILE_BYTES);
153            if let (true, Ok(text)) = (small, std::fs::read_to_string(&file)) {
154                self.insert(&rel, &text, Source::User);
155            }
156        }
157        self
158    }
159
160    /// Only the pages and categories built into the program (what a user folder overlays).
161    pub fn built_in_only(&self) -> Self {
162        Self {
163            docs: self.docs.iter().filter(|d| d.source == Source::BuiltIn).cloned().collect(),
164            categories: self.categories.iter().filter(|c| c.source == Source::BuiltIn).cloned().collect(),
165        }
166    }
167
168    pub fn is_empty(&self) -> bool {
169        self.docs.is_empty() && self.categories.is_empty()
170    }
171
172    /// Every document, sorted by path.
173    pub fn docs(&self) -> &[Doc] {
174        &self.docs
175    }
176
177    /// The categories that have a `_category.md`, sorted by path.
178    pub fn described(&self) -> &[Category] {
179        &self.categories
180    }
181
182    /// The root overview (the body of the root `_category.md`).
183    pub fn overview(&self) -> Option<&str> {
184        self.categories.iter().find(|c| c.path.is_empty()).map(|c| c.overview.as_str()).filter(|o| !o.is_empty())
185    }
186
187    /// A category by path (`""` is the root): described by its `_category.md`, or implied by the
188    /// documents under it.
189    pub fn category(&self, path: &str) -> Option<Category> {
190        let path = normalize(path);
191        if let Some(c) = self.categories.iter().find(|c| c.path == path) {
192            return Some(c.clone());
193        }
194        let known = path.is_empty() || self.all_category_paths().contains(&path);
195        known.then(|| Category {
196            title: default_title(&path),
197            path,
198            description: String::new(),
199            overview: String::new(),
200            text: None,
201            source: Source::BuiltIn,
202        })
203    }
204
205    /// The category tree under `path` (`""` for all of it); `None` for an unknown category.
206    pub fn tree(&self, path: &str) -> Option<Node> {
207        let cat = self.category(path)?;
208        Some(self.node(cat))
209    }
210
211    /// Documents in `category` (all when `None`; with `recursive`, also those in its
212    /// subcategories) that contain `query` (case-insensitive, in path, title, description, tags
213    /// or text), ordered by category, then `order`, then path.
214    pub fn list(&self, category: Option<&str>, query: Option<&str>, recursive: bool) -> Vec<&Doc> {
215        let cat = category.map(normalize);
216        let q = query.map(str::to_lowercase).filter(|q| !q.is_empty());
217        let mut out: Vec<&Doc> = self
218            .docs
219            .iter()
220            .filter(|d| match &cat {
221                None => true,
222                Some(c) if c.is_empty() => recursive || d.category.is_empty(),
223                Some(c) => d.category == *c || (recursive && d.category.starts_with(&format!("{c}/"))),
224            })
225            .filter(|d| q.as_deref().is_none_or(|q| d.matches(q)))
226            .collect();
227        out.sort_by(|a, b| a.category.cmp(&b.category).then(a.order.unwrap_or(i64::MAX).cmp(&b.order.unwrap_or(i64::MAX))).then(a.path.cmp(&b.path)));
228        out
229    }
230
231    /// The document at exactly `path` (or a category's `_category.md`); an unknown path is an
232    /// error that names the closest pages.
233    pub fn read(&self, path: &str) -> Result<&str, String> {
234        let p = normalize(path.trim().trim_start_matches("docs://"));
235        if let Some(d) = self.docs.iter().find(|d| d.path == p) {
236            return Ok(&d.text);
237        }
238        let (dir, name) = split(&p);
239        if name == CATEGORY_FILE
240            && let Some(text) = self.categories.iter().find(|c| c.path == dir).and_then(|c| c.text.as_deref())
241        {
242            return Ok(text);
243        }
244        if self.category(&p).is_some() {
245            return Err(format!("`{p}` is a category, not a document: list it to see its documents"));
246        }
247        let close = self.close_matches(&p);
248        if close.is_empty() {
249            Err(format!("no document `{p}`"))
250        } else {
251            Err(format!("no document `{p}`; did you mean {}?", close.iter().map(|c| format!("`{c}`")).collect::<Vec<_>>().join(", ")))
252        }
253    }
254
255    /// Up to five document paths that share the file name or contain the name's stem.
256    pub fn close_matches(&self, path: &str) -> Vec<&str> {
257        let (_, name) = split(path);
258        let stem = name.strip_suffix(".md").unwrap_or(name).to_lowercase();
259        if stem.is_empty() {
260            return Vec::new();
261        }
262        let same: Vec<&str> = self.docs.iter().filter(|d| split(&d.path).1.eq_ignore_ascii_case(name)).map(|d| d.path.as_str()).collect();
263        let near = self.docs.iter().filter(|d| d.path.to_lowercase().contains(&stem)).map(|d| d.path.as_str());
264        let mut out = same;
265        for p in near {
266            if !out.contains(&p) {
267                out.push(p);
268            }
269        }
270        out.truncate(5);
271        out
272    }
273
274    fn all_category_paths(&self) -> Vec<String> {
275        let mut paths: Vec<String> = Vec::new();
276        let dirs = self.docs.iter().map(|d| d.category.as_str()).chain(self.categories.iter().map(|c| c.path.as_str()));
277        for dir in dirs {
278            let mut acc = String::new();
279            for part in dir.split('/').filter(|p| !p.is_empty()) {
280                if !acc.is_empty() {
281                    acc.push('/');
282                }
283                acc.push_str(part);
284                if !paths.contains(&acc) {
285                    paths.push(acc.clone());
286                }
287            }
288        }
289        paths.sort();
290        paths
291    }
292
293    fn node(&self, cat: Category) -> Node {
294        let prefix = if cat.path.is_empty() { String::new() } else { format!("{}/", cat.path) };
295        let children: Vec<Node> = self
296            .all_category_paths()
297            .into_iter()
298            .filter(|p| p.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
299            .filter_map(|p| self.category(&p))
300            .map(|c| self.node(c))
301            .collect();
302        let docs = self.docs.iter().filter(|d| d.category == cat.path).count();
303        let total = docs + children.iter().map(|c| c.total).sum::<usize>();
304        Node { path: cat.path, title: cat.title, description: cat.description, docs, total, children }
305    }
306}
307
308impl Doc {
309    fn matches(&self, q: &str) -> bool {
310        [&self.path, &self.title, &self.description, &self.text].iter().any(|s| s.to_lowercase().contains(q))
311            || self.tags.iter().any(|t| t.to_lowercase().contains(q))
312    }
313}
314
315/// The frontmatter fields this library reads, and the text after it.
316#[derive(Debug, Default)]
317pub struct Frontmatter<'a> {
318    pub title: Option<String>,
319    pub description: Option<String>,
320    pub order: Option<i64>,
321    pub tags: Vec<String>,
322    pub body: &'a str,
323}
324
325impl<'a> Frontmatter<'a> {
326    /// Read a leading `---` … `---` block of `key: value` lines (values may be quoted; `tags` is
327    /// `[a, b]` or `a, b`). Text without one is all body.
328    pub fn parse(text: &'a str) -> Self {
329        let mut out = Self { body: text, ..Self::default() };
330        let rest = text.strip_prefix('\u{feff}').unwrap_or(text);
331        let Some(after_open) = rest.strip_prefix("---\n").or_else(|| rest.strip_prefix("---\r\n")) else { return out };
332        let mut offset = 0;
333        let mut closed = None;
334        for line in after_open.split_inclusive('\n') {
335            if line.trim_end() == "---" || line.trim_end() == "..." {
336                closed = Some(offset + line.len());
337                break;
338            }
339            if let Some((key, value)) = line.split_once(':') {
340                let value = unquote(value.trim());
341                match key.trim() {
342                    "title" => out.title = Some(value).filter(|v| !v.is_empty()),
343                    "description" => out.description = Some(value).filter(|v| !v.is_empty()),
344                    "order" => out.order = value.parse().ok(),
345                    "tags" => {
346                        out.tags = value
347                            .trim_start_matches('[')
348                            .trim_end_matches(']')
349                            .split(',')
350                            .map(|t| unquote(t.trim()))
351                            .filter(|t| !t.is_empty())
352                            .collect()
353                    }
354                    _ => {}
355                }
356            }
357            offset += line.len();
358        }
359        if let Some(end) = closed {
360            out.body = after_open.get(end..).unwrap_or("");
361        } else {
362            out = Self { body: text, ..Self::default() };
363        }
364        out
365    }
366}
367
368/// Write the markdown files under `dir` as a Rust table for `include!`:
369/// `pub static FILES: &[(&str, &str)] = &[("skills/review.md", include_str!("…")), …];`, sorted
370/// by path, and print `cargo:rerun-if-changed` for every folder and file so an added, removed or
371/// edited page rebuilds the crate. For build scripts. Returns how many files were written.
372pub fn embed(dir: &Path, out_file: &Path) -> std::io::Result<usize> {
373    let mut files = Vec::new();
374    walk(dir, "", 0, &mut files);
375    files.sort();
376    println!("cargo:rerun-if-changed={}", dir.display());
377    let mut dirs = Vec::new();
378    walk_dirs(dir, 0, &mut dirs);
379    for d in &dirs {
380        println!("cargo:rerun-if-changed={}", d.display());
381    }
382    let mut src =
383        String::from("/// Every documentation file, `(path from the docs root, text)`, sorted by path.\npub static FILES: &[(&str, &str)] = &[\n");
384    for (rel, file) in &files {
385        println!("cargo:rerun-if-changed={}", file.display());
386        let abs = std::fs::canonicalize(file)?;
387        let _ = writeln!(src, "    ({rel:?}, include_str!({:?})),", abs.display().to_string());
388    }
389    src.push_str("];\n");
390    std::fs::write(out_file, src)?;
391    Ok(files.len())
392}
393
394/// Markdown files under `dir` as `(path from the root, file)`; links, dotfiles and other files
395/// skipped.
396fn walk(dir: &Path, rel: &str, depth: usize, out: &mut Vec<(String, std::path::PathBuf)>) {
397    if depth > MAX_DEPTH || out.len() >= MAX_FILES {
398        return;
399    }
400    let Ok(entries) = std::fs::read_dir(dir) else { return };
401    let mut entries: Vec<_> = entries.flatten().collect();
402    entries.sort_by_key(|e| e.file_name());
403    for entry in entries {
404        let name = entry.file_name().to_string_lossy().into_owned();
405        // `file_type` does not follow links, so a link is neither a file nor a folder here
406        let Ok(kind) = entry.file_type() else { continue };
407        if name.starts_with('.') || kind.is_symlink() {
408            continue;
409        }
410        let child = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
411        if kind.is_dir() {
412            walk(&entry.path(), &child, depth + 1, out);
413        } else if kind.is_file() && name.ends_with(".md") {
414            out.push((child, entry.path()));
415        }
416    }
417}
418
419fn walk_dirs(dir: &Path, depth: usize, out: &mut Vec<std::path::PathBuf>) {
420    if depth > MAX_DEPTH {
421        return;
422    }
423    let Ok(entries) = std::fs::read_dir(dir) else { return };
424    for entry in entries.flatten() {
425        let hidden = entry.file_name().to_string_lossy().starts_with('.');
426        if !hidden && entry.file_type().is_ok_and(|k| k.is_dir()) {
427            out.push(entry.path());
428            walk_dirs(&entry.path(), depth + 1, out);
429        }
430    }
431}
432
433/// `/a//b/` → `a/b`, backslashes as slashes.
434fn normalize(path: &str) -> String {
435    path.replace('\\', "/").split('/').filter(|p| !p.is_empty() && *p != ".").collect::<Vec<_>>().join("/")
436}
437
438/// `(folder, file name)` of a normalized path.
439fn split(path: &str) -> (&str, &str) {
440    path.rsplit_once('/').unwrap_or(("", path))
441}
442
443fn unquote(v: &str) -> String {
444    let inner = v.strip_prefix('"').and_then(|s| s.strip_suffix('"')).or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')));
445    match inner {
446        Some(s) if v.starts_with('"') => s.replace("\\\"", "\"").replace("\\\\", "\\"),
447        Some(s) => s.replace("''", "'"),
448        None => v.to_string(),
449    }
450}
451
452fn first_heading(body: &str) -> Option<String> {
453    body.lines().find_map(|l| l.strip_prefix("# ")).map(|t| t.trim().to_string()).filter(|t| !t.is_empty())
454}
455
456fn default_title(dir: &str) -> String {
457    if dir.is_empty() { "Documentation".to_string() } else { humanize(split(dir).1) }
458}
459
460/// `ui-demo_recipes` → `Ui demo recipes`.
461fn humanize(name: &str) -> String {
462    let words: Vec<&str> = name.split(['-', '_']).filter(|w| !w.is_empty()).collect();
463    let joined = words.join(" ");
464    let mut chars = joined.chars();
465    match chars.next() {
466        Some(first) => first.to_uppercase().chain(chars).collect(),
467        None => name.to_string(),
468    }
469}