1use 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 pub properties: BTreeMap<String, String>,
27}
28
29pub 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
93fn 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
114fn 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 fn read(line: &str) -> CaptureDraft {
137 parse_capture_line(line, today(), Notation::Agd).unwrap()
138 }
139
140 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 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 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 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 assert_eq!(read("Standup 14:00 #work").title, "Standup 14:00");
219 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}