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