vtcode_commons/
tracker_title.rs1use crate::formatting::truncate_byte_budget;
9use crate::slug::is_humanized_codename;
10
11pub const TRACKER_TITLE_ITEM_BUDGET: usize = 60;
14
15#[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#[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}