Iteration 1: file-backed projects with auto-named temps, lock file, and L1 CLI
Replaces the in-memory database with an on-disk project. Startup either opens a project at the positional CLI path (L1) or creates an auto-named temp project (<YYYYMMDD>-<word>-<word>-<word>) under the OS-standard data directory or a --data-dir override. The new project::Project type owns the directory skeleton and a PID+hostname lock file with stale-lock takeover via sysinfo. The status bar now shows "Project: <Display Name>", derived by a small kebab/snake/camel prettifier. Per-command persistence to YAML/CSV/history.log is NOT yet wired -- that's Iteration 2; for now playground.db carries the state across quits. Tests: 257 passing (231 lib + 9 new integration + 17 existing), 0 failing, 0 skipped. Clippy clean with nursery lints.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
//! 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 words = split_into_words(trimmed);
|
||||
words
|
||||
.into_iter()
|
||||
.map(title_case_word)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// 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 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user