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#[cfg(test)]
54#[path = "org_mode_tests.rs"]
55mod org_mode_tests;
56
57#[derive(Debug)]
58pub struct OrgMode {
59    config: OrgConfig,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct TreeNode {
64    pub label: String,
65    pub level: usize,
66    #[serde(skip_serializing_if = "Vec::is_empty", default)]
67    pub children: Vec<TreeNode>,
68    #[serde(skip_serializing_if = "Vec::is_empty", default)]
69    pub tags: Vec<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SearchResult {
74    pub file_path: String,
75    pub snippet: String,
76    pub score: u32,
77    #[serde(skip_serializing_if = "Vec::is_empty", default)]
78    pub tags: Vec<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub enum TodoState {
83    Todo,
84    Done,
85    Other(String),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub enum Priority {
90    A,
91    B,
92    C,
93    None,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Position {
98    pub start: u32,
99    pub end: u32,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct AgendaItem {
104    pub file_path: String,
105    pub heading: String,
106    pub level: usize,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    // TODO: review type (string vs enum)
109    pub todo_state: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    // TODO: review type (string vs enum)
112    pub priority: Option<String>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub scheduled: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub deadline: Option<String>,
117    #[serde(skip_serializing_if = "Vec::is_empty", default)]
118    pub tags: Vec<String>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub position: Option<Position>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct AgendaView {
125    pub items: Vec<AgendaItem>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub start_date: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub end_date: Option<String>,
130}
131
132#[derive(Default)]
133pub enum AgendaViewType {
134    Today,
135    Day(DateTime<Local>),
136    #[default]
137    CurrentWeek,
138    Week(u8),
139    CurrentMonth,
140    Month(u32),
141    Custom {
142        from: DateTime<Local>,
143        to: DateTime<Local>,
144    },
145}
146
147impl TreeNode {
148    pub fn new(label: String) -> Self {
149        Self {
150            label,
151            level: 0,
152            children: Vec::new(),
153            tags: Vec::new(),
154        }
155    }
156
157    pub fn new_with_level(label: String, level: usize) -> Self {
158        Self {
159            label,
160            level,
161            children: Vec::new(),
162            tags: Vec::new(),
163        }
164    }
165
166    pub fn to_indented_string(&self, indent: usize) -> String {
167        let mut result = String::new();
168        let prefix = "  ".repeat(indent);
169        result.push_str(&format!(
170            "{}{} {}\n",
171            prefix,
172            "*".repeat(self.level),
173            self.label
174        ));
175
176        for child in &self.children {
177            result.push_str(&child.to_indented_string(indent + 1));
178        }
179
180        result
181    }
182}
183
184impl OrgMode {
185    pub fn new(config: OrgConfig) -> Result<Self, OrgModeError> {
186        let config = config.validate()?;
187
188        Ok(OrgMode { config })
189    }
190
191    pub fn with_defaults() -> Result<Self, OrgModeError> {
192        Self::new(load_org_config(None, None)?)
193    }
194
195    pub fn config(&self) -> &OrgConfig {
196        &self.config
197    }
198}
199
200impl OrgMode {
201    pub fn list_files(
202        &self,
203        tags: Option<&[String]>,
204        limit: Option<usize>,
205    ) -> Result<Vec<String>, OrgModeError> {
206        Walk::new(&self.config.org_directory)
207            .filter_map(|entry| match entry {
208                Ok(dir_entry) => {
209                    let path = dir_entry.path();
210
211                    if path.is_file()
212                        && let Some(extension) = path.extension()
213                        && extension == "org"
214                        && let Ok(relative_path) = path.strip_prefix(&self.config.org_directory)
215                        && let Some(path_str) = relative_path.to_str()
216                    {
217                        Some(Ok(path_str.to_string()))
218                    } else {
219                        None
220                    }
221                }
222                Err(e) => Some(Err(OrgModeError::WalkError(e))),
223            })
224            .collect::<Result<Vec<String>, OrgModeError>>()
225            .map(|files| {
226                files
227                    .into_iter()
228                    .filter(|path| {
229                        if let Some(tags) = tags {
230                            let file_tags = self.tags_in_file(path).unwrap_or_default();
231                            tags.iter().any(|tag| file_tags.contains(tag))
232                        } else {
233                            true
234                        }
235                    })
236                    .take(limit.unwrap_or(usize::MAX))
237                    .collect::<Vec<String>>()
238            })
239    }
240
241    pub fn search(
242        &self,
243        query: &str,
244        limit: Option<usize>,
245        snippet_max_size: Option<usize>,
246    ) -> Result<Vec<SearchResult>, OrgModeError> {
247        if query.trim().is_empty() {
248            return Ok(vec![]);
249        }
250
251        let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
252        let pattern = Pattern::new(
253            query,
254            CaseMatching::Ignore,
255            Normalization::Smart,
256            AtomKind::Fuzzy,
257        );
258
259        let files = self.list_files(None, None)?;
260        let mut all_results = Vec::new();
261
262        for file in files {
263            let content = match self.read_file(&file) {
264                Ok(content) => content,
265                Err(_) => continue, // Skip files that can't be read
266            };
267
268            let matches = pattern.match_list(
269                content.lines().map(|s| s.to_owned()).collect::<Vec<_>>(),
270                &mut matcher,
271            );
272
273            for (snippet, score) in matches {
274                let snippet = Self::snippet(&snippet, snippet_max_size.unwrap_or(100));
275                all_results.push(SearchResult {
276                    file_path: file.clone(),
277                    snippet,
278                    score,
279                    tags: self.tags_in_file(&file).unwrap_or_default(),
280                });
281            }
282        }
283
284        all_results.sort_by(|a, b| b.score.cmp(&a.score));
285        all_results.truncate(limit.unwrap_or(all_results.len()));
286
287        Ok(all_results)
288    }
289
290    pub fn search_with_tags(
291        &self,
292        query: &str,
293        tags: Option<&[String]>,
294        limit: Option<usize>,
295        snippet_max_size: Option<usize>,
296    ) -> Result<Vec<SearchResult>, OrgModeError> {
297        self.search(query, None, snippet_max_size)
298            .map(|results| match tags {
299                Some(filter_tags) => results
300                    .into_iter()
301                    .filter(|r| filter_tags.iter().any(|tag| r.tags.contains(tag)))
302                    .collect(),
303                None => results,
304            })
305            .map(|mut all_results| {
306                all_results.truncate(limit.unwrap_or(all_results.len()));
307                all_results
308            })
309    }
310
311    pub fn list_files_by_tags(&self, tags: &[String]) -> Result<Vec<String>, OrgModeError> {
312        self.list_files(Some(tags), None)
313    }
314
315    pub fn read_file(&self, path: &str) -> Result<String, OrgModeError> {
316        let org_dir = PathBuf::from(&self.config.org_directory);
317        let full_path = org_dir.join(path);
318
319        if !full_path.exists() {
320            return Err(OrgModeError::IoError(io::Error::new(
321                io::ErrorKind::NotFound,
322                format!("File not found: {}", path),
323            )));
324        }
325
326        if !full_path.is_file() {
327            return Err(OrgModeError::IoError(io::Error::new(
328                io::ErrorKind::InvalidInput,
329                format!("Path is not a file: {}", path),
330            )));
331        }
332
333        fs::read_to_string(full_path).map_err(OrgModeError::IoError)
334    }
335
336    pub fn get_outline(&self, path: &str) -> Result<TreeNode, OrgModeError> {
337        let content = self.read_file(path)?;
338
339        let mut root = TreeNode::new("Document".into());
340        let mut stack: Vec<TreeNode> = Vec::new();
341
342        let mut handler = from_fn(|event| {
343            if let Event::Enter(Container::Headline(h)) = event {
344                let level = h.level();
345                let label = h.title_raw();
346                let tags = h.tags().map(|s| s.to_string()).collect();
347                let node = TreeNode {
348                    label,
349                    level,
350                    tags,
351                    children: Vec::new(),
352                };
353
354                while let Some(n) = stack.last() {
355                    if n.level < level {
356                        break;
357                    }
358                    let finished_node = stack.pop().unwrap();
359                    if let Some(parent) = stack.last_mut() {
360                        parent.children.push(finished_node);
361                    } else {
362                        root.children.push(finished_node);
363                    }
364                }
365
366                stack.push(node);
367            }
368        });
369
370        Org::parse(&content).traverse(&mut handler);
371
372        while let Some(node) = stack.pop() {
373            if let Some(parent) = stack.last_mut() {
374                parent.children.push(node);
375            } else {
376                root.children.push(node);
377            }
378        }
379
380        Ok(root)
381    }
382
383    pub fn get_heading(&self, path: &str, heading: &str) -> Result<String, OrgModeError> {
384        let content = self.read_file(path)?;
385
386        let heading_path: Vec<&str> = heading.split('/').collect();
387        let mut current_level = 0;
388        let mut found = None;
389
390        let mut handler = from_fn_with_ctx(|event, ctx| {
391            if let Event::Enter(Container::Headline(h)) = event {
392                let title = h.title_raw();
393                let level = h.level();
394
395                if let Some(part) = heading_path.get(current_level) {
396                    if title == *part {
397                        if level == heading_path.len() {
398                            found = Some(h);
399                            ctx.stop();
400                        }
401                        current_level += 1;
402                    }
403                } else {
404                    ctx.stop()
405                }
406            }
407        });
408
409        Org::parse(&content).traverse(&mut handler);
410
411        found
412            .ok_or_else(|| OrgModeError::InvalidHeadingPath(heading.into()))
413            .map(|h| h.raw())
414    }
415
416    pub fn get_element_by_id(&self, id: &str) -> Result<String, OrgModeError> {
417        let files = self.list_files(None, None)?;
418
419        let found = files.iter().find_map(|path| {
420            self.read_file(path)
421                .map(|content| self.search_id(content, id))
422                .unwrap_or_default()
423        });
424
425        found.ok_or_else(|| OrgModeError::InvalidElementId(id.into()))
426    }
427
428    fn search_id(&self, content: String, id: &str) -> Option<String> {
429        let mut found = None;
430        let has_id_property = |properties: Option<PropertyDrawer>| {
431            properties
432                .and_then(|props| {
433                    props
434                        .to_hash_map()
435                        .into_iter()
436                        .find(|(k, v)| k.to_lowercase() == "id" && v == id)
437                })
438                .is_some()
439        };
440        let mut handler = from_fn_with_ctx(|event, ctx| {
441            if let Event::Enter(Container::Headline(ref h)) = event
442                && has_id_property(h.properties())
443            {
444                found = Some(h.raw());
445                ctx.stop();
446            } else if let Event::Enter(Container::Document(ref d)) = event
447                && has_id_property(d.properties())
448            {
449                found = Some(d.raw());
450                ctx.stop();
451            }
452        });
453
454        Org::parse(&content).traverse(&mut handler);
455
456        found
457    }
458
459    fn snippet(s: &str, max: usize) -> String {
460        if s.chars().count() > max {
461            s.chars().take(max).collect::<String>() + "..."
462        } else {
463            s.to_string()
464        }
465    }
466
467    fn tags_in_file(&self, path: &str) -> Result<Vec<String>, OrgModeError> {
468        let content = self.read_file(path)?;
469        let mut tags = Vec::new();
470
471        let mut handler = from_fn(|event| {
472            if let Event::Enter(Container::Headline(h)) = event {
473                tags.extend(h.tags().map(|s| s.to_string()));
474            }
475        });
476
477        Org::parse(&content).traverse(&mut handler);
478
479        Ok(tags)
480    }
481
482    fn files_in_path(&self, path: &str) -> Result<impl Iterator<Item = PathBuf>, OrgModeError> {
483        let org_root = PathBuf::from(&self.config.org_directory);
484
485        let path = tilde(&path).into_owned();
486        let path = if PathBuf::from(&path).is_absolute() {
487            path.to_string()
488        } else {
489            org_root.join(&path).to_str().unwrap_or(&path).to_string()
490        };
491
492        let root = path
493            .split_once('*')
494            .map(|(prefix, _)| prefix)
495            .unwrap_or(&path);
496
497        let globset = GlobSetBuilder::new().add(Glob::new(&path)?).build()?;
498
499        let iter = WalkBuilder::new(root)
500            .build()
501            .flatten()
502            .filter(move |e| e.path().is_file() && globset.is_match(e.path()))
503            .map(|e| e.path().to_path_buf());
504
505        Ok(iter)
506    }
507
508    fn agenda_tasks(&self) -> impl Iterator<Item = (Headline, String)> {
509        self.config
510            .org_agenda_files
511            .iter()
512            .filter_map(|loc| self.files_in_path(loc).ok())
513            .flatten()
514            .collect::<HashSet<_>>()
515            .into_iter()
516            .flat_map(|file| {
517                let config = ParseConfig {
518                    todo_keywords: (
519                        self.config.unfinished_keywords(),
520                        self.config.finished_keywords(),
521                    ),
522                    ..Default::default()
523                };
524                // TODO: handle file read errors
525                let org = config.parse(fs::read_to_string(&file).unwrap_or_default());
526
527                let org_root = Path::new(&self.config.org_directory);
528
529                let mut tasks = Vec::new();
530                let mut handler = from_fn(|event| {
531                    if let Event::Enter(container) = event
532                        && let Container::Headline(headline) = container
533                        && (headline.is_todo() || headline.is_done())
534                    {
535                        let file_path = file
536                            .strip_prefix(org_root)
537                            .unwrap_or(&file)
538                            .to_string_lossy()
539                            .to_string();
540                        tasks.push((headline, file_path));
541                    }
542                });
543                org.traverse(&mut handler);
544                tasks
545            })
546    }
547
548    /// List all tasks (TODO/DONE items) across all org files
549    ///
550    /// # Arguments
551    /// * `todo_states` - Optional filter for specific TODO states (e.g., ["TODO", "DONE"])
552    /// * `tags` - Optional filter by tags
553    /// * `priority` - Optional filter by priority level
554    /// * `limit` - Maximum number of items to return
555    ///
556    /// # Returns
557    /// Vector of `AgendaItem` containing task information
558    pub fn list_tasks(
559        &self,
560        todo_states: Option<&[String]>,
561        tags: Option<&[String]>,
562        priority: Option<Priority>,
563        limit: Option<usize>,
564    ) -> Result<Vec<AgendaItem>, OrgModeError> {
565        let tasks = self
566            .agenda_tasks()
567            .filter(|(headline, _)| {
568                headline.is_todo()
569                    && tags
570                        .map(|tags| {
571                            tags_match(
572                                &headline.tags().map(|s| s.to_string()).collect::<Vec<_>>(),
573                                tags,
574                            )
575                        })
576                        .unwrap_or(true)
577                    && priority
578                        .as_ref()
579                        .map(|p| {
580                            if let Some(prio) = headline.priority() {
581                                let prio = prio.to_string();
582                                matches!(
583                                    (p, prio.as_str()),
584                                    (Priority::A, "A") | (Priority::B, "B") | (Priority::C, "C")
585                                )
586                            } else {
587                                *p == Priority::None
588                            }
589                        })
590                        .unwrap_or(true)
591                    && todo_states
592                        .map(|states| {
593                            headline
594                                .todo_keyword()
595                                .map(|kw| states.contains(&kw.to_string()))
596                                .unwrap_or(false)
597                        })
598                        .unwrap_or(true)
599            })
600            .map(|(headline, file_path)| Self::headline_to_agenda_item(&headline, file_path))
601            .take(limit.unwrap_or(usize::MAX))
602            .collect::<Vec<_>>();
603
604        Ok(tasks)
605    }
606
607    /// Get agenda view organized by date
608    ///
609    /// # Arguments
610    /// * `agenda_view_type` - Type of agenda view (e.g., Today, CurrentWeek, Custom range)
611    /// * `todo_states` - Optional filter for specific TODO states
612    /// * `tags` - Optional filter by tags
613    ///
614    /// # Returns
615    /// `AgendaView` containing items within the date range
616    pub fn get_agenda_view(
617        &self,
618        agenda_view_type: AgendaViewType,
619        todo_states: Option<&[String]>,
620        tags: Option<&[String]>,
621    ) -> Result<AgendaView, OrgModeError> {
622        let items = self
623            .agenda_tasks()
624            .filter(|(headline, _)| {
625                tags.map(|tags| {
626                    tags_match(
627                        &headline.tags().map(|s| s.to_string()).collect::<Vec<_>>(),
628                        tags,
629                    )
630                })
631                .unwrap_or(true)
632                    && todo_states
633                        .map(|states| {
634                            headline
635                                .todo_keyword()
636                                .map(|kw| states.contains(&kw.to_string()))
637                                .unwrap_or(false)
638                        })
639                        .unwrap_or(true)
640                    && self.is_in_agenda_range(headline, &agenda_view_type)
641            })
642            .map(|(headline, file_path)| Self::headline_to_agenda_item(&headline, file_path))
643            .collect::<Vec<_>>();
644
645        Ok(AgendaView {
646            items,
647            start_date: Some(agenda_view_type.start_date().format("%Y-%m-%d").to_string()),
648            end_date: Some(agenda_view_type.end_date().format("%Y-%m-%d").to_string()),
649        })
650    }
651
652    // TODO: support recurrent tasks
653    fn is_in_agenda_range(&self, headline: &Headline, agenda_view_type: &AgendaViewType) -> bool {
654        let start_date = agenda_view_type.start_date();
655        let end_date = agenda_view_type.end_date();
656
657        let timestamps = headline
658            .syntax()
659            .children()
660            .filter(|c| !Headline::can_cast(c.kind()))
661            .flat_map(|node| node.descendants().filter_map(Timestamp::cast))
662            .filter(|ts| ts.is_active())
663            .filter(|ts| {
664                headline.scheduled().map(|s| &s != ts).unwrap_or(true)
665                    && headline.deadline().map(|s| &s != ts).unwrap_or(true)
666            })
667            .collect::<Vec<_>>();
668
669        let is_within_range = |ts_opt: Option<Timestamp>| {
670            // more info https://orgmode.org/org.html#Repeated-tasks
671            if let Some(ts) = ts_opt
672                && let Some(date) = OrgMode::start_to_chrono(&ts)
673                && let Some(date) = Local.from_local_datetime(&date).single()
674            {
675                if let Some(repeater_value) = ts.repeater_value()
676                    && let Some(repeater_unit) = ts.repeater_unit()
677                {
678                    let value = repeater_value as u64;
679                    let mut current_date =
680                        OrgMode::add_repeater_duration(date, value, &repeater_unit);
681
682                    while current_date < start_date {
683                        current_date =
684                            OrgMode::add_repeater_duration(current_date, value, &repeater_unit);
685                    }
686
687                    current_date >= start_date && current_date <= end_date
688                } else {
689                    date >= start_date && date <= end_date
690                }
691            } else {
692                false
693            }
694        };
695
696        is_within_range(headline.scheduled())
697            || is_within_range(headline.deadline())
698            || timestamps.into_iter().any(|ts| is_within_range(Some(ts)))
699    }
700
701    pub fn start_to_chrono(ts: &Timestamp) -> Option<chrono::NaiveDateTime> {
702        convert_timestamp!(ts, start)
703    }
704
705    pub fn end_to_chrono(ts: &Timestamp) -> Option<chrono::NaiveDateTime> {
706        convert_timestamp!(ts, end)
707    }
708}
709
710// DateTime helper functions
711impl OrgMode {
712    /// Convert a DateTime to start of day (00:00:00)
713    fn to_start_of_day(date: DateTime<Local>) -> DateTime<Local> {
714        date.date_naive()
715            .and_hms_opt(0, 0, 0)
716            .and_then(|dt| Local.from_local_datetime(&dt).single())
717            .unwrap_or(date)
718    }
719
720    /// Convert a DateTime to end of day (23:59:59.999)
721    fn to_end_of_day(date: DateTime<Local>) -> DateTime<Local> {
722        date.date_naive()
723            .and_hms_opt(23, 59, 59)
724            .and_then(|dt| Local.from_local_datetime(&dt).single())
725            .unwrap_or(date)
726    }
727
728    /// Convert NaiveDate to local DateTime with specified time
729    fn naive_date_to_local(
730        date: NaiveDate,
731        hour: u32,
732        min: u32,
733        sec: u32,
734    ) -> Result<DateTime<Local>, OrgModeError> {
735        date.and_hms_opt(hour, min, sec)
736            .and_then(|dt| Local.from_local_datetime(&dt).single())
737            .ok_or_else(|| {
738                OrgModeError::InvalidAgendaViewType(format!(
739                    "Could not convert date '{}' to local timezone",
740                    date
741                ))
742            })
743    }
744
745    /// Get the last day of the month for a given date
746    fn last_day_of_month(date: DateTime<Local>) -> DateTime<Local> {
747        let month = date.month();
748        let year = date.year();
749
750        // Get first day of next month
751        let (next_month, next_year) = if month == 12 {
752            (1, year + 1)
753        } else {
754            (month + 1, year)
755        };
756
757        // First day of next month at midnight
758        let next_month_first = Self::to_start_of_day(
759            date.with_year(next_year)
760                .unwrap()
761                .with_month(next_month)
762                .unwrap()
763                .with_day(1)
764                .unwrap(),
765        );
766
767        // Subtract one day to get last day of current month
768        next_month_first - Duration::days(1)
769    }
770
771    /// Add a repeater duration to a date based on org-mode time units
772    ///
773    /// # Arguments
774    /// * `date` - The starting date
775    /// * `value` - The numeric value for the duration
776    /// * `unit` - The time unit (Hour, Day, Week, Month, Year)
777    fn add_repeater_duration(
778        date: DateTime<Local>,
779        value: u64,
780        unit: &orgize::ast::TimeUnit,
781    ) -> DateTime<Local> {
782        match unit {
783            orgize::ast::TimeUnit::Hour => Some(date + Duration::hours(value as i64)),
784            orgize::ast::TimeUnit::Day => date.checked_add_days(Days::new(value)),
785            orgize::ast::TimeUnit::Week => date.checked_add_days(Days::new(value * 7)),
786            orgize::ast::TimeUnit::Month => date.checked_add_months(Months::new(value as u32)),
787            orgize::ast::TimeUnit::Year => date.checked_add_months(Months::new(value as u32 * 12)),
788        }
789        .unwrap_or(date)
790    }
791
792    /// Convert a Headline to an AgendaItem
793    ///
794    /// # Arguments
795    /// * `headline` - The org-mode headline to convert
796    /// * `file_path` - The file path containing the headline
797    fn headline_to_agenda_item(headline: &Headline, file_path: String) -> AgendaItem {
798        AgendaItem {
799            file_path,
800            heading: headline.title_raw(),
801            level: headline.level(),
802            todo_state: headline.todo_keyword().map(|t| t.to_string()),
803            priority: headline.priority().map(|p| p.to_string()),
804            deadline: headline.deadline().map(|d| d.raw()),
805            scheduled: headline.scheduled().map(|d| d.raw()),
806            tags: headline.tags().map(|s| s.to_string()).collect(),
807            position: Some(Position {
808                start: headline.start().into(),
809                end: headline.end().into(),
810            }),
811        }
812    }
813
814    /// Parse a date string in YYYY-MM-DD format with contextual error messages
815    ///
816    /// # Arguments
817    /// * `date_str` - The date string to parse
818    /// * `context` - Context for error messages (e.g., "from date", "to date")
819    fn parse_date_string(date_str: &str, context: &str) -> Result<NaiveDate, OrgModeError> {
820        NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
821            OrgModeError::InvalidAgendaViewType(format!(
822                "Invalid {context} '{date_str}', expected YYYY-MM-DD"
823            ))
824        })
825    }
826}
827
828// TODO: improve date management
829impl AgendaViewType {
830    pub fn start_date(&self) -> DateTime<Local> {
831        let date = match self {
832            AgendaViewType::Today => Local::now(),
833            AgendaViewType::Day(d) => *d,
834            AgendaViewType::CurrentWeek => {
835                let now = Local::now();
836                let weekday = now.weekday().num_days_from_monday();
837                now - Duration::days(weekday as i64)
838            }
839            AgendaViewType::Week(week_num) => {
840                let now = Local::now();
841                let year_start =
842                    OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
843                year_start + Duration::weeks(*week_num as i64)
844            }
845            AgendaViewType::CurrentMonth => {
846                let now = Local::now();
847                now.with_day(1).unwrap()
848            }
849            AgendaViewType::Month(month) => {
850                let now = Local::now();
851                now.with_month(*month).unwrap_or(now).with_day(1).unwrap()
852            }
853            AgendaViewType::Custom { from, .. } => *from,
854        };
855        OrgMode::to_start_of_day(date)
856    }
857
858    pub fn end_date(&self) -> DateTime<Local> {
859        let date = match self {
860            AgendaViewType::Today => Local::now(),
861            AgendaViewType::Day(d) => *d,
862            AgendaViewType::CurrentWeek => {
863                let now = Local::now();
864                let weekday = now.weekday().num_days_from_monday();
865                let start = now - Duration::days(weekday as i64);
866                start + Duration::days(6)
867            }
868            AgendaViewType::Week(week_num) => {
869                let now = Local::now();
870                let year_start =
871                    OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
872                let target_week_start = year_start + Duration::weeks(*week_num as i64);
873                target_week_start + Duration::days(6)
874            }
875            AgendaViewType::CurrentMonth => {
876                let now = Local::now();
877                OrgMode::last_day_of_month(now)
878            }
879            AgendaViewType::Month(month) => {
880                let now = Local::now();
881                let date_in_month = now.with_month(*month).unwrap_or(now);
882                OrgMode::last_day_of_month(date_in_month)
883            }
884            AgendaViewType::Custom { to, .. } => *to,
885        };
886        OrgMode::to_end_of_day(date)
887    }
888}
889
890/// Possible values to convert
891/// "": default
892/// "today": Today
893/// "day/YYYY-MM-DD": specific day
894/// "week": current week
895/// "week/N": week number N
896/// "month": current month
897/// "month/N": month number N
898/// "query/from/YYYY-MM-DD/to/YYYY-MM-DD": custom range
899impl TryFrom<&str> for AgendaViewType {
900    type Error = OrgModeError;
901
902    fn try_from(value: &str) -> Result<Self, Self::Error> {
903        if value.is_empty() {
904            return Ok(AgendaViewType::default());
905        }
906
907        match value {
908            "today" => Ok(AgendaViewType::Today),
909            "week" => Ok(AgendaViewType::CurrentWeek),
910            "month" => Ok(AgendaViewType::CurrentMonth),
911            _ => {
912                // Try to parse more complex patterns
913                let parts: Vec<&str> = value.split('/').collect();
914
915                match parts.as_slice() {
916                    ["day", date_str] => {
917                        // Parse YYYY-MM-DD format
918                        let parsed_date = OrgMode::parse_date_string(date_str, "date format")?;
919                        let datetime = OrgMode::naive_date_to_local(parsed_date, 0, 0, 0)?;
920                        Ok(AgendaViewType::Day(datetime))
921                    }
922                    ["week", week_str] => {
923                        // Parse week number
924                        let week_num = week_str.parse::<u8>().map_err(|_| {
925                            OrgModeError::InvalidAgendaViewType(format!(
926                                "Invalid week number '{}', expected 0-53",
927                                week_str
928                            ))
929                        })?;
930                        if week_num > 53 {
931                            return Err(OrgModeError::InvalidAgendaViewType(format!(
932                                "Week number {} out of range, expected 0-53",
933                                week_num
934                            )));
935                        }
936                        Ok(AgendaViewType::Week(week_num))
937                    }
938                    ["month", month_str] => {
939                        // Parse month number
940                        let month_num = month_str.parse::<u32>().map_err(|_| {
941                            OrgModeError::InvalidAgendaViewType(format!(
942                                "Invalid month number '{}', expected 1-12",
943                                month_str
944                            ))
945                        })?;
946                        if !(1..=12).contains(&month_num) {
947                            return Err(OrgModeError::InvalidAgendaViewType(format!(
948                                "Month number {} out of range, expected 1-12",
949                                month_num
950                            )));
951                        }
952                        Ok(AgendaViewType::Month(month_num))
953                    }
954                    ["query", "from", from_str, "to", to_str] => {
955                        // Parse custom date range
956                        let from_date = OrgMode::parse_date_string(from_str, "from date")?;
957                        let to_date = OrgMode::parse_date_string(to_str, "to date")?;
958
959                        let from_datetime = OrgMode::naive_date_to_local(from_date, 0, 0, 0)?;
960                        let to_datetime = OrgMode::naive_date_to_local(to_date, 23, 59, 59)?;
961
962                        if from_datetime > to_datetime {
963                            return Err(OrgModeError::InvalidAgendaViewType(
964                                "From date must be before to date".into(),
965                            ));
966                        }
967                        Ok(AgendaViewType::Custom {
968                            from: from_datetime,
969                            to: to_datetime,
970                        })
971                    }
972                    _ => Err(OrgModeError::InvalidAgendaViewType(format!(
973                        "Unknown agenda view type format: '{}'",
974                        value
975                    ))),
976                }
977            }
978        }
979    }
980}