Files
rdbms-playground/src/project/prettifier.rs
T
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00

217 lines
6.0 KiB
Rust

//! 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::<Vec<_>>()
.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<char> = 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<String> {
let mut words: Vec<String> = Vec::new();
let mut current = String::new();
let push = |current: &mut String, words: &mut Vec<String>| {
if !current.is_empty() {
words.push(std::mem::take(current));
}
};
let mut prev: Option<char> = 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"
);
}
}