1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::{AgdError, CaptureTemplate, SORTING_KEYS, Settings, ViewBlock, ViewSettings};
7
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ImportStatus {
11 Exact,
12 Difference,
13 Unsupported,
14}
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
17pub struct ImportDiagnostic {
18 pub status: ImportStatus,
19 pub key: String,
20 pub message: String,
21}
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26pub struct ImportedCapture {
27 pub desc: String,
28 pub file: Option<String>,
29 pub heading: Option<String>,
30 pub outline_path: Option<Vec<String>>,
31 pub id: Option<String>,
32 #[serde(default)]
33 pub datetree: bool,
34 pub template: String,
35 pub template_file: Option<String>,
36 pub enabled: bool,
37 #[serde(default)]
38 pub diagnostics: Vec<String>,
39}
40
41#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct ImportedCommand {
46 pub key: String,
47 pub description: String,
48 pub enabled: bool,
49 #[serde(default)]
50 pub blocks: Vec<ViewBlock>,
51 #[serde(default)]
52 pub diagnostics: Vec<String>,
53}
54
55#[derive(Clone, Debug, Deserialize, Serialize)]
56#[serde(deny_unknown_fields)]
57pub struct ResolvedEditorConfig {
58 pub editor: String,
59 #[serde(default)]
60 pub editor_version: String,
61 #[serde(default)]
64 pub org_directory: Option<String>,
65 pub agenda_files: Vec<String>,
68 pub todo_sequences: Vec<Vec<String>>,
69 pub tag_alist: Vec<String>,
70 pub persistent_tags: Vec<String>,
71 pub tags_column: i64,
72 pub tag_inheritance_exclusions: Vec<String>,
73 pub highest_priority: String,
74 pub lowest_priority: String,
75 pub default_priority: String,
76 pub deadline_warning_days: i64,
77 pub agenda_span: String,
78 pub agenda_start_day: Option<String>,
79 pub week_start: String,
80 pub skip_scheduled_if_done: bool,
81 pub skip_deadline_if_done: bool,
82 pub skip_timestamp_if_done: bool,
83 pub sorting_strategy: Vec<String>,
84 pub log_done: String,
85 pub log_repeat: String,
86 pub log_reschedule: String,
87 pub log_redeadline: String,
88 pub log_into_drawer: bool,
89 #[serde(default)]
90 pub capture: BTreeMap<String, ImportedCapture>,
91 #[serde(default)]
92 pub commands: Vec<ImportedCommand>,
93 #[serde(default)]
94 pub diagnostics: Vec<ImportDiagnostic>,
95 #[serde(default)]
96 pub verification: BTreeMap<String, String>,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub enum ConfigEditor {
101 Emacs,
102 Neovim,
103}
104
105impl ConfigEditor {
106 pub fn name(self) -> &'static str {
107 match self {
108 Self::Emacs => "Emacs",
109 Self::Neovim => "Neovim",
110 }
111 }
112}
113
114#[derive(Clone, Debug)]
117pub struct ConfigSources {
118 pub editor: ConfigEditor,
119 pub files: Vec<PathBuf>,
120 pub notes: Vec<String>,
121}
122
123const MAX_DEPTH: usize = 4;
124const MAX_FILES: usize = 300;
125const MAX_BYTES: u64 = 1 << 20;
126const SKIPPED_DIRECTORIES: [&str; 12] = [
128 ".git",
129 ".local",
130 ".cache",
131 "elpa",
132 "straight",
133 "eln-cache",
134 "var",
135 "auto-save-list",
136 "pack",
137 "plugged",
138 "lazy",
139 "node_modules",
140];
141const ENTRY_POINTS: [&str; 7] = [
143 "early-init.el",
144 "init.el",
145 "config.el",
146 ".emacs",
147 ".spacemacs",
148 "config.org",
149 "init.lua",
150];
151
152pub fn find_config_sources(paths: &[PathBuf]) -> Result<ConfigSources, AgdError> {
159 let mut files = Vec::new();
160 let mut notes = Vec::new();
161 for path in paths {
162 let path = PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).into_owned());
163 if path.is_file() {
164 if editor_of(&path).is_none() {
165 return Err(import_error(format!(
166 "{} is not an Emacs Lisp (.el, .org) or Lua (.lua) file",
167 path.display()
168 )));
169 }
170 files.push(path);
171 } else if path.is_dir() {
172 walk_config_directory(&path, &mut files, &mut notes)?;
173 } else {
174 return Err(import_error(format!("{} does not exist", path.display())));
175 }
176 }
177 let editors: Vec<ConfigEditor> = files.iter().filter_map(|file| editor_of(file)).collect();
178 let Some(&editor) = editors.first() else {
179 return Err(import_error(
180 "found no org settings: no .el file mentioning org-, .lua file mentioning orgmode, or config.org",
181 ));
182 };
183 if editors.iter().any(|other| *other != editor) {
184 return Err(import_error(
185 "found both Emacs and Neovim files; import one editor's config at a time",
186 ));
187 }
188 files.sort_by_key(|file| {
189 let name = file
190 .file_name()
191 .map(|name| name.to_string_lossy().into_owned());
192 let rank = ENTRY_POINTS
193 .iter()
194 .position(|entry| Some(*entry) == name.as_deref())
195 .unwrap_or(ENTRY_POINTS.len());
196 (rank, file.clone())
197 });
198 files.dedup();
199 Ok(ConfigSources {
200 editor,
201 files,
202 notes,
203 })
204}
205
206fn walk_config_directory(
207 root: &Path,
208 files: &mut Vec<PathBuf>,
209 notes: &mut Vec<String>,
210) -> Result<(), AgdError> {
211 let walker = ignore::WalkBuilder::new(root)
212 .hidden(false)
213 .max_depth(Some(MAX_DEPTH))
214 .filter_entry(|entry| {
215 !(entry.file_type().is_some_and(|kind| kind.is_dir())
216 && SKIPPED_DIRECTORIES.contains(&entry.file_name().to_string_lossy().as_ref()))
217 })
218 .build();
219 let mut seen = 0;
220 for entry in walker {
221 let entry = entry
222 .map_err(|error| import_error(format!("cannot read {}: {error}", root.display())))?;
223 let path = entry.path();
224 let Some(editor) = editor_of(path) else {
225 continue;
226 };
227 seen += 1;
228 if seen > MAX_FILES {
229 notes.push(format!(
230 "stopped after {MAX_FILES} files; point at the config file instead"
231 ));
232 break;
233 }
234 if entry
235 .metadata()
236 .is_ok_and(|metadata| metadata.len() > MAX_BYTES)
237 {
238 notes.push(format!("{}: skipped, larger than 1 MB", path.display()));
239 continue;
240 }
241 let Ok(text) = std::fs::read_to_string(path) else {
242 notes.push(format!("{}: skipped, not UTF-8 text", path.display()));
243 continue;
244 };
245 let relevant = match editor {
246 ConfigEditor::Neovim => text.contains("orgmode"),
247 ConfigEditor::Emacs if path.extension().is_some_and(|extension| extension == "org") => {
248 text.contains("org-") && text.to_lowercase().contains("#+begin_src emacs-lisp")
249 }
250 ConfigEditor::Emacs => text.contains("org-"),
251 };
252 if relevant {
253 files.push(path.to_path_buf());
254 }
255 }
256 Ok(())
257}
258
259fn editor_of(path: &Path) -> Option<ConfigEditor> {
262 let name = path.file_name()?.to_string_lossy();
263 match path.extension().and_then(|extension| extension.to_str()) {
264 Some("el") => Some(ConfigEditor::Emacs),
265 Some("lua") => Some(ConfigEditor::Neovim),
266 Some("org")
267 if ["config.org", "init.org", "emacs.org", "README.org"].contains(&name.as_ref()) =>
268 {
269 Some(ConfigEditor::Emacs)
270 }
271 _ if name == ".emacs" || name == ".spacemacs" => Some(ConfigEditor::Emacs),
272 _ => None,
273 }
274}
275
276fn import_error(message: impl Into<String>) -> AgdError {
277 AgdError::new("import", message)
278}
279
280#[derive(Clone, Debug)]
282pub struct ImportResult {
283 pub settings: Settings,
284 pub diagnostics: Vec<ImportDiagnostic>,
285}
286
287pub fn apply_import(
294 resolved: &ResolvedEditorConfig,
295 base: &Settings,
296 derive_roots: bool,
297) -> Result<ImportResult, AgdError> {
298 validate_resolved(resolved)?;
299 let mut diagnostics = resolved.diagnostics.clone();
300 let mut settings = base.clone();
301 let entries: Vec<PathBuf> = resolved
302 .agenda_files
303 .iter()
304 .map(|entry| PathBuf::from(shellexpand::tilde(entry).into_owned()))
305 .collect();
306 if derive_roots {
307 let derived = derived_roots(resolved.org_directory.as_deref(), &entries);
308 if !derived.is_empty() {
309 settings.roots = derived;
310 }
311 }
312 settings.agenda_files = Vec::new();
313 for (entry, original) in entries.iter().zip(&resolved.agenda_files) {
314 match agenda_pattern(entry, &settings.roots) {
315 Some(pattern) => settings.agenda_files.push(pattern),
316 None => diagnostics.push(ImportDiagnostic {
317 status: ImportStatus::Unsupported,
318 key: original.clone(),
319 message: "agenda entry left out: outside the roots".to_string(),
320 }),
321 }
322 }
323 settings.agenda_files.dedup();
324
325 settings.todo_keywords = resolved.todo_sequences.clone();
326 settings.tag_alist = resolved.tag_alist.clone();
327 settings.persistent_tags = resolved.persistent_tags.clone();
328 settings.tags_column = resolved.tags_column;
329 settings.agenda_span = resolved.agenda_span.clone();
330 settings.agenda_start_day = resolved.agenda_start_day.clone();
331 settings.week_start = resolved.week_start.clone();
332 settings.deadline_warning_days = resolved.deadline_warning_days;
333 settings.skip_scheduled_if_done = resolved.skip_scheduled_if_done;
334 settings.skip_deadline_if_done = resolved.skip_deadline_if_done;
335 settings.skip_timestamp_if_done = resolved.skip_timestamp_if_done;
336 settings.sorting_strategy.clear();
337 for key in &resolved.sorting_strategy {
338 if SORTING_KEYS.contains(&key.as_str()) {
339 settings.sorting_strategy.push(key.clone());
340 } else {
341 diagnostics.push(ImportDiagnostic {
342 status: ImportStatus::Difference,
343 key: "sorting_strategy".to_string(),
344 message: format!("sorting key {key} is not supported and was left out"),
345 });
346 }
347 }
348 settings.highest_priority = resolved.highest_priority.clone();
349 settings.lowest_priority = resolved.lowest_priority.clone();
350 settings.default_priority = resolved.default_priority.clone();
351 settings.tag_inheritance_exclusions = resolved.tag_inheritance_exclusions.clone();
352 settings.log_done = resolved.log_done.clone();
353 settings.log_repeat = resolved.log_repeat.clone();
354 settings.log_reschedule = resolved.log_reschedule.clone();
355 settings.log_redeadline = resolved.log_redeadline.clone();
356 settings.log_into_drawer = resolved.log_into_drawer;
357
358 settings.captures.clear();
359 for (key, capture) in &resolved.capture {
360 if !capture.enabled {
361 diagnostics.push(ImportDiagnostic {
362 status: ImportStatus::Unsupported,
363 key: format!("capture {key}"),
364 message: capture.diagnostics.join("; "),
365 });
366 continue;
367 }
368 settings.captures.push(CaptureTemplate {
369 key: key.clone(),
370 desc: capture.desc.clone(),
371 file: capture.file.clone(),
372 heading: capture.heading.clone(),
373 outline_path: capture.outline_path.clone(),
374 id: capture.id.clone(),
375 datetree: capture.datetree,
376 template: capture.template.clone(),
377 template_file: capture.template_file.clone(),
378 });
379 }
380 if resolved.capture.is_empty() {
382 settings.captures = base.captures.clone();
383 }
384
385 settings.views.clear();
386 for command in &resolved.commands {
387 let key = format!("view {}", command.key);
388 let mut command = command.clone();
389 for block in &mut command.blocks {
390 if let Some(rewritten) = block.match_expression.as_deref().and_then(todo_state_match) {
391 diagnostics.push(ImportDiagnostic {
392 status: ImportStatus::Difference,
393 key: key.clone(),
394 message: format!(
395 "match {} imported as {rewritten}",
396 block.match_expression.as_deref().unwrap_or_default()
397 ),
398 });
399 block.match_expression = Some(rewritten);
400 }
401 }
402 let unusable_match = command
403 .blocks
404 .iter()
405 .find_map(|block| crate::agenda_engine::validate_view_block(block).err());
406 if let Some(error) = &unusable_match {
407 diagnostics.push(ImportDiagnostic {
408 status: ImportStatus::Unsupported,
409 key: key.clone(),
410 message: error.to_string(),
411 });
412 }
413 if command.enabled && unusable_match.is_none() {
414 settings.views.push(ViewSettings {
415 key: command.key.clone(),
416 desc: command.description.clone(),
417 blocks: command.blocks.clone(),
418 });
419 }
420 let status = if command.enabled {
421 ImportStatus::Difference
422 } else {
423 ImportStatus::Unsupported
424 };
425 for message in &command.diagnostics {
426 diagnostics.push(ImportDiagnostic {
427 status: status.clone(),
428 key: key.clone(),
429 message: message.clone(),
430 });
431 }
432 }
433 if resolved.commands.is_empty() {
434 settings.views = base.views.clone();
435 }
436
437 Ok(ImportResult {
438 settings,
439 diagnostics,
440 })
441}
442
443fn todo_state_match(expression: &str) -> Option<String> {
446 let state = expression
447 .trim()
448 .strip_prefix('+')
449 .unwrap_or(expression.trim())
450 .strip_prefix("TODO=\"")?
451 .strip_suffix('"')?;
452 (!state.is_empty()
453 && state
454 .chars()
455 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
456 .then(|| format!("/{state}"))
457}
458
459fn entry_base(entry: &Path) -> PathBuf {
462 let mut base = PathBuf::new();
463 for component in entry.components() {
464 if component
465 .as_os_str()
466 .to_string_lossy()
467 .contains(['*', '?', '['])
468 {
469 return base;
470 }
471 base.push(component);
472 }
473 if entry.is_dir() {
474 base
475 } else {
476 base.parent().map(Path::to_path_buf).unwrap_or(base)
477 }
478}
479
480fn derived_roots(org_directory: Option<&str>, entries: &[PathBuf]) -> Vec<String> {
484 let mut directories: Vec<PathBuf> = match org_directory {
485 Some(directory) => vec![PathBuf::from(shellexpand::tilde(directory).into_owned())],
486 None => entries.iter().map(|entry| entry_base(entry)).collect(),
487 };
488 directories.retain(|directory| directory.is_dir());
489 directories.sort();
490 directories.dedup();
491 let mut roots: Vec<PathBuf> = Vec::new();
492 for directory in directories {
493 if !roots.iter().any(|root| directory.starts_with(root)) {
494 roots.push(directory);
495 }
496 }
497 let home = dirs::home_dir();
498 roots
499 .into_iter()
500 .map(|root| {
501 match home
502 .as_deref()
503 .and_then(|home| root.strip_prefix(home).ok())
504 {
505 Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
506 Some(rest) => format!("~/{}", rest.to_string_lossy()),
507 None => root.to_string_lossy().into_owned(),
508 }
509 })
510 .collect()
511}
512
513fn agenda_pattern(entry: &Path, roots: &[String]) -> Option<String> {
517 let base = entry_base(entry);
518 let root = roots
519 .iter()
520 .map(|root| PathBuf::from(shellexpand::tilde(root).into_owned()))
521 .filter(|root| base.starts_with(root))
522 .max_by_key(|root| root.components().count())?;
523 let relative = entry
524 .strip_prefix(&root)
525 .ok()?
526 .to_string_lossy()
527 .into_owned();
528 if entry.is_dir() {
529 Some(if relative.is_empty() {
530 "*.org".to_string()
531 } else {
532 format!("{relative}/*.org")
533 })
534 } else {
535 Some(relative)
536 }
537}
538
539fn validate_resolved(resolved: &ResolvedEditorConfig) -> Result<(), AgdError> {
540 if resolved.todo_sequences.is_empty()
541 || resolved
542 .todo_sequences
543 .iter()
544 .any(|sequence| sequence.is_empty())
545 {
546 return Err(AgdError::new(
547 "unsupported_import_shape",
548 "editor returned no usable TODO sequence",
549 ));
550 }
551 for (name, value) in [
552 ("highest priority", &resolved.highest_priority),
553 ("lowest priority", &resolved.lowest_priority),
554 ("default priority", &resolved.default_priority),
555 ] {
556 if value.chars().count() != 1 {
557 return Err(AgdError::new(
558 "unsupported_import_shape",
559 format!("{name} must be one character"),
560 ));
561 }
562 }
563 if resolved.week_start != "monday" && resolved.week_start != "sunday" {
564 return Err(AgdError::new(
565 "unsupported_import_shape",
566 format!("unsupported week start: {}", resolved.week_start),
567 ));
568 }
569 Ok(())
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::fs;
576
577 #[test]
578 fn discovery_reads_entry_points_first_and_only_org_mentions() {
579 let temp = tempfile::tempdir().unwrap();
580 let root = temp.path();
581 fs::write(root.join("config.el"), "(setq org-log-done 'time)\n").unwrap();
582 fs::write(root.join("init.el"), "(setq org-directory \"~/org\")\n").unwrap();
583 fs::write(root.join("keys.el"), "(map! :n \"x\" #'ignore)\n").unwrap();
584 fs::create_dir_all(root.join(".local/straight")).unwrap();
585 fs::write(root.join(".local/straight/org.el"), "(setq org-x 1)\n").unwrap();
586 let sources = find_config_sources(&[root.to_path_buf()]).unwrap();
587 assert_eq!(sources.editor, ConfigEditor::Emacs);
588 assert_eq!(
589 sources.files,
590 vec![root.join("init.el"), root.join("config.el")]
591 );
592
593 fs::write(root.join("init.lua"), "require('orgmode').setup({})\n").unwrap();
594 let error = find_config_sources(&[root.to_path_buf()]).unwrap_err();
595 assert!(
596 error.to_string().contains("both Emacs and Neovim"),
597 "{error}"
598 );
599 }
600
601 #[test]
602 fn first_import_derives_roots_and_relative_patterns() {
603 let temp = tempfile::tempdir().unwrap();
604 let org = temp.path().join("org");
605 fs::create_dir_all(org.join("projects")).unwrap();
606 let entries = [
607 org.join("projects"),
608 org.join("**/*.org"),
609 org.join("inbox.org"),
610 ];
611 let roots = derived_roots(None, &entries);
612 assert_eq!(roots, vec![org.to_string_lossy().into_owned()]);
613 let patterns: Vec<Option<String>> = entries
614 .iter()
615 .map(|entry| agenda_pattern(entry, &roots))
616 .collect();
617 assert_eq!(
618 patterns,
619 vec![
620 Some("projects/*.org".to_string()),
621 Some("**/*.org".to_string()),
622 Some("inbox.org".to_string())
623 ]
624 );
625 assert_eq!(
626 agenda_pattern(&temp.path().join("elsewhere.org"), &roots),
627 None
628 );
629 }
630
631 #[test]
632 fn reimport_keeps_roots_and_agd_only_keys_and_drops_outside_entries() {
633 let temp = tempfile::tempdir().unwrap();
634 let base = Settings {
635 roots: vec![temp.path().to_string_lossy().into_owned()],
636 theme: "dark".to_string(),
637 ..Settings::default()
638 };
639 let resolved: ResolvedEditorConfig = serde_json::from_value(serde_json::json!({
640 "editor": "emacs",
641 "org_directory": "/elsewhere",
642 "agenda_files": [temp.path().join("inbox.org"), "/elsewhere/private.org"],
643 "todo_sequences": [["TODO", "|", "DONE"]],
644 "tag_alist": [], "persistent_tags": [], "tags_column": -77,
645 "tag_inheritance_exclusions": [],
646 "highest_priority": "A", "lowest_priority": "C", "default_priority": "B",
647 "deadline_warning_days": 14, "agenda_span": "week", "agenda_start_day": null,
648 "week_start": "monday", "skip_scheduled_if_done": false,
649 "skip_deadline_if_done": false, "skip_timestamp_if_done": false,
650 "sorting_strategy": ["time-up"], "log_done": "time", "log_repeat": "time",
651 "log_reschedule": "nil", "log_redeadline": "nil", "log_into_drawer": false
652 }))
653 .unwrap();
654 let result = apply_import(&resolved, &base, false).unwrap();
655 assert_eq!(result.settings.roots, base.roots);
656 assert_eq!(result.settings.theme, "dark");
657 assert_eq!(result.settings.agenda_files, vec!["inbox.org"]);
658 assert!(
659 result
660 .diagnostics
661 .iter()
662 .any(|diagnostic| diagnostic.key == "/elsewhere/private.org")
663 );
664 assert_eq!(result.settings.captures, base.captures);
665 }
666
667 #[test]
668 fn only_a_bare_todo_state_match_is_rewritten() {
669 assert_eq!(todo_state_match("TODO=\"NEXT\""), Some("/NEXT".to_string()));
670 assert_eq!(
671 todo_state_match("+TODO=\"WAIT\""),
672 Some("/WAIT".to_string())
673 );
674 assert_eq!(todo_state_match("TODO=\"DONE\"+CLOSED>=\"<-3w>\""), None);
675 assert_eq!(todo_state_match("+work/NEXT"), None);
676 }
677}