zim-studio 1.5.0

A Terminal-Based Audio Project Scaffold and Metadata System
Documentation
//! Deterministic walker that groups markdown files by directory.
//!
//! Returns one [`DirNode`] per directory that directly contains at least one
//! `.md` file. Directories are emitted in pre-order, lexicographically sorted
//! by relative path, so re-runs produce identical output.

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};

/// One directory's worth of markdown content.
#[derive(Debug, Clone)]
pub struct DirNode {
    /// Path relative to the walk root. Empty for the root itself.
    pub rel_path: PathBuf,
    /// Absolute path to `README.md` if this directory has one.
    pub readme: Option<PathBuf>,
    /// Other `.md` files in this directory, sorted alphabetically.
    pub other_md: Vec<PathBuf>,
}

impl DirNode {
    /// True if this node represents the walk root.
    pub fn is_root(&self) -> bool {
        self.rel_path.as_os_str().is_empty()
    }

    /// Total number of markdown files contributed by this directory.
    pub fn file_count(&self) -> usize {
        self.other_md.len() + usize::from(self.readme.is_some())
    }
}

/// Walk `root` and return one [`DirNode`] per directory that contains markdown.
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"]);
    }
}