Skip to main content

mecha_core/
capture.rs

1//! What a typed or spoken capture says about *when*, without rewriting what
2//! it says.
3//!
4//! **B2, and the whole of it is two rules.** The parse happens here rather
5//! than in a model — a capture that costs a model call is a capture nobody
6//! uses, and a model that rewrites what you typed is worse than one that does
7//! nothing. And it follows Things rather than Todoist on the one thing the
8//! surveyed apps disagree about: the token is *reported* so a surface can show
9//! a chip you can dismiss, and **the name is returned untouched**. A capture
10//! surface that silently edits what you said is the wrong default for a store
11//! whose job is to hold your own intentions verbatim.
12//!
13//! **It detects; it does not resolve.** The span it finds is handed to the
14//! graph's `gtd::parse_due` as the `--due` argument, which already owns what
15//! `+3d` means. Resolving dates here would be a second date parser in a second
16//! repository, drifting against the one that actually writes the field — the
17//! divergence this project refuses everywhere else. The consequence worth
18//! knowing: this only ever emits spellings that parser accepts.
19//!
20//! **There is no time of day, and that is the store's shape rather than an
21//! omission.** `due_at` is written `%Y-%m-%d`. So *"call Bob tomorrow at 3"*
22//! yields `tomorrow`, and *"at 3"* stays in the name where the owner put it —
23//! the honest outcome, and the one consistent with keeping names literal.
24
25/// A `when` found in a capture: what to pass to `--due`, and the exact span of
26/// the original it was read from.
27///
28/// The span is what lets a surface render a dismissable chip *and* show which
29/// words produced it. Byte offsets into the input, so a caller can slice
30/// without re-searching and without assuming the token is unique — "tomorrow,
31/// and tell Bob tomorrow" has two, and only the one that was matched may be
32/// highlighted.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct When {
35    /// The spelling to hand to the graph, always one `parse_due` accepts.
36    pub due: String,
37    /// What the owner actually wrote, verbatim — the chip's label.
38    pub text: String,
39    pub start: usize,
40    pub end: usize,
41}
42
43/// Weekday names are deliberately **absent**, and so is "next week".
44///
45/// `parse_due` accepts `today`, `tomorrow`, `+Nd` and `YYYY-MM-DD` and nothing
46/// else, so a "friday" detected here could only be honoured by resolving it to
47/// a date locally — which is the second parser this module exists not to be.
48/// Detecting a token the store cannot accept would produce a chip that lies,
49/// or an error on a capture that looked fine. Narrow and honest beats wide and
50/// wrong; widening this means widening `parse_due` first, in the repo that
51/// owns the meaning.
52const RELATIVE_DAYS: &[(&str, u32)] = &[("tomorrow", 1), ("tmrw", 1), ("overmorrow", 2)];
53
54/// Find the one `when` in a capture, or none.
55///
56/// **The first match wins and there is deliberately no second.** A capture is
57/// one sentence with one deadline; a parser that collected several would have
58/// to decide which the task is due on, which is a guess with the owner's own
59/// words available to ask about instead. Scanning left to right matches how
60/// the sentence was said.
61pub fn find_when(input: &str) -> Option<When> {
62    let lower = input.to_lowercase();
63
64    // Longest-first, so "the day after tomorrow" is not read as "tomorrow"
65    // with three stray words in front of it — the classic substring bug, and
66    // the one that silently sets a date a day early.
67    let mut best: Option<When> = None;
68    let mut consider = |due: String, start: usize, end: usize| {
69        let cand = When {
70            due,
71            text: input[start..end].to_string(),
72            start,
73            end,
74        };
75        // **Earlier wins; at the same position, longer wins.** Spelled out
76        // rather than as a tuple comparison, which is how the first cut got
77        // it backwards: `(start, len)` orders *ascending* on start, so `>=`
78        // preferred the match further into the sentence. That read "the day
79        // after tomorrow" as "tomorrow" and set the date a day early —
80        // silently, on the one field where being off by one matters.
81        let better = match &best {
82            None => true,
83            Some(b) if cand.start < b.start => true,
84            Some(b) if cand.start == b.start => (cand.end - cand.start) > (b.end - b.start),
85            Some(_) => false,
86        };
87        if better {
88            best = Some(cand);
89        }
90    };
91
92    if let Some(m) = find_word(&lower, "the day after tomorrow") {
93        consider("+2d".into(), m.0, m.1);
94    }
95    if let Some(m) = find_word(&lower, "today") {
96        consider("today".into(), m.0, m.1);
97    }
98    if let Some(m) = find_word(&lower, "tonight") {
99        consider("today".into(), m.0, m.1);
100    }
101    for (word, days) in RELATIVE_DAYS {
102        if let Some(m) = find_word(&lower, word) {
103            let due = if *days == 1 {
104                "tomorrow".to_string()
105            } else {
106                format!("+{days}d")
107            };
108            consider(due, m.0, m.1);
109        }
110    }
111    if let Some(m) = find_in_n_days(&lower) {
112        consider(m.2, m.0, m.1);
113    }
114    if let Some(m) = find_iso_date(&lower) {
115        consider(input[m.0..m.1].to_string(), m.0, m.1);
116    }
117    best
118}
119
120/// Whole-word search, so "todays" and "tomorrowland" do not match.
121///
122/// The boundary test is "not alphanumeric" rather than whitespace: a capture
123/// ends sentences with punctuation, and *"call Bob tomorrow."* is the common
124/// case rather than the edge one.
125fn find_word(haystack: &str, needle: &str) -> Option<(usize, usize)> {
126    let mut from = 0;
127    while let Some(i) = haystack[from..].find(needle) {
128        let start = from + i;
129        let end = start + needle.len();
130        let before_ok = start == 0
131            || !haystack[..start]
132                .chars()
133                .next_back()
134                .is_some_and(|c| c.is_alphanumeric());
135        let after_ok = end == haystack.len()
136            || !haystack[end..]
137                .chars()
138                .next()
139                .is_some_and(|c| c.is_alphanumeric());
140        if before_ok && after_ok {
141            return Some((start, end));
142        }
143        from = end;
144    }
145    None
146}
147
148/// `in 3 days` / `in 2 weeks` → the `+Nd` the graph already understands.
149fn find_in_n_days(haystack: &str) -> Option<(usize, usize, String)> {
150    let bytes = haystack.as_bytes();
151    let mut from = 0;
152    while let Some(i) = haystack[from..].find("in ") {
153        let start = from + i;
154        let before_ok = start == 0 || !(bytes[start - 1] as char).is_alphanumeric();
155        let rest = &haystack[start + 3..];
156        let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
157        if before_ok && !digits.is_empty() {
158            let after = rest[digits.len()..].trim_start();
159            let gap = rest.len() - digits.len() - after.len();
160            for (unit, mult) in [("days", 1u32), ("day", 1), ("weeks", 7), ("week", 7)] {
161                if let Some(m) = after.strip_prefix(unit) {
162                    if m.is_empty() || !m.starts_with(|c: char| c.is_alphanumeric()) {
163                        let n: u32 = digits.parse().ok()?;
164                        let end = start + 3 + digits.len() + gap + unit.len();
165                        return Some((start, end, format!("+{}d", n * mult)));
166                    }
167                }
168            }
169        }
170        from = start + 3;
171    }
172    None
173}
174
175/// A bare `YYYY-MM-DD`, which `parse_due` takes verbatim.
176fn find_iso_date(haystack: &str) -> Option<(usize, usize)> {
177    let b = haystack.as_bytes();
178    for start in 0..b.len().saturating_sub(9) {
179        let w = &haystack[start..start + 10];
180        let ok = w.as_bytes().iter().enumerate().all(|(i, c)| match i {
181            4 | 7 => *c == b'-',
182            _ => c.is_ascii_digit(),
183        });
184        if !ok {
185            continue;
186        }
187        let before_ok = start == 0 || !(b[start - 1] as char).is_alphanumeric();
188        let end = start + 10;
189        let after_ok = end == b.len() || !(b[end] as char).is_alphanumeric();
190        if before_ok && after_ok {
191            return Some((start, end));
192        }
193    }
194    None
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn due(input: &str) -> Option<String> {
202        find_when(input).map(|w| w.due)
203    }
204
205    /// **The name is never rewritten**, which is the half of B2 that decides
206    /// whether this is a capture surface or an editor. Things keeps the name
207    /// literal; Todoist strips the token out of it. The token is *reported*
208    /// with its span so a chip can be drawn and dismissed, and the caller is
209    /// handed the original untouched.
210    #[test]
211    fn the_owners_words_come_back_exactly_as_typed() {
212        let input = "Call Bob tomorrow about the grant";
213        let w = find_when(input).unwrap();
214        assert_eq!(w.due, "tomorrow");
215        assert_eq!(
216            &input[w.start..w.end],
217            "tomorrow",
218            "the span points at the real bytes"
219        );
220        assert_eq!(w.text, "tomorrow");
221        // Nothing here returns a rewritten name, and there is deliberately no
222        // function that does.
223    }
224
225    /// **The store has no time of day**, so `at 3` is not a failure to parse
226    /// — it is a thing with nowhere to go. It stays in the name, where the
227    /// owner put it, and the chip claims only the date.
228    #[test]
229    fn a_time_of_day_is_left_in_the_name_because_the_board_cannot_hold_one() {
230        let input = "call Bob tomorrow at 3";
231        let w = find_when(input).unwrap();
232        assert_eq!(w.due, "tomorrow");
233        assert_eq!(w.text, "tomorrow", "the chip does not claim the time");
234        assert!(
235            input[w.end..].contains("at 3"),
236            "and the time survives in the name"
237        );
238    }
239
240    /// The substring bug this ordering exists to prevent: "the day after
241    /// tomorrow" contains "tomorrow", and matching the shorter one sets a
242    /// date a day early — silently, on a task with a deadline.
243    #[test]
244    fn the_day_after_tomorrow_is_not_tomorrow() {
245        assert_eq!(due("ship it the day after tomorrow"), Some("+2d".into()));
246        assert_eq!(due("ship it tomorrow"), Some("tomorrow".into()));
247    }
248
249    /// Whole words only. A parser that fired on any substring would date a
250    /// task from the middle of an ordinary noun.
251    #[test]
252    fn a_word_that_merely_contains_one_is_not_one() {
253        assert_eq!(due("visit tomorrowland"), None);
254        assert_eq!(due("read the todays paper archive"), None);
255        assert_eq!(due("book a stay in 3 daysworth of rooms"), None);
256    }
257
258    /// Punctuation is the common case in a real capture, not the edge one.
259    #[test]
260    fn a_sentence_that_ends_in_punctuation_still_parses() {
261        assert_eq!(due("call Bob tomorrow."), Some("tomorrow".into()));
262        assert_eq!(due("today: sort the inbox"), Some("today".into()));
263    }
264
265    /// Only spellings `gtd::parse_due` accepts are ever emitted — this
266    /// detects, the graph resolves. `in 2 weeks` becomes `+14d` rather than a
267    /// date computed here.
268    #[test]
269    fn everything_emitted_is_something_the_graph_already_understands() {
270        for (input, expect) in [
271            ("do it today", "today"),
272            ("do it tonight", "today"),
273            ("do it tomorrow", "tomorrow"),
274            ("do it in 3 days", "+3d"),
275            ("do it in 1 day", "+1d"),
276            ("do it in 2 weeks", "+14d"),
277            ("do it 2026-09-05", "2026-09-05"),
278        ] {
279            let got = due(input).unwrap_or_else(|| panic!("no when in {input:?}"));
280            assert_eq!(got, expect, "for {input:?}");
281            // The contract with the other repo, asserted rather than assumed.
282            assert!(
283                got == "today"
284                    || got == "tomorrow"
285                    || (got.starts_with('+') && got.ends_with('d'))
286                    || got.len() == 10,
287                "{got:?} is not a spelling parse_due accepts"
288            );
289        }
290    }
291
292    /// A weekday is **not** detected, deliberately. `parse_due` cannot take
293    /// one, so honouring it would mean resolving a date here — the second
294    /// parser this module exists not to be — and detecting it without
295    /// honouring it would draw a chip that lies.
296    #[test]
297    fn a_weekday_is_not_detected_because_the_store_could_not_take_it() {
298        assert_eq!(due("call Bob on friday"), None);
299        assert_eq!(due("call Bob next week"), None);
300    }
301
302    /// One capture, one deadline. Two would make the parser choose, and the
303    /// owner is right there to be asked instead.
304    #[test]
305    fn the_first_when_wins() {
306        let w = find_when("tomorrow tell Bob about today").unwrap();
307        assert_eq!(w.due, "tomorrow");
308        assert_eq!(w.start, 0);
309    }
310
311    #[test]
312    fn a_capture_with_no_date_says_so() {
313        assert_eq!(due("call Bob about the grant"), None);
314        assert_eq!(due(""), None);
315    }
316}