Skip to main content

vtcode_commons/
tracker_title.rs

1//! Shared task-tracker checklist title resolution.
2//!
3//! Both the CLI transcript/panel and the desktop Tasks viewer resolve raw
4//! checklist titles through these helpers so generated timestamp slugs never
5//! surface as random names. String-based by design: CLI callers walk tool
6//! payloads while desktop callers walk typed snapshots, and both converge
7//! here for the humanization and fallback rules.
8use crate::formatting::truncate_byte_budget;
9use crate::slug::is_humanized_codename;
10
11/// Byte budget for fallback titles derived from the first checklist item,
12/// matching the header budget used at tracker creation.
13pub const TRACKER_TITLE_ITEM_BUDGET: usize = 60;
14
15/// Humanize generated tracker titles (`1789108823046-kind-lagoon` → `Kind
16/// Lagoon`) so headers read as a name instead of a file-stem ID. Only
17/// millisecond-timestamp-prefixed slugs are rewritten; user titles
18/// (`Release`, paths, sentences) pass through verbatim.
19#[must_use]
20pub fn humanize_tracker_title(raw: &str) -> String {
21    let trimmed = raw.trim();
22    if trimmed.is_empty() {
23        return "Task tracker".to_string();
24    }
25    let mut parts = trimmed.splitn(2, ['-', '_']);
26    let prefix = parts.next().unwrap_or_default();
27    let slug = parts.next().unwrap_or_default();
28    let is_generated_id = prefix.len() >= 10
29        && prefix.chars().all(|character| character.is_ascii_digit())
30        && !slug.is_empty()
31        && slug
32            .chars()
33            .all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_');
34    if !is_generated_id {
35        return trimmed.to_string();
36    }
37    let humanized = slug
38        .split(['-', '_'])
39        .filter(|word| !word.is_empty())
40        .map(|word| {
41            let mut chars = word.chars();
42            match chars.next() {
43                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
44                None => String::new(),
45            }
46        })
47        .collect::<Vec<_>>()
48        .join(" ");
49    if humanized.is_empty() {
50        trimmed.to_string()
51    } else {
52        humanized
53    }
54}
55
56/// Descriptive header title: never surface a generated plan codename.
57///
58/// Checklist titles created from approved plans already carry the plan-summary
59/// clause, but older/manual payloads may still carry a timestamp slug
60/// (`1789108823046-jolly-forest`) or its humanized form (`Jolly Forest`).
61/// Those read as random names and say nothing about the work, so derive a
62/// purpose-indicating title from the first checklist item instead. User titles
63/// pass through verbatim (humanized only for timestamp slugs).
64#[must_use]
65pub fn resolve_tracker_title(
66    raw_title: Option<&str>,
67    first_item_short: Option<&str>,
68    fallback_default: &str,
69) -> String {
70    let raw = raw_title.map(str::trim).filter(|title| !title.is_empty());
71    let Some(raw) = raw else {
72        return descriptive_or_fallback(first_item_short, fallback_default);
73    };
74    if is_generated_tracker_title(raw) {
75        return descriptive_or_fallback(first_item_short, fallback_default);
76    }
77    humanize_tracker_title(raw)
78}
79
80fn is_generated_tracker_title(raw: &str) -> bool {
81    let trimmed = raw.trim();
82    let mut parts = trimmed.splitn(2, ['-', '_']);
83    let prefix = parts.next().unwrap_or_default();
84    let slug = parts.next().unwrap_or_default();
85    let is_timestamped =
86        prefix.len() >= 10 && prefix.chars().all(|character| character.is_ascii_digit()) && !slug.is_empty();
87    if is_timestamped {
88        return true;
89    }
90    is_humanized_codename(&humanize_tracker_title(trimmed))
91}
92
93fn descriptive_or_fallback(first_item_short: Option<&str>, fallback_default: &str) -> String {
94    first_item_short
95        .map(str::trim)
96        .filter(|short| !short.is_empty())
97        .map(|short| truncate_byte_budget(short, TRACKER_TITLE_ITEM_BUDGET, "…"))
98        .unwrap_or_else(|| fallback_default.to_string())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn humanize_rewrites_timestamp_slugs_only() {
107        assert_eq!(humanize_tracker_title("1789108823046-kind-lagoon"), "Kind Lagoon");
108        assert_eq!(humanize_tracker_title("1789108823046-kind_lagoon"), "Kind Lagoon");
109        assert_eq!(humanize_tracker_title("Release"), "Release");
110        assert_eq!(humanize_tracker_title("2024-report"), "2024-report");
111        assert_eq!(humanize_tracker_title("  Task tracker  "), "Task tracker");
112        assert_eq!(humanize_tracker_title(""), "Task tracker");
113    }
114
115    #[test]
116    fn resolve_prefers_user_titles_then_first_item_then_fallback() {
117        assert_eq!(resolve_tracker_title(Some("Release"), Some("Write code"), "Tasks"), "Release");
118        assert_eq!(resolve_tracker_title(Some("1789108823046-kind-lagoon"), Some("Write code"), "Tasks"), "Write code");
119        assert_eq!(resolve_tracker_title(None, Some("Write code"), "Tasks"), "Write code");
120        assert_eq!(resolve_tracker_title(None, None, "Tasks"), "Tasks");
121        assert_eq!(resolve_tracker_title(Some("   "), Some("  "), "Tasks"), "Tasks");
122    }
123
124    #[test]
125    fn resolve_bounds_first_item_fallback_to_header_budget() {
126        let long = "Rewrite the authentication flow end to end with tests";
127        let resolved = resolve_tracker_title(None, Some(long), "Tasks");
128        assert!(resolved.len() <= TRACKER_TITLE_ITEM_BUDGET + "…".len(), "{resolved}");
129        assert!(resolved.starts_with("Rewrite the authentication"), "{resolved}");
130    }
131}