1use std::collections::HashSet;
2use std::path::Path;
3use std::{convert::TryFrom, fs, io, path::PathBuf};
4
5use chrono::{DateTime, Datelike, Days, Duration, Local, Months, NaiveDate, TimeZone};
6use globset::{Glob, GlobSetBuilder};
7use ignore::{Walk, WalkBuilder};
8use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
9use nucleo_matcher::{Config as NucleoConfig, Matcher};
10use orgize::ast::{Headline, PropertyDrawer, Timestamp};
11use orgize::export::{Container, Event, from_fn, from_fn_with_ctx};
12use orgize::{Org, ParseConfig};
13use rowan::ast::AstNode;
14use serde::{Deserialize, Serialize};
15use shellexpand::tilde;
16
17use crate::OrgModeError;
18use crate::config::{OrgConfig, load_org_config};
19use crate::utils::tags_match;
20
21macro_rules! convert_timestamp {
26 ($ts:expr, $prefix:ident) => {{
27 paste::paste! {
28 let year = $ts.[<year_ $prefix>]()?;
29 let month = $ts.[<month_ $prefix>]()?;
30 let day = $ts.[<day_ $prefix>]()?;
31 let hour = $ts.[<hour_ $prefix>]();
32 let minute = $ts.[<minute_ $prefix>]();
33
34 Some(chrono::NaiveDateTime::new(
35 chrono::NaiveDate::from_ymd_opt(
36 year.parse().ok()?,
37 month.parse().ok()?,
38 day.parse().ok()?,
39 )?,
40 chrono::NaiveTime::from_hms_opt(
41 hour.map(|v| v.parse().unwrap_or_default())
42 .unwrap_or_default(),
43 minute
44 .map(|v| v.parse().unwrap_or_default())
45 .unwrap_or_default(),
46 0,
47 )?,
48 ))
49 }
50 }};
51}
52
53#[derive(Debug)]
54pub struct OrgMode {
55 config: OrgConfig,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct TreeNode {
60 pub label: String,
61 pub level: usize,
62 #[serde(skip_serializing_if = "Vec::is_empty", default)]
63 pub children: Vec<TreeNode>,
64 #[serde(skip_serializing_if = "Vec::is_empty", default)]
65 pub tags: Vec<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SearchResult {
70 pub file_path: String,
71 pub snippet: String,
72 pub score: u32,
73 #[serde(skip_serializing_if = "Vec::is_empty", default)]
74 pub tags: Vec<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub enum TodoState {
79 Todo,
80 Done,
81 Other(String),
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub enum Priority {
86 A,
87 B,
88 C,
89 None,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Position {
94 pub start: u32,
95 pub end: u32,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct AgendaItem {
100 pub file_path: String,
101 pub heading: String,
102 pub level: usize,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub todo_state: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub priority: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub scheduled: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub deadline: Option<String>,
113 #[serde(skip_serializing_if = "Vec::is_empty", default)]
114 pub tags: Vec<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub position: Option<Position>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct AgendaView {
121 pub items: Vec<AgendaItem>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub start_date: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub end_date: Option<String>,
126}
127
128#[derive(Default)]
129pub enum AgendaViewType {
130 Today,
131 Day(DateTime<Local>),
132 #[default]
133 CurrentWeek,
134 Week(u8),
135 CurrentMonth,
136 Month(u32),
137 Custom {
138 from: DateTime<Local>,
139 to: DateTime<Local>,
140 },
141}
142
143impl TreeNode {
144 pub fn new(label: String) -> Self {
145 Self {
146 label,
147 level: 0,
148 children: Vec::new(),
149 tags: Vec::new(),
150 }
151 }
152
153 pub fn new_with_level(label: String, level: usize) -> Self {
154 Self {
155 label,
156 level,
157 children: Vec::new(),
158 tags: Vec::new(),
159 }
160 }
161
162 pub fn to_indented_string(&self, indent: usize) -> String {
163 let mut result = String::new();
164 let prefix = " ".repeat(indent);
165 result.push_str(&format!(
166 "{}{} {}\n",
167 prefix,
168 "*".repeat(self.level),
169 self.label
170 ));
171
172 for child in &self.children {
173 result.push_str(&child.to_indented_string(indent + 1));
174 }
175
176 result
177 }
178}
179
180impl OrgMode {
181 pub fn new(config: OrgConfig) -> Result<Self, OrgModeError> {
182 let config = config.validate()?;
183
184 Ok(OrgMode { config })
185 }
186
187 pub fn with_defaults() -> Result<Self, OrgModeError> {
188 Self::new(load_org_config(None, None)?)
189 }
190
191 pub fn config(&self) -> &OrgConfig {
192 &self.config
193 }
194}
195
196impl OrgMode {
197 pub fn list_files(
198 &self,
199 tags: Option<&[String]>,
200 limit: Option<usize>,
201 ) -> Result<Vec<String>, OrgModeError> {
202 Walk::new(&self.config.org_directory)
203 .filter_map(|entry| match entry {
204 Ok(dir_entry) => {
205 let path = dir_entry.path();
206
207 if path.is_file()
208 && let Some(extension) = path.extension()
209 && extension == "org"
210 && let Ok(relative_path) = path.strip_prefix(&self.config.org_directory)
211 && let Some(path_str) = relative_path.to_str()
212 {
213 Some(Ok(path_str.to_string()))
214 } else {
215 None
216 }
217 }
218 Err(e) => Some(Err(OrgModeError::WalkError(e))),
219 })
220 .collect::<Result<Vec<String>, OrgModeError>>()
221 .map(|files| {
222 files
223 .into_iter()
224 .filter(|path| {
225 if let Some(tags) = tags {
226 let file_tags = self.tags_in_file(path).unwrap_or_default();
227 tags.iter().any(|tag| file_tags.contains(tag))
228 } else {
229 true
230 }
231 })
232 .take(limit.unwrap_or(usize::MAX))
233 .collect::<Vec<String>>()
234 })
235 }
236
237 pub fn search(
238 &self,
239 query: &str,
240 limit: Option<usize>,
241 snippet_max_size: Option<usize>,
242 ) -> Result<Vec<SearchResult>, OrgModeError> {
243 if query.trim().is_empty() {
244 return Ok(vec![]);
245 }
246
247 let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
248 let pattern = Pattern::new(
249 query,
250 CaseMatching::Ignore,
251 Normalization::Smart,
252 AtomKind::Fuzzy,
253 );
254
255 let files = self.list_files(None, None)?;
256 let mut all_results = Vec::new();
257
258 for file in files {
259 let content = match self.read_file(&file) {
260 Ok(content) => content,
261 Err(_) => continue, };
263
264 let matches = pattern.match_list(
265 content.lines().map(|s| s.to_owned()).collect::<Vec<_>>(),
266 &mut matcher,
267 );
268
269 for (snippet, score) in matches {
270 let snippet = Self::snippet(&snippet, snippet_max_size.unwrap_or(100));
271 all_results.push(SearchResult {
272 file_path: file.clone(),
273 snippet,
274 score,
275 tags: self.tags_in_file(&file).unwrap_or_default(),
276 });
277 }
278 }
279
280 all_results.sort_by(|a, b| b.score.cmp(&a.score));
281 all_results.truncate(limit.unwrap_or(all_results.len()));
282
283 Ok(all_results)
284 }
285
286 pub fn search_with_tags(
287 &self,
288 query: &str,
289 tags: Option<&[String]>,
290 limit: Option<usize>,
291 snippet_max_size: Option<usize>,
292 ) -> Result<Vec<SearchResult>, OrgModeError> {
293 self.search(query, None, snippet_max_size)
294 .map(|results| match tags {
295 Some(filter_tags) => results
296 .into_iter()
297 .filter(|r| filter_tags.iter().any(|tag| r.tags.contains(tag)))
298 .collect(),
299 None => results,
300 })
301 .map(|mut all_results| {
302 all_results.truncate(limit.unwrap_or(all_results.len()));
303 all_results
304 })
305 }
306
307 pub fn list_files_by_tags(&self, tags: &[String]) -> Result<Vec<String>, OrgModeError> {
308 self.list_files(Some(tags), None)
309 }
310
311 pub fn read_file(&self, path: &str) -> Result<String, OrgModeError> {
312 let org_dir = PathBuf::from(&self.config.org_directory);
313 let full_path = org_dir.join(path);
314
315 if !full_path.exists() {
316 return Err(OrgModeError::IoError(io::Error::new(
317 io::ErrorKind::NotFound,
318 format!("File not found: {}", path),
319 )));
320 }
321
322 if !full_path.is_file() {
323 return Err(OrgModeError::IoError(io::Error::new(
324 io::ErrorKind::InvalidInput,
325 format!("Path is not a file: {}", path),
326 )));
327 }
328
329 fs::read_to_string(full_path).map_err(OrgModeError::IoError)
330 }
331
332 pub fn get_outline(&self, path: &str) -> Result<TreeNode, OrgModeError> {
333 let content = self.read_file(path)?;
334
335 let mut root = TreeNode::new("Document".into());
336 let mut stack: Vec<TreeNode> = Vec::new();
337
338 let mut handler = from_fn(|event| {
339 if let Event::Enter(Container::Headline(h)) = event {
340 let level = h.level();
341 let label = h.title_raw();
342 let tags = h.tags().map(|s| s.to_string()).collect();
343 let node = TreeNode {
344 label,
345 level,
346 tags,
347 children: Vec::new(),
348 };
349
350 while let Some(n) = stack.last() {
351 if n.level < level {
352 break;
353 }
354 let finished_node = stack.pop().unwrap();
355 if let Some(parent) = stack.last_mut() {
356 parent.children.push(finished_node);
357 } else {
358 root.children.push(finished_node);
359 }
360 }
361
362 stack.push(node);
363 }
364 });
365
366 Org::parse(&content).traverse(&mut handler);
367
368 while let Some(node) = stack.pop() {
369 if let Some(parent) = stack.last_mut() {
370 parent.children.push(node);
371 } else {
372 root.children.push(node);
373 }
374 }
375
376 Ok(root)
377 }
378
379 pub fn get_heading(&self, path: &str, heading: &str) -> Result<String, OrgModeError> {
380 let content = self.read_file(path)?;
381
382 let heading_path: Vec<&str> = heading.split('/').collect();
383 let mut current_level = 0;
384 let mut found = None;
385
386 let mut handler = from_fn_with_ctx(|event, ctx| {
387 if let Event::Enter(Container::Headline(h)) = event {
388 let title = h.title_raw();
389 let level = h.level();
390
391 if let Some(part) = heading_path.get(current_level) {
392 if title == *part {
393 if level == heading_path.len() {
394 found = Some(h);
395 ctx.stop();
396 }
397 current_level += 1;
398 }
399 } else {
400 ctx.stop()
401 }
402 }
403 });
404
405 Org::parse(&content).traverse(&mut handler);
406
407 found
408 .ok_or_else(|| OrgModeError::InvalidHeadingPath(heading.into()))
409 .map(|h| h.raw())
410 }
411
412 pub fn get_element_by_id(&self, id: &str) -> Result<String, OrgModeError> {
413 let files = self.list_files(None, None)?;
414
415 let found = files.iter().find_map(|path| {
416 self.read_file(path)
417 .map(|content| self.search_id(content, id))
418 .unwrap_or_default()
419 });
420
421 found.ok_or_else(|| OrgModeError::InvalidElementId(id.into()))
422 }
423
424 fn search_id(&self, content: String, id: &str) -> Option<String> {
425 let mut found = None;
426 let has_id_property = |properties: Option<PropertyDrawer>| {
427 properties
428 .and_then(|props| {
429 props
430 .to_hash_map()
431 .into_iter()
432 .find(|(k, v)| k.to_lowercase() == "id" && v == id)
433 })
434 .is_some()
435 };
436 let mut handler = from_fn_with_ctx(|event, ctx| {
437 if let Event::Enter(Container::Headline(ref h)) = event
438 && has_id_property(h.properties())
439 {
440 found = Some(h.raw());
441 ctx.stop();
442 } else if let Event::Enter(Container::Document(ref d)) = event
443 && has_id_property(d.properties())
444 {
445 found = Some(d.raw());
446 ctx.stop();
447 }
448 });
449
450 Org::parse(&content).traverse(&mut handler);
451
452 found
453 }
454
455 fn snippet(s: &str, max: usize) -> String {
456 if s.chars().count() > max {
457 s.chars().take(max).collect::<String>() + "..."
458 } else {
459 s.to_string()
460 }
461 }
462
463 fn tags_in_file(&self, path: &str) -> Result<Vec<String>, OrgModeError> {
464 let content = self.read_file(path)?;
465 let mut tags = Vec::new();
466
467 let mut handler = from_fn(|event| {
468 if let Event::Enter(Container::Headline(h)) = event {
469 tags.extend(h.tags().map(|s| s.to_string()));
470 }
471 });
472
473 Org::parse(&content).traverse(&mut handler);
474
475 Ok(tags)
476 }
477
478 fn files_in_path(&self, path: &str) -> Result<impl Iterator<Item = PathBuf>, OrgModeError> {
479 let org_root = PathBuf::from(&self.config.org_directory);
480
481 let path = tilde(&path).into_owned();
482 let path = if PathBuf::from(&path).is_absolute() {
483 path.to_string()
484 } else {
485 org_root.join(&path).to_str().unwrap_or(&path).to_string()
486 };
487
488 let root = path
489 .split_once('*')
490 .map(|(prefix, _)| prefix)
491 .unwrap_or(&path);
492
493 let globset = GlobSetBuilder::new().add(Glob::new(&path)?).build()?;
494
495 let iter = WalkBuilder::new(root)
496 .build()
497 .flatten()
498 .filter(move |e| e.path().is_file() && globset.is_match(e.path()))
499 .map(|e| e.path().to_path_buf());
500
501 Ok(iter)
502 }
503
504 fn agenda_tasks(&self) -> impl Iterator<Item = (Headline, String)> {
505 self.config
506 .org_agenda_files
507 .iter()
508 .filter_map(|loc| self.files_in_path(loc).ok())
509 .flatten()
510 .collect::<HashSet<_>>()
511 .into_iter()
512 .flat_map(|file| {
513 let config = ParseConfig {
514 todo_keywords: (
515 self.config.unfinished_keywords(),
516 self.config.finished_keywords(),
517 ),
518 ..Default::default()
519 };
520 let org = config.parse(fs::read_to_string(&file).unwrap_or_default());
522
523 let org_root = Path::new(&self.config.org_directory);
524
525 let mut tasks = Vec::new();
526 let mut handler = from_fn(|event| {
527 if let Event::Enter(container) = event
528 && let Container::Headline(headline) = container
529 && (headline.is_todo() || headline.is_done())
530 {
531 let file_path = file
532 .strip_prefix(org_root)
533 .unwrap_or(&file)
534 .to_string_lossy()
535 .to_string();
536 tasks.push((headline, file_path));
537 }
538 });
539 org.traverse(&mut handler);
540 tasks
541 })
542 }
543
544 pub fn list_tasks(
555 &self,
556 todo_states: Option<&[String]>,
557 tags: Option<&[String]>,
558 priority: Option<Priority>,
559 limit: Option<usize>,
560 ) -> Result<Vec<AgendaItem>, OrgModeError> {
561 let tasks = self
562 .agenda_tasks()
563 .filter(|(headline, _)| {
564 headline.is_todo()
565 && tags
566 .map(|tags| {
567 tags_match(
568 &headline.tags().map(|s| s.to_string()).collect::<Vec<_>>(),
569 tags,
570 )
571 })
572 .unwrap_or(true)
573 && priority
574 .as_ref()
575 .map(|p| {
576 if let Some(prio) = headline.priority() {
577 let prio = prio.to_string();
578 matches!(
579 (p, prio.as_str()),
580 (Priority::A, "A") | (Priority::B, "B") | (Priority::C, "C")
581 )
582 } else {
583 *p == Priority::None
584 }
585 })
586 .unwrap_or(true)
587 && todo_states
588 .map(|states| {
589 headline
590 .todo_keyword()
591 .map(|kw| states.contains(&kw.to_string()))
592 .unwrap_or(false)
593 })
594 .unwrap_or(true)
595 })
596 .map(|(headline, file_path)| Self::headline_to_agenda_item(&headline, file_path))
597 .take(limit.unwrap_or(usize::MAX))
598 .collect::<Vec<_>>();
599
600 Ok(tasks)
601 }
602
603 pub fn get_agenda_view(
613 &self,
614 agenda_view_type: AgendaViewType,
615 todo_states: Option<&[String]>,
616 tags: Option<&[String]>,
617 ) -> Result<AgendaView, OrgModeError> {
618 let items = self
619 .agenda_tasks()
620 .filter(|(headline, _)| {
621 tags.map(|tags| {
622 tags_match(
623 &headline.tags().map(|s| s.to_string()).collect::<Vec<_>>(),
624 tags,
625 )
626 })
627 .unwrap_or(true)
628 && todo_states
629 .map(|states| {
630 headline
631 .todo_keyword()
632 .map(|kw| states.contains(&kw.to_string()))
633 .unwrap_or(false)
634 })
635 .unwrap_or(true)
636 && self.is_in_agenda_range(headline, &agenda_view_type)
637 })
638 .map(|(headline, file_path)| Self::headline_to_agenda_item(&headline, file_path))
639 .collect::<Vec<_>>();
640
641 Ok(AgendaView {
642 items,
643 start_date: Some(agenda_view_type.start_date().format("%Y-%m-%d").to_string()),
644 end_date: Some(agenda_view_type.end_date().format("%Y-%m-%d").to_string()),
645 })
646 }
647
648 fn is_in_agenda_range(&self, headline: &Headline, agenda_view_type: &AgendaViewType) -> bool {
650 let start_date = agenda_view_type.start_date();
651 let end_date = agenda_view_type.end_date();
652
653 let timestamps = headline
654 .syntax()
655 .children()
656 .filter(|c| !Headline::can_cast(c.kind()))
657 .flat_map(|node| node.descendants().filter_map(Timestamp::cast))
658 .filter(|ts| ts.is_active())
659 .filter(|ts| {
660 headline.scheduled().map(|s| &s != ts).unwrap_or(true)
661 && headline.deadline().map(|s| &s != ts).unwrap_or(true)
662 })
663 .collect::<Vec<_>>();
664
665 let is_within_range = |ts_opt: Option<Timestamp>| {
666 if let Some(ts) = ts_opt
668 && let Some(date) = OrgMode::start_to_chrono(&ts)
669 && let Some(date) = Local.from_local_datetime(&date).single()
670 {
671 if let Some(repeater_value) = ts.repeater_value()
672 && let Some(repeater_unit) = ts.repeater_unit()
673 {
674 let value = repeater_value as u64;
675 let mut current_date =
676 OrgMode::add_repeater_duration(date, value, &repeater_unit);
677
678 while current_date < start_date {
679 current_date =
680 OrgMode::add_repeater_duration(current_date, value, &repeater_unit);
681 }
682
683 current_date >= start_date && current_date <= end_date
684 } else {
685 date >= start_date && date <= end_date
686 }
687 } else {
688 false
689 }
690 };
691
692 is_within_range(headline.scheduled())
693 || is_within_range(headline.deadline())
694 || timestamps.into_iter().any(|ts| is_within_range(Some(ts)))
695 }
696
697 pub fn start_to_chrono(ts: &Timestamp) -> Option<chrono::NaiveDateTime> {
698 convert_timestamp!(ts, start)
699 }
700
701 pub fn end_to_chrono(ts: &Timestamp) -> Option<chrono::NaiveDateTime> {
702 convert_timestamp!(ts, end)
703 }
704}
705
706impl OrgMode {
708 fn to_start_of_day(date: DateTime<Local>) -> DateTime<Local> {
710 date.date_naive()
711 .and_hms_opt(0, 0, 0)
712 .and_then(|dt| Local.from_local_datetime(&dt).single())
713 .unwrap_or(date)
714 }
715
716 fn to_end_of_day(date: DateTime<Local>) -> DateTime<Local> {
718 date.date_naive()
719 .and_hms_opt(23, 59, 59)
720 .and_then(|dt| Local.from_local_datetime(&dt).single())
721 .unwrap_or(date)
722 }
723
724 fn naive_date_to_local(
726 date: NaiveDate,
727 hour: u32,
728 min: u32,
729 sec: u32,
730 ) -> Result<DateTime<Local>, OrgModeError> {
731 date.and_hms_opt(hour, min, sec)
732 .and_then(|dt| Local.from_local_datetime(&dt).single())
733 .ok_or_else(|| {
734 OrgModeError::InvalidAgendaViewType(format!(
735 "Could not convert date '{}' to local timezone",
736 date
737 ))
738 })
739 }
740
741 fn last_day_of_month(date: DateTime<Local>) -> DateTime<Local> {
743 let month = date.month();
744 let year = date.year();
745
746 let (next_month, next_year) = if month == 12 {
748 (1, year + 1)
749 } else {
750 (month + 1, year)
751 };
752
753 let next_month_first = Self::to_start_of_day(
755 date.with_year(next_year)
756 .unwrap()
757 .with_day(1)
758 .unwrap()
759 .with_month(next_month)
760 .unwrap(),
761 );
762
763 next_month_first - Duration::days(1)
765 }
766
767 fn add_repeater_duration(
774 date: DateTime<Local>,
775 value: u64,
776 unit: &orgize::ast::TimeUnit,
777 ) -> DateTime<Local> {
778 match unit {
779 orgize::ast::TimeUnit::Hour => Some(date + Duration::hours(value as i64)),
780 orgize::ast::TimeUnit::Day => date.checked_add_days(Days::new(value)),
781 orgize::ast::TimeUnit::Week => date.checked_add_days(Days::new(value * 7)),
782 orgize::ast::TimeUnit::Month => date.checked_add_months(Months::new(value as u32)),
783 orgize::ast::TimeUnit::Year => date.checked_add_months(Months::new(value as u32 * 12)),
784 }
785 .unwrap_or(date)
786 }
787
788 fn headline_to_agenda_item(headline: &Headline, file_path: String) -> AgendaItem {
794 AgendaItem {
795 file_path,
796 heading: headline.title_raw(),
797 level: headline.level(),
798 todo_state: headline.todo_keyword().map(|t| t.to_string()),
799 priority: headline.priority().map(|p| p.to_string()),
800 deadline: headline.deadline().map(|d| d.raw()),
801 scheduled: headline.scheduled().map(|d| d.raw()),
802 tags: headline.tags().map(|s| s.to_string()).collect(),
803 position: Some(Position {
804 start: headline.start().into(),
805 end: headline.end().into(),
806 }),
807 }
808 }
809
810 fn parse_date_string(date_str: &str, context: &str) -> Result<NaiveDate, OrgModeError> {
816 NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
817 OrgModeError::InvalidAgendaViewType(format!(
818 "Invalid {context} '{date_str}', expected YYYY-MM-DD"
819 ))
820 })
821 }
822}
823
824impl AgendaViewType {
826 pub fn start_date(&self) -> DateTime<Local> {
827 let date = match self {
828 AgendaViewType::Today => Local::now(),
829 AgendaViewType::Day(d) => *d,
830 AgendaViewType::CurrentWeek => {
831 let now = Local::now();
832 let weekday = now.weekday().num_days_from_monday();
833 now - Duration::days(weekday as i64)
834 }
835 AgendaViewType::Week(week_num) => {
836 let now = Local::now();
837 let year_start =
838 OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
839 year_start + Duration::weeks(*week_num as i64)
840 }
841 AgendaViewType::CurrentMonth => {
842 let now = Local::now();
843 now.with_day(1).unwrap()
844 }
845 AgendaViewType::Month(month) => {
846 let now = Local::now().with_day(1).unwrap_or(Local::now());
848 now.with_month(*month).unwrap_or(now).with_day(1).unwrap()
849 }
850 AgendaViewType::Custom { from, .. } => *from,
851 };
852 OrgMode::to_start_of_day(date)
853 }
854
855 pub fn end_date(&self) -> DateTime<Local> {
856 let date = match self {
857 AgendaViewType::Today => Local::now(),
858 AgendaViewType::Day(d) => *d,
859 AgendaViewType::CurrentWeek => {
860 let now = Local::now();
861 let weekday = now.weekday().num_days_from_monday();
862 let start = now - Duration::days(weekday as i64);
863 start + Duration::days(6)
864 }
865 AgendaViewType::Week(week_num) => {
866 let now = Local::now();
867 let year_start =
868 OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
869 let target_week_start = year_start + Duration::weeks(*week_num as i64);
870 target_week_start + Duration::days(6)
871 }
872 AgendaViewType::CurrentMonth => {
873 let now = Local::now();
874 OrgMode::last_day_of_month(now)
875 }
876 AgendaViewType::Month(month) => {
877 let now = Local::now().with_day(1).unwrap_or(Local::now());
879 let date_in_month = now.with_month(*month).unwrap_or(now);
880 OrgMode::last_day_of_month(date_in_month)
881 }
882 AgendaViewType::Custom { to, .. } => *to,
883 };
884 OrgMode::to_end_of_day(date)
885 }
886}
887
888impl TryFrom<&str> for AgendaViewType {
898 type Error = OrgModeError;
899
900 fn try_from(value: &str) -> Result<Self, Self::Error> {
901 if value.is_empty() {
902 return Ok(AgendaViewType::default());
903 }
904
905 match value {
906 "today" => Ok(AgendaViewType::Today),
907 "week" => Ok(AgendaViewType::CurrentWeek),
908 "month" => Ok(AgendaViewType::CurrentMonth),
909 _ => {
910 let parts: Vec<&str> = value.split('/').collect();
912
913 match parts.as_slice() {
914 ["day", date_str] => {
915 let parsed_date = OrgMode::parse_date_string(date_str, "date format")?;
917 let datetime = OrgMode::naive_date_to_local(parsed_date, 0, 0, 0)?;
918 Ok(AgendaViewType::Day(datetime))
919 }
920 ["week", week_str] => {
921 let week_num = week_str.parse::<u8>().map_err(|_| {
923 OrgModeError::InvalidAgendaViewType(format!(
924 "Invalid week number '{}', expected 0-53",
925 week_str
926 ))
927 })?;
928 if week_num > 53 {
929 return Err(OrgModeError::InvalidAgendaViewType(format!(
930 "Week number {} out of range, expected 0-53",
931 week_num
932 )));
933 }
934 Ok(AgendaViewType::Week(week_num))
935 }
936 ["month", month_str] => {
937 let month_num = month_str.parse::<u32>().map_err(|_| {
939 OrgModeError::InvalidAgendaViewType(format!(
940 "Invalid month number '{}', expected 1-12",
941 month_str
942 ))
943 })?;
944 if !(1..=12).contains(&month_num) {
945 return Err(OrgModeError::InvalidAgendaViewType(format!(
946 "Month number {} out of range, expected 1-12",
947 month_num
948 )));
949 }
950 Ok(AgendaViewType::Month(month_num))
951 }
952 ["query", "from", from_str, "to", to_str] => {
953 let from_date = OrgMode::parse_date_string(from_str, "from date")?;
955 let to_date = OrgMode::parse_date_string(to_str, "to date")?;
956
957 let from_datetime = OrgMode::naive_date_to_local(from_date, 0, 0, 0)?;
958 let to_datetime = OrgMode::naive_date_to_local(to_date, 23, 59, 59)?;
959
960 if from_datetime > to_datetime {
961 return Err(OrgModeError::InvalidAgendaViewType(
962 "From date must be before to date".into(),
963 ));
964 }
965 Ok(AgendaViewType::Custom {
966 from: from_datetime,
967 to: to_datetime,
968 })
969 }
970 _ => Err(OrgModeError::InvalidAgendaViewType(format!(
971 "Unknown agenda view type format: '{}'",
972 value
973 ))),
974 }
975 }
976 }
977 }
978}
979
980#[cfg(test)]
981mod tests {
982 use super::*;
983 use chrono::Timelike;
984 use orgize::ast::TimeUnit;
985
986 #[test]
987 fn test_add_repeater_duration_hour() {
988 let date = Local.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap();
989 let result = OrgMode::add_repeater_duration(date, 2, &TimeUnit::Hour);
990
991 assert_eq!(result.hour(), 16);
992 assert_eq!(result.day(), 15);
993 }
994
995 #[test]
996 fn test_add_repeater_duration_day() {
997 let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
998 let result = OrgMode::add_repeater_duration(date, 5, &TimeUnit::Day);
999
1000 assert_eq!(result.day(), 20);
1001 assert_eq!(result.month(), 6);
1002 }
1003
1004 #[test]
1005 fn test_add_repeater_duration_week() {
1006 let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1007 let result = OrgMode::add_repeater_duration(date, 2, &TimeUnit::Week);
1008
1009 assert_eq!(result.day(), 29);
1010 assert_eq!(result.month(), 6);
1011 }
1012
1013 #[test]
1014 fn test_add_repeater_duration_month() {
1015 let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1016 let result = OrgMode::add_repeater_duration(date, 3, &TimeUnit::Month);
1017
1018 assert_eq!(result.month(), 9);
1019 assert_eq!(result.day(), 15);
1020 assert_eq!(result.year(), 2025);
1021 }
1022
1023 #[test]
1024 fn test_add_repeater_duration_year() {
1025 let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1026 let result = OrgMode::add_repeater_duration(date, 2, &TimeUnit::Year);
1027
1028 assert_eq!(result.year(), 2027);
1029 assert_eq!(result.month(), 6);
1030 assert_eq!(result.day(), 15);
1031 }
1032
1033 #[test]
1034 fn test_add_repeater_duration_month_boundary() {
1035 let date = Local.with_ymd_and_hms(2025, 10, 15, 12, 0, 0).unwrap();
1036 let result = OrgMode::add_repeater_duration(date, 3, &TimeUnit::Month);
1037
1038 assert_eq!(result.year(), 2026);
1039 assert_eq!(result.month(), 1);
1040 assert_eq!(result.day(), 15);
1041 }
1042
1043 #[test]
1044 fn test_last_day_of_month_from_day_31() {
1045 let date = Local.with_ymd_and_hms(2025, 1, 31, 12, 0, 0).unwrap();
1048 let result = OrgMode::last_day_of_month(date);
1049
1050 assert_eq!(result.month(), 1);
1051 assert_eq!(result.day(), 31);
1052 }
1053
1054 #[test]
1055 fn test_last_day_of_month_february() {
1056 let date = Local.with_ymd_and_hms(2025, 2, 15, 12, 0, 0).unwrap();
1057 let result = OrgMode::last_day_of_month(date);
1058
1059 assert_eq!(result.month(), 2);
1060 assert_eq!(result.day(), 28);
1061 }
1062
1063 #[test]
1064 fn test_last_day_of_month_leap_year() {
1065 let date = Local.with_ymd_and_hms(2024, 2, 15, 12, 0, 0).unwrap();
1066 let result = OrgMode::last_day_of_month(date);
1067
1068 assert_eq!(result.month(), 2);
1069 assert_eq!(result.day(), 29);
1070 }
1071}