Skip to main content

org_core/
imports.rs

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/// One capture template as the editor reports it; disabled templates are
24/// reported, not imported.
25#[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/// One custom agenda command as the editor reports it. Enabled commands
42/// become views; `diagnostics` name what was left out.
43#[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    /// org-directory when the config sets it; it becomes the root on a first
62    /// import.
63    #[serde(default)]
64    pub org_directory: Option<String>,
65    /// Agenda entries as written in the editor config, made absolute: files,
66    /// directories or globs.
67    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/// The editor config files an import reads, in load order, and what was
115/// passed over.
116#[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;
126/// Package managers' and editors' own state: never the user's settings.
127const 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];
141/// Files read first, in this order, as the editors load them.
142const 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
152/// Finds the files to read under `paths`. A file is taken as given; a
153/// directory is walked (respecting .gitignore, at most MAX_DEPTH levels,
154/// MAX_FILES files and MAX_BYTES each, skipping SKIPPED_DIRECTORIES), keeping
155/// Emacs Lisp files that mention `org-`, Lua files that mention `orgmode`
156/// and Org files with emacs-lisp source blocks. Both editors' files under
157/// one path is an error: import one editor at a time.
158pub 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
259/// The editor a config file belongs to, by name: Emacs Lisp, literate Org
260/// configs, `.emacs` and `.spacemacs`, or Lua.
261fn 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/// The editor's settings applied over `base`, and what could not be.
281#[derive(Clone, Debug)]
282pub struct ImportResult {
283    pub settings: Settings,
284    pub diagnostics: Vec<ImportDiagnostic>,
285}
286
287/// Applies resolved editor settings over `base` (the current config). The
288/// editor's values replace the keys it reports; agd-only keys (editor,
289/// theme, agents, triage) keep their `base` values. On a first import
290/// (`derive_roots`) the roots become org-directory, or the directories the
291/// agenda entries live in; otherwise `base.roots` stay and entries outside
292/// them are left out with a diagnostic.
293pub 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    // The defaults stay only when the editor defines no templates at all.
381    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
443/// `TODO="NEXT"`, the common nvim-orgmode way to match a TODO state, as
444/// agd's equivalent `/NEXT`; None for any other match.
445fn 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
459/// The literal directory an agenda entry starts from: a directory itself, a
460/// file's parent, or a glob's components before its first wildcard.
461fn 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
480/// org-directory if set, else each agenda entry's directory; nested
481/// directories collapse into their ancestor. Home-relative paths are written
482/// with `~`.
483fn 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
513/// An agenda entry as a pattern relative to the root that holds it: a file
514/// stays a file, a directory becomes its `*.org` files (Emacs does not
515/// recurse), and a glob keeps its wildcards. None when no root holds it.
516fn 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}