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}