use std::collections::BTreeMap;
use chrono::NaiveDate;
use crate::date::unsupported_date;
use crate::document::is_org_tag;
use crate::{AgdError, ErrorCode, Notation, PlanningTimestamp, read_date};
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CaptureDraft {
pub title: String,
pub scheduled: Option<PlanningTimestamp>,
pub deadline: Option<PlanningTimestamp>,
pub tags: Vec<String>,
pub priority: Option<String>,
pub properties: BTreeMap<String, String>,
}
pub fn parse_capture_line(
input: &str,
today: NaiveDate,
notation: Notation,
) -> Result<CaptureDraft, AgdError> {
let mut title = Vec::new();
let mut scheduled = None;
let mut deadline = None;
let mut tags = Vec::new();
let mut priority = None;
let tokens: Vec<&str> = input.split_whitespace().collect();
let mut index = 0;
while let Some(&token) = tokens.get(index) {
let planning = if let Some(value) = token.strip_prefix("due:") {
Some((value, &mut deadline))
} else {
token
.strip_prefix("on:")
.map(|value| (value, &mut scheduled))
};
if let Some((value, slot)) = planning
&& !value.is_empty()
{
let mut words = vec![value];
words.extend(&tokens[index + 1..]);
let (timestamp, taken) =
read_date(&words, today)?.ok_or_else(|| unsupported_date(value))?;
*slot = Some(timestamp);
index += taken;
continue;
}
if let Some(names) = tag_token(token, notation) {
tags.extend(names.into_iter().map(str::to_string));
} else if let Some(cookie) = priority_token(token, notation) {
priority = Some(cookie.to_string());
} else {
title.push(token);
}
index += 1;
}
let title = title.join(" ");
if title.trim().is_empty() {
return Err(AgdError::new(
ErrorCode::InvalidCapture,
"capture title is empty",
));
}
tags.sort();
tags.dedup();
Ok(CaptureDraft {
title,
scheduled,
deadline,
tags,
priority,
properties: BTreeMap::new(),
})
}
fn tag_token(token: &str, notation: Notation) -> Option<Vec<&str>> {
let tags: Vec<&str> = match notation {
Notation::Agd => {
let tag = token.strip_prefix('#')?;
if tag.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
vec![tag]
}
Notation::Org => token
.strip_prefix(':')?
.strip_suffix(':')?
.split(':')
.collect(),
};
tags.iter().all(|tag| is_org_tag(tag)).then_some(tags)
}
fn priority_token(token: &str, notation: Notation) -> Option<&str> {
let marker = match notation {
Notation::Agd => '!',
Notation::Org => '#',
};
let cookie = token.strip_prefix(marker)?;
(cookie.len() == 1 && cookie.bytes().all(|byte| byte.is_ascii_uppercase())).then_some(cookie)
}
#[cfg(test)]
mod tests {
use super::*;
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, 13).unwrap()
}
fn read(line: &str) -> CaptureDraft {
parse_capture_line(line, today(), Notation::Agd).unwrap()
}
fn tokens(line: &str, notation: Notation) -> (String, Vec<String>, Option<String>) {
let draft = parse_capture_line(line, today(), notation).unwrap();
(draft.title, draft.tags, draft.priority)
}
fn expected(
title: &str,
tags: &[&str],
priority: Option<&str>,
) -> (String, Vec<String>, Option<String>) {
(
title.to_string(),
tags.iter().map(|tag| tag.to_string()).collect(),
priority.map(str::to_string),
)
}
#[test]
fn unrecognized_capture_tokens_remain_in_title() {
let draft = read("Renew passport due:fri #admin !A owner:me");
assert_eq!(draft.title, "Renew passport owner:me");
assert_eq!(draft.deadline.unwrap().date, "2026-10-16");
assert_eq!(draft.tags, vec!["admin"]);
assert_eq!(draft.priority.as_deref(), Some("A"));
}
#[test]
fn agd_notation_takes_hash_tags_and_bang_priorities() {
let agd = |line| tokens(line, Notation::Agd);
assert_eq!(
agd("Call the bank #home #admin !B"),
expected("Call the bank", &["admin", "home"], Some("B"))
);
assert_eq!(agd("Fix #42 now"), expected("Fix #42 now", &[], None));
assert_eq!(
agd("Ship it !! !a !AB"),
expected("Ship it !! !a !AB", &[], None)
);
assert_eq!(
agd("Plan :work: +home #C"),
expected("Plan :work: +home", &["C"], None)
);
assert_eq!(agd("Price #5-off"), expected("Price #5-off", &[], None));
}
#[test]
fn org_notation_takes_colon_tags_and_hash_priorities() {
let org = |line| tokens(line, Notation::Org);
assert_eq!(
org("Call the bank :home: :a:b: #B"),
expected("Call the bank", &["a", "b", "home"], Some("B"))
);
assert_eq!(org("Fix #42 now"), expected("Fix #42 now", &[], None));
assert_eq!(
org("Plan #work !A :: 10:30"),
expected("Plan #work !A :: 10:30", &[], None)
);
}
#[test]
fn capture_dates_take_a_following_time_token() {
let draft = read("Dentist due:2026-10-30 14:00 on:fri 9:30");
assert_eq!(draft.title, "Dentist");
let deadline = draft.deadline.unwrap();
assert_eq!(
(deadline.date.as_str(), deadline.time.as_deref()),
("2026-10-30", Some("14:00"))
);
let scheduled = draft.scheduled.unwrap();
assert_eq!(
(scheduled.date.as_str(), scheduled.time.as_deref()),
("2026-10-16", Some("09:30"))
);
assert_eq!(read("Standup 14:00 #work").title, "Standup 14:00");
let draft = read("Review due:oct 30 9am draft");
let deadline = draft.deadline.unwrap();
assert_eq!(
(deadline.date.as_str(), deadline.time.as_deref()),
("2026-10-30", Some("09:00"))
);
assert_eq!(draft.title, "Review draft");
let draft = read("Plan due:fri 3 talks");
assert_eq!(draft.deadline.unwrap().date, "2026-10-16");
assert_eq!(draft.title, "Plan 3 talks");
}
#[test]
fn a_capture_date_takes_its_repeater_and_warning_and_refuses_a_bad_time() {
let draft = read("Pay rent due:2026-10-30 14:00 +1m -2d #home");
assert_eq!(draft.title, "Pay rent");
assert_eq!(draft.tags, ["home"]);
let deadline = draft.deadline.unwrap();
assert_eq!(
(
deadline.time.as_deref(),
deadline.repeater.as_deref(),
deadline.warning.as_deref()
),
(Some("14:00"), Some("+1m"), Some("-2d"))
);
let error = parse_capture_line("Call due:fri 25:00", today(), Notation::Agd).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidDate);
let error = parse_capture_line("Call due:someday", today(), Notation::Agd).unwrap_err();
assert!(
error.to_string().contains("unsupported date 'someday'"),
"{error}"
);
}
}