org_core/
org_mode.rs

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
21/// Macro to convert org-mode Timestamp to chrono::NaiveDateTime
22///
23/// Takes a timestamp and a prefix (start/end) and automatically constructs
24/// the appropriate method calls (year_start/year_end, etc.).
25macro_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    // TODO: review type (string vs enum)
105    pub todo_state: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    // TODO: review type (string vs enum)
108    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, // Skip files that can't be read
262            };
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                // TODO: handle file read errors
521                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    /// List all tasks (TODO/DONE items) across all org files
545    ///
546    /// # Arguments
547    /// * `todo_states` - Optional filter for specific TODO states (e.g., ["TODO", "DONE"])
548    /// * `tags` - Optional filter by tags
549    /// * `priority` - Optional filter by priority level
550    /// * `limit` - Maximum number of items to return
551    ///
552    /// # Returns
553    /// Vector of `AgendaItem` containing task information
554    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    /// Get agenda view organized by date
604    ///
605    /// # Arguments
606    /// * `agenda_view_type` - Type of agenda view (e.g., Today, CurrentWeek, Custom range)
607    /// * `todo_states` - Optional filter for specific TODO states
608    /// * `tags` - Optional filter by tags
609    ///
610    /// # Returns
611    /// `AgendaView` containing items within the date range
612    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    // TODO: support recurrent tasks
649    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            // more info https://orgmode.org/org.html#Repeated-tasks
667            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
706// DateTime helper functions
707impl OrgMode {
708    /// Convert a DateTime to start of day (00:00:00)
709    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    /// Convert a DateTime to end of day (23:59:59.999)
717    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    /// Convert NaiveDate to local DateTime with specified time
725    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    /// Get the last day of the month for a given date
742    fn last_day_of_month(date: DateTime<Local>) -> DateTime<Local> {
743        let month = date.month();
744        let year = date.year();
745
746        // Get first day of next month
747        let (next_month, next_year) = if month == 12 {
748            (1, year + 1)
749        } else {
750            (month + 1, year)
751        };
752
753        // First day of next month at midnight
754        let next_month_first = Self::to_start_of_day(
755            date.with_year(next_year)
756                .unwrap()
757                .with_month(next_month)
758                .unwrap()
759                .with_day(1)
760                .unwrap(),
761        );
762
763        // Subtract one day to get last day of current month
764        next_month_first - Duration::days(1)
765    }
766
767    /// Add a repeater duration to a date based on org-mode time units
768    ///
769    /// # Arguments
770    /// * `date` - The starting date
771    /// * `value` - The numeric value for the duration
772    /// * `unit` - The time unit (Hour, Day, Week, Month, Year)
773    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    /// Convert a Headline to an AgendaItem
789    ///
790    /// # Arguments
791    /// * `headline` - The org-mode headline to convert
792    /// * `file_path` - The file path containing the headline
793    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    /// Parse a date string in YYYY-MM-DD format with contextual error messages
811    ///
812    /// # Arguments
813    /// * `date_str` - The date string to parse
814    /// * `context` - Context for error messages (e.g., "from date", "to date")
815    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
824// TODO: improve date management
825impl 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                // FIXME: improve error handling
847                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                // FIXME: improve error handling
878                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
888/// Possible values to convert
889/// "": default
890/// "today": Today
891/// "day/YYYY-MM-DD": specific day
892/// "week": current week
893/// "week/N": week number N
894/// "month": current month
895/// "month/N": month number N
896/// "query/from/YYYY-MM-DD/to/YYYY-MM-DD": custom range
897impl 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                // Try to parse more complex patterns
911                let parts: Vec<&str> = value.split('/').collect();
912
913                match parts.as_slice() {
914                    ["day", date_str] => {
915                        // Parse YYYY-MM-DD format
916                        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                        // Parse week number
922                        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                        // Parse month number
938                        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                        // Parse custom date range
954                        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}