//! Convert a project directory name into a human-readable //! display name (P-NAME-2 from `requirements.md`, ADR-0015 §2). //! //! Rules: //! //! - Strip a leading `YYYYMMDD-` for temp projects. //! - Split on `-` (kebab), `_` (snake), or case boundaries //! (camelCase / PascalCase). //! - Title-case each resulting word. //! //! Examples (covered by tests below): //! //! ```text //! 20260507-water-buffalo-skating -> "Water Buffalo Skating" //! MyOrders -> "My Orders" //! customer_demo -> "Customer Demo" //! exam-1-prep -> "Exam 1 Prep" //! ``` /// Produce a display name from a project directory name. #[must_use] pub fn prettify(dirname: &str) -> String { let trimmed = strip_date_prefix(dirname); let trimmed = strip_temp_marker(trimmed); let words = split_into_words(trimmed); words .into_iter() .map(title_case_word) .collect::>() .join(" ") } /// Strip a leading `[temp]-` segment if present. Temp project /// directory names look like `20260507-[temp]-water-buffalo-skating` /// after `strip_date_prefix`, this removes the marker so the /// display name reduces to just the three random words. fn strip_temp_marker(s: &str) -> &str { s.strip_prefix("[temp]-").unwrap_or(s) } /// Strip a leading `YYYYMMDD-` if present. Eight ASCII digits /// followed by a single `-` are required; anything else is /// returned unchanged. fn strip_date_prefix(s: &str) -> &str { if s.len() < 9 { return s; } let (head, tail) = s.split_at(9); let mut chars = head.chars(); let date_chars: Vec = chars.by_ref().take(8).collect(); let separator = chars.next(); let date_ok = date_chars.len() == 8 && date_chars.iter().all(char::is_ascii_digit); if date_ok && separator == Some('-') { tail } else { s } } /// Split a string into "words" using kebab, snake, and case /// boundaries. Empty segments are dropped; leading/trailing /// separators are tolerated. fn split_into_words(s: &str) -> Vec { let mut words: Vec = Vec::new(); let mut current = String::new(); let push = |current: &mut String, words: &mut Vec| { if !current.is_empty() { words.push(std::mem::take(current)); } }; let mut prev: Option = None; for c in s.chars() { let is_separator = c == '-' || c == '_'; if is_separator { push(&mut current, &mut words); prev = None; continue; } // Case-boundary detection: insert a split before an // uppercase letter that follows a lowercase letter or // digit (camelCase / PascalCase). Also split before an // uppercase letter that begins a run after a lowercase // letter (e.g. `MyOrders` -> `My Orders`). if let Some(p) = prev && c.is_uppercase() && (p.is_lowercase() || p.is_ascii_digit()) { push(&mut current, &mut words); } // Also split before a digit run after letters // (e.g. `exam1prep` -> `exam 1 prep`). if let Some(p) = prev && c.is_ascii_digit() && p.is_alphabetic() { push(&mut current, &mut words); } // And before letters following a digit (e.g. // `1prep` -> `1 prep`). if let Some(p) = prev && c.is_alphabetic() && p.is_ascii_digit() { push(&mut current, &mut words); } current.push(c); prev = Some(c); } push(&mut current, &mut words); words } /// Title-case a single word. /// /// Uppercases the first character and leaves the rest /// unchanged; empty strings pass through. fn title_case_word(word: String) -> String { let mut chars = word.chars(); chars.next().map_or_else(String::new, |first| { first.to_uppercase().chain(chars).collect() }) } #[cfg(test)] mod tests { use super::*; #[test] fn strips_date_prefix_from_temp_project_names() { assert_eq!( prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating" ); } #[test] fn strips_date_and_temp_marker() { assert_eq!( prettify("20260507-[temp]-water-buffalo-skating"), "Water Buffalo Skating", ); } #[test] fn handles_pascal_case() { assert_eq!(prettify("MyOrders"), "My Orders"); } #[test] fn handles_camel_case() { assert_eq!(prettify("myOrders"), "My Orders"); } #[test] fn handles_snake_case() { assert_eq!(prettify("customer_demo"), "Customer Demo"); } #[test] fn handles_kebab_case_without_date_prefix() { assert_eq!(prettify("customer-demo"), "Customer Demo"); } #[test] fn splits_at_digit_boundaries() { assert_eq!(prettify("exam1prep"), "Exam 1 Prep"); } #[test] fn keeps_kebab_around_digits_intact() { assert_eq!(prettify("exam-1-prep"), "Exam 1 Prep"); } #[test] fn does_not_strip_non_date_eight_chars() { // Eight letters then `-` is not a date prefix. assert_eq!(prettify("Customers-orders"), "Customers Orders"); } #[test] fn does_not_strip_when_no_separator_after_digits() { // Eight digits but no `-` immediately after. assert_eq!(prettify("12345678abc"), "12345678 Abc"); } #[test] fn handles_consecutive_separators() { assert_eq!(prettify("a__b--c"), "A B C"); } #[test] fn handles_empty() { assert_eq!(prettify(""), ""); } #[test] fn handles_single_word() { assert_eq!(prettify("orders"), "Orders"); } #[test] fn handles_unicode_word() { // Non-ASCII letters are preserved; first-char uppercase. assert_eq!(prettify("café-règles"), "Café Règles"); } #[test] fn handles_mixed_separators_and_case() { assert_eq!( prettify("MyTeam_lessonPlan-2026"), "My Team Lesson Plan 2026" ); } }