mini-docs 0.7.0

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use crate::error::DocError;

/// Hard ceiling on recursion depth. A real docs tree never approaches this; it exists
/// so a pathological input (or a symlink loop that somehow evaded the symlink skip)
/// fails loudly instead of recursing without bound.
const MAX_WALK_DEPTH: usize = 64;

/// Recursively collect files under `root` whose extension matches `extension`.
///
/// Symlinks are never followed (files or directories), so this cannot cycle on a
/// symlink loop. Recursion depth is bounded by [`MAX_WALK_DEPTH`]; exceeding it is
/// reported as [`DocError::Io`] rather than recursing further. Results are sorted for
/// deterministic ordering across runs and platforms.
pub(crate) fn walk_files_with_extension(
    root: &Path,
    extension: &str,
) -> Result<Vec<PathBuf>, DocError> {
    let mut results = Vec::new();
    walk_dir(root, extension, 0, &mut results)?;
    results.sort();
    Ok(results)
}

fn walk_dir(
    dir: &Path,
    extension: &str,
    depth: usize,
    results: &mut Vec<PathBuf>,
) -> Result<(), DocError> {
    if depth > MAX_WALK_DEPTH {
        return Err(DocError::Io(io::Error::other(format!(
            "directory depth exceeded {MAX_WALK_DEPTH} under {}",
            dir.display()
        ))));
    }

    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let file_type = entry.file_type()?;

        if file_type.is_symlink() {
            continue;
        }

        let path = entry.path();
        if file_type.is_dir() {
            walk_dir(&path, extension, depth + 1, results)?;
        } else if file_type.is_file() && path.extension().is_some_and(|e| e == extension) {
            results.push(path);
        }
    }

    Ok(())
}