use std::fs; use std::io; use std::path::{Path, PathBuf}; /// Ein Knoten im Notiz-Baum. /// /// Verschachtelt statt flache Arena: Der Baum wird beim Öffnen einmal /// aufgebaut und danach nur gelesen — die Indirektion einer Arena zahlt sich /// erst aus, wenn Knoten wandern oder sich gegenseitig referenzieren. /// /// Als Enum statt als Struct mit `children: Vec<_>`, weil damit unmöglich /// wird, was ein Struct erlaubt: eine Datei mit Kindern. /// /// Eine einzige `File`-Variante für alle Dateitypen. Wer wissen will, ob es /// eine Notiz ist, fragt `path.extension()` — das ist eine Zeile an der /// Stelle, die es braucht, statt einer Klassifizierung, die der Baum beim /// Aufbau festschreibt. #[derive(Debug, Clone, PartialEq)] pub enum TreeNode { File { name: String, path: PathBuf, }, Folder { name: String, path: PathBuf, children: Vec, }, } impl TreeNode { pub fn name(&self) -> &str { match self { Self::File { name, .. } | Self::Folder { name, .. } => name, } } pub fn path(&self) -> &Path { match self { Self::File { path, .. } | Self::Folder { path, .. } => path, } } pub fn is_folder(&self) -> bool { matches!(self, Self::Folder { .. }) } } /// Liest den Inhalt eines Ordners rekursiv ein und liefert seine Kinder. /// /// `std::fs::read_dir` mit eigener Rekursion statt `walkdir`: Letzteres liegt /// zwar bereits im Abhängigkeitsgraph, iteriert aber flach — daraus einen /// Baum zusammenzusetzen wäre mehr Code, nicht weniger. pub fn read_tree(root: &Path) -> io::Result> { let mut nodes = Vec::new(); for entry in fs::read_dir(root)? { let entry = entry?; let name = entry.file_name().to_string_lossy().into_owned(); // Alles mit führendem Punkt bleibt draußen. Das erledigt `.dionotes` // und `.git` in einem Aufwasch, ohne eine Ausnahmeliste, die bei // `.obsidian` oder `.vscode` wieder wachsen müsste. if name.starts_with('.') { continue; } let path = entry.path(); // `symlink_metadata` folgt dem Link nicht, `metadata` schon. Symlinks // zu folgen hieße, einen Zyklusschutz über kanonisierte Pfade // mitzuführen — realer Aufwand für einen Fall, den in einem // Notizordner kaum jemand hat, während ein Hänger beim Öffnen für den // Nutzer schwer zu deuten wäre. let Ok(meta) = fs::symlink_metadata(&path) else { continue; }; if meta.is_symlink() { continue; } if meta.is_dir() { // Ein Unterordner ohne Leserecht wird leer dargestellt, statt den // ganzen Baum scheitern zu lassen. let children = read_tree(&path).unwrap_or_default(); nodes.push(TreeNode::Folder { name, path, children }); } else { nodes.push(TreeNode::File { name, path }); } } sort(&mut nodes); Ok(nodes) } /// Ordner zuerst, dann Dateien, jeweils alphabetisch ohne Rücksicht auf /// Groß-/Kleinschreibung — die Konvention aus jedem Dateibrowser. `read_dir` /// liefert plattformabhängig unsortiert. fn sort(nodes: &mut [TreeNode]) { nodes.sort_by(|a, b| { b.is_folder() .cmp(&a.is_folder()) .then_with(|| a.name().to_lowercase().cmp(&b.name().to_lowercase())) }); } #[cfg(test)] mod tests { use super::{read_tree, TreeNode}; use std::fs; use std::path::PathBuf; fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("dionotes-tree-{name}")); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar"); dir } fn names(nodes: &[TreeNode]) -> Vec<&str> { nodes.iter().map(|n| n.name()).collect() } #[test] fn folders_come_first_then_files_each_alphabetically() { let dir = scratch("sorting"); fs::write(dir.join("zebra.md"), "").unwrap(); fs::write(dir.join("Apfel.md"), "").unwrap(); fs::create_dir(dir.join("Werkzeug")).unwrap(); fs::create_dir(dir.join("archiv")).unwrap(); let tree = read_tree(&dir).unwrap(); // Gross-/Kleinschreibung darf die Reihenfolge nicht bestimmen. assert_eq!(names(&tree), ["archiv", "Werkzeug", "Apfel.md", "zebra.md"]); } #[test] fn every_file_type_is_included_not_just_markdown() { let dir = scratch("all-files"); fs::write(dir.join("notiz.md"), "").unwrap(); fs::write(dir.join("bild.png"), "").unwrap(); fs::write(dir.join("ohne-endung"), "").unwrap(); let tree = read_tree(&dir).unwrap(); assert_eq!(names(&tree), ["bild.png", "notiz.md", "ohne-endung"]); } #[test] fn dotted_names_are_skipped_files_and_folders_alike() { let dir = scratch("hidden"); fs::create_dir_all(dir.join(".dionotes")).unwrap(); fs::create_dir_all(dir.join(".git").join("objects")).unwrap(); fs::write(dir.join(".gitignore"), "").unwrap(); fs::write(dir.join("sichtbar.md"), "").unwrap(); assert_eq!(names(&read_tree(&dir).unwrap()), ["sichtbar.md"]); } #[test] fn empty_folders_are_shown() { let dir = scratch("empty-folder"); fs::create_dir(dir.join("leer")).unwrap(); let tree = read_tree(&dir).unwrap(); match &tree[..] { [TreeNode::Folder { name, children, .. }] => { assert_eq!(name, "leer"); assert!(children.is_empty()); } other => panic!("erwartet ein leerer Ordner, war {other:?}"), } } #[test] fn nesting_is_recursive() { let dir = scratch("nested"); fs::create_dir_all(dir.join("a").join("b")).unwrap(); fs::write(dir.join("a").join("b").join("tief.md"), "").unwrap(); let tree = read_tree(&dir).unwrap(); let TreeNode::Folder { children: a, .. } = &tree[0] else { panic!("erwartet Ordner a"); }; let TreeNode::Folder { children: b, .. } = &a[0] else { panic!("erwartet Ordner b"); }; assert_eq!(names(b), ["tief.md"]); } #[test] fn missing_root_is_an_error_not_an_empty_tree() { let dir = scratch("no-root").join("gibtsnicht"); assert!(read_tree(&dir).is_err()); } }