whycodes_session/
title.rs1use std::path::Path;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum TitleSource {
13 #[default]
15 Default,
16 Heuristic,
18 Generated,
20 Manual,
22}
23
24impl TitleSource {
25 pub fn allows_heuristic(self) -> bool {
27 matches!(self, Self::Default)
28 }
29
30 pub fn allows_llm(self) -> bool {
32 matches!(self, Self::Default | Self::Heuristic)
33 }
34}
35
36pub fn default_title(project_path: &Path, session_id: &str) -> String {
41 let base = project_basename(project_path);
42 let suffix = short_id_suffix(session_id);
43 format!("{base}-{suffix}")
44}
45
46fn project_basename(project_path: &Path) -> String {
47 project_path
48 .file_name()
49 .and_then(|s| s.to_str())
50 .map(|s| s.trim())
51 .filter(|s| !s.is_empty() && *s != "/" && *s != ".")
52 .unwrap_or("session")
53 .chars()
54 .map(|c| {
55 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
56 c
57 } else {
58 '-'
59 }
60 })
61 .collect::<String>()
62 .trim_matches('-')
63 .chars()
64 .take(32)
65 .collect::<String>()
66 .pipe(|s| if s.is_empty() { "session".into() } else { s })
67}
68
69trait Pipe: Sized {
71 fn pipe<F, R>(self, f: F) -> R
72 where
73 F: FnOnce(Self) -> R;
74}
75
76impl<T> Pipe for T {
77 fn pipe<F, R>(self, f: F) -> R
78 where
79 F: FnOnce(Self) -> R,
80 {
81 f(self)
82 }
83}
84
85fn short_id_suffix(session_id: &str) -> String {
86 let hex: String = session_id
87 .chars()
88 .filter(|c| c.is_ascii_hexdigit())
89 .take(2)
90 .collect::<String>()
91 .to_ascii_lowercase();
92 if hex.len() == 2 { hex } else { "00".into() }
93}
94
95pub fn looks_like_default_title(title: &str, project_path: &Path) -> bool {
97 let t = title.trim();
98 if t.starts_with("New session") {
99 return true;
100 }
101 let base = project_basename(project_path);
102 if let Some(rest) = t.strip_prefix(&format!("{base}-"))
104 && rest.len() == 2
105 && rest.chars().all(|c| c.is_ascii_hexdigit())
106 {
107 return true;
108 }
109 false
110}
111
112pub fn heuristic_title(text: &str) -> String {
117 let raw = first_meaningful_line(text);
118 let stripped = strip_leading_filler(raw);
119 let words: Vec<&str> = stripped
120 .split_whitespace()
121 .filter(|w| !w.is_empty())
122 .take(8)
123 .collect();
124 if words.is_empty() {
125 return String::new();
126 }
127 let take = words.len().clamp(1, 8);
129 let joined = words[..take].join(" ");
130 sanitize_title(&joined)
131}
132
133fn first_meaningful_line(text: &str) -> &str {
134 for line in text.lines() {
135 let t = line.trim();
136 if t.is_empty() {
137 continue;
138 }
139 if t.starts_with('@') && !t.contains(' ') && text.lines().count() > 1 {
141 continue;
142 }
143 return t;
144 }
145 text.trim()
146}
147
148fn strip_leading_filler(s: &str) -> &str {
149 const FILLERS: &[&str] = &[
150 "please ",
151 "pls ",
152 "can you ",
153 "could you ",
154 "would you ",
155 "help me ",
156 "i need you to ",
157 "i want you to ",
158 "hey ",
159 "hi ",
160 "hello ",
161 ];
162 let mut lower = s.to_ascii_lowercase();
163 let mut offset = 0usize;
164 let bytes = s.as_bytes();
165 loop {
166 let mut hit = false;
167 for f in FILLERS {
168 if lower.starts_with(f) {
169 offset += f.len();
170 lower = lower[f.len()..].to_string();
171 hit = true;
172 break;
173 }
174 }
175 if !hit {
176 break;
177 }
178 }
179 let start = offset.min(bytes.len());
181 s.get(start..).unwrap_or(s).trim()
182}
183
184pub fn sanitize_title(raw: &str) -> String {
186 let mut s = raw.trim().to_string();
187 if (s.starts_with('"') && s.ends_with('"'))
189 || (s.starts_with('\'') && s.ends_with('\''))
190 || (s.starts_with('“') && s.ends_with('”'))
191 {
192 s = s[1..s.len() - 1].trim().to_string();
193 }
194 while s.ends_with(['.', '!', ':', ';', ',']) {
196 s.pop();
197 s = s.trim_end().to_string();
198 }
199 let cleaned: String = s
201 .chars()
202 .map(|c| if c.is_control() { ' ' } else { c })
203 .collect::<String>()
204 .split_whitespace()
205 .collect::<Vec<_>>()
206 .join(" ");
207 const MAX_CHARS: usize = 64;
209 let n = cleaned.chars().count();
210 if n <= MAX_CHARS {
211 cleaned
212 } else {
213 let mut out: String = cleaned.chars().take(MAX_CHARS.saturating_sub(1)).collect();
214 out.push('…');
215 out
216 }
217}
218
219pub fn infer_source_from_title(title: &str, project_path: &Path) -> TitleSource {
221 if looks_like_default_title(title, project_path) {
222 TitleSource::Default
223 } else {
224 TitleSource::Generated
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::path::PathBuf;
233
234 #[test]
235 fn default_title_uses_basename_and_id() {
236 let t = default_title(&PathBuf::from("/home/u/whycodes"), "a3f1c2d4-…");
237 assert_eq!(t, "whycodes-a3");
238 }
239
240 #[test]
241 fn heuristic_strips_filler_and_limits_words() {
242 let t = heuristic_title("Please can you fix the stripe webhook timeout in payments.rs");
243 assert!(t.to_ascii_lowercase().contains("stripe"));
244 assert!(!t.to_ascii_lowercase().starts_with("please"));
245 assert!(t.split_whitespace().count() <= 8);
246 }
247
248 #[test]
249 fn sanitize_strips_quotes() {
250 assert_eq!(sanitize_title(" \"Auth refactor\" "), "Auth refactor");
251 }
252
253 #[test]
254 fn looks_like_default() {
255 let p = PathBuf::from("/tmp/my-app");
256 assert!(looks_like_default_title("my-app-3f", &p));
257 assert!(looks_like_default_title("New session - 2026-01-01", &p));
258 assert!(!looks_like_default_title("Fix webhook timeout", &p));
259 }
260
261 #[test]
262 fn infer_source_from_title_classifies_default_vs_named() {
263 let p = PathBuf::from("/tmp/my-app");
264 assert_eq!(
265 infer_source_from_title("my-app-3f", &p),
266 TitleSource::Default
267 );
268 assert_eq!(
269 infer_source_from_title("Fix webhook timeout", &p),
270 TitleSource::Generated
271 );
272 }
273
274 #[test]
275 fn source_gates() {
276 assert!(TitleSource::Default.allows_heuristic());
277 assert!(TitleSource::Default.allows_llm());
278 assert!(!TitleSource::Heuristic.allows_heuristic());
279 assert!(TitleSource::Heuristic.allows_llm());
280 assert!(!TitleSource::Manual.allows_llm());
281 assert!(!TitleSource::Generated.allows_llm());
282 }
283
284 #[test]
285 fn defaults_handle_unusual_paths_and_ids() {
286 assert_eq!(default_title(Path::new("/"), "x"), "session-00");
287 assert_eq!(default_title(Path::new("/tmp/!!!"), "AB"), "session-ab");
288 assert_eq!(
289 default_title(Path::new("/tmp/a name"), "--fZ3"),
290 "a-name-f3"
291 );
292 let long = "a".repeat(40);
293 assert_eq!(default_title(Path::new(&long), "12").len(), 35);
294 assert_eq!(default_title(Path::new("."), "zz"), "session-00");
295 assert_eq!(project_basename(Path::new("")), "session");
296 assert_eq!(short_id_suffix("zz"), "00");
297 }
298
299 #[test]
300 fn heuristic_handles_empty_paths_and_multiline_prompts() {
301 assert_eq!(heuristic_title(" \n\t"), "");
302 assert_eq!(
303 heuristic_title("@src/lib.rs\nPlease fix login now"),
304 "fix login now"
305 );
306 assert_eq!(heuristic_title("@only.rs"), "@only.rs");
307 assert_eq!(heuristic_title("Hello help me repair it"), "repair it");
308 }
309
310 #[test]
311 fn sanitize_handles_quote_styles_punctuation_controls_and_caps() {
312 assert_eq!(sanitize_title("'Single title'"), "Single title");
313 assert_eq!(sanitize_title("Title!!! "), "Title");
314 assert_eq!(sanitize_title("\"Double title\""), "Double title");
315 assert_eq!(sanitize_title("a\n\tb"), "a b");
316 let long = "é".repeat(70);
317 let cleaned = sanitize_title(&long);
318 assert_eq!(cleaned.chars().count(), 64);
319 assert!(cleaned.ends_with('…'));
320 }
321
322 #[test]
323 fn default_detection_and_source_inference_cover_false_shapes() {
324 let p = PathBuf::from("/tmp/my-app");
325 assert!(!looks_like_default_title("other-3f", &p));
326 assert!(!looks_like_default_title("my-app-z9", &p));
327 assert!(!looks_like_default_title("my-app-123", &p));
328 assert_eq!(
329 infer_source_from_title("my-app-af", &p),
330 TitleSource::Default
331 );
332 assert_eq!(
333 infer_source_from_title("Useful title", &p),
334 TitleSource::Generated
335 );
336 }
337}