tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Title → kebab-case filename stem.
//!
//! The date-prefixed issue filename is `{created}-{slug}.md` where the slug
//! is produced by [`slugify`]. The slug is deterministic kebab-case: ASCII
//! alphanumerics are lower-cased and kept, every other character acts as a
//! separator, separator runs collapse to a single `-`, hyphens are trimmed
//! at both ends, and the result is capped at 80 characters. An empty result
//! falls back to `untitled`.

/// Build the kebab-case filename stem for a title.
///
/// # Examples
///
/// ```
/// use tix::slug::slugify;
/// assert_eq!(slugify("Fix: Login crash!"), "fix-login-crash");
/// ```
pub fn slugify(title: &str) -> String {
    let mut out = String::with_capacity(title.len());
    let mut prev_separator = false;
    for ch in title.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_separator = false;
        } else if !out.is_empty() && !prev_separator {
            out.push('-');
            prev_separator = true;
        }
    }
    if out.ends_with('-') {
        out.pop();
    }
    if out.len() > 80 {
        out.truncate(80);
        let trimmed = out.trim_end_matches('-');
        // `truncate` may have left a trailing hyphen; trim it too.
        out = trimmed.to_string();
    }
    if out.is_empty() {
        "untitled".to_string()
    } else {
        out
    }
}

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

    #[test]
    fn basic_title_becomes_kebab() {
        assert_eq!(slugify("Fix: Login crash!"), "fix-login-crash");
    }

    #[test]
    fn lowercases_kept_ascii() {
        assert_eq!(
            slugify("Add DOCUMENTATION for the Feature"),
            "add-documentation-for-the-feature"
        );
    }

    #[test]
    fn punctuation_and_spaces_collapse_to_single_hyphen() {
        assert_eq!(slugify("a  b   c"), "a-b-c");
        assert_eq!(slugify("one & two / three"), "one-two-three");
    }

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

    #[test]
    fn unicode_is_handled_without_panic_and_never_leaks_non_ascii() {
        let slug = slugify("héllo wörld ☃ café");
        assert!(
            slug.chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
        );
        assert_ne!(slug, "");
    }

    #[test]
    fn empty_and_meta_titles_fall_back_to_untitled() {
        assert_eq!(slugify(""), "untitled");
        assert_eq!(slugify("   "), "untitled");
        assert_eq!(slugify("!!! --- ???"), "untitled");
    }

    #[test]
    fn long_titles_are_capped_at_80_chars_without_trailing_hyphen() {
        let long = format!("{} tail", "x-".repeat(200));
        let slug = slugify(&long);
        assert!(slug.len() <= 80);
        assert!(!slug.ends_with('-'));
        assert!(slug.starts_with("x-x-"));
    }

    #[test]
    fn hyphen_run_in_middle_collapses() {
        assert_eq!(slugify("fix   login---crash"), "fix-login-crash");
    }
}