Skip to main content

agd_core/
capture_line.rs

1//! The one-line capture syntax: `Renew passport due:fri #admin !A` read into a
2//! [`CaptureDraft`], with `due:` and `on:` dates as [`read_date`] reads them,
3//! and tags and a priority in the configured [`Notation`]: `#admin` and `!A`,
4//! or Org's `:admin:` and `#A`.
5//!
6//! A token it does not recognize stays in the title (`#42`, `!!`, `+admin`),
7//! so a line never loses text. Whether a priority is in the target file's
8//! range is checked when the capture is written, not here.
9
10use std::collections::BTreeMap;
11
12use chrono::NaiveDate;
13
14use crate::date::unsupported_date;
15use crate::document::is_org_tag;
16use crate::{AgdError, ErrorCode, Notation, PlanningTimestamp, read_date};
17
18#[derive(Clone, Debug, Default, Eq, PartialEq)]
19pub struct CaptureDraft {
20    pub title: String,
21    pub scheduled: Option<PlanningTimestamp>,
22    pub deadline: Option<PlanningTimestamp>,
23    pub tags: Vec<String>,
24    pub priority: Option<String>,
25    /// Extra `:PROPERTIES:` entries, e.g. where an agent found the task.
26    pub properties: BTreeMap<String, String>,
27}
28
29/// Reads one capture line: `due:DATE` and `on:DATE` (a date as
30/// [`read_date`] reads it, which may continue into the next words: `due:fri
31/// 9am`, `on:oct 30`, `due:2026-10-30 14:00 +1w -2d`), tags and a priority
32/// in `notation` (`tag_token`, `priority_token`). Every other token stays
33/// in the title.
34pub fn parse_capture_line(
35    input: &str,
36    today: NaiveDate,
37    notation: Notation,
38) -> Result<CaptureDraft, AgdError> {
39    let mut title = Vec::new();
40    let mut scheduled = None;
41    let mut deadline = None;
42    let mut tags = Vec::new();
43    let mut priority = None;
44    let tokens: Vec<&str> = input.split_whitespace().collect();
45    let mut index = 0;
46    while let Some(&token) = tokens.get(index) {
47        let planning = if let Some(value) = token.strip_prefix("due:") {
48            Some((value, &mut deadline))
49        } else {
50            token
51                .strip_prefix("on:")
52                .map(|value| (value, &mut scheduled))
53        };
54        if let Some((value, slot)) = planning
55            && !value.is_empty()
56        {
57            let mut words = vec![value];
58            words.extend(&tokens[index + 1..]);
59            let (timestamp, taken) =
60                read_date(&words, today)?.ok_or_else(|| unsupported_date(value))?;
61            *slot = Some(timestamp);
62            index += taken;
63            continue;
64        }
65        if let Some(names) = tag_token(token, notation) {
66            tags.extend(names.into_iter().map(str::to_string));
67        } else if let Some(cookie) = priority_token(token, notation) {
68            priority = Some(cookie.to_string());
69        } else {
70            title.push(token);
71        }
72        index += 1;
73    }
74    let title = title.join(" ");
75    if title.trim().is_empty() {
76        return Err(AgdError::new(
77            ErrorCode::InvalidCapture,
78            "capture title is empty",
79        ));
80    }
81    tags.sort();
82    tags.dedup();
83    Ok(CaptureDraft {
84        title,
85        scheduled,
86        deadline,
87        tags,
88        priority,
89        properties: BTreeMap::new(),
90    })
91}
92
93/// The tags a capture token names: `#admin` in agd notation, `:admin:` or
94/// `:a:b:` in org notation. Digits after `#` stay text, so `Fix #42` keeps
95/// its issue number, as do characters outside Org's tag characters.
96fn tag_token(token: &str, notation: Notation) -> Option<Vec<&str>> {
97    let tags: Vec<&str> = match notation {
98        Notation::Agd => {
99            let tag = token.strip_prefix('#')?;
100            if tag.bytes().all(|byte| byte.is_ascii_digit()) {
101                return None;
102            }
103            vec![tag]
104        }
105        Notation::Org => token
106            .strip_prefix(':')?
107            .strip_suffix(':')?
108            .split(':')
109            .collect(),
110    };
111    tags.iter().all(|tag| is_org_tag(tag)).then_some(tags)
112}
113
114/// The priority a capture token names: one uppercase letter after `!` in
115/// agd notation (`!A`), after `#` in org notation (`#A`). Anything else
116/// stays text (`!!`, `!a`, `#42`). Whether the letter is in the file's
117/// range is checked when the capture is written.
118fn priority_token(token: &str, notation: Notation) -> Option<&str> {
119    let marker = match notation {
120        Notation::Agd => '!',
121        Notation::Org => '#',
122    };
123    let cookie = token.strip_prefix(marker)?;
124    (cookie.len() == 1 && cookie.bytes().all(|byte| byte.is_ascii_uppercase())).then_some(cookie)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn today() -> NaiveDate {
132        NaiveDate::from_ymd_opt(2026, 10, 13).unwrap()
133    }
134
135    /// A line read in agd's notation, the default.
136    fn read(line: &str) -> CaptureDraft {
137        parse_capture_line(line, today(), Notation::Agd).unwrap()
138    }
139
140    /// (title, tags, priority) of a line read in `notation`.
141    fn tokens(line: &str, notation: Notation) -> (String, Vec<String>, Option<String>) {
142        let draft = parse_capture_line(line, today(), notation).unwrap();
143        (draft.title, draft.tags, draft.priority)
144    }
145
146    fn expected(
147        title: &str,
148        tags: &[&str],
149        priority: Option<&str>,
150    ) -> (String, Vec<String>, Option<String>) {
151        (
152            title.to_string(),
153            tags.iter().map(|tag| tag.to_string()).collect(),
154            priority.map(str::to_string),
155        )
156    }
157
158    #[test]
159    fn unrecognized_capture_tokens_remain_in_title() {
160        let draft = read("Renew passport due:fri #admin !A owner:me");
161        assert_eq!(draft.title, "Renew passport owner:me");
162        assert_eq!(draft.deadline.unwrap().date, "2026-10-16");
163        assert_eq!(draft.tags, vec!["admin"]);
164        assert_eq!(draft.priority.as_deref(), Some("A"));
165    }
166
167    #[test]
168    fn agd_notation_takes_hash_tags_and_bang_priorities() {
169        let agd = |line| tokens(line, Notation::Agd);
170        assert_eq!(
171            agd("Call the bank #home #admin !B"),
172            expected("Call the bank", &["admin", "home"], Some("B"))
173        );
174        // Digits after # are an issue number; ! needs one capital letter.
175        assert_eq!(agd("Fix #42 now"), expected("Fix #42 now", &[], None));
176        assert_eq!(
177            agd("Ship it !! !a !AB"),
178            expected("Ship it !! !a !AB", &[], None)
179        );
180        // Org's own markers are text here.
181        assert_eq!(
182            agd("Plan :work: +home #C"),
183            expected("Plan :work: +home", &["C"], None)
184        );
185        assert_eq!(agd("Price #5-off"), expected("Price #5-off", &[], None));
186    }
187
188    #[test]
189    fn org_notation_takes_colon_tags_and_hash_priorities() {
190        let org = |line| tokens(line, Notation::Org);
191        assert_eq!(
192            org("Call the bank :home: :a:b: #B"),
193            expected("Call the bank", &["a", "b", "home"], Some("B"))
194        );
195        assert_eq!(org("Fix #42 now"), expected("Fix #42 now", &[], None));
196        // agd's markers, an empty group and a time are text here.
197        assert_eq!(
198            org("Plan #work !A :: 10:30"),
199            expected("Plan #work !A :: 10:30", &[], None)
200        );
201    }
202
203    #[test]
204    fn capture_dates_take_a_following_time_token() {
205        let draft = read("Dentist due:2026-10-30 14:00 on:fri 9:30");
206        assert_eq!(draft.title, "Dentist");
207        let deadline = draft.deadline.unwrap();
208        assert_eq!(
209            (deadline.date.as_str(), deadline.time.as_deref()),
210            ("2026-10-30", Some("14:00"))
211        );
212        let scheduled = draft.scheduled.unwrap();
213        assert_eq!(
214            (scheduled.date.as_str(), scheduled.time.as_deref()),
215            ("2026-10-16", Some("09:30"))
216        );
217        // A time with no date token before it is title text.
218        assert_eq!(read("Standup 14:00 #work").title, "Standup 14:00");
219        // A date may take two more words; words that do not continue it are
220        // the title's.
221        let draft = read("Review due:oct 30 9am draft");
222        let deadline = draft.deadline.unwrap();
223        assert_eq!(
224            (deadline.date.as_str(), deadline.time.as_deref()),
225            ("2026-10-30", Some("09:00"))
226        );
227        assert_eq!(draft.title, "Review draft");
228        let draft = read("Plan due:fri 3 talks");
229        assert_eq!(draft.deadline.unwrap().date, "2026-10-16");
230        assert_eq!(draft.title, "Plan 3 talks");
231    }
232
233    #[test]
234    fn a_capture_date_takes_its_repeater_and_warning_and_refuses_a_bad_time() {
235        let draft = read("Pay rent due:2026-10-30 14:00 +1m -2d #home");
236        assert_eq!(draft.title, "Pay rent");
237        assert_eq!(draft.tags, ["home"]);
238        let deadline = draft.deadline.unwrap();
239        assert_eq!(
240            (
241                deadline.time.as_deref(),
242                deadline.repeater.as_deref(),
243                deadline.warning.as_deref()
244            ),
245            (Some("14:00"), Some("+1m"), Some("-2d"))
246        );
247        let error = parse_capture_line("Call due:fri 25:00", today(), Notation::Agd).unwrap_err();
248        assert_eq!(error.code(), ErrorCode::InvalidDate);
249        let error = parse_capture_line("Call due:someday", today(), Notation::Agd).unwrap_err();
250        assert!(
251            error.to_string().contains("unsupported date 'someday'"),
252            "{error}"
253        );
254    }
255}