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, };
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}