//! A small library crate: turns titles into URL slugs and counts words. //! //! Free sample from 99helpers.com/file-types/rs //! //! In a Cargo package this file would be `src/lib.rs`. Lines starting with //! `//!` document the crate itself; `///` documents the item below it, and //! `cargo doc` turns both into HTML. Code blocks in doc comments are //! compiled and run as tests by `cargo test`. //! //! ``` //! use sample_lib::slugify; //! assert_eq!(slugify("Hello, World!"), "hello-world"); //! ``` use std::collections::HashMap; /// The longest slug [`slugify`] returns, in bytes. pub const MAX_SLUG_LEN: usize = 60; /// Turns a title into a lowercase, hyphen-separated slug. /// /// Letters and digits are kept (lowercased), everything else becomes a /// single hyphen, and the result is cut to [`MAX_SLUG_LEN`] bytes. /// /// ``` /// use sample_lib::slugify; /// assert_eq!(slugify(" Rust 2024: what's new? "), "rust-2024-what-s-new"); /// ``` #[must_use] pub fn slugify(title: &str) -> String { let mut slug = String::with_capacity(title.len()); for c in title.chars() { if c.is_alphanumeric() { slug.extend(c.to_lowercase()); } else if !slug.is_empty() && !slug.ends_with('-') { slug.push('-'); } } let mut end = slug.trim_end_matches('-').len().min(MAX_SLUG_LEN); // Never cut a multi-byte character in half. while !slug.is_char_boundary(end) { end -= 1; } slug.truncate(end); slug.trim_end_matches('-').to_string() } /// Counts how often each word occurs, ignoring case. /// /// The returned map borrows nothing from `text`: every key is a new /// `String`, so the map can outlive the text it came from. #[must_use] pub fn word_counts(text: &str) -> HashMap { let mut counts = HashMap::new(); for word in text .split(|c: char| !c.is_alphanumeric()) .filter(|w| !w.is_empty()) { *counts.entry(word.to_lowercase()).or_insert(0) += 1; } counts } /// Something that has a title, and so a slug. /// /// A trait is a set of methods that several types can share. `slug` has /// a default implementation, so implementers only write `title`. pub trait Titled { /// The human-readable title. fn title(&self) -> &str; /// The URL slug for the title. fn slug(&self) -> String { slugify(self.title()) } } /// A blog post, as an example of a type that implements [`Titled`]. #[derive(Debug, Clone)] pub struct Post { pub title: String, pub body: String, } impl Titled for Post { fn title(&self) -> &str { &self.title } } /// Returns the item with the longest title. /// /// Generic over any type that implements [`Titled`]. The returned /// reference borrows from `items`: written out in full, the signature is /// `fn longest_title<'a, T: Titled>(items: &'a [T]) -> Option<&'a T>`, /// but the compiler infers these lifetimes, so they can be left out. pub fn longest_title(items: &[T]) -> Option<&T> { items.iter().max_by_key(|item| item.title().chars().count()) } #[cfg(test)] mod tests { use super::*; // A helper that only the tests use. Tests are a child module, so they // can also call the crate's private functions. fn is_slug(text: &str) -> bool { !text.is_empty() && !text.starts_with('-') && !text.ends_with('-') && !text.contains("--") && text.chars().all(|c| c == '-' || c.is_alphanumeric()) } #[test] fn slugs_are_clean() { for title in ["Hello, World!", "--a--b--", "Crème brûlée à 5 €", "x"] { assert!(is_slug(&slugify(title)), "{title:?}"); } assert_eq!(slugify("Crème brûlée à 5 €"), "crème-brûlée-à-5"); } #[test] fn long_titles_are_cut_on_a_character_boundary() { let slug = slugify(&"é".repeat(100)); assert!(slug.len() <= MAX_SLUG_LEN); assert_eq!(slug.chars().count(), MAX_SLUG_LEN / 2); } #[test] fn counts_words_case_insensitively() { let counts = word_counts("The cat and THE hat. The end"); assert_eq!(counts["the"], 3); assert_eq!(counts.get("dog"), None); } #[test] fn finds_the_longest_title() { let posts = vec![ Post { title: "Short".into(), body: String::new(), }, Post { title: "A much longer title".into(), body: "Text".into(), }, ]; let longest = longest_title(&posts).expect("list is not empty"); assert_eq!(longest.slug(), "a-much-longer-title"); assert_eq!(longest.body, "Text"); } }