Skip to main content

todoapp_core/
title_syntax.rs

1//! Special title syntax: `@name` mentions (FR-32), `#tag` tags (FR-33), and
2//! `[...]` due-date/recurrence brackets (FR-34) — a family sharing one scan
3//! so the title (and its code-span skipping) is only walked once. Pure text
4//! scan, no I/O — belongs in core.
5
6use 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/// Result of [`extract_title_syntax`]: the cleaned title plus whatever
17/// special syntax was found, each in first-seen, deduplicated order (`due`/
18/// `recurrence` keep the first successfully-parsed bracket of their kind).
19#[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
33/// Dispatch a `[...]` bracket's trimmed content: a due-date value
34/// ([`DueSpec::parse`]) or a recurrence expression ([`Recurrence::parse`]) —
35/// the same relaxed grammars reused by the TUI edit form and CLI `--due`/
36/// `--recurrence`. `None` if nothing matches — the bracket is then left as
37/// literal text by the caller.
38fn 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
48/// Extract `@name` mentions, `#tag` tags, and `[...]` due/recurrence
49/// brackets from `title` in a single pass, skipping backtick-delimited code
50/// spans (CommonMark-style: a run of N backticks opens, the next run of
51/// exactly N backticks closes; an unterminated run is just literal text). A
52/// `@`/`#` trigger is one not preceded by a word character, followed by 1+ of
53/// `[A-Za-z0-9_-]`. A `[...]` bracket is stripped only if its content parses
54/// (see [`parse_bracket`]); otherwise it's left as literal text. Returns the
55/// cleaned title (triggers removed, whitespace collapsed, then trimmed).
56pub 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            // Find the matching close run of exactly `fence_len` backticks.
74            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                    // Unterminated fence: treat the backticks as literal text.
99                    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                // Swallow one following plain space so removing the trigger
147                // doesn't leave a double space; other whitespace (e.g. a
148                // multi-line title's newlines) is left untouched.
149                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}