1use crate::model::{DueSpec, Id, Recurrence};
7
8fn is_name_char(c: char) -> bool {
9 c.is_ascii_alphanumeric() || c == '_' || c == '-'
10}
11
12fn is_word_char(c: char) -> bool {
13 c.is_alphanumeric() || c == '_'
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct ExtractedTitle {
21 pub title: String,
22 pub mentions: Vec<Id>,
23 pub tags: Vec<String>,
24 pub due: Option<DueSpec>,
25 pub recurrence: Option<Recurrence>,
26}
27
28enum BracketValue {
29 Due(DueSpec),
30 Recurrence(Recurrence),
31}
32
33fn parse_bracket(content: &str) -> Option<BracketValue> {
39 let content = content.trim();
40 if let Ok(spec) = DueSpec::parse(content) {
41 return Some(BracketValue::Due(spec));
42 }
43 Recurrence::parse(content)
44 .ok()
45 .map(BracketValue::Recurrence)
46}
47
48pub fn extract_title_syntax(title: &str) -> ExtractedTitle {
57 let chars: Vec<char> = title.chars().collect();
58 let mut out = String::with_capacity(title.len());
59 let mut mentions: Vec<Id> = Vec::new();
60 let mut tags: Vec<String> = Vec::new();
61 let mut due: Option<DueSpec> = None;
62 let mut recurrence: Option<Recurrence> = None;
63 let mut prev_word = false;
64 let mut i = 0;
65 while i < chars.len() {
66 let c = chars[i];
67 if c == '`' {
68 let start = i;
69 while i < chars.len() && chars[i] == '`' {
70 i += 1;
71 }
72 let fence_len = i - start;
73 let mut j = i;
75 let close = loop {
76 if j >= chars.len() {
77 break None;
78 }
79 if chars[j] == '`' {
80 let run_start = j;
81 while j < chars.len() && chars[j] == '`' {
82 j += 1;
83 }
84 if j - run_start == fence_len {
85 break Some((run_start, j));
86 }
87 } else {
88 j += 1;
89 }
90 };
91 match close {
92 Some((_, close_end)) => {
93 out.extend(&chars[start..close_end]);
94 prev_word = false;
95 i = close_end;
96 }
97 None => {
98 out.extend(&chars[start..i]);
100 prev_word = false;
101 }
102 }
103 continue;
104 }
105 if c == '[' {
106 let start = i;
107 if let Some(rel) = chars[i + 1..].iter().position(|&x| x == ']') {
108 let content_end = i + 1 + rel;
109 let content: String = chars[start + 1..content_end].iter().collect();
110 if let Some(value) = parse_bracket(&content) {
111 match value {
112 BracketValue::Due(d) => {
113 due.get_or_insert(d);
114 }
115 BracketValue::Recurrence(r) => {
116 recurrence.get_or_insert(r);
117 }
118 }
119 let end = content_end + 1;
120 i = if chars.get(end) == Some(&' ') {
121 end + 1
122 } else {
123 end
124 };
125 prev_word = false;
126 continue;
127 }
128 }
129 }
130 if (c == '@' || c == '#') && !prev_word {
131 let start = i;
132 let mut j = i + 1;
133 while j < chars.len() && is_name_char(chars[j]) {
134 j += 1;
135 }
136 if j > start + 1 {
137 let name: String = chars[start + 1..j].iter().collect();
138 if c == '@' {
139 let id = Id::new(name);
140 if !mentions.contains(&id) {
141 mentions.push(id);
142 }
143 } else if !tags.contains(&name) {
144 tags.push(name);
145 }
146 i = if chars.get(j) == Some(&' ') { j + 1 } else { j };
150 prev_word = false;
151 continue;
152 }
153 }
154 out.push(c);
155 prev_word = is_word_char(c);
156 i += 1;
157 }
158 ExtractedTitle {
159 title: out.trim().to_string(),
160 mentions,
161 tags,
162 due,
163 recurrence,
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use std::collections::BTreeSet;
170
171 use rstest::rstest;
172
173 use super::*;
174 use crate::model::{RepeatCycle, Weekday};
175 use crate::temporal::{Due, Time};
176
177 #[test]
178 fn plain_mention() {
179 let e = extract_title_syntax("fix @alice bug");
180 assert_eq!(e.title, "fix bug");
181 assert_eq!(e.mentions, vec![Id::new("alice")]);
182 assert!(e.tags.is_empty());
183 }
184
185 #[test]
186 fn multiple_distinct_mentions() {
187 let e = extract_title_syntax("@bob review @alice please, @bob");
188 assert_eq!(e.title, "review please,");
189 assert_eq!(e.mentions, vec![Id::new("bob"), Id::new("alice")]);
190 }
191
192 #[test]
193 fn mention_inside_inline_code_untouched() {
194 let e = extract_title_syntax("use `@alice` as a var name");
195 assert_eq!(e.title, "use `@alice` as a var name");
196 assert!(e.mentions.is_empty());
197 }
198
199 #[test]
200 fn mention_inside_fenced_span_untouched() {
201 let e = extract_title_syntax("```@alice``` is not a mention");
202 assert_eq!(e.title, "```@alice``` is not a mention");
203 assert!(e.mentions.is_empty());
204 }
205
206 #[test]
207 fn no_mention() {
208 let e = extract_title_syntax("just a normal title");
209 assert_eq!(e.title, "just a normal title");
210 assert!(e.mentions.is_empty());
211 }
212
213 #[test]
214 fn bare_at_with_no_name_chars_is_not_a_match() {
215 let e = extract_title_syntax("email me @ the office");
216 assert_eq!(e.title, "email me @ the office");
217 assert!(e.mentions.is_empty());
218 }
219
220 #[test]
221 fn email_shaped_text_is_not_a_mention() {
222 let e = extract_title_syntax("contact foo@bar for details");
223 assert_eq!(e.title, "contact foo@bar for details");
224 assert!(e.mentions.is_empty());
225 }
226
227 #[test]
228 fn unterminated_fence_is_literal_and_mentions_after_it_still_parse() {
229 let e = extract_title_syntax("oops ``` @alice unterminated");
230 assert_eq!(e.title, "oops ``` unterminated");
231 assert_eq!(e.mentions, vec![Id::new("alice")]);
232 }
233
234 #[test]
235 fn plain_tag() {
236 let e = extract_title_syntax("fix bug #urgent");
237 assert_eq!(e.title, "fix bug");
238 assert_eq!(e.tags, vec!["urgent".to_string()]);
239 assert!(e.mentions.is_empty());
240 }
241
242 #[test]
243 fn multiple_distinct_tags() {
244 let e = extract_title_syntax("#urgent fix #bug please, #urgent");
245 assert_eq!(e.title, "fix please,");
246 assert_eq!(e.tags, vec!["urgent".to_string(), "bug".to_string()]);
247 }
248
249 #[test]
250 fn tag_inside_inline_code_untouched() {
251 let e = extract_title_syntax("use `#1` as an id");
252 assert_eq!(e.title, "use `#1` as an id");
253 assert!(e.tags.is_empty());
254 }
255
256 #[test]
257 fn tag_inside_fenced_span_untouched() {
258 let e = extract_title_syntax("```#urgent``` is not a tag");
259 assert_eq!(e.title, "```#urgent``` is not a tag");
260 assert!(e.tags.is_empty());
261 }
262
263 #[test]
264 fn bare_hash_with_no_name_chars_is_not_a_match() {
265 let e = extract_title_syntax("c# is a language");
266 assert_eq!(e.title, "c# is a language");
267 assert!(e.tags.is_empty());
268 }
269
270 #[test]
271 fn mention_and_tag_together_single_pass() {
272 let e = extract_title_syntax("fix @alice bug #urgent");
273 assert_eq!(e.title, "fix bug");
274 assert_eq!(e.mentions, vec![Id::new("alice")]);
275 assert_eq!(e.tags, vec!["urgent".to_string()]);
276 }
277
278 #[test]
279 fn bracket_absolute_date_only() {
280 let e = extract_title_syntax("Ship it [2026-07-20]");
281 assert_eq!(e.title, "Ship it");
282 assert_eq!(
283 e.due,
284 Some(DueSpec::Absolute(Due::parse("2026-07-20").unwrap()))
285 );
286 }
287
288 #[test]
289 fn bracket_absolute_date_and_time() {
290 let e = extract_title_syntax("Ship it [2026-07-20 09:00]");
291 assert_eq!(
292 e.title, "Ship it",
293 "the date+time bracket should be stripped, not just the date-only prefix"
294 );
295 assert_eq!(
296 e.due,
297 Some(DueSpec::Absolute(Due::parse("2026-07-20 09:00").unwrap()))
298 );
299 }
300
301 #[test]
302 fn bracket_time_only() {
303 let e = extract_title_syntax("Standup [09:00]");
304 assert_eq!(e.title, "Standup");
305 assert_eq!(
306 e.due,
307 Some(DueSpec::TimeOnly(Time::parse("09:00").unwrap()))
308 );
309 }
310
311 #[test]
312 fn bracket_inside_code_span_untouched() {
313 let e = extract_title_syntax("use `[2026-07-20]` as an example");
314 assert_eq!(e.title, "use `[2026-07-20]` as an example");
315 assert!(e.due.is_none());
316 }
317
318 #[test]
319 fn unparsable_bracket_left_literal() {
320 let e = extract_title_syntax("see [the docs] for details");
321 assert_eq!(e.title, "see [the docs] for details");
322 assert!(e.due.is_none());
323 assert!(e.recurrence.is_none());
324 }
325
326 #[test]
327 fn first_due_bracket_wins() {
328 let e = extract_title_syntax("Standup [09:00] then [10:00]");
329 assert_eq!(e.title, "Standup then");
330 assert_eq!(
331 e.due,
332 Some(DueSpec::TimeOnly(Time::parse("09:00").unwrap()))
333 );
334 }
335
336 #[test]
337 fn mention_tag_and_due_together_single_pass() {
338 let e = extract_title_syntax("fix @alice bug #urgent [2026-07-20]");
339 assert_eq!(e.title, "fix bug");
340 assert_eq!(e.mentions, vec![Id::new("alice")]);
341 assert_eq!(e.tags, vec!["urgent".to_string()]);
342 assert_eq!(
343 e.due,
344 Some(DueSpec::Absolute(Due::parse("2026-07-20").unwrap()))
345 );
346 }
347
348 #[rstest]
349 #[case("mon", Weekday::Mon)]
350 #[case("Monday", Weekday::Mon)]
351 #[case("MONDAY", Weekday::Mon)]
352 #[case("fri", Weekday::Fri)]
353 #[case("sunday", Weekday::Sun)]
354 fn weekday_bracket_spellings(#[case] spelling: &str, #[case] expected: Weekday) {
355 let e = extract_title_syntax(&format!("Review [{spelling}]"));
356 assert_eq!(e.title, "Review");
357 assert_eq!(e.due, Some(DueSpec::Weekday(expected)));
358 }
359
360 #[test]
361 fn weekday_bracket_invalid_spelling_left_literal() {
362 let e = extract_title_syntax("Review [someday]");
363 assert_eq!(e.title, "Review [someday]");
364 assert!(e.due.is_none());
365 }
366
367 #[rstest]
368 #[case("daily", RepeatCycle::Daily { every_n_days: 1 })]
369 #[case("every day", RepeatCycle::Daily { every_n_days: 1 })]
370 #[case("every 3 days", RepeatCycle::Daily { every_n_days: 3 })]
371 #[case("weekly", RepeatCycle::Weekly { weekdays: BTreeSet::new() })]
372 #[case("every week", RepeatCycle::Weekly { weekdays: BTreeSet::new() })]
373 #[case(
374 "every mon,wed,fri",
375 RepeatCycle::Weekly { weekdays: BTreeSet::from([Weekday::Mon, Weekday::Wed, Weekday::Fri]) }
376 )]
377 #[case("monthly", RepeatCycle::Monthly { every_n_months: 1 })]
378 #[case("every month", RepeatCycle::Monthly { every_n_months: 1 })]
379 #[case("every 2 months", RepeatCycle::Monthly { every_n_months: 2 })]
380 fn recurrence_bracket_expressions(#[case] expr: &str, #[case] expected_cycle: RepeatCycle) {
381 let e = extract_title_syntax(&format!("Water plants [{expr}]"));
382 assert_eq!(e.title, "Water plants");
383 assert_eq!(
384 e.recurrence,
385 Some(Recurrence {
386 cycle: expected_cycle,
387 time: None
388 })
389 );
390 }
391
392 #[rstest]
393 #[case("every")]
394 #[case("every fortnight")]
395 #[case("every mon,someday")]
396 #[case("hourly")]
397 fn recurrence_bracket_invalid_expressions_left_literal(#[case] expr: &str) {
398 let e = extract_title_syntax(&format!("Water plants [{expr}]"));
399 assert_eq!(e.title, format!("Water plants [{expr}]"));
400 assert!(e.recurrence.is_none());
401 }
402}