1use std::collections::BTreeMap;
2use std::sync::LazyLock;
3
4use chrono::{Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, Weekday};
5use regex::Regex;
6
7use crate::{AgdError, CaptureTemplate, PlanningTimestamp};
8
9static ABSOLUTE_RE: LazyLock<Regex> = LazyLock::new(|| {
10 Regex::new(r"^(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2}:\d{2}))?(?:\s+([+.]*\+\d+[hdwmy]))?(?:\s+(-\d+[hdwmy]))?$")
11 .expect("absolute date regex")
12});
13static RELATIVE_RE: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"^\+(\d+)([dwmy])$").expect("relative date regex"));
15static ESCAPE_RE: LazyLock<Regex> = LazyLock::new(|| {
16 Regex::new(r"%(\?|i|U|u|T|t|%|\^g|\^\{([^}]+)\})").expect("capture escape regex")
17});
18
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct CaptureDraft {
21 pub title: String,
22 pub scheduled: Option<PlanningTimestamp>,
23 pub deadline: Option<PlanningTimestamp>,
24 pub tags: Vec<String>,
25 pub priority: Option<String>,
26 pub properties: BTreeMap<String, String>,
28}
29
30pub fn parse_date_input(input: &str, today: NaiveDate) -> Result<PlanningTimestamp, AgdError> {
31 let input = input.trim();
32 if let Some(captures) = ABSOLUTE_RE.captures(input) {
33 let date = NaiveDate::parse_from_str(captures.get(1).expect("date").as_str(), "%Y-%m-%d")
34 .map_err(|_| AgdError::new("invalid_date", format!("invalid date: {input}")))?;
35 let time = captures
36 .get(2)
37 .map(|matched| parse_time(matched.as_str()))
38 .transpose()?;
39 return Ok(PlanningTimestamp {
40 date: date.format("%Y-%m-%d").to_string(),
41 time: time.map(|value| value.format("%H:%M").to_string()),
42 end_time: None,
43 repeater: captures.get(3).map(|matched| matched.as_str().to_string()),
44 warning: captures.get(4).map(|matched| matched.as_str().to_string()),
45 raw: None,
46 });
47 }
48 if let Some(captures) = RELATIVE_RE.captures(input) {
49 let amount = captures
50 .get(1)
51 .expect("amount")
52 .as_str()
53 .parse::<u64>()
54 .map_err(|_| AgdError::new("invalid_date", format!("invalid date: {input}")))?;
55 let unit = captures.get(2).expect("unit").as_str();
56 let date = match unit {
57 "d" => today.checked_add_signed(Duration::days(amount as i64)),
58 "w" => today.checked_add_signed(Duration::weeks(amount as i64)),
59 "m" => today.checked_add_months(Months::new(amount as u32)),
60 "y" => today.checked_add_months(Months::new((amount * 12) as u32)),
61 _ => None,
62 }
63 .ok_or_else(|| AgdError::new("invalid_date", format!("date is out of range: {input}")))?;
64 return Ok(simple_date(date));
65 }
66 if input.eq_ignore_ascii_case("today") {
67 return Ok(simple_date(today));
68 }
69 if input.eq_ignore_ascii_case("tomorrow") {
70 return Ok(simple_date(today + Duration::days(1)));
71 }
72 if let Some(weekday) = parse_weekday(input) {
73 let current = today.weekday().num_days_from_monday() as i64;
74 let requested = weekday.num_days_from_monday() as i64;
75 let distance = (requested - current).rem_euclid(7);
76 return Ok(simple_date(today + Duration::days(distance)));
77 }
78 Err(AgdError::new(
79 "invalid_date",
80 format!("unsupported Org date '{input}'; use YYYY-MM-DD, +Nd, +Nw, +Nm, +Ny, or weekday"),
81 ))
82}
83
84pub fn parse_capture_line(input: &str, today: NaiveDate) -> Result<CaptureDraft, AgdError> {
85 let mut title = Vec::new();
86 let mut scheduled = None;
87 let mut deadline = None;
88 let mut tags = Vec::new();
89 let mut priority = None;
90 for token in input.split_whitespace() {
91 if let Some(value) = token.strip_prefix("due:")
92 && !value.is_empty()
93 {
94 deadline = Some(parse_date_input(value, today)?);
95 } else if let Some(value) = token.strip_prefix("on:")
96 && !value.is_empty()
97 {
98 scheduled = Some(parse_date_input(value, today)?);
99 } else if let Some(tag) = token.strip_prefix('+')
100 && !tag.is_empty()
101 && valid_tag(tag)
102 {
103 tags.push(tag.to_string());
104 } else if let Some(value) = token.strip_prefix('#')
105 && value.len() == 1
106 && value.bytes().all(|byte| byte.is_ascii_alphabetic())
107 {
108 priority = Some(value.to_ascii_uppercase());
109 } else {
110 title.push(token);
111 }
112 }
113 let title = title.join(" ");
114 if title.trim().is_empty() {
115 return Err(AgdError::new("invalid_capture", "capture title is empty"));
116 }
117 tags.sort();
118 tags.dedup();
119 Ok(CaptureDraft {
120 title,
121 scheduled,
122 deadline,
123 tags,
124 priority,
125 properties: BTreeMap::new(),
126 })
127}
128
129pub fn render_capture_template(
130 template: &CaptureTemplate,
131 draft: &CaptureDraft,
132 body: &str,
133 prompts: &BTreeMap<String, String>,
134 now: NaiveDateTime,
135) -> Result<String, AgdError> {
136 if template.template.contains("%(") {
137 return Err(AgdError::new(
138 "unsupported_template",
139 "capture templates may not execute %(sexp) forms",
140 ));
141 }
142 let rendered = expand_capture_escapes(&template.template, draft, body, prompts, now)?;
143
144 let newline = if rendered.contains("\r\n") {
145 "\r\n"
146 } else {
147 "\n"
148 };
149 let mut lines: Vec<String> = rendered.split(newline).map(str::to_string).collect();
150 if let Some(first) = lines.first_mut() {
151 if let Some(priority) = &draft.priority {
152 insert_before_title(first, &format!("[#{}]", priority));
153 }
154 if !draft.tags.is_empty() {
155 first.push_str(&format!(" :{}:", draft.tags.join(":")));
156 }
157 }
158 let mut planning = Vec::new();
160 if let Some(scheduled) = &draft.scheduled {
161 planning.push(format!(
162 "SCHEDULED: {}",
163 format_active_timestamp(scheduled)?
164 ));
165 }
166 if let Some(deadline) = &draft.deadline {
167 planning.push(format!("DEADLINE: {}", format_active_timestamp(deadline)?));
168 }
169 if !planning.is_empty() {
170 lines.insert(1, planning.join(" "));
171 }
172 add_properties(
173 &mut lines,
174 &draft.properties,
175 usize::from(!planning.is_empty()) + 1,
176 )?;
177 Ok(lines.join(newline))
178}
179
180fn add_properties(
184 lines: &mut Vec<String>,
185 properties: &BTreeMap<String, String>,
186 after: usize,
187) -> Result<(), AgdError> {
188 if properties.is_empty() {
189 return Ok(());
190 }
191 let mut entries = Vec::new();
192 for (key, value) in properties {
193 if key.is_empty() || key.contains([' ', '\t', ':']) || value.contains(['\n', '\r']) {
194 return Err(AgdError::new(
195 "invalid_property",
196 format!("capture property '{key}' needs a plain key and a one-line value"),
197 ));
198 }
199 let present = lines.iter().any(|line| {
200 line.trim_start()
201 .to_ascii_uppercase()
202 .starts_with(&format!(":{}:", key.to_ascii_uppercase()))
203 });
204 if !present {
205 entries.push(format!(":{key}: {value}"));
206 }
207 }
208 let drawer = lines.iter().position(|line| line.trim() == ":PROPERTIES:");
209 let end = drawer.and_then(|start| {
210 lines[start..]
211 .iter()
212 .position(|line| line.trim() == ":END:")
213 .map(|offset| start + offset)
214 });
215 match end {
216 Some(end) => {
217 lines.splice(end..end, entries);
218 }
219 None => {
220 let at = after.min(lines.len());
221 let mut drawer = vec![":PROPERTIES:".to_string()];
222 drawer.extend(entries);
223 drawer.push(":END:".to_string());
224 lines.splice(at..at, drawer);
225 }
226 }
227 Ok(())
228}
229
230pub fn capture_prompts(template: &CaptureTemplate) -> Vec<String> {
233 let mut prompts: Vec<String> = Vec::new();
234 for captures in ESCAPE_RE.captures_iter(&template.template) {
235 let name = match &captures[1] {
236 "^g" => "tags".to_string(),
237 escape if escape.starts_with("^{") => captures[2].to_string(),
238 _ => continue,
239 };
240 if !prompts.contains(&name) {
241 prompts.push(name);
242 }
243 }
244 prompts
245}
246
247fn expand_capture_escapes(
250 template: &str,
251 draft: &CaptureDraft,
252 body: &str,
253 prompts: &BTreeMap<String, String>,
254 now: NaiveDateTime,
255) -> Result<String, AgdError> {
256 let mut rendered = String::with_capacity(template.len() + draft.title.len() + body.len());
257 let mut copied_to = 0;
258 for captures in ESCAPE_RE.captures_iter(template) {
259 let escape = captures.get(0).expect("escape match");
260 rendered.push_str(&template[copied_to..escape.start()]);
261 copied_to = escape.end();
262 match &captures[1] {
263 "?" => rendered.push_str(&draft.title),
264 "i" => rendered.push_str(body),
265 "U" => rendered.push_str(&format!("[{}]", now.format("%Y-%m-%d %a %H:%M"))),
266 "u" => rendered.push_str(&format!("[{}]", now.format("%Y-%m-%d %a"))),
267 "T" => rendered.push_str(&format!("<{}>", now.format("%Y-%m-%d %a %H:%M"))),
268 "t" => rendered.push_str(&format!("<{}>", now.format("%Y-%m-%d %a"))),
269 "%" => rendered.push('%'),
270 "^g" => rendered.push_str(prompts.get("tags").ok_or_else(|| {
271 AgdError::new("missing_prompt", "capture tag prompt has no 'tags' value")
272 })?),
273 _ => {
274 let prompt = captures.get(2).expect("prompt name").as_str();
275 let value = prompts.get(prompt).ok_or_else(|| {
276 AgdError::new(
277 "missing_prompt",
278 format!("capture prompt '{prompt}' has no value"),
279 )
280 })?;
281 rendered.push_str(value);
282 }
283 }
284 }
285 rendered.push_str(&template[copied_to..]);
286 Ok(rendered)
287}
288
289pub fn format_active_timestamp(timestamp: &PlanningTimestamp) -> Result<String, AgdError> {
290 let date = NaiveDate::parse_from_str(×tamp.date, "%Y-%m-%d")
291 .map_err(|_| AgdError::new("invalid_date", format!("invalid date: {}", timestamp.date)))?;
292 let mut value = format!("<{} {}", timestamp.date, date.format("%a"));
293 if let Some(time) = ×tamp.time {
294 value.push(' ');
295 value.push_str(time);
296 if let Some(end_time) = ×tamp.end_time {
297 value.push('-');
298 value.push_str(end_time);
299 }
300 }
301 if let Some(repeater) = ×tamp.repeater {
302 value.push(' ');
303 value.push_str(repeater);
304 }
305 if let Some(warning) = ×tamp.warning {
306 value.push(' ');
307 value.push_str(warning);
308 }
309 value.push('>');
310 Ok(value)
311}
312
313fn simple_date(date: NaiveDate) -> PlanningTimestamp {
314 PlanningTimestamp {
315 date: date.format("%Y-%m-%d").to_string(),
316 time: None,
317 end_time: None,
318 repeater: None,
319 warning: None,
320 raw: None,
321 }
322}
323
324fn parse_time(input: &str) -> Result<NaiveTime, AgdError> {
325 NaiveTime::parse_from_str(input, "%H:%M")
326 .map_err(|_| AgdError::new("invalid_date", format!("invalid time: {input}")))
327}
328
329fn parse_weekday(input: &str) -> Option<Weekday> {
330 match input.to_ascii_lowercase().as_str() {
331 "mon" | "monday" => Some(Weekday::Mon),
332 "tue" | "tues" | "tuesday" => Some(Weekday::Tue),
333 "wed" | "wednesday" => Some(Weekday::Wed),
334 "thu" | "thur" | "thurs" | "thursday" => Some(Weekday::Thu),
335 "fri" | "friday" => Some(Weekday::Fri),
336 "sat" | "saturday" => Some(Weekday::Sat),
337 "sun" | "sunday" => Some(Weekday::Sun),
338 _ => None,
339 }
340}
341
342fn valid_tag(tag: &str) -> bool {
343 tag.chars()
344 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
345}
346
347fn insert_before_title(line: &mut String, value: &str) {
348 let stars_end = line
349 .find(|character: char| !matches!(character, '*' | ' ' | '\t'))
350 .unwrap_or(line.len());
351 if stars_end >= line.len() {
352 line.push_str(value);
353 return;
354 }
355 let rest = &line[stars_end..];
356 let first_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
357 let insertion = if rest[..first_end]
358 .bytes()
359 .all(|byte| byte.is_ascii_uppercase() || matches!(byte, b'_' | b'-'))
360 {
361 stars_end + first_end
362 } else {
363 stars_end
364 };
365 line.insert_str(insertion, &format!(" {value}"));
366}
367
368pub fn now_local_naive() -> NaiveDateTime {
369 Local::now().naive_local()
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn parses_org_relative_and_warning_dates() {
378 let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
379 assert_eq!(parse_date_input("+2d", today).unwrap().date, "2026-10-15");
380 assert_eq!(parse_date_input("fri", today).unwrap().date, "2026-10-16");
381 let absolute = parse_date_input("2026-10-30 09:15 +1w -7d", today).unwrap();
382 assert_eq!(absolute.time.as_deref(), Some("09:15"));
383 assert_eq!(absolute.repeater.as_deref(), Some("+1w"));
384 assert_eq!(absolute.warning.as_deref(), Some("-7d"));
385 }
386
387 #[test]
388 fn unrecognized_capture_tokens_remain_in_title() {
389 let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
390 let draft = parse_capture_line("Renew passport due:fri +admin #A owner:me", today).unwrap();
391 assert_eq!(draft.title, "Renew passport owner:me");
392 assert_eq!(draft.deadline.unwrap().date, "2026-10-16");
393 assert_eq!(draft.tags, vec!["admin"]);
394 assert_eq!(draft.priority.as_deref(), Some("A"));
395 }
396
397 #[test]
398 fn user_text_is_inserted_literally() {
399 let template = CaptureTemplate {
400 template: "* TODO %?\n%i\n:CREATED: %U\n100%%".to_string(),
401 ..CaptureTemplate::default()
402 };
403 let draft = CaptureDraft {
404 title: "Literal %U and %i".to_string(),
405 scheduled: None,
406 deadline: None,
407 tags: Vec::new(),
408 priority: None,
409 properties: BTreeMap::new(),
410 };
411 let now = NaiveDate::from_ymd_opt(2026, 10, 13)
412 .unwrap()
413 .and_hms_opt(14, 22, 0)
414 .unwrap();
415 let rendered =
416 render_capture_template(&template, &draft, "body %? %t", &BTreeMap::new(), now)
417 .unwrap();
418 assert_eq!(
419 rendered,
420 "* TODO Literal %U and %i\nbody %? %t\n:CREATED: [2026-10-13 Tue 14:22]\n100%"
421 );
422 }
423
424 #[test]
425 fn capture_properties_join_the_template_drawer_or_open_one() {
426 let now = NaiveDate::from_ymd_opt(2026, 10, 13)
427 .unwrap()
428 .and_hms_opt(14, 22, 0)
429 .unwrap();
430 let draft = CaptureDraft {
431 title: "Fix flaky test".to_string(),
432 properties: BTreeMap::from([
433 ("REPO".to_string(), "agd".to_string()),
434 (
435 "SOURCE".to_string(),
436 "[[file:~/src/agd/write.rs::42]]".to_string(),
437 ),
438 ]),
439 ..CaptureDraft::default()
440 };
441 let with_drawer = render_capture_template(
442 &CaptureTemplate::default(),
443 &draft,
444 "",
445 &BTreeMap::new(),
446 now,
447 )
448 .unwrap();
449 assert_eq!(
450 with_drawer,
451 "* TODO Fix flaky test\n:PROPERTIES:\n:CREATED: [2026-10-13 Tue 14:22]\n:REPO: agd\n:SOURCE: [[file:~/src/agd/write.rs::42]]\n:END:"
452 );
453 let plain = CaptureTemplate {
454 template: "* TODO %?\nNotes".to_string(),
455 ..CaptureTemplate::default()
456 };
457 let opened = render_capture_template(&plain, &draft, "", &BTreeMap::new(), now).unwrap();
458 assert!(opened.starts_with("* TODO Fix flaky test\n:PROPERTIES:\n:REPO: agd\n"));
459 assert!(opened.ends_with(":END:\nNotes"));
460 }
461
462 #[test]
463 fn template_preview_is_exact_org_text() {
464 let template = CaptureTemplate::default();
465 let draft = CaptureDraft {
466 title: "Renew passport".to_string(),
467 scheduled: None,
468 deadline: Some(simple_date(NaiveDate::from_ymd_opt(2026, 10, 30).unwrap())),
469 tags: vec!["admin".to_string()],
470 priority: Some("A".to_string()),
471 properties: BTreeMap::new(),
472 };
473 let rendered = render_capture_template(
474 &template,
475 &draft,
476 "",
477 &BTreeMap::new(),
478 NaiveDate::from_ymd_opt(2026, 10, 13)
479 .unwrap()
480 .and_hms_opt(9, 30, 0)
481 .unwrap(),
482 )
483 .unwrap();
484 assert_eq!(
485 rendered,
486 "* TODO [#A] Renew passport :admin:\nDEADLINE: <2026-10-30 Fri>\n:PROPERTIES:\n:CREATED: [2026-10-13 Tue 09:30]\n:END:"
487 );
488 }
489}