use chrono::NaiveDate;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CancelledSpelling {
DoubleL,
SingleL,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TaskType {
Todo,
Done,
Cancelled(CancelledSpelling),
}
impl fmt::Display for TaskType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
TaskType::Todo => "TODO",
TaskType::Done => "DONE",
TaskType::Cancelled(CancelledSpelling::DoubleL) => "CANCELLED",
TaskType::Cancelled(CancelledSpelling::SingleL) => "CANCELED",
})
}
}
impl TaskType {
pub fn from_keyword(s: &str) -> Option<Self> {
match s {
"TODO" => Some(TaskType::Todo),
"DONE" => Some(TaskType::Done),
"CANCELLED" => Some(TaskType::Cancelled(CancelledSpelling::DoubleL)),
"CANCELED" => Some(TaskType::Cancelled(CancelledSpelling::SingleL)),
_ => None,
}
}
}
impl Serialize for TaskType {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for TaskType {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct TaskTypeVisitor;
impl Visitor<'_> for TaskTypeVisitor {
type Value = TaskType;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("one of \"TODO\", \"DONE\", \"CANCELLED\", \"CANCELED\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<TaskType, E> {
TaskType::from_keyword(v).ok_or_else(|| {
de::Error::unknown_variant(v, &["TODO", "DONE", "CANCELLED", "CANCELED"])
})
}
}
de.deserialize_str(TaskTypeVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
Numeric(u8),
A,
B,
C,
Other(char),
}
impl Priority {
pub fn parse(s: &str) -> Option<Self> {
if s.is_empty() {
return None;
}
let bytes = s.as_bytes();
if bytes.len() == 1 {
let b = bytes[0];
if b.is_ascii_uppercase() {
return Some(match b {
b'A' => Priority::A,
b'B' => Priority::B,
b'C' => Priority::C,
_ => Priority::Other(b as char),
});
}
}
if bytes.len() > 1 && bytes[0] == b'0' {
return None;
}
if !bytes.iter().all(|b| b.is_ascii_digit()) {
return None;
}
let n: u8 = s.parse().ok()?;
if n <= 64 {
Some(Priority::Numeric(n))
} else {
None
}
}
pub fn order(&self) -> u32 {
match self {
Priority::Numeric(n) => *n as u32,
Priority::A => 'A' as u32,
Priority::B => 'B' as u32,
Priority::C => 'C' as u32,
Priority::Other(c) => *c as u32,
}
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Priority::Numeric(n) => write!(f, "{n}"),
Priority::A => f.write_str("A"),
Priority::B => f.write_str("B"),
Priority::C => f.write_str("C"),
Priority::Other(c) => write!(f, "{c}"),
}
}
}
impl FromStr for Priority {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Priority::parse(s).ok_or(())
}
}
impl Serialize for Priority {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Priority {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct PriorityVisitor;
impl Visitor<'_> for PriorityVisitor {
type Value = Priority;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an uppercase letter A-Z or an integer 0..=64")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Priority, E> {
Priority::parse(v).ok_or_else(|| E::custom(format!("invalid priority: {v}")))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Priority, E> {
if v <= 64 {
Ok(Priority::Numeric(v as u8))
} else {
Err(E::custom(format!("priority out of range: {v}")))
}
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Priority, E> {
if (0..=64).contains(&v) {
Ok(Priority::Numeric(v as u8))
} else {
Err(E::custom(format!("priority out of range: {v}")))
}
}
}
de.deserialize_any(PriorityVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClockEntry {
pub start: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub file: String,
pub line: u32,
pub heading: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_type: Option<TaskType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<Priority>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_end_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_repeater: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_next: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clocks: Option<Vec<ClockEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_clock_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<BTreeMap<String, String>>,
}
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
pub const DEFAULT_MAX_TASKS: usize = 10_000;
pub const MAX_DIAGNOSTIC_ITEMS: usize = 20;
#[derive(Debug, Default)]
pub struct ProcessingStats {
pub files_processed: usize,
pub files_skipped_size: usize,
pub files_failed_search: usize,
pub files_failed_read: usize,
pub files_not_utf8: usize,
pub walk_errors: usize,
pub max_tasks_reached: bool,
pub max_tasks_limit: usize,
pub failed_paths: Vec<String>,
pub ts_warnings_emitted: usize,
pub prop_warnings_emitted: usize,
pub interrupted: bool,
pub nonutf8_paths: usize,
}
impl ProcessingStats {
pub fn has_warnings(&self) -> bool {
self.files_skipped_size > 0
|| self.files_failed_search > 0
|| self.files_failed_read > 0
|| self.files_not_utf8 > 0
|| self.walk_errors > 0
|| self.max_tasks_reached
|| self.interrupted
|| self.nonutf8_paths > 0
}
pub fn record_failed_path(&mut self, path: &str) {
if self.failed_paths.len() < MAX_DIAGNOSTIC_ITEMS {
self.failed_paths.push(path.to_string());
}
}
pub fn note_nonutf8_path(&mut self, lossy: &str) {
if self.nonutf8_paths == 0 {
tracing::warn!(
file = %lossy,
"file path is not valid UTF-8; the `file` field is rendered with U+FFFD replacement characters and may not round-trip. Further such paths this run are counted in the summary only."
);
}
self.nonutf8_paths += 1;
}
pub fn print_summary(&self) {
if !self.has_warnings() {
return;
}
tracing::warn!(
files_processed = self.files_processed,
files_skipped_size = self.files_skipped_size,
files_failed_search = self.files_failed_search,
files_failed_read = self.files_failed_read,
files_not_utf8 = self.files_not_utf8,
walk_errors = self.walk_errors,
max_tasks_reached = self.max_tasks_reached,
max_tasks_limit = self.max_tasks_limit,
interrupted = self.interrupted,
nonutf8_paths = self.nonutf8_paths,
failed_paths_count = self.failed_paths.len(),
failed_paths_cap = MAX_DIAGNOSTIC_ITEMS,
failed_paths = ?self.failed_paths,
"processing summary"
);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TaskWithOffset {
#[serde(flatten)]
pub task: Task,
#[serde(skip_serializing_if = "Option::is_none")]
pub days_offset: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DayAgenda {
pub date: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overdue: Vec<TaskWithOffset>,
pub scheduled_timed: Vec<TaskWithOffset>,
pub scheduled_no_time: Vec<TaskWithOffset>,
pub upcoming: Vec<TaskWithOffset>,
}
impl DayAgenda {
pub fn new(date: NaiveDate) -> Self {
Self {
date: date.format("%Y-%m-%d").to_string(),
overdue: Vec::new(),
scheduled_timed: Vec::new(),
scheduled_no_time: Vec::new(),
upcoming: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_type_from_keyword() {
assert_eq!(TaskType::from_keyword("TODO"), Some(TaskType::Todo));
assert_eq!(TaskType::from_keyword("DONE"), Some(TaskType::Done));
assert_eq!(TaskType::from_keyword("MAYBE"), None);
assert_eq!(
TaskType::from_keyword("CANCELLED"),
Some(TaskType::Cancelled(CancelledSpelling::DoubleL))
);
assert_eq!(
TaskType::from_keyword("CANCELED"),
Some(TaskType::Cancelled(CancelledSpelling::SingleL))
);
assert_eq!(TaskType::from_keyword("cancelled"), None);
assert_eq!(TaskType::from_keyword("canceled"), None);
}
#[test]
fn task_type_display() {
assert_eq!(TaskType::Todo.to_string(), "TODO");
assert_eq!(TaskType::Done.to_string(), "DONE");
assert_eq!(
TaskType::Cancelled(CancelledSpelling::DoubleL).to_string(),
"CANCELLED"
);
assert_eq!(
TaskType::Cancelled(CancelledSpelling::SingleL).to_string(),
"CANCELED"
);
}
#[test]
fn task_type_serializes_as_plain_string_preserving_spelling() {
assert_eq!(serde_json::to_string(&TaskType::Todo).unwrap(), "\"TODO\"");
assert_eq!(serde_json::to_string(&TaskType::Done).unwrap(), "\"DONE\"");
assert_eq!(
serde_json::to_string(&TaskType::Cancelled(CancelledSpelling::DoubleL)).unwrap(),
"\"CANCELLED\""
);
assert_eq!(
serde_json::to_string(&TaskType::Cancelled(CancelledSpelling::SingleL)).unwrap(),
"\"CANCELED\""
);
}
#[test]
fn task_type_round_trips_through_serde() {
for tt in [
TaskType::Todo,
TaskType::Done,
TaskType::Cancelled(CancelledSpelling::DoubleL),
TaskType::Cancelled(CancelledSpelling::SingleL),
] {
let json = serde_json::to_string(&tt).unwrap();
let back: TaskType = serde_json::from_str(&json).unwrap();
assert_eq!(back, tt, "round trip must preserve the variant: {json}");
}
}
#[test]
fn task_type_deserialize_rejects_unknown_string() {
let r: Result<TaskType, _> = serde_json::from_str("\"MAYBE\"");
assert!(r.is_err(), "unknown task_type string must fail to parse");
}
#[test]
fn interrupted_flag_makes_summary_visible() {
let mut stats = ProcessingStats::default();
assert!(!stats.has_warnings(), "default stats must be quiet");
stats.interrupted = true;
assert!(
stats.has_warnings(),
"interrupted runs must always show a summary"
);
}
#[test]
fn note_nonutf8_path_counts_and_makes_summary_visible() {
let mut stats = ProcessingStats::default();
assert!(!stats.has_warnings(), "default stats must be quiet");
stats.note_nonutf8_path("bad\u{FFFD}name.md");
stats.note_nonutf8_path("other\u{FFFD}.md");
assert_eq!(
stats.nonutf8_paths, 2,
"every non-UTF-8 path must be counted"
);
assert!(
stats.has_warnings(),
"a run with non-UTF-8 paths must surface a summary"
);
}
#[test]
fn record_failed_path_caps_list_at_diagnostic_limit() {
let mut stats = ProcessingStats::default();
for i in 0..(MAX_DIAGNOSTIC_ITEMS * 3) {
stats.record_failed_path(&format!("/tmp/file-{i}.md"));
}
assert_eq!(
stats.failed_paths.len(),
MAX_DIAGNOSTIC_ITEMS,
"failed_paths must be capped at MAX_DIAGNOSTIC_ITEMS regardless of input size"
);
assert_eq!(stats.failed_paths[0], "/tmp/file-0.md");
assert_eq!(
stats.failed_paths[MAX_DIAGNOSTIC_ITEMS - 1],
format!("/tmp/file-{}.md", MAX_DIAGNOSTIC_ITEMS - 1)
);
}
#[test]
fn priority_parse_letters() {
assert_eq!(Priority::parse("A"), Some(Priority::A));
assert_eq!(Priority::parse("B"), Some(Priority::B));
assert_eq!(Priority::parse("C"), Some(Priority::C));
assert_eq!(Priority::parse("Z"), Some(Priority::Other('Z')));
}
#[test]
fn priority_parse_numeric() {
assert_eq!(Priority::parse("0"), Some(Priority::Numeric(0)));
assert_eq!(Priority::parse("1"), Some(Priority::Numeric(1)));
assert_eq!(Priority::parse("9"), Some(Priority::Numeric(9)));
assert_eq!(Priority::parse("15"), Some(Priority::Numeric(15)));
assert_eq!(Priority::parse("64"), Some(Priority::Numeric(64)));
}
#[test]
fn priority_parse_rejects_out_of_range() {
assert_eq!(Priority::parse("65"), None);
assert_eq!(Priority::parse("100"), None);
assert_eq!(Priority::parse("a"), None);
assert_eq!(Priority::parse("@"), None);
assert_eq!(Priority::parse("-1"), None);
assert_eq!(Priority::parse(""), None);
}
#[test]
fn priority_parse_rejects_leading_zero() {
assert_eq!(Priority::parse("01"), None);
assert_eq!(Priority::parse("00"), None);
}
#[test]
fn priority_order_matches_org_priority_to_value() {
assert_eq!(Priority::Numeric(0).order(), 0);
assert_eq!(Priority::Numeric(64).order(), 64);
assert_eq!(Priority::A.order(), 65);
assert_eq!(Priority::B.order(), 66);
assert_eq!(Priority::C.order(), 67);
assert_eq!(Priority::Other('D').order(), 68);
assert_eq!(Priority::Other('Z').order(), 90);
assert!(Priority::Numeric(64).order() < Priority::A.order());
assert!(Priority::A.order() < Priority::B.order());
assert!(Priority::C.order() < Priority::Other('D').order());
}
#[test]
fn priority_serializes_as_string() {
let json = serde_json::to_string(&Priority::A).unwrap();
assert_eq!(json, "\"A\"");
let json = serde_json::to_string(&Priority::Other('D')).unwrap();
assert_eq!(json, "\"D\"");
let json = serde_json::to_string(&Priority::Numeric(5)).unwrap();
assert_eq!(json, "\"5\"");
let json = serde_json::to_string(&Priority::Numeric(64)).unwrap();
assert_eq!(json, "\"64\"");
}
fn empty_task() -> Task {
Task {
file: "t.md".into(),
line: 1,
heading: String::new(),
content: String::new(),
task_type: None,
priority: None,
created: None,
timestamp: None,
timestamp_type: None,
timestamp_active: None,
timestamp_date: None,
timestamp_time: None,
timestamp_end_time: None,
timestamp_repeater: None,
timestamp_next: None,
clocks: None,
total_clock_time: None,
properties: None,
}
}
#[test]
fn task_serializes_timestamp_active_true_when_active() {
let mut t = empty_task();
t.timestamp = Some("SCHEDULED: <2026-05-25 Mon>".into());
t.timestamp_type = Some("SCHEDULED".into());
t.timestamp_active = Some(true);
let json = serde_json::to_string(&t).unwrap();
assert!(
json.contains("\"timestamp_active\":true"),
"JSON must surface timestamp_active=true: {json}"
);
}
#[test]
fn task_serializes_timestamp_active_false_when_inactive() {
let mut t = empty_task();
t.timestamp = Some("CLOSED: [2026-05-24 Sun 14:30]".into());
t.timestamp_type = Some("CLOSED".into());
t.timestamp_active = Some(false);
let json = serde_json::to_string(&t).unwrap();
assert!(
json.contains("\"timestamp_active\":false"),
"JSON must surface timestamp_active=false: {json}"
);
}
#[test]
fn task_omits_timestamp_active_when_none() {
let json = serde_json::to_string(&empty_task()).unwrap();
assert!(
!json.contains("timestamp_active"),
"absent timestamp must not emit the field: {json}"
);
}
#[test]
fn task_round_trips_timestamp_active_through_serde() {
let mut t = empty_task();
t.timestamp_active = Some(false);
let json = serde_json::to_string(&t).unwrap();
let back: Task = serde_json::from_str(&json).unwrap();
assert_eq!(back.timestamp_active, Some(false));
}
#[test]
fn task_serializes_properties_when_present() {
let mut t = empty_task();
let mut props = std::collections::BTreeMap::new();
props.insert("GCAL_EVENT_ID".to_string(), "abc123/primary".to_string());
t.properties = Some(props);
let json = serde_json::to_string(&t).unwrap();
assert!(
json.contains("\"properties\":{\"GCAL_EVENT_ID\":\"abc123/primary\"}"),
"JSON must surface properties map: {json}"
);
}
#[test]
fn task_omits_properties_when_none() {
let json = serde_json::to_string(&empty_task()).unwrap();
assert!(
!json.contains("properties"),
"absent properties must not emit the field: {json}"
);
}
#[test]
fn task_round_trips_properties_through_serde() {
let mut t = empty_task();
let mut props = std::collections::BTreeMap::new();
props.insert("K".to_string(), "v".to_string());
t.properties = Some(props.clone());
let json = serde_json::to_string(&t).unwrap();
let back: Task = serde_json::from_str(&json).unwrap();
assert_eq!(back.properties, Some(props));
}
#[test]
fn priority_deserializes_from_string_and_integer() {
let p: Priority = serde_json::from_str("\"A\"").unwrap();
assert_eq!(p, Priority::A);
let p: Priority = serde_json::from_str("\"5\"").unwrap();
assert_eq!(p, Priority::Numeric(5));
let p: Priority = serde_json::from_str("5").unwrap();
assert_eq!(p, Priority::Numeric(5));
let r: Result<Priority, _> = serde_json::from_str("65");
assert!(r.is_err());
}
}