1use std::io;
2use std::path::{Path, PathBuf};
3
4use walkdir::{DirEntry, WalkDir};
5
6use crate::model::RawDoc;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct AssetFile {
12 pub rel_path: String,
14 pub src_path: PathBuf,
16}
17
18const PRUNED_DIR_NAMES: &[&str] = &["node_modules", "target", "vendor", ".git"];
25
26fn 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 if name.starts_with('.') || PRUNED_DIR_NAMES.contains(&name.as_ref()) {
37 return false;
38 }
39 }
40 true
41}
42
43pub fn discover_docs(root: &Path) -> std::io::Result<Vec<RawDoc>> {
49 let mut docs = Vec::new();
50 let walker = WalkDir::new(root)
54 .sort_by_file_name()
55 .into_iter()
56 .filter_entry(is_kept);
57 for entry in walker {
58 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
76pub 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 if path.extension().and_then(|e| e.to_str()) == Some("md") {
97 continue;
98 }
99 if path.extension().and_then(|e| e.to_str()) == Some("puml") {
102 continue;
103 }
104 if path.extension().and_then(|e| e.to_str()) == Some("base") {
107 continue;
108 }
109 if path
111 .file_name()
112 .and_then(|n| n.to_str())
113 .map(|n| n.starts_with('.'))
114 .unwrap_or(false)
115 {
116 continue;
117 }
118 let rel = path
119 .strip_prefix(root)
120 .unwrap_or(path)
121 .to_string_lossy()
122 .replace('\\', "/");
123 assets.push(AssetFile {
124 rel_path: rel,
125 src_path: path.to_path_buf(),
126 });
127 }
128 Ok(assets)
129}
130
131pub fn discover_diagrams(root: &Path) -> std::io::Result<crate::pipeline::Diagrams> {
136 let mut out = crate::pipeline::Diagrams::new();
137 let walker = WalkDir::new(root)
138 .sort_by_file_name()
139 .into_iter()
140 .filter_entry(is_kept);
141 for entry in walker {
142 let entry = entry.map_err(io::Error::from)?;
143 if !entry.file_type().is_file() {
144 continue;
145 }
146 let path = entry.path();
147 if path.extension().and_then(|e| e.to_str()) != Some("puml") {
148 continue;
149 }
150 let rel = path
151 .strip_prefix(root)
152 .unwrap_or(path)
153 .to_string_lossy()
154 .replace('\\', "/");
155 out.insert(rel, std::fs::read_to_string(path)?);
156 }
157 Ok(out)
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct BaseFileInput {
163 pub rel_path: String,
165 pub slug: String,
167 pub source: String,
169}
170
171pub fn discover_bases(root: &Path) -> std::io::Result<Vec<BaseFileInput>> {
175 let mut out = Vec::new();
176 let walker = WalkDir::new(root)
177 .sort_by_file_name()
178 .into_iter()
179 .filter_entry(is_kept);
180 for entry in walker {
181 let entry = entry.map_err(io::Error::from)?;
182 if !entry.file_type().is_file() {
183 continue;
184 }
185 let path = entry.path();
186 if path.extension().and_then(|e| e.to_str()) != Some("base") {
187 continue;
188 }
189 let rel = path
190 .strip_prefix(root)
191 .unwrap_or(path)
192 .to_string_lossy()
193 .replace('\\', "/");
194 let slug = rel.strip_suffix(".base").unwrap_or(&rel).to_string();
195 out.push(BaseFileInput {
196 rel_path: rel,
197 slug,
198 source: std::fs::read_to_string(path)?,
199 });
200 }
201 Ok(out)
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn skips_non_md_and_returns_slash_separated_rel_paths() {
210 let dir = std::env::temp_dir().join(format!("docgen_discover_{}", std::process::id()));
211 let _ = std::fs::remove_dir_all(&dir);
212 std::fs::create_dir_all(dir.join("guide")).unwrap();
213 std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
214 std::fs::write(dir.join("notes.txt"), "ignore me\n").unwrap();
215 std::fs::write(dir.join("guide/intro.md"), "# Intro\n").unwrap();
216
217 let docs = discover_docs(&dir).unwrap();
218 let rels: Vec<&str> = docs.iter().map(|d| d.rel_path.as_str()).collect();
219
220 assert_eq!(rels, vec!["guide/intro.md", "index.md"]);
222
223 let _ = std::fs::remove_dir_all(&dir);
224 }
225
226 #[test]
227 fn prunes_hidden_and_vendor_dirs() {
228 let dir = std::env::temp_dir().join(format!("docgen_prune_{}", std::process::id()));
229 let _ = std::fs::remove_dir_all(&dir);
230 std::fs::create_dir_all(dir.join(".obsidian/plugins")).unwrap();
231 std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
232 std::fs::create_dir_all(dir.join("guide")).unwrap();
233 std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
234 std::fs::write(dir.join("guide/intro.md"), "# Intro\n").unwrap();
235 std::fs::write(dir.join(".obsidian/plugins/conf.md"), "# junk\n").unwrap();
237 std::fs::write(dir.join("node_modules/pkg/README.md"), "# dep\n").unwrap();
238
239 let docs = discover_docs(&dir).unwrap();
240 let rels: Vec<&str> = docs.iter().map(|d| d.rel_path.as_str()).collect();
241 assert_eq!(rels, vec!["guide/intro.md", "index.md"]);
242
243 let _ = std::fs::remove_dir_all(&dir);
244 }
245
246 #[test]
247 fn discover_assets_collects_non_md_preserving_tree() {
248 let dir = std::env::temp_dir().join(format!("docgen_assets_{}", std::process::id()));
249 let _ = std::fs::remove_dir_all(&dir);
250 std::fs::create_dir_all(dir.join("system/attachments")).unwrap();
251 std::fs::create_dir_all(dir.join(".obsidian")).unwrap();
252 std::fs::write(dir.join("system/index.md"), "# Sys\n").unwrap();
253 std::fs::write(dir.join("system/attachments/image.png"), b"\x89PNG").unwrap();
254 std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
255 std::fs::write(dir.join(".DS_Store"), b"junk").unwrap();
257 std::fs::write(dir.join(".obsidian/workspace.json"), "{}").unwrap();
258
259 let assets = discover_assets(&dir).unwrap();
260 let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
261 assert_eq!(rels, vec!["logo.svg", "system/attachments/image.png"]);
262
263 let _ = std::fs::remove_dir_all(&dir);
264 }
265
266 #[test]
267 fn discover_diagrams_loads_puml_and_assets_excludes_them() {
268 let dir = std::env::temp_dir().join(format!("docgen_puml_{}", std::process::id()));
269 let _ = std::fs::remove_dir_all(&dir);
270 std::fs::create_dir_all(dir.join("uml")).unwrap();
271 std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
272 std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
273 std::fs::write(dir.join("uml/a.puml"), "@startuml\nA->B\n@enduml\n").unwrap();
274
275 let diagrams = discover_diagrams(&dir).unwrap();
277 assert_eq!(diagrams.len(), 1);
278 assert_eq!(
279 diagrams.get("uml/a.puml").map(String::as_str),
280 Some("@startuml\nA->B\n@enduml\n")
281 );
282
283 let assets = discover_assets(&dir).unwrap();
285 let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
286 assert_eq!(rels, vec!["logo.svg"]);
287
288 let _ = std::fs::remove_dir_all(&dir);
289 }
290
291 #[test]
292 fn discover_bases_loads_base_and_assets_excludes_them() {
293 let dir = std::env::temp_dir().join(format!("docgen_base_{}", std::process::id()));
294 let _ = std::fs::remove_dir_all(&dir);
295 std::fs::create_dir_all(dir.join("Bases")).unwrap();
296 std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
297 std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
298 std::fs::write(dir.join("Bases/Books.base"), "views:\n - type: table\n").unwrap();
299
300 let bases = discover_bases(&dir).unwrap();
302 assert_eq!(bases.len(), 1);
303 assert_eq!(bases[0].rel_path, "Bases/Books.base");
304 assert_eq!(bases[0].slug, "Bases/Books");
305 assert!(bases[0].source.contains("type: table"));
306
307 let assets = discover_assets(&dir).unwrap();
309 let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
310 assert_eq!(rels, vec!["logo.svg"]);
311
312 let _ = std::fs::remove_dir_all(&dir);
313 }
314}