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.
217 lines
6.0 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|