1use std::collections::BTreeMap;
12use std::sync::LazyLock;
13
14use chrono::{NaiveDate, NaiveDateTime};
15use regex::Regex;
16
17use crate::date::format_inactive_timestamp;
18use crate::write::line_ending;
19use crate::{
20 AgdError, CaptureDraft, CaptureTemplate, ErrorCode, format_active_timestamp, parse_date_input,
21};
22
23static ESCAPE_RE: LazyLock<Regex> = LazyLock::new(|| {
24 Regex::new(r"%(\?|i|U|u|T|t|%|\^g|\^\{([^}]+)\}([tTuU])?|\^([tTuU]))")
26 .expect("capture escape regex")
27});
28
29#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct CapturePrompt {
36 pub name: String,
37 pub date: bool,
38 pub planning: Option<String>,
39}
40
41impl CapturePrompt {
42 pub fn answered_by(&self, draft: &CaptureDraft) -> bool {
45 match self.planning.as_deref() {
46 Some("DEADLINE") => draft.deadline.is_some(),
47 Some("SCHEDULED") => draft.scheduled.is_some(),
48 _ => false,
49 }
50 }
51}
52
53pub fn capture_prompts(template: &CaptureTemplate) -> Vec<CapturePrompt> {
56 let text = &template.template;
57 let mut prompts: Vec<CapturePrompt> = Vec::new();
58 for captures in ESCAPE_RE.captures_iter(text) {
59 let start = captures.get(0).expect("escape match").start();
60 let line_start = text[..start].rfind('\n').map_or(0, |index| index + 1);
61 let before = &text[line_start..start];
62 let planning = ["DEADLINE:", "SCHEDULED:"]
64 .iter()
65 .filter_map(|keyword| before.rfind(keyword).map(|at| (at, *keyword)))
66 .max()
67 .map(|(_, keyword)| keyword.trim_end_matches(':').to_string());
68 let prompt = match (&captures[1], captures.get(2), captures.get(4)) {
69 ("^g", _, _) => CapturePrompt {
70 name: "tags".to_string(),
71 date: false,
72 planning: None,
73 },
74 (_, Some(name), _) => {
75 let date = captures.get(3).is_some();
76 CapturePrompt {
77 name: name.as_str().to_string(),
78 date,
79 planning: if date { planning } else { None },
80 }
81 }
82 (_, None, Some(_)) => CapturePrompt {
83 name: "date".to_string(),
84 date: true,
85 planning,
86 },
87 _ => continue,
88 };
89 if !prompts.iter().any(|existing| existing.name == prompt.name) {
90 prompts.push(prompt);
91 }
92 }
93 prompts
94}
95
96pub fn render_capture_template(
97 template: &CaptureTemplate,
98 draft: &CaptureDraft,
99 body: &str,
100 prompts: &BTreeMap<String, String>,
101 now: NaiveDateTime,
102) -> Result<String, AgdError> {
103 if template.template.contains("%(") {
104 return Err(AgdError::new(
105 ErrorCode::InvalidTemplate,
106 "capture templates may not execute %(sexp) forms",
107 ));
108 }
109 let rendered = expand_capture_escapes(&template.template, draft, body, prompts, now)?;
110
111 let newline = line_ending(&rendered);
112 let mut lines: Vec<String> = rendered.split(newline).map(str::to_string).collect();
113 if let Some(first) = lines.first_mut() {
114 if let Some(priority) = &draft.priority {
115 insert_before_title(first, &format!("[#{}]", priority));
116 }
117 if !draft.tags.is_empty() {
118 first.push_str(&format!(" :{}:", draft.tags.join(":")));
119 }
120 }
121 let template_planning = lines
126 .get(1)
127 .filter(|line| is_schedule_or_deadline_line(line))
128 .map(|line| planning_dates(line));
129 if template_planning.is_some() {
130 lines.remove(1);
131 }
132 let (mut scheduled, mut deadline) = template_planning.unwrap_or_default();
133 if let Some(timestamp) = &draft.scheduled {
134 scheduled = Some(format_active_timestamp(timestamp)?);
135 }
136 if let Some(timestamp) = &draft.deadline {
137 deadline = Some(format_active_timestamp(timestamp)?);
138 }
139 let mut planning = Vec::new();
140 if let Some(timestamp) = scheduled {
141 planning.push(format!("SCHEDULED: {timestamp}"));
142 }
143 if let Some(timestamp) = deadline {
144 planning.push(format!("DEADLINE: {timestamp}"));
145 }
146 if !planning.is_empty() {
147 lines.insert(1.min(lines.len()), planning.join(" "));
148 }
149 add_properties(
150 &mut lines,
151 &draft.properties,
152 usize::from(!planning.is_empty()) + 1,
153 )?;
154 Ok(lines.join(newline))
155}
156
157fn expand_capture_escapes(
160 template: &str,
161 draft: &CaptureDraft,
162 body: &str,
163 prompts: &BTreeMap<String, String>,
164 now: NaiveDateTime,
165) -> Result<String, AgdError> {
166 let mut rendered = String::with_capacity(template.len() + draft.title.len() + body.len());
167 let mut copied_to = 0;
168 for captures in ESCAPE_RE.captures_iter(template) {
169 let escape = captures.get(0).expect("escape match");
170 rendered.push_str(&template[copied_to..escape.start()]);
171 copied_to = escape.end();
172 match &captures[1] {
173 "?" => rendered.push_str(&draft.title),
174 "i" => rendered.push_str(body),
175 "U" => rendered.push_str(&format!("[{}]", now.format("%Y-%m-%d %a %H:%M"))),
176 "u" => rendered.push_str(&format!("[{}]", now.format("%Y-%m-%d %a"))),
177 "T" => rendered.push_str(&format!("<{}>", now.format("%Y-%m-%d %a %H:%M"))),
178 "t" => rendered.push_str(&format!("<{}>", now.format("%Y-%m-%d %a"))),
179 "%" => rendered.push('%'),
180 "^g" => rendered.push_str(prompts.get("tags").ok_or_else(|| {
181 AgdError::new(
182 ErrorCode::InvalidPrompt,
183 "capture tag prompt has no 'tags' value",
184 )
185 })?),
186 _ => {
187 let date_kind = captures
188 .get(3)
189 .or(captures.get(4))
190 .map(|kind| kind.as_str());
191 let prompt = captures.get(2).map_or("date", |name| name.as_str());
192 match date_kind {
193 Some(kind) => {
194 let value = prompts.get(prompt).map_or("", String::as_str);
195 let date = prompted_date(value, kind, now.date()).map_err(|error| {
196 AgdError::new(ErrorCode::InvalidDate, format!("{prompt}: {error}"))
197 })?;
198 rendered.push_str(&date);
199 }
200 None => {
201 let value = prompts.get(prompt).ok_or_else(|| {
202 AgdError::new(
203 ErrorCode::InvalidPrompt,
204 format!("capture prompt '{prompt}' has no value"),
205 )
206 })?;
207 rendered.push_str(value);
208 }
209 }
210 }
211 }
212 }
213 rendered.push_str(&template[copied_to..]);
214 Ok(rendered)
215}
216
217fn prompted_date(value: &str, kind: &str, today: NaiveDate) -> Result<String, AgdError> {
220 if value.trim().is_empty() {
221 return Ok(String::new());
222 }
223 let mut timestamp = parse_date_input(value, today)?;
224 if kind == "t" || kind == "u" {
225 timestamp.time = None;
226 }
227 if kind == "u" || kind == "U" {
228 format_inactive_timestamp(×tamp)
229 } else {
230 format_active_timestamp(×tamp)
231 }
232}
233
234fn insert_before_title(line: &mut String, value: &str) {
235 let stars_end = line
236 .find(|character: char| !matches!(character, '*' | ' ' | '\t'))
237 .unwrap_or(line.len());
238 if stars_end >= line.len() {
239 line.push_str(value);
240 return;
241 }
242 let rest = &line[stars_end..];
243 let first_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
244 let insertion = if rest[..first_end]
245 .bytes()
246 .all(|byte| byte.is_ascii_uppercase() || matches!(byte, b'_' | b'-'))
247 {
248 stars_end + first_end
249 } else {
250 stars_end
251 };
252 line.insert_str(insertion, &format!(" {value}"));
253}
254
255fn is_schedule_or_deadline_line(line: &str) -> bool {
258 let trimmed = line.trim_start();
259 trimmed.starts_with("SCHEDULED:") || trimmed.starts_with("DEADLINE:")
260}
261
262fn planning_dates(line: &str) -> (Option<String>, Option<String>) {
265 let date_after = |keyword: &str| {
266 let rest = line.split_once(keyword)?.1.trim_start();
267 let close = match rest.chars().next()? {
268 '<' => '>',
269 '[' => ']',
270 _ => return None,
271 };
272 let end = rest.find(close)?;
273 Some(rest[..=end].to_string())
274 };
275 (date_after("SCHEDULED:"), date_after("DEADLINE:"))
276}
277
278fn add_properties(
282 lines: &mut Vec<String>,
283 properties: &BTreeMap<String, String>,
284 after: usize,
285) -> Result<(), AgdError> {
286 if properties.is_empty() {
287 return Ok(());
288 }
289 let mut entries = Vec::new();
290 for (key, value) in properties {
291 if key.is_empty() || key.contains([' ', '\t', ':']) || value.contains(['\n', '\r']) {
292 return Err(AgdError::new(
293 ErrorCode::InvalidProperty,
294 format!("capture property '{key}' needs a plain key and a one-line value"),
295 ));
296 }
297 let present = lines.iter().any(|line| {
298 line.trim_start()
299 .to_ascii_uppercase()
300 .starts_with(&format!(":{}:", key.to_ascii_uppercase()))
301 });
302 if !present {
303 entries.push(format!(":{key}: {value}"));
304 }
305 }
306 let drawer = lines.iter().position(|line| line.trim() == ":PROPERTIES:");
307 let end = drawer.and_then(|start| {
308 lines[start..]
309 .iter()
310 .position(|line| line.trim() == ":END:")
311 .map(|offset| start + offset)
312 });
313 match end {
314 Some(end) => {
315 lines.splice(end..end, entries);
316 }
317 None => {
318 let at = after.min(lines.len());
319 let mut drawer = vec![":PROPERTIES:".to_string()];
320 drawer.extend(entries);
321 drawer.push(":END:".to_string());
322 lines.splice(at..at, drawer);
323 }
324 }
325 Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::date::simple_date;
332
333 #[test]
334 fn user_text_is_inserted_literally() {
335 let template = CaptureTemplate {
336 template: "* TODO %?\n%i\n:CREATED: %U\n100%%".to_string(),
337 ..CaptureTemplate::default()
338 };
339 let draft = CaptureDraft {
340 title: "Literal %U and %i".to_string(),
341 scheduled: None,
342 deadline: None,
343 tags: Vec::new(),
344 priority: None,
345 properties: BTreeMap::new(),
346 };
347 let now = NaiveDate::from_ymd_opt(2026, 10, 13)
348 .unwrap()
349 .and_hms_opt(14, 22, 0)
350 .unwrap();
351 let rendered =
352 render_capture_template(&template, &draft, "body %? %t", &BTreeMap::new(), now)
353 .unwrap();
354 assert_eq!(
355 rendered,
356 "* TODO Literal %U and %i\nbody %? %t\n:CREATED: [2026-10-13 Tue 14:22]\n100%"
357 );
358 }
359
360 #[test]
361 fn capture_properties_join_the_template_drawer_or_open_one() {
362 let now = NaiveDate::from_ymd_opt(2026, 10, 13)
363 .unwrap()
364 .and_hms_opt(14, 22, 0)
365 .unwrap();
366 let draft = CaptureDraft {
367 title: "Fix flaky test".to_string(),
368 properties: BTreeMap::from([
369 ("REPO".to_string(), "agd".to_string()),
370 (
371 "SOURCE".to_string(),
372 "[[file:~/src/agd/write.rs::42]]".to_string(),
373 ),
374 ]),
375 ..CaptureDraft::default()
376 };
377 let with_drawer = render_capture_template(
378 &CaptureTemplate::default(),
379 &draft,
380 "",
381 &BTreeMap::new(),
382 now,
383 )
384 .unwrap();
385 assert_eq!(
386 with_drawer,
387 "* 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:"
388 );
389 let plain = CaptureTemplate {
390 template: "* TODO %?\nNotes".to_string(),
391 ..CaptureTemplate::default()
392 };
393 let opened = render_capture_template(&plain, &draft, "", &BTreeMap::new(), now).unwrap();
394 assert!(opened.starts_with("* TODO Fix flaky test\n:PROPERTIES:\n:REPO: agd\n"));
395 assert!(opened.ends_with(":END:\nNotes"));
396 }
397
398 #[test]
399 fn template_preview_is_exact_org_text() {
400 let template = CaptureTemplate::default();
401 let draft = CaptureDraft {
402 title: "Renew passport".to_string(),
403 scheduled: None,
404 deadline: Some(simple_date(NaiveDate::from_ymd_opt(2026, 10, 30).unwrap())),
405 tags: vec!["admin".to_string()],
406 priority: Some("A".to_string()),
407 properties: BTreeMap::new(),
408 };
409 let rendered = render_capture_template(
410 &template,
411 &draft,
412 "",
413 &BTreeMap::new(),
414 NaiveDate::from_ymd_opt(2026, 10, 13)
415 .unwrap()
416 .and_hms_opt(9, 30, 0)
417 .unwrap(),
418 )
419 .unwrap();
420 assert_eq!(
421 rendered,
422 "* TODO [#A] Renew passport :admin:\nDEADLINE: <2026-10-30 Fri>\n:PROPERTIES:\n:CREATED: [2026-10-13 Tue 09:30]\n:END:"
423 );
424 }
425
426 fn deadline_template() -> CaptureTemplate {
427 CaptureTemplate {
428 template: "* TODO %?\nDEADLINE: %^{deadline}t\nCREATED: %U".to_string(),
429 ..CaptureTemplate::default()
430 }
431 }
432
433 fn render(
434 template: &CaptureTemplate,
435 draft: &CaptureDraft,
436 deadline: &str,
437 ) -> Result<String, AgdError> {
438 let now = NaiveDate::from_ymd_opt(2026, 10, 13)
439 .unwrap()
440 .and_hms_opt(9, 30, 0)
441 .unwrap();
442 let prompts = BTreeMap::from([("deadline".to_string(), deadline.to_string())]);
443 render_capture_template(template, draft, "", &prompts, now)
444 }
445
446 #[test]
447 fn a_date_prompt_is_a_date_and_lists_as_one() {
448 assert_eq!(
449 capture_prompts(&deadline_template()),
450 vec![CapturePrompt {
451 name: "deadline".to_string(),
452 date: true,
453 planning: Some("DEADLINE".to_string()),
454 }]
455 );
456 let draft = CaptureDraft {
457 title: "Renew passport".to_string(),
458 ..CaptureDraft::default()
459 };
460 assert_eq!(
461 render(&deadline_template(), &draft, "fri").unwrap(),
462 "* TODO Renew passport\nDEADLINE: <2026-10-16 Fri>\nCREATED: [2026-10-13 Tue 09:30]"
463 );
464 let error = render(&deadline_template(), &draft, "someday").unwrap_err();
465 assert!(error.to_string().contains("deadline"), "{error}");
466 }
467
468 #[test]
469 fn token_dates_merge_into_the_template_planning_line() {
470 let draft = CaptureDraft {
471 title: "Renew passport".to_string(),
472 scheduled: Some(simple_date(NaiveDate::from_ymd_opt(2026, 10, 19).unwrap())),
473 deadline: Some(simple_date(NaiveDate::from_ymd_opt(2026, 10, 28).unwrap())),
474 ..CaptureDraft::default()
475 };
476 let expected = "* TODO Renew passport\nSCHEDULED: <2026-10-19 Mon> DEADLINE: <2026-10-28 Wed>\nCREATED: [2026-10-13 Tue 09:30]";
477 assert_eq!(render(&deadline_template(), &draft, "").unwrap(), expected);
479 assert_eq!(
481 render(&deadline_template(), &draft, "fri").unwrap(),
482 expected
483 );
484 let plain = CaptureDraft {
486 title: "Renew passport".to_string(),
487 ..CaptureDraft::default()
488 };
489 assert_eq!(
490 render(&deadline_template(), &plain, "").unwrap(),
491 "* TODO Renew passport\nCREATED: [2026-10-13 Tue 09:30]"
492 );
493 }
494
495 #[test]
496 fn timed_and_inactive_date_prompts_render_as_org_does() {
497 let template = CaptureTemplate {
498 template: "* Meeting %?\n%^{when}T\nLogged %^{deadline}u".to_string(),
499 ..CaptureTemplate::default()
500 };
501 let draft = CaptureDraft {
502 title: "Sync".to_string(),
503 ..CaptureDraft::default()
504 };
505 let now = NaiveDate::from_ymd_opt(2026, 10, 13)
506 .unwrap()
507 .and_hms_opt(9, 30, 0)
508 .unwrap();
509 let prompts = BTreeMap::from([
510 ("when".to_string(), "2026-10-30 14:00".to_string()),
511 ("deadline".to_string(), "2026-10-30 14:00".to_string()),
512 ]);
513 assert_eq!(
514 render_capture_template(&template, &draft, "", &prompts, now).unwrap(),
515 "* Meeting Sync\n<2026-10-30 Fri 14:00>\nLogged [2026-10-30 Fri]"
516 );
517 }
518}