use std::io;
use std::path::{Path, PathBuf};
use walkdir::{DirEntry, WalkDir};
use crate::model::RawDoc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssetFile {
pub rel_path: String,
pub src_path: PathBuf,
}
const PRUNED_DIR_NAMES: &[&str] = &["node_modules", "target", "vendor", ".git"];
fn is_kept(entry: &DirEntry) -> bool {
if entry.depth() == 0 {
return true;
}
let name = entry.file_name().to_string_lossy();
if entry.file_type().is_dir() {
if name.starts_with('.') || PRUNED_DIR_NAMES.contains(&name.as_ref()) {
return false;
}
}
true
}
pub fn discover_docs(root: &Path) -> std::io::Result<Vec<RawDoc>> {
let mut docs = Vec::new();
let walker = WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_entry(is_kept);
for entry in walker {
let entry = entry.map_err(io::Error::from)?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let raw = std::fs::read_to_string(path)?;
docs.push(RawDoc { rel_path: rel, raw });
}
Ok(docs)
}
pub fn discover_assets(root: &Path) -> std::io::Result<Vec<AssetFile>> {
let mut assets = Vec::new();
let walker = WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_entry(is_kept);
for entry in walker {
let entry = entry.map_err(io::Error::from)?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
continue;
}
if path.extension().and_then(|e| e.to_str()) == Some("puml") {
continue;
}
if path.extension().and_then(|e| e.to_str()) == Some("base") {
continue;
}
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
{
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
assets.push(AssetFile {
rel_path: rel,
src_path: path.to_path_buf(),
});
}
Ok(assets)
}
pub fn discover_diagrams(root: &Path) -> std::io::Result<crate::pipeline::Diagrams> {
let mut out = crate::pipeline::Diagrams::new();
let walker = WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_entry(is_kept);
for entry in walker {
let entry = entry.map_err(io::Error::from)?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("puml") {
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
out.insert(rel, std::fs::read_to_string(path)?);
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaseFileInput {
pub rel_path: String,
pub slug: String,
pub source: String,
}
pub fn discover_bases(root: &Path) -> std::io::Result<Vec<BaseFileInput>> {
let mut out = Vec::new();
let walker = WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_entry(is_kept);
for entry in walker {
let entry = entry.map_err(io::Error::from)?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("base") {
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let slug = rel.strip_suffix(".base").unwrap_or(&rel).to_string();
out.push(BaseFileInput {
rel_path: rel,
slug,
source: std::fs::read_to_string(path)?,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skips_non_md_and_returns_slash_separated_rel_paths() {
let dir = std::env::temp_dir().join(format!("docgen_discover_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("guide")).unwrap();
std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
std::fs::write(dir.join("notes.txt"), "ignore me\n").unwrap();
std::fs::write(dir.join("guide/intro.md"), "# Intro\n").unwrap();
let docs = discover_docs(&dir).unwrap();
let rels: Vec<&str> = docs.iter().map(|d| d.rel_path.as_str()).collect();
assert_eq!(rels, vec!["guide/intro.md", "index.md"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn prunes_hidden_and_vendor_dirs() {
let dir = std::env::temp_dir().join(format!("docgen_prune_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".obsidian/plugins")).unwrap();
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(dir.join("guide")).unwrap();
std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
std::fs::write(dir.join("guide/intro.md"), "# Intro\n").unwrap();
std::fs::write(dir.join(".obsidian/plugins/conf.md"), "# junk\n").unwrap();
std::fs::write(dir.join("node_modules/pkg/README.md"), "# dep\n").unwrap();
let docs = discover_docs(&dir).unwrap();
let rels: Vec<&str> = docs.iter().map(|d| d.rel_path.as_str()).collect();
assert_eq!(rels, vec!["guide/intro.md", "index.md"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_assets_collects_non_md_preserving_tree() {
let dir = std::env::temp_dir().join(format!("docgen_assets_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("system/attachments")).unwrap();
std::fs::create_dir_all(dir.join(".obsidian")).unwrap();
std::fs::write(dir.join("system/index.md"), "# Sys\n").unwrap();
std::fs::write(dir.join("system/attachments/image.png"), b"\x89PNG").unwrap();
std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
std::fs::write(dir.join(".DS_Store"), b"junk").unwrap();
std::fs::write(dir.join(".obsidian/workspace.json"), "{}").unwrap();
let assets = discover_assets(&dir).unwrap();
let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
assert_eq!(rels, vec!["logo.svg", "system/attachments/image.png"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_diagrams_loads_puml_and_assets_excludes_them() {
let dir = std::env::temp_dir().join(format!("docgen_puml_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("uml")).unwrap();
std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
std::fs::write(dir.join("uml/a.puml"), "@startuml\nA->B\n@enduml\n").unwrap();
let diagrams = discover_diagrams(&dir).unwrap();
assert_eq!(diagrams.len(), 1);
assert_eq!(
diagrams.get("uml/a.puml").map(String::as_str),
Some("@startuml\nA->B\n@enduml\n")
);
let assets = discover_assets(&dir).unwrap();
let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
assert_eq!(rels, vec!["logo.svg"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_bases_loads_base_and_assets_excludes_them() {
let dir = std::env::temp_dir().join(format!("docgen_base_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Bases")).unwrap();
std::fs::write(dir.join("index.md"), "# Home\n").unwrap();
std::fs::write(dir.join("logo.svg"), "<svg/>").unwrap();
std::fs::write(dir.join("Bases/Books.base"), "views:\n - type: table\n").unwrap();
let bases = discover_bases(&dir).unwrap();
assert_eq!(bases.len(), 1);
assert_eq!(bases[0].rel_path, "Bases/Books.base");
assert_eq!(bases[0].slug, "Bases/Books");
assert!(bases[0].source.contains("type: table"));
let assets = discover_assets(&dir).unwrap();
let rels: Vec<&str> = assets.iter().map(|a| a.rel_path.as_str()).collect();
assert_eq!(rels, vec!["logo.svg"]);
let _ = std::fs::remove_dir_all(&dir);
}
}