cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// Extract the leading zero-padded number from a file path like `docs/adr/0003-foo.md`.
pub fn extract_number_from_path(path: &std::path::Path) -> Option<u64> {
    let filename = path.file_name()?.to_string_lossy();
    let numeric: String = filename
        .chars()
        .take_while(|c| c.is_ascii_digit())
        .collect();
    if numeric.is_empty() {
        return None;
    }
    numeric.parse::<u64>().ok()
}

/// Extract the canonical-id prefix of a directory or file name — the part
/// before the first `-`. For a v3 record this is `"0042"`; for a v4 record
/// this is the 13-char Crockford base32 TSID; for a v5 record the 26-char
/// Crockford base32 ULID.
///
/// Returns `None` if the name has no `-`.
pub fn extract_id_prefix_from_path(path: &std::path::Path) -> Option<String> {
    let filename = path.file_name()?.to_string_lossy();
    filename.split_once('-').map(|(p, _)| p.to_string())
}

/// Compare a directory-name prefix against an id suffix.
///
/// - For Crockford-shape ids (13-char TSID or 26-char ULID), the directory
///   prefix must match case-insensitively.
/// - For legacy numeric ids, both sides are parsed as `u32` (so `"42"` and
///   `"0042"` agree) and compared.
pub fn id_prefix_matches(dir_prefix: &str, id_suffix: &str) -> bool {
    if id_suffix.len() == 13 || id_suffix.len() == 26 {
        return dir_prefix.eq_ignore_ascii_case(id_suffix);
    }
    let Ok(id_num) = id_suffix.parse::<u32>() else {
        return false;
    };
    let Ok(dir_num) = dir_prefix.parse::<u32>() else {
        return false;
    };
    id_num == dir_num
}

#[cfg(test)]
pub mod strategy {
    use proptest::prelude::*;
    use std::path::PathBuf;

    /// Generate a v3-style record path like `docs/adr/0003-some-slug.md`.
    /// `extract_number_from_path` reads the file's leading digits, so the
    /// number must sit on the leaf component.
    pub fn record_path() -> impl Strategy<Value = PathBuf> {
        ("[a-z]{1,8}", 1u32..10_000, "[a-z]{1,12}")
            .prop_map(|(kind, n, slug)| PathBuf::from(format!("docs/{kind}/{n:04}-{slug}.md")))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn extract_number_round_trips(path in strategy::record_path()) {
            let n = extract_number_from_path(&path);
            prop_assert!(n.is_some());
        }

        #[test]
        fn id_prefix_matches_is_reflexive_for_legacy(n in 1u32..10_000) {
            let s = format!("{n:04}");
            prop_assert!(id_prefix_matches(&s, &s));
        }
    }
}