Skip to main content

org_core/
date.rs

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    /// Extra `:PROPERTIES:` entries, e.g. where an agent found the task.
27    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    // One planning line, as Org writes it: SCHEDULED then DEADLINE.
159    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
180/// Adds properties to the template's own `:PROPERTIES:` drawer, or opens one
181/// right after the headline and planning line (`after`), keeping keys the
182/// template already sets.
183fn 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
230/// Names of the `%^{prompt}` fields a template asks for, in order and
231/// without duplicates; `%^g` asks for "tags".
232pub 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
247// Expands every escape in one pass over the template so that user-supplied
248// title, body and prompt text is inserted literally and never re-expanded.
249fn 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(&timestamp.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) = &timestamp.time {
294        value.push(' ');
295        value.push_str(time);
296        if let Some(end_time) = &timestamp.end_time {
297            value.push('-');
298            value.push_str(end_time);
299        }
300    }
301    if let Some(repeater) = &timestamp.repeater {
302        value.push(' ');
303        value.push_str(repeater);
304    }
305    if let Some(warning) = &timestamp.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}