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("-")
}
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))
}
}
}
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::*;
#[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() {
assert_eq!(slugify("A--B"), "a-b");
}
#[test]
fn slugify_collapses_hyphens_from_adjacent_special_chars() {
assert_eq!(slugify("foo & bar"), "foo-bar");
}
#[test]
fn slugify_trims_leading_trailing_hyphens() {
assert_eq!(slugify("!hello!"), "hello");
}
#[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, "");
}
#[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");
}
}