1pub fn humanize_title(raw: &str) -> Option<String> {
18 intent_line(raw).map(|l| truncate(l, 80))
19}
20
21pub fn humanize_goal(raw: &str, max: usize) -> Option<String> {
24 intent_line(raw).map(|l| truncate(l, max))
25}
26
27fn intent_line(raw: &str) -> Option<&str> {
29 raw.lines().map(str::trim).find(|l| is_human_intent(l))
30}
31
32fn is_human_intent(line: &str) -> bool {
33 let l = line.trim();
34 if l.chars().count() < 6 {
35 return false;
36 }
37 if let Some(c) = l.chars().next() {
39 if matches!(c, '/' | '$' | '#' | '{' | '[' | '<' | '|' | '`') {
40 return false;
41 }
42 }
43 if l.starts_with("http://") || l.starts_with("https://") {
44 return false;
45 }
46 if l.contains("Task Journal resumed") {
47 return false;
48 }
49 if looks_like_log(l) || looks_like_shell_prompt(l) {
50 return false;
51 }
52 l.chars().any(char::is_alphabetic) && l.split_whitespace().count() >= 2
54}
55
56fn looks_like_log(l: &str) -> bool {
58 if let Some(rb) = l.find(']') {
60 if rb > 0 && rb <= 8 && l[..rb].chars().all(|c| c.is_ascii_digit()) {
61 return true;
62 }
63 }
64 const LEVELS: [&str; 5] = ["INFO:", "WARN:", "ERROR:", "DEBUG:", "TRACE:"];
65 LEVELS.iter().any(|lvl| l.contains(lvl))
66}
67
68fn looks_like_shell_prompt(l: &str) -> bool {
70 let Some(at) = l.find('@') else {
71 return false;
72 };
73 if at >= 40 {
74 return false;
75 }
76 let after = &l[at + 1..];
77 after.contains(":~") || after.contains(":/") || after.contains("$ ") || after.contains("# ")
78}
79
80fn truncate(s: &str, max: usize) -> String {
82 if s.chars().count() <= max {
83 return s.to_string();
84 }
85 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
86 format!("{}…", cut.trim_end())
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn rejects_numeric_log_line() {
95 assert_eq!(
96 humanize_title("685] INFO: Mapped {/rest-api/paymentlnk-notify, POST} route"),
97 None
98 );
99 }
100
101 #[test]
102 fn rejects_timestamped_log_line() {
103 assert_eq!(
104 humanize_title("[11:13:30.685] INFO: Mapped {/rest-api/qiwi-notify, POST}"),
105 None
106 );
107 }
108
109 #[test]
110 fn rejects_resume_banner() {
111 assert_eq!(
112 humanize_title("[Task Journal resumed: tj-ma8393mg0d — Так, давай ты сделаешь match]"),
113 None
114 );
115 }
116
117 #[test]
118 fn rejects_shell_prompt() {
119 assert_eq!(
120 humanize_title(
121 "shahinyanm@DESKTOP-KM9V32O:~/docker-local-env$ claude plugin marketplace update"
122 ),
123 None
124 );
125 }
126
127 #[test]
128 fn rejects_json_and_paths() {
129 assert_eq!(humanize_title("{\"command\":\"task-journal\"}"), None);
130 assert_eq!(humanize_title("/home/shahinyanm/www/claude-memory"), None);
131 }
132
133 #[test]
134 fn accepts_plain_prose() {
135 assert_eq!(
136 humanize_title("Fix the auth bug in the payment middleware"),
137 Some("Fix the auth bug in the payment middleware".to_string())
138 );
139 }
140
141 #[test]
142 fn accepts_russian_prose() {
143 assert_eq!(
144 humanize_title("Сделай так чтобы имя сессии не ломалось"),
145 Some("Сделай так чтобы имя сессии не ломалось".to_string())
146 );
147 }
148
149 #[test]
150 fn picks_first_human_line_after_noise() {
151 let raw = "685] INFO: Mapped {/x, POST}\n\
152 shahinyanm@host:~$ ls\n\
153 Add validation for negative order amounts";
154 assert_eq!(
155 humanize_title(raw),
156 Some("Add validation for negative order amounts".to_string())
157 );
158 }
159
160 #[test]
161 fn truncates_long_title_to_80_chars() {
162 let long = "Implement ".repeat(20);
163 let out = humanize_title(&long).unwrap();
164 assert!(
165 out.chars().count() <= 80,
166 "got {} chars",
167 out.chars().count()
168 );
169 assert!(out.ends_with('…'));
170 }
171
172 #[test]
173 fn none_when_only_noise() {
174 let raw = "685] INFO: a\n$ git status\n/clear\n{\"k\":1}";
175 assert_eq!(humanize_title(raw), None);
176 }
177
178 #[test]
179 fn goal_allows_longer_text() {
180 let line = "Make sure every coding session always records its reasoning chain \
181 into the task journal without spawning a model";
182 let goal = humanize_goal(line, 200).unwrap();
183 assert!(goal.chars().count() > 80);
184 assert!(goal.starts_with("Make sure every coding session"));
185 }
186}