use crate::constants::{SIDECAR_EXTENSION, SKIP_DIRECTORIES};
use crate::utils::parallel_scan::is_hidden_file;
use crate::zimignore::ZimIgnore;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct DirNode {
pub rel_path: PathBuf,
pub readme: Option<PathBuf>,
pub other_md: Vec<PathBuf>,
}
impl DirNode {
pub fn is_root(&self) -> bool {
self.rel_path.as_os_str().is_empty()
}
pub fn file_count(&self) -> usize {
self.other_md.len() + usize::from(self.readme.is_some())
}
}
pub fn walk(root: &Path, zimignore: &ZimIgnore) -> Result<Vec<DirNode>, Box<dyn Error>> {
let mut out = Vec::new();
walk_recursive(root, root, zimignore, &mut out)?;
Ok(out)
}
fn walk_recursive(
root: &Path,
dir: &Path,
zimignore: &ZimIgnore,
out: &mut Vec<DirNode>,
) -> Result<(), Box<dyn Error>> {
let entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
let mut subdirs: Vec<PathBuf> = Vec::new();
let mut md_files: Vec<PathBuf> = Vec::new();
let mut readme: Option<PathBuf> = None;
for entry in entries {
let path = entry.path();
if is_hidden_file(&path) {
continue;
}
let is_dir = path.is_dir();
if zimignore.is_ignored(&path, is_dir) {
continue;
}
if is_dir {
let dir_name = match path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
if !SKIP_DIRECTORIES.contains(&dir_name.as_str()) {
subdirs.push(path);
}
} else if path.is_file()
&& path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(SIDECAR_EXTENSION))
{
let stem_is_readme = path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.eq_ignore_ascii_case("README"));
if stem_is_readme && readme.is_none() {
readme = Some(path);
} else {
md_files.push(path);
}
}
}
md_files.sort();
subdirs.sort();
if readme.is_some() || !md_files.is_empty() {
let rel_path = dir
.strip_prefix(root)
.unwrap_or(Path::new(""))
.to_path_buf();
out.push(DirNode {
rel_path,
readme,
other_md: md_files,
});
}
for sub in subdirs {
walk_recursive(root, &sub, zimignore, out)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn touch(p: &Path, body: &str) {
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
#[test]
fn walk_empty_returns_no_dirs() {
let tmp = TempDir::new().unwrap();
let dirs = walk(tmp.path(), &ZimIgnore::new()).unwrap();
assert!(dirs.is_empty());
}
#[test]
fn walk_root_with_md_only() {
let tmp = TempDir::new().unwrap();
touch(&tmp.path().join("a.md"), "# A");
touch(&tmp.path().join("README.md"), "# Top");
touch(&tmp.path().join("ignore.txt"), "x");
let dirs = walk(tmp.path(), &ZimIgnore::new()).unwrap();
assert_eq!(dirs.len(), 1);
assert!(dirs[0].is_root());
assert!(dirs[0].readme.is_some());
assert_eq!(dirs[0].other_md.len(), 1);
}
#[test]
fn walk_nested_lexicographic_order() {
let tmp = TempDir::new().unwrap();
touch(&tmp.path().join("README.md"), "# Top");
touch(&tmp.path().join("mixes/README.md"), "# Mixes");
touch(&tmp.path().join("mixes/track-2.md"), "# Two");
touch(&tmp.path().join("mixes/track-1.md"), "# One");
touch(&tmp.path().join("bounces/README.md"), "# Bounces");
let dirs = walk(tmp.path(), &ZimIgnore::new()).unwrap();
let names: Vec<String> = dirs
.iter()
.map(|d| d.rel_path.to_string_lossy().to_string())
.collect();
assert_eq!(names, vec!["", "bounces", "mixes"]);
let mixes = dirs
.iter()
.find(|d| d.rel_path == Path::new("mixes"))
.unwrap();
assert_eq!(mixes.other_md.len(), 2);
assert!(mixes.other_md[0].ends_with("track-1.md"));
assert!(mixes.other_md[1].ends_with("track-2.md"));
}
#[test]
fn walk_skips_dotgit_and_hidden() {
let tmp = TempDir::new().unwrap();
touch(&tmp.path().join("a.md"), "# A");
touch(&tmp.path().join(".git/config.md"), "# nope");
touch(&tmp.path().join(".hidden/x.md"), "# nope");
let dirs = walk(tmp.path(), &ZimIgnore::new()).unwrap();
assert_eq!(dirs.len(), 1);
assert!(dirs[0].is_root());
}
#[test]
fn walk_skips_dirs_with_only_subdir_md() {
let tmp = TempDir::new().unwrap();
touch(&tmp.path().join("mixes/v2/track.md"), "# track");
let dirs = walk(tmp.path(), &ZimIgnore::new()).unwrap();
let names: Vec<String> = dirs
.iter()
.map(|d| d.rel_path.to_string_lossy().to_string())
.collect();
assert_eq!(names, vec!["mixes/v2"]);
}
}