org_core/
org_mode.rs

1use std::{fs, io, path::PathBuf};
2
3use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
4use nucleo_matcher::{Config as NucleoConfig, Matcher};
5use orgize::Org;
6use orgize::ast::PropertyDrawer;
7use orgize::export::{Container, Event, from_fn, from_fn_with_ctx};
8use walkdir::WalkDir;
9
10use crate::{Config, OrgModeError};
11
12#[cfg(test)]
13#[path = "org_mode_tests.rs"]
14mod org_mode_tests;
15
16#[derive(Debug)]
17pub struct OrgMode {
18    config: Config,
19}
20
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct TreeNode {
23    pub label: String,
24    pub level: usize,
25    #[serde(skip_serializing_if = "Vec::is_empty", default)]
26    pub children: Vec<TreeNode>,
27}
28
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct SearchResult {
31    pub file_path: String,
32    pub snippet: String,
33    pub score: u32,
34}
35
36impl TreeNode {
37    pub fn new(label: String) -> Self {
38        Self {
39            label,
40            level: 0,
41            children: Vec::new(),
42        }
43    }
44
45    pub fn new_with_level(label: String, level: usize) -> Self {
46        Self {
47            label,
48            level,
49            children: Vec::new(),
50        }
51    }
52
53    pub fn to_indented_string(&self, indent: usize) -> String {
54        let mut result = String::new();
55        let prefix = "  ".repeat(indent);
56        result.push_str(&format!(
57            "{}{} {}\n",
58            prefix,
59            "*".repeat(self.level),
60            self.label
61        ));
62
63        for child in &self.children {
64            result.push_str(&child.to_indented_string(indent + 1));
65        }
66
67        result
68    }
69}
70
71impl OrgMode {
72    pub fn new(config: Config) -> Result<Self, OrgModeError> {
73        let config = config.validate()?;
74
75        Ok(OrgMode { config })
76    }
77
78    pub fn with_defaults() -> Result<Self, OrgModeError> {
79        Self::new(Config::load()?)
80    }
81
82    pub fn config(&self) -> &Config {
83        &self.config
84    }
85}
86
87impl OrgMode {
88    pub fn list_files(&self) -> Result<Vec<String>, OrgModeError> {
89        WalkDir::new(&self.config.org.org_directory)
90            .into_iter()
91            .filter_map(|entry| match entry {
92                Ok(dir_entry) => {
93                    let path = dir_entry.path();
94
95                    if path.is_file()
96                        && let Some(extension) = path.extension()
97                        && extension == "org"
98                        && let Ok(relative_path) = path.strip_prefix(&self.config.org.org_directory)
99                        && let Some(path_str) = relative_path.to_str()
100                    {
101                        Some(Ok(path_str.to_string()))
102                    } else {
103                        None
104                    }
105                }
106                Err(e) => Some(Err(OrgModeError::WalkDirError(e))),
107            })
108            .collect::<Result<Vec<String>, OrgModeError>>()
109    }
110
111    pub fn search(
112        &self,
113        query: &str,
114        limit: Option<usize>,
115        snippet_max_size: Option<usize>,
116    ) -> Result<Vec<SearchResult>, OrgModeError> {
117        if query.trim().is_empty() {
118            return Ok(vec![]);
119        }
120
121        let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
122        let pattern = Pattern::new(
123            query,
124            CaseMatching::Ignore,
125            Normalization::Smart,
126            AtomKind::Fuzzy,
127        );
128
129        let files = self.list_files()?;
130        let mut all_results = Vec::new();
131
132        for file in files {
133            let content = match self.read_file(&file) {
134                Ok(content) => content,
135                Err(_) => continue, // Skip files that can't be read
136            };
137
138            let matches = pattern.match_list(
139                content.lines().map(|s| s.to_owned()).collect::<Vec<_>>(),
140                &mut matcher,
141            );
142
143            for (snippet, score) in matches {
144                let snippet = Self::snippet(&snippet, snippet_max_size.unwrap_or(100));
145                all_results.push(SearchResult {
146                    file_path: file.clone(),
147                    snippet,
148                    score,
149                });
150            }
151        }
152
153        all_results.sort_by(|a, b| b.score.cmp(&a.score));
154        all_results.truncate(limit.unwrap_or(all_results.len()));
155
156        Ok(all_results)
157    }
158
159    pub fn read_file(&self, path: &str) -> Result<String, OrgModeError> {
160        let org_dir = PathBuf::from(&self.config.org.org_directory);
161        let full_path = org_dir.join(path);
162
163        if !full_path.exists() {
164            return Err(OrgModeError::IoError(io::Error::new(
165                io::ErrorKind::NotFound,
166                format!("File not found: {}", path),
167            )));
168        }
169
170        if !full_path.is_file() {
171            return Err(OrgModeError::IoError(io::Error::new(
172                io::ErrorKind::InvalidInput,
173                format!("Path is not a file: {}", path),
174            )));
175        }
176
177        fs::read_to_string(full_path).map_err(OrgModeError::IoError)
178    }
179
180    pub fn get_outline(&self, path: &str) -> Result<TreeNode, OrgModeError> {
181        let content = self.read_file(path)?;
182
183        let mut root = TreeNode::new("Document".into());
184        let mut stack: Vec<TreeNode> = Vec::new();
185
186        let mut handler = from_fn(|event| {
187            if let Event::Enter(Container::Headline(h)) = event {
188                let level = h.level();
189                let title = h.title_raw();
190                let node = TreeNode::new_with_level(title, level);
191
192                while let Some(n) = stack.last() {
193                    if n.level < level {
194                        break;
195                    }
196                    let finished_node = stack.pop().unwrap();
197                    if let Some(parent) = stack.last_mut() {
198                        parent.children.push(finished_node);
199                    } else {
200                        root.children.push(finished_node);
201                    }
202                }
203
204                stack.push(node);
205            }
206        });
207
208        Org::parse(&content).traverse(&mut handler);
209
210        while let Some(node) = stack.pop() {
211            if let Some(parent) = stack.last_mut() {
212                parent.children.push(node);
213            } else {
214                root.children.push(node);
215            }
216        }
217
218        Ok(root)
219    }
220
221    pub fn get_heading(&self, path: &str, heading: &str) -> Result<String, OrgModeError> {
222        let content = self.read_file(path)?;
223
224        let heading_path: Vec<&str> = heading.split('/').collect();
225        let mut current_level = 0;
226        let mut found = None;
227
228        let mut handler = from_fn_with_ctx(|event, ctx| {
229            if let Event::Enter(Container::Headline(h)) = event {
230                let title = h.title_raw();
231                let level = h.level();
232
233                if let Some(part) = heading_path.get(current_level) {
234                    if title == *part {
235                        if level == heading_path.len() {
236                            found = Some(h);
237                            ctx.stop();
238                        }
239                        current_level += 1;
240                    }
241                } else {
242                    ctx.stop()
243                }
244            }
245        });
246
247        Org::parse(&content).traverse(&mut handler);
248
249        found
250            .ok_or_else(|| OrgModeError::InvalidHeadingPath(heading.into()))
251            .map(|h| h.raw())
252    }
253
254    pub fn get_element_by_id(&self, id: &str) -> Result<String, OrgModeError> {
255        let files = self.list_files()?;
256
257        let found = files.iter().find_map(|path| {
258            self.read_file(path)
259                .map(|content| self.search_id(content, id))
260                .unwrap_or_default()
261        });
262
263        found.ok_or_else(|| OrgModeError::InvalidElementId(id.into()))
264    }
265
266    pub fn search_id(&self, content: String, id: &str) -> Option<String> {
267        let mut found = None;
268        let has_id_property = |properties: Option<PropertyDrawer>| {
269            properties
270                .and_then(|props| {
271                    props
272                        .to_hash_map()
273                        .into_iter()
274                        .find(|(k, v)| k.to_lowercase() == "id" && v == id)
275                })
276                .is_some()
277        };
278        let mut handler = from_fn_with_ctx(|event, ctx| {
279            if let Event::Enter(Container::Headline(ref h)) = event
280                && has_id_property(h.properties())
281            {
282                found = Some(h.raw());
283                ctx.stop();
284            } else if let Event::Enter(Container::Document(ref d)) = event
285                && has_id_property(d.properties())
286            {
287                found = Some(d.raw());
288                ctx.stop();
289            }
290        });
291
292        Org::parse(&content).traverse(&mut handler);
293
294        found
295    }
296
297    fn snippet(s: &str, max: usize) -> String {
298        if s.chars().count() > max {
299            s.chars().take(max).collect::<String>() + "..."
300        } else {
301            s.to_string()
302        }
303    }
304}