Skip to main content

docgen_core/
discover.rs

1use std::io;
2use std::path::{Path, PathBuf};
3
4use walkdir::{DirEntry, WalkDir};
5
6use crate::model::RawDoc;
7
8/// A non-markdown file discovered under the docs root, to be copied verbatim into
9/// the built site so relative asset references (images, PDFs, …) resolve.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct AssetFile {
12    /// Path relative to the docs root, `/`-separated — also its output location.
13    pub rel_path: String,
14    /// Absolute source path to copy from.
15    pub src_path: PathBuf,
16}
17
18/// Directories that should never be treated as documentation, even when they
19/// live under `docs/`. A mature project's docs tree often vendors tooling (an
20/// Obsidian vault's `.obsidian`, a checked-in `node_modules`, a `.git`), and
21/// walking those drowns the sidebar/search/graph in irrelevant files. We prune
22/// any hidden directory (name starting with `.`) and a handful of well-known
23/// dependency/output dirs by name.
24const PRUNED_DIR_NAMES: &[&str] = &["node_modules", "target", "vendor", ".git"];
25
26/// Whether the walker should descend into / yield this entry. The walk root
27/// itself (depth 0) is always kept — only nested hidden / dependency dirs are
28/// pruned, so a project whose root happens to be hidden still builds.
29fn is_kept(entry: &DirEntry) -> bool {
30    if entry.depth() == 0 {
31        return true;
32    }
33    let name = entry.file_name().to_string_lossy();
34    if entry.file_type().is_dir() {
35        // Prune hidden dirs (`.obsidian`, `.superpowers`, …) and known vendor dirs.
36        if name.starts_with('.') || PRUNED_DIR_NAMES.contains(&name.as_ref()) {
37            return false;
38        }
39    }
40    true
41}
42
43/// Walk `root` recursively and read every `.md` file into a RawDoc.
44/// `rel_path` is the path relative to `root`, normalized to `/` separators.
45///
46/// Hidden directories and well-known dependency/output dirs (`node_modules`,
47/// `target`, …) are pruned — see [`PRUNED_DIR_NAMES`] / [`is_kept`].
48pub fn discover_docs(root: &Path) -> std::io::Result<Vec<RawDoc>> {
49    let mut docs = Vec::new();
50    // Sort entries for deterministic, reproducible discovery order; prune
51    // hidden/vendor dirs so a vendored `node_modules` or Obsidian vault under
52    // `docs/` doesn't pollute the site.
53    let walker = WalkDir::new(root)
54        .sort_by_file_name()
55        .into_iter()
56        .filter_entry(is_kept);
57    for entry in walker {
58        // Propagate traversal errors (unreadable dirs, broken symlinks, loops)
59        // instead of silently dropping docs.
60        let entry = entry.map_err(io::Error::from)?;
61        let path = entry.path();
62        if path.extension().and_then(|e| e.to_str()) != Some("md") {
63            continue;
64        }
65        let rel = path
66            .strip_prefix(root)
67            .unwrap_or(path)
68            .to_string_lossy()
69            .replace('\\', "/");
70        let raw = std::fs::read_to_string(path)?;
71        docs.push(RawDoc { rel_path: rel, raw });
72    }
73    Ok(docs)
74}
75
76/// Walk `root` recursively and collect every non-`.md` file as an [`AssetFile`].
77///
78/// Shares the same prune rules as [`discover_docs`] (hidden / vendor dirs), so a
79/// vendored `node_modules` or an Obsidian `.obsidian` dir under `docs/` is not
80/// copied into the site. Hidden files (`.DS_Store`, …) are skipped too. These are
81/// the images/PDFs/etc. that authored markdown links to relatively; the build
82/// copies them into the output mirroring this relative tree.
83pub fn discover_assets(root: &Path) -> std::io::Result<Vec<AssetFile>> {
84    let mut assets = Vec::new();
85    let walker = WalkDir::new(root)
86        .sort_by_file_name()
87        .into_iter()
88        .filter_entry(is_kept);
89    for entry in walker {
90        let entry = entry.map_err(io::Error::from)?;
91        if !entry.file_type().is_file() {
92            continue;
93        }
94        let path = entry.path();
95        // Markdown files become pages, not copied assets.
96        if path.extension().and_then(|e| e.to_str()) == Some("md") {
97            continue;
98        }
99        // PlantUML sources are build inputs (rendered to inline SVG via
100        // `:::plantuml{src=...}`), not published assets — never copy them.
101        if path.extension().and_then(|e| e.to_str()) == Some("puml") {
102            continue;
103        }
104        // Skip hidden files (e.g. `.DS_Store`) even though they aren't dirs.
105        if path
106            .file_name()
107            .and_then(|n| n.to_str())
108            .map(|n| n.starts_with('.'))
109            .unwrap_or(false)
110        {
111            continue;
112        }
113        let rel = path
114            .strip_prefix(root)
115            .unwrap_or(path)
116            .to_string_lossy()
117            .replace('\\', "/");
118        assets.push(AssetFile {
119            rel_path: rel,
120            src_path: path.to_path_buf(),
121        });
122    }
123    Ok(assets)
124}
125
126/// Walk `root` and load every `.puml` file into a docs-relative path → source
127/// map, for `:::plantuml{src=...}` references. Shares the prune rules of
128/// [`discover_docs`]. These files are build inputs, not published assets (they
129/// are excluded from [`discover_assets`]).
130pub fn discover_diagrams(root: &Path) -> std::io::Result<crate::pipeline::Diagrams> {
131    let mut out = crate::pipeline::Diagrams::new();
132    let walker = WalkDir::new(root)
133        .sort_by_file_name()
134        .into_iter()
135        .filter_entry(is_kept);
136    for entry in walker {
137        let entry = entry.map_err(io::Error::from)?;
138        if !entry.file_type().is_file() {
139            continue;
140        }
141        let path = entry.path();
142        if path.extension().and_then(|e| e.to_str()) != Some("puml") {
143            continue;
144        }
145        let rel = path
146            .strip_prefix(root)
147            .unwrap_or(path)
148            .to_string_lossy()
149            .replace('\\', "/");
150        out.insert(rel, std::fs::read_to_string(path)?);
151    }
152    Ok(out)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn skips_non_md_and_returns_slash_separated_rel_paths() {
161        let dir = std::env::temp_dir().join(format!("docgen_discover_{}", std::process::id()));
162        let _ = std::fs::remove_dir_all(&dir);
163        std::fs::create_dir_all(dir.join("guide")).unwrap();
164        std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
165        std::fs::write(dir.join("notes.txt"), "ignore me\n").unwrap();
166        std::fs::write(dir.join("guide/intro.md"), "# Intro\n").unwrap();
167
168        let docs = discover_docs(&dir).unwrap();
169        let rels: Vec<&str> = docs.iter().map(|d| d.rel_path.as_str()).collect();
170
171        // Only the two .md files, in deterministic (sorted) order, slash-separated.
172        assert_eq!(rels, vec!["guide/intro.md", "index.md"]);
173
174        let _ = std::fs::remove_dir_all(&dir);
175    }
176
177    #[test]
178    fn prunes_hidden_and_vendor_dirs() {
179        let dir = std::env::temp_dir().join(format!("docgen_prune_{}", std::process::id()));
180        let _ = std::fs::remove_dir_all(&dir);
181        std::fs::create_dir_all(dir.join(".obsidian/plugins")).unwrap();
182        std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
183        std::fs::create_dir_all(dir.join("guide")).unwrap();
184        std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
185        std::fs::write(dir.join("guide/intro.md"), "# Intro\n").unwrap();
186        // These must be pruned, not ingested.
187        std::fs::write(dir.join(".obsidian/plugins/conf.md"), "# junk\n").unwrap();
188        std::fs::write(dir.join("node_modules/pkg/README.md"), "# dep\n").unwrap();
189
190        let docs = discover_docs(&dir).unwrap();
191        let rels: Vec<&str> = docs.iter().map(|d| d.rel_path.as_str()).collect();
192        assert_eq!(rels, vec!["guide/intro.md", "index.md"]);
193
194        let _ = std::fs::remove_dir_all(&dir);
195    }
196
197    #[test]
198    fn discover_assets_collects_non_md_preserving_tree() {
199        let dir = std::env::temp_dir().join(format!("docgen_assets_{}", std::process::id()));
200        let _ = std::fs::remove_dir_all(&dir);
201        std::fs::create_dir_all(dir.join("system/attachments")).unwrap();
202        std::fs::create_dir_all(dir.join(".obsidian")).unwrap();
203        std::fs::write(dir.join("system/index.md"), "# Sys\n").unwrap();
204        std::fs::write(dir.join("system/attachments/image.png"), b"\x89PNG").unwrap();
205        std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
206        // Must be skipped: markdown, hidden file, and files under a pruned dir.
207        std::fs::write(dir.join(".DS_Store"), b"junk").unwrap();
208        std::fs::write(dir.join(".obsidian/workspace.json"), "{}").unwrap();
209
210        let assets = discover_assets(&dir).unwrap();
211        let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
212        assert_eq!(rels, vec!["logo.svg", "system/attachments/image.png"]);
213
214        let _ = std::fs::remove_dir_all(&dir);
215    }
216
217    #[test]
218    fn discover_diagrams_loads_puml_and_assets_excludes_them() {
219        let dir = std::env::temp_dir().join(format!("docgen_puml_{}", std::process::id()));
220        let _ = std::fs::remove_dir_all(&dir);
221        std::fs::create_dir_all(dir.join("uml")).unwrap();
222        std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
223        std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
224        std::fs::write(dir.join("uml/a.puml"), "@startuml\nA->B\n@enduml\n").unwrap();
225
226        // Diagrams: only the .puml, keyed by slash-separated rel path.
227        let diagrams = discover_diagrams(&dir).unwrap();
228        assert_eq!(diagrams.len(), 1);
229        assert_eq!(
230            diagrams.get("uml/a.puml").map(String::as_str),
231            Some("@startuml\nA->B\n@enduml\n")
232        );
233
234        // Assets: the .puml is NOT copied (build input), only the real asset is.
235        let assets = discover_assets(&dir).unwrap();
236        let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
237        assert_eq!(rels, vec!["logo.svg"]);
238
239        let _ = std::fs::remove_dir_all(&dir);
240    }
241}