cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Shared filesystem utilities for the issue and decision-record repositories.

use std::path::{Path, PathBuf};

// ── Entry lookup by ID suffix ─────────────────────────────────────────────────

/// Find the subdirectory of `dir` whose name starts with the given ID suffix
/// followed by a hyphen.
///
/// `suffix` is the part of the canonical ID after the prefix — for legacy
/// records it is a numeric string like `"0042"`, for v4 records (ADR-0022)
/// it is a 13-char Crockford base32 TSID like `"0DCT3MKW5T2K0"`. Numeric
/// suffixes are normalised to a 4-digit zero-padded form before matching so
/// `"42"` still finds `0042-add-login/`.
///
/// Returns the path to the matching directory, or `None` if not found.
pub(super) fn find_subdir(dir: &Path, suffix: &str) -> Option<PathBuf> {
    // A 13-char suffix is a TSID (ADR-0022) — match it verbatim, uppercased.
    // Anything shorter is a legacy numeric suffix; zero-pad to 4 to match
    // the on-disk `NNNN-...` directory names.
    let candidate = if suffix.len() == 13 {
        format!("{}-", suffix.to_ascii_uppercase())
    } else if let Ok(n) = suffix.parse::<u32>() {
        format!("{n:04}-")
    } else {
        format!("{suffix}-")
    };
    std::fs::read_dir(dir).ok()?.find_map(|e| {
        let path = e.ok()?.path();
        (path.is_dir() && path.file_name()?.to_string_lossy().starts_with(&candidate))
            .then_some(path)
    })
}

/// Format the directory-name prefix for the given canonical-id suffix.
///
/// Numeric suffixes are zero-padded to 4 digits (legacy `0042-...` form).
/// TSID suffixes are uppercased verbatim. This is the value that precedes
/// the `-<slug>` portion of the on-disk directory name.
pub(super) fn directory_prefix(suffix: &str) -> String {
    if suffix.len() == 13 {
        return suffix.to_ascii_uppercase();
    }
    if let Ok(n) = suffix.parse::<u32>() {
        return format!("{n:04}");
    }
    suffix.to_ascii_uppercase()
}

// ── Path collection ───────────────────────────────────────────────────────────

/// Collect `index.md` paths from immediate subdirectories of `dir`.
///
/// Returns an empty vec if `dir` does not exist.
pub(super) fn collect_index_paths(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
    if !dir.exists() {
        return Ok(vec![]);
    }
    let mut paths = Vec::new();
    for entry in std::fs::read_dir(dir)
        .map_err(|e| anyhow::anyhow!("reading directory {}: {e}", dir.display()))?
    {
        let path = entry?.path();
        if path.is_dir() {
            let index = path.join("index.md");
            if index.exists() {
                paths.push(index);
            }
        }
    }
    Ok(paths)
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn make_subdir(parent: &Path, name: &str) {
        std::fs::create_dir(parent.join(name)).unwrap();
    }

    #[test]
    fn find_subdir_returns_matching_directory() {
        let tmp = TempDir::new().unwrap();
        make_subdir(tmp.path(), "0001-add-login");
        let found = find_subdir(tmp.path(), "0001");
        assert!(found.is_some());
        assert!(found.unwrap().ends_with("0001-add-login"));
    }

    #[test]
    fn find_subdir_pads_short_numeric_suffix_to_four_digits() {
        let tmp = TempDir::new().unwrap();
        make_subdir(tmp.path(), "0042-foo");
        let found = find_subdir(tmp.path(), "42");
        assert!(found.is_some());
        assert!(found.unwrap().ends_with("0042-foo"));
    }

    #[test]
    fn find_subdir_matches_tsid_prefix() {
        let tmp = TempDir::new().unwrap();
        make_subdir(tmp.path(), "0DCT3MKW5T2K0-add-login");
        let found = find_subdir(tmp.path(), "0DCT3MKW5T2K0");
        assert!(found.is_some());
        assert!(found.unwrap().ends_with("0DCT3MKW5T2K0-add-login"));
    }

    #[test]
    fn find_subdir_returns_none_when_absent() {
        let tmp = TempDir::new().unwrap();
        assert!(find_subdir(tmp.path(), "99").is_none());
    }

    #[test]
    fn collect_index_paths_returns_index_md_from_subdirs() {
        let tmp = TempDir::new().unwrap();
        let sub = tmp.path().join("0001-issue");
        std::fs::create_dir(&sub).unwrap();
        std::fs::write(sub.join("index.md"), "").unwrap();
        let paths = collect_index_paths(tmp.path()).unwrap();
        assert_eq!(paths.len(), 1);
        assert!(paths[0].ends_with("index.md"));
    }
}