1use std::collections::BTreeSet;
11use std::path::Path;
12
13use chrono::{Duration, NaiveDate};
14
15use crate::date::parse_org_date;
16use crate::write::{CaptureTarget, find_olpath, heading_titled};
17use crate::{
18 AGENDA_ENTRY_SCHEMA, AgdError, AgendaEntry, CaptureTemplate, ErrorCode, HeadlineSpan,
19 IndexedDocument, Item, Occurrence, OccurrenceType, WorkspaceIndex,
20};
21
22use super::matcher::{TagTodoMatcher, matcher_now};
23use super::repeat::{planning_dates, repeated_dates};
24use super::sort::{compare_by_strategy, compare_items, urgency};
25
26#[derive(Clone, Debug)]
27pub struct AgendaQuery {
28 pub from: NaiveDate,
29 pub to: NaiveDate,
30 pub today: NaiveDate,
31 pub match_expression: Option<String>,
32 pub log_mode: bool,
34}
35
36#[derive(Clone, Debug)]
37pub struct AgendaEngine<'a> {
38 pub(super) index: &'a WorkspaceIndex,
39}
40
41impl<'a> AgendaEngine<'a> {
42 pub fn new(index: &'a WorkspaceIndex) -> Self {
43 Self { index }
44 }
45
46 pub fn agenda(&self, query: &AgendaQuery) -> Result<Vec<AgendaEntry>, AgdError> {
47 if query.to < query.from {
48 return Err(AgdError::new(
49 ErrorCode::InvalidRange,
50 "agenda end date is before its start date",
51 ));
52 }
53 let matcher = query
54 .match_expression
55 .as_deref()
56 .map(TagTodoMatcher::parse)
57 .transpose()?;
58 let now = matcher_now(query.today);
59 let settings = self.index.settings();
60 let mut entries = Vec::new();
65 for document in self.index.documents() {
66 let mut file_entries = Vec::new();
67 for headline in document.headlines() {
68 if matcher
69 .as_ref()
70 .is_some_and(|matcher| !matcher.matches(headline, now, settings))
71 {
72 continue;
73 }
74 let occurrences = self.occurrences(headline, query).map_err(|error| {
75 AgdError::new(
76 error.code(),
77 format!("{}:{}: {error}", headline.item.file, headline.item.line),
78 )
79 })?;
80 file_entries.extend(occurrences);
81 }
82 file_entries.sort_by_key(|entry| collection_rank(entry.occurrence.kind));
83 entries.extend(file_entries);
84 }
85 let mut keyed: Vec<(i64, AgendaEntry)> = entries
86 .into_iter()
87 .map(|entry| (urgency(&entry, query.today, self.index), entry))
88 .collect();
89 let todo_order: Vec<String> = crate::document::todo_keywords("", self.index.settings())
91 .into_iter()
92 .map(|keyword| keyword.name)
93 .collect();
94 keyed.sort_by(|left, right| {
95 left.1
96 .occurrence
97 .date
98 .cmp(&right.1.occurrence.date)
99 .then_with(|| compare_by_strategy(left, right, self.index, &todo_order))
100 });
101 Ok(keyed.into_iter().map(|(_, entry)| entry).collect())
102 }
103
104 pub fn todo(
105 &self,
106 match_expression: Option<&str>,
107 today: NaiveDate,
108 ) -> Result<Vec<Item>, AgdError> {
109 let matcher = match_expression.map(TagTodoMatcher::parse).transpose()?;
110 let now = matcher_now(today);
111 let settings = self.index.settings();
112 let mut items: Vec<Item> = self
113 .headlines()
114 .filter(|headline| headline.item.todo.is_some() && !headline.item.done)
115 .filter(|headline| {
116 matcher
117 .as_ref()
118 .is_none_or(|matcher| matcher.matches(headline, now, settings))
119 })
120 .map(|headline| headline.item.clone())
121 .collect();
122 items.sort_by(|left, right| compare_items(left, right, self.index));
123 Ok(items)
124 }
125
126 pub fn inbox(&self) -> Vec<Item> {
133 let mut landed: BTreeSet<(&Path, usize)> = BTreeSet::new();
134 for template in &self.index.settings().captures {
135 if template.datetree {
136 continue;
137 }
138 if let Some((document, parent)) = self.capture_target(template) {
139 let level = parent.map_or(1, |parent| parent.level + 1);
140 let region = parent.map_or(0..document.source().len(), |parent| {
141 parent.subtree_span.start..parent.subtree_span.end
142 });
143 for headline in document.headlines() {
144 if headline.level == level && region.contains(&headline.line_span.start) {
145 landed.insert((document.path(), headline.item.line));
146 }
147 }
148 }
149 }
150 self.headlines()
151 .filter(|headline| {
152 !headline.item.done
153 && landed.contains(&(Path::new(&headline.item.file), headline.item.line))
154 })
155 .map(|headline| headline.item.clone())
156 .collect()
157 }
158
159 fn capture_target(
163 &self,
164 template: &CaptureTemplate,
165 ) -> Option<(&IndexedDocument, Option<&HeadlineSpan>)> {
166 if let CaptureTarget::Id(id) = CaptureTarget::of(template) {
167 let (document, headline) = self.index.resolve(&id).ok()?;
168 return Some((document, Some(headline)));
169 }
170 let file = shellexpand::tilde(template.file.as_deref()?).into_owned();
171 let path = self.index.roots().resolve_read(Path::new(&file)).ok()?;
172 let document = self.index.document(&path)?;
173 let parent = match CaptureTarget::of(template) {
174 CaptureTarget::File | CaptureTarget::Id(_) => None,
175 CaptureTarget::Heading(title) => Some(heading_titled(document, &title)?),
176 CaptureTarget::OutlinePath(outline) => {
177 let start = find_olpath(document, &outline).ok()?;
178 Some(
179 document
180 .headlines()
181 .iter()
182 .find(|headline| headline.line_span.start == start)?,
183 )
184 }
185 };
186 Some((document, parent))
187 }
188
189 pub fn tags(&self, match_expression: &str, today: NaiveDate) -> Result<Vec<Item>, AgdError> {
192 let matcher = TagTodoMatcher::parse(match_expression)?;
193 let now = matcher_now(today);
194 let settings = self.index.settings();
195 let mut items: Vec<Item> = self
196 .headlines()
197 .filter(|headline| matcher.matches(headline, now, settings))
198 .map(|headline| headline.item.clone())
199 .collect();
200 items.sort_by(|left, right| compare_items(left, right, self.index));
201 Ok(items)
202 }
203
204 pub(super) fn headlines(&self) -> impl Iterator<Item = &HeadlineSpan> {
205 self.index
206 .documents()
207 .flat_map(|document| document.headlines().iter())
208 }
209
210 fn occurrences(
211 &self,
212 headline: &HeadlineSpan,
213 query: &AgendaQuery,
214 ) -> Result<Vec<AgendaEntry>, AgdError> {
215 let (from, to, today) = (query.from, query.to, query.today);
216 let item = &headline.item;
217 let settings = self.index.settings();
218 let mut entries = Vec::new();
219
220 if !(item.done && settings.skip_scheduled_if_done)
225 && let Some(scheduled) = &item.planning.scheduled
226 {
227 let base = parse_org_date(&scheduled.date)?;
228 for date in planning_dates(scheduled, from, to, today, item.done)? {
229 entries.push(entry(
230 item,
231 OccurrenceType::Scheduled,
232 date,
233 (scheduled.time.clone(), scheduled.end_time.clone()),
234 "Scheduled:".to_string(),
235 ));
236 }
237 if !item.done && base < today && today >= from && today <= to {
238 let late = (today - base).num_days();
239 entries.push(reminder(
240 item,
241 OccurrenceType::PastScheduled,
242 today,
243 format!("Sched.{late:>2}x:"),
244 late,
245 ));
246 }
247 }
248
249 if !(item.done && settings.skip_deadline_if_done)
250 && let Some(deadline) = &item.planning.deadline
251 {
252 let base = parse_org_date(&deadline.date)?;
253 for date in planning_dates(deadline, from, to, today, item.done)? {
254 entries.push(entry(
255 item,
256 OccurrenceType::Deadline,
257 date,
258 (deadline.time.clone(), deadline.end_time.clone()),
259 "Deadline:".to_string(),
260 ));
261 }
262 let warning_days = deadline
263 .warning
264 .as_deref()
265 .and_then(warning_days)
266 .unwrap_or(settings.deadline_warning_days)
267 .max(0);
268 if !item.done && today >= from && today <= to {
269 let late = (today - base).num_days();
270 if late > 0 {
271 entries.push(reminder(
272 item,
273 OccurrenceType::Deadline,
274 today,
275 format!("{late} d. ago:"),
276 late,
277 ));
278 } else if late < 0 && -late <= warning_days {
279 entries.push(reminder(
280 item,
281 OccurrenceType::UpcomingDeadline,
282 today,
283 format!("In {:>3} d.:", -late),
284 late,
285 ));
286 }
287 }
288 }
289
290 if query.log_mode
291 && let Some(closed) = &item.planning.closed
292 {
293 let date = parse_org_date(&closed.date)?;
294 if date >= from && date <= to {
295 entries.push(entry(
296 item,
297 OccurrenceType::Closed,
298 date,
299 (closed.time.clone(), None),
300 "Closed:".to_string(),
301 ));
302 }
303 }
304
305 if !(item.done && settings.skip_timestamp_if_done) {
306 let mut index = 0;
307 while index < headline.timestamps.len() {
308 let timestamp = &headline.timestamps[index];
309 if !timestamp.active {
310 index += 1;
311 continue;
312 }
313 if let Some(end) = headline.timestamps.get(index + 1)
314 && end.active
315 && headline.timestamps[index].span.end <= end.span.start
316 {
317 let document = self
318 .index
319 .document(std::path::Path::new(&item.file))
320 .expect("headline document");
321 if document.source()[timestamp.span.end..end.span.start].trim() == "--" {
322 let start_date = parse_org_date(×tamp.timestamp.date)?;
323 let end_date = parse_org_date(&end.timestamp.date)?;
324 let total = (end_date - start_date).num_days() + 1;
325 for day in date_range(start_date.max(from), end_date.min(to)) {
326 let ordinal = (day - start_date).num_days() + 1;
327 let time = if day == start_date {
328 timestamp.timestamp.time.clone()
329 } else if day == end_date {
330 end.timestamp.time.clone()
331 } else {
332 None
333 };
334 entries.push(entry(
335 item,
336 OccurrenceType::Block,
337 day,
338 (time, None),
339 format!("({ordinal}/{total}):"),
340 ));
341 }
342 index += 2;
343 continue;
344 }
345 }
346 for date in repeated_dates(×tamp.timestamp, from, to)? {
347 entries.push(entry(
348 item,
349 OccurrenceType::Timestamp,
350 date,
351 (
352 timestamp.timestamp.time.clone(),
353 timestamp.timestamp.end_time.clone(),
354 ),
355 String::new(),
356 ));
357 }
358 index += 1;
359 }
360 }
361 Ok(entries)
362 }
363}
364
365fn reminder(
368 item: &Item,
369 kind: OccurrenceType,
370 today: NaiveDate,
371 extra: String,
372 days: i64,
373) -> AgendaEntry {
374 let mut row = entry(item, kind, today, (None, None), extra);
375 row.occurrence.days = days;
376 row.occurrence.late = days > 0;
377 row
378}
379
380fn entry(
383 item: &Item,
384 kind: OccurrenceType,
385 date: NaiveDate,
386 (time, end_time): (Option<String>, Option<String>),
387 extra: String,
388) -> AgendaEntry {
389 AgendaEntry {
390 schema: AGENDA_ENTRY_SCHEMA.to_string(),
391 item: item.clone(),
392 occurrence: Occurrence {
393 kind,
394 date: date.format("%Y-%m-%d").to_string(),
395 time,
396 end_time,
397 extra,
398 days: 0,
399 late: false,
400 },
401 }
402}
403
404fn warning_days(warning: &str) -> Option<i64> {
408 let value = warning.trim_start_matches('-');
409 let unit = value.chars().last()?;
410 let factor = match unit {
411 'h' => 0.041667,
412 'd' => 1.0,
413 'w' => 7.0,
414 'm' => 30.4,
415 'y' => 365.25,
416 _ => return None,
417 };
418 let count: u32 = value[..value.len() - 1].parse().ok()?;
419 Some((f64::from(count) * factor).floor() as i64)
420}
421
422pub(super) fn date_range(start: NaiveDate, end: NaiveDate) -> impl Iterator<Item = NaiveDate> {
423 (0..=(end - start).num_days().max(-1)).map(move |days| start + Duration::days(days))
424}
425
426fn collection_rank(kind: OccurrenceType) -> u8 {
427 match kind {
428 OccurrenceType::Deadline | OccurrenceType::UpcomingDeadline => 0,
429 OccurrenceType::Scheduled | OccurrenceType::PastScheduled => 1,
430 OccurrenceType::Block => 2,
431 OccurrenceType::Timestamp => 3,
432 OccurrenceType::Closed | OccurrenceType::Todo => 4,
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::agenda::test_support::fixture;
440 use crate::{CaptureTemplate, Settings};
441
442 #[test]
443 fn the_inbox_is_where_capture_templates_land() {
444 let temp = tempfile::tempdir().unwrap();
445 let write = |name: &str, text: &str| std::fs::write(temp.path().join(name), text).unwrap();
446 write(
447 "refile.org",
448 "* Refile\n** TODO Renew passport\n*** Notes under it\n** DONE Filed already\n",
449 );
450 write("work.org", "* Inbox\n** TODO Not a capture target\n");
451 write(
452 "projects.org",
453 "* Agd\n** Ideas\n*** Faster index\n* Other\n** Ideas\n*** Elsewhere\n",
454 );
455 write("notes.org", "* Loose note\n** Its detail\n");
456 write(
457 "journal.org",
458 "* 2026\n** 2026-10 October\n*** 2026-10-16 Friday\n**** Wrote\n",
459 );
460 let template = |key: &str, file: &str| CaptureTemplate {
461 key: key.to_string(),
462 file: Some(file.to_string()),
463 heading: None,
464 ..CaptureTemplate::default()
465 };
466 let settings = Settings {
467 roots: vec![temp.path().to_string_lossy().into_owned()],
468 agenda_files: vec!["*.org".to_string()],
469 captures: vec![
470 CaptureTemplate {
471 heading: Some("Refile".to_string()),
472 ..template("t", "refile.org")
473 },
474 CaptureTemplate {
475 olpath: Some(vec!["Agd".to_string(), "Ideas".to_string()]),
476 ..template("i", "projects.org")
477 },
478 template("n", "notes.org"),
479 CaptureTemplate {
480 datetree: true,
481 ..template("j", "journal.org")
482 },
483 ],
484 ..Settings::default()
485 };
486 let index = WorkspaceIndex::load(settings).unwrap();
487 let titles: Vec<String> = AgendaEngine::new(&index)
488 .inbox()
489 .into_iter()
490 .map(|item| item.title)
491 .collect();
492 assert_eq!(titles, ["Loose note", "Faster index", "Renew passport"]);
493 }
494
495 #[test]
496 fn expands_warnings_repeaters_ranges_and_past_scheduled() {
497 let (_temp, index) = fixture(
498 "#+TODO: TODO | DONE\n#+FILETAGS: :work:\n* TODO Late\nSCHEDULED: <2026-10-01 Thu>\n* TODO Due\nDEADLINE: <2026-10-16 Fri -2d>\n* TODO Repeat\nSCHEDULED: <2026-10-12 Mon +2d>\n* TODO Block\n<2026-10-14 Wed>--<2026-10-15 Thu>\n",
499 );
500 let entries = AgendaEngine::new(&index)
501 .agenda(&AgendaQuery {
502 from: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
503 to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(),
504 today: NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(),
505 match_expression: Some("+work/TODO".to_string()),
506 log_mode: false,
507 })
508 .unwrap();
509 assert!(
510 entries
511 .iter()
512 .any(|entry| { entry.occurrence.kind == OccurrenceType::PastScheduled })
513 );
514 assert_eq!(
515 entries
516 .iter()
517 .filter(|entry| entry.occurrence.kind == OccurrenceType::UpcomingDeadline)
518 .count(),
519 1
520 );
521 assert_eq!(
522 entries
523 .iter()
524 .filter(|entry| entry.item.title == "Repeat")
525 .count(),
526 2
527 );
528 assert_eq!(
529 entries
530 .iter()
531 .filter(|entry| entry.occurrence.kind == OccurrenceType::Block)
532 .count(),
533 2
534 );
535 }
536}