cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// Slugify a title into a filename-safe string.
///
/// Converts to lowercase, replaces non-alphanumeric characters with hyphens,
/// collapses consecutive hyphens, and trims leading/trailing hyphens.
pub(crate) fn slugify(title: &str) -> String {
    let raw: String = title
        .to_lowercase()
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
        .collect();
    raw.split('-')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

/// Validate a `description:` frontmatter value. Returns `Ok(None)` for
/// missing or empty values, `Ok(Some(_))` for valid one-liners, and an
/// error mentioning `path` when the value contains a newline or fails
/// any other [`Description`] invariant.
pub(crate) fn parse_description(
    value: Option<&str>,
    path: &std::path::Path,
) -> anyhow::Result<Option<crate::domain::model::description::Description>> {
    match value {
        None => Ok(None),
        Some(s) if s.trim().is_empty() => Ok(None),
        Some(s) => {
            let d = crate::domain::model::description::Description::new(s)
                .map_err(|e| anyhow::anyhow!("{e} in {}", path.display()))?;
            Ok(Some(d))
        }
    }
}

/// Extract YAML frontmatter (between `---` delimiters) and the body that follows.
pub(super) fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
    let source = source.strip_prefix("---\n")?;
    let end = source.find("\n---")?;
    let frontmatter = &source[..end];
    let body = &source[end + 4..];
    Some((frontmatter, body.trim_start_matches('\n')))
}

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

    // --- slugify ---

    #[test]
    fn slugify_basic() {
        assert_eq!(slugify("Use Rust"), "use-rust");
    }

    #[test]
    fn slugify_removes_special_chars() {
        assert_eq!(slugify("Use PostgreSQL!"), "use-postgresql");
    }

    #[test]
    fn slugify_trims_leading_and_trailing_spaces() {
        assert_eq!(slugify("  leading spaces  "), "leading-spaces");
    }

    #[test]
    fn slugify_replaces_slash_with_hyphen() {
        assert_eq!(slugify("A/B testing"), "a-b-testing");
    }

    #[test]
    fn slugify_collapses_consecutive_hyphens() {
        // A title like "A--B" (two hyphens) should not produce "a--b"
        assert_eq!(slugify("A--B"), "a-b");
    }

    #[test]
    fn slugify_collapses_hyphens_from_adjacent_special_chars() {
        // "foo & bar" → special chars on both sides of space → "foo---bar" → "foo-bar"
        assert_eq!(slugify("foo & bar"), "foo-bar");
    }

    #[test]
    fn slugify_trims_leading_trailing_hyphens() {
        assert_eq!(slugify("!hello!"), "hello");
    }

    // --- split_frontmatter ---

    #[test]
    fn split_frontmatter_extracts_fm_and_body() {
        let src = "---\nkey: value\n---\nbody text\n";
        let (fm, body) = split_frontmatter(src).unwrap();
        assert_eq!(fm, "key: value");
        assert_eq!(body, "body text\n");
    }

    #[test]
    fn split_frontmatter_trims_leading_newline_from_body() {
        let src = "---\nkey: value\n---\n\nbody text\n";
        let (_, body) = split_frontmatter(src).unwrap();
        assert_eq!(body, "body text\n");
    }

    #[test]
    fn split_frontmatter_returns_none_when_no_opening_delimiter() {
        assert!(split_frontmatter("key: value\n---\nbody").is_none());
    }

    #[test]
    fn split_frontmatter_returns_none_when_no_closing_delimiter() {
        assert!(split_frontmatter("---\nkey: value\nbody").is_none());
    }

    #[test]
    fn split_frontmatter_returns_empty_body_when_nothing_after_closing_delimiter() {
        let src = "---\nkey: value\n---\n";
        let (fm, body) = split_frontmatter(src).unwrap();
        assert_eq!(fm, "key: value");
        assert_eq!(body, "");
    }

    // --- parse_description ---

    #[test]
    fn parse_description_none_for_missing_value() {
        let path = std::path::Path::new("dummy.md");
        assert_eq!(parse_description(None, path).unwrap(), None);
    }

    #[test]
    fn parse_description_some_for_one_line_value() {
        let path = std::path::Path::new("dummy.md");
        let out = parse_description(Some("a one-line summary"), path).unwrap();
        assert_eq!(out.as_ref().map(|d| d.as_str()), Some("a one-line summary"));
    }

    #[test]
    fn parse_description_preserves_special_characters() {
        let path = std::path::Path::new("dummy.md");
        let out = parse_description(Some("café — résumé à 101%"), path).unwrap();
        assert_eq!(
            out.as_ref().map(|d| d.as_str()),
            Some("café — résumé à 101%")
        );
    }

    #[test]
    fn parse_description_rejects_multiline_value_and_cites_path() {
        let path = std::path::Path::new("docs/issues/0042-foo/index.md");
        let err = parse_description(Some("first\nsecond"), path).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("single line"), "got: {msg}");
        assert!(msg.contains("docs/issues/0042-foo/index.md"), "got: {msg}");
    }

    #[test]
    fn split_frontmatter_handles_empty_frontmatter() {
        let src = "---\n\n---\nbody\n";
        let (fm, body) = split_frontmatter(src).unwrap();
        assert_eq!(fm, "");
        assert_eq!(body, "body\n");
    }
}