Skip to main content

agd_core/
import.rs

1//! `agd setup import`: finding the files of an Emacs or Neovim configuration
2//! that mention Org ([`find_config_sources`]), and applying the settings the
3//! editor reports ([`ResolvedEditorConfig`]) over the current configuration
4//! ([`apply_import`]), with a diagnostic for everything that differs or cannot
5//! be imported.
6//!
7//! The editor resolves its own settings (the agd binary runs it with a reader
8//! script); this module never evaluates editor code, it maps the result. A
9//! result that would not load as a config is refused (`invalid_import`).
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::{
17    AgdError, AgendaSpan, CaptureTemplate, ErrorCode, LogMode, SORTING_KEYS, Settings, ViewBlock,
18    ViewSettings, WeekStart,
19};
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ImportStatus {
24    Exact,
25    Difference,
26    Unsupported,
27}
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub struct ImportDiagnostic {
31    pub status: ImportStatus,
32    pub key: String,
33    pub message: String,
34}
35
36/// One capture template as the editor reports it; disabled templates are
37/// reported, not imported.
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub struct ImportedCapture {
40    pub key: String,
41    pub desc: String,
42    pub file: Option<String>,
43    pub heading: Option<String>,
44    pub olpath: Option<Vec<String>>,
45    pub id: Option<String>,
46    #[serde(default)]
47    pub datetree: bool,
48    pub template: String,
49    pub template_file: Option<String>,
50    pub enabled: bool,
51    #[serde(default)]
52    pub diagnostics: Vec<String>,
53}
54
55/// One custom agenda command as the editor reports it. Enabled commands
56/// become views; `diagnostics` name what was left out.
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct ImportedCommand {
60    pub key: String,
61    pub description: String,
62    pub enabled: bool,
63    #[serde(default)]
64    pub blocks: Vec<ViewBlock>,
65    #[serde(default)]
66    pub diagnostics: Vec<String>,
67}
68
69#[derive(Clone, Debug, Deserialize, Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct ResolvedEditorConfig {
72    pub editor: String,
73    #[serde(default)]
74    pub editor_version: String,
75    /// org-directory when the config sets it; it becomes the root on a first
76    /// import.
77    #[serde(default)]
78    pub org_directory: Option<String>,
79    /// Agenda entries as written in the editor config, made absolute: files,
80    /// directories or globs.
81    pub agenda_files: Vec<String>,
82    pub todo_sequences: Vec<Vec<String>>,
83    pub tags_column: i64,
84    pub tag_inheritance_exclusions: Vec<String>,
85    pub highest_priority: String,
86    pub lowest_priority: String,
87    pub default_priority: String,
88    pub deadline_warning_days: i64,
89    pub agenda_span: String,
90    pub agenda_start_day: Option<String>,
91    pub week_start: WeekStart,
92    pub skip_scheduled_if_done: bool,
93    pub skip_deadline_if_done: bool,
94    pub skip_timestamp_if_done: bool,
95    pub sorting_strategy: Vec<String>,
96    pub log_done: String,
97    pub log_repeat: String,
98    pub log_reschedule: String,
99    pub log_redeadline: String,
100    pub log_into_drawer: bool,
101    /// In the editor's order: as written for Emacs; by key for nvim-orgmode,
102    /// whose Lua table keeps no order. config.toml keeps this order.
103    #[serde(default)]
104    pub capture: Vec<ImportedCapture>,
105    #[serde(default)]
106    pub commands: Vec<ImportedCommand>,
107    #[serde(default)]
108    pub diagnostics: Vec<ImportDiagnostic>,
109    #[serde(default)]
110    pub verification: BTreeMap<String, String>,
111}
112
113#[derive(Clone, Copy, Debug, Eq, PartialEq)]
114pub enum ConfigEditor {
115    Emacs,
116    Neovim,
117}
118
119impl ConfigEditor {
120    pub fn name(self) -> &'static str {
121        match self {
122            Self::Emacs => "Emacs",
123            Self::Neovim => "Neovim",
124        }
125    }
126}
127
128/// The editor config files an import reads, in load order, and what was
129/// passed over.
130#[derive(Clone, Debug)]
131pub struct ConfigSources {
132    pub editor: ConfigEditor,
133    pub files: Vec<PathBuf>,
134    pub notes: Vec<String>,
135}
136
137const MAX_DEPTH: usize = 4;
138const MAX_FILES: usize = 300;
139const MAX_BYTES: u64 = 1 << 20;
140/// Package managers' and editors' own state: never the user's settings.
141const SKIPPED_DIRECTORIES: [&str; 12] = [
142    ".git",
143    ".local",
144    ".cache",
145    "elpa",
146    "straight",
147    "eln-cache",
148    "var",
149    "auto-save-list",
150    "pack",
151    "plugged",
152    "lazy",
153    "node_modules",
154];
155/// Files read first, in this order, as the editors load them.
156const ENTRY_POINTS: [&str; 7] = [
157    "early-init.el",
158    "init.el",
159    "config.el",
160    ".emacs",
161    ".spacemacs",
162    "config.org",
163    "init.lua",
164];
165
166/// Finds the files to read under `paths`. A file is taken as given; a
167/// directory is walked (respecting .gitignore, at most MAX_DEPTH levels,
168/// MAX_FILES files and MAX_BYTES each, skipping SKIPPED_DIRECTORIES), keeping
169/// Emacs Lisp files that mention `org-`, Lua files that mention `orgmode`
170/// and Org files with emacs-lisp source blocks. Both editors' files under
171/// one path is an error: import one editor at a time.
172pub fn find_config_sources(paths: &[PathBuf]) -> Result<ConfigSources, AgdError> {
173    let mut files = Vec::new();
174    let mut notes = Vec::new();
175    for path in paths {
176        let path = PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).into_owned());
177        if path.is_file() {
178            if editor_of(&path).is_none() {
179                return Err(import_error(format!(
180                    "{} is not an Emacs Lisp (.el, .org) or Lua (.lua) file",
181                    path.display()
182                )));
183            }
184            files.push(path);
185        } else if path.is_dir() {
186            walk_config_directory(&path, &mut files, &mut notes)?;
187        } else {
188            return Err(import_error(format!("{} does not exist", path.display())));
189        }
190    }
191    let editors: Vec<ConfigEditor> = files.iter().filter_map(|file| editor_of(file)).collect();
192    let Some(&editor) = editors.first() else {
193        return Err(import_error(
194            "found no org settings: no .el file mentioning org-, .lua file mentioning orgmode, or config.org",
195        ));
196    };
197    if editors.iter().any(|other| *other != editor) {
198        return Err(import_error(
199            "found both Emacs and Neovim files; import one editor's config at a time",
200        ));
201    }
202    files.sort_by_key(|file| {
203        let name = file
204            .file_name()
205            .map(|name| name.to_string_lossy().into_owned());
206        let rank = ENTRY_POINTS
207            .iter()
208            .position(|entry| Some(*entry) == name.as_deref())
209            .unwrap_or(ENTRY_POINTS.len());
210        (rank, file.clone())
211    });
212    files.dedup();
213    Ok(ConfigSources {
214        editor,
215        files,
216        notes,
217    })
218}
219
220fn walk_config_directory(
221    root: &Path,
222    files: &mut Vec<PathBuf>,
223    notes: &mut Vec<String>,
224) -> Result<(), AgdError> {
225    let walker = ignore::WalkBuilder::new(root)
226        .hidden(false)
227        .max_depth(Some(MAX_DEPTH))
228        .filter_entry(|entry| {
229            !(entry.file_type().is_some_and(|kind| kind.is_dir())
230                && SKIPPED_DIRECTORIES.contains(&entry.file_name().to_string_lossy().as_ref()))
231        })
232        .build();
233    let mut seen = 0;
234    for entry in walker {
235        let entry = entry
236            .map_err(|error| import_error(format!("cannot read {}: {error}", root.display())))?;
237        let path = entry.path();
238        let Some(editor) = editor_of(path) else {
239            continue;
240        };
241        seen += 1;
242        if seen > MAX_FILES {
243            notes.push(format!(
244                "stopped after {MAX_FILES} files; point at the config file instead"
245            ));
246            break;
247        }
248        if entry
249            .metadata()
250            .is_ok_and(|metadata| metadata.len() > MAX_BYTES)
251        {
252            notes.push(format!("{}: skipped, larger than 1 MB", path.display()));
253            continue;
254        }
255        let Ok(text) = std::fs::read_to_string(path) else {
256            notes.push(format!("{}: skipped, not UTF-8 text", path.display()));
257            continue;
258        };
259        let relevant = match editor {
260            ConfigEditor::Neovim => text.contains("orgmode"),
261            ConfigEditor::Emacs if path.extension().is_some_and(|extension| extension == "org") => {
262                text.contains("org-") && text.to_lowercase().contains("#+begin_src emacs-lisp")
263            }
264            ConfigEditor::Emacs => text.contains("org-"),
265        };
266        if relevant {
267            files.push(path.to_path_buf());
268        }
269    }
270    Ok(())
271}
272
273/// The editor a config file belongs to, by name: Emacs Lisp, literate Org
274/// configs, `.emacs` and `.spacemacs`, or Lua.
275fn editor_of(path: &Path) -> Option<ConfigEditor> {
276    let name = path.file_name()?.to_string_lossy();
277    match path.extension().and_then(|extension| extension.to_str()) {
278        Some("el") => Some(ConfigEditor::Emacs),
279        Some("lua") => Some(ConfigEditor::Neovim),
280        Some("org")
281            if ["config.org", "init.org", "emacs.org", "README.org"].contains(&name.as_ref()) =>
282        {
283            Some(ConfigEditor::Emacs)
284        }
285        _ if name == ".emacs" || name == ".spacemacs" => Some(ConfigEditor::Emacs),
286        _ => None,
287    }
288}
289
290fn import_error(message: impl Into<String>) -> AgdError {
291    AgdError::new(ErrorCode::ImportFailed, message)
292}
293
294/// The editor's settings applied over `base`, and what could not be.
295#[derive(Clone, Debug)]
296pub struct ImportResult {
297    pub settings: Settings,
298    pub diagnostics: Vec<ImportDiagnostic>,
299}
300
301/// Applies resolved editor settings over `base` (the current config). The
302/// editor's values replace the keys it reports; agd-only keys (editor,
303/// theme, agents, triage) keep their `base` values. On a first import
304/// (`derive_roots`) the roots become org-directory, or the directories the
305/// agenda entries live in; otherwise `base.roots` stay and entries outside
306/// them are left out with a diagnostic.
307pub fn apply_import(
308    resolved: &ResolvedEditorConfig,
309    base: &Settings,
310    derive_roots: bool,
311) -> Result<ImportResult, AgdError> {
312    validate_resolved(resolved)?;
313    let mut diagnostics = resolved.diagnostics.clone();
314    let mut settings = base.clone();
315    let entries: Vec<PathBuf> = resolved
316        .agenda_files
317        .iter()
318        .map(|entry| PathBuf::from(shellexpand::tilde(entry).into_owned()))
319        .collect();
320    if derive_roots {
321        let derived = derived_roots(resolved.org_directory.as_deref(), &entries);
322        if !derived.is_empty() {
323            settings.roots = derived;
324        }
325    }
326    settings.agenda_files = Vec::new();
327    for (entry, original) in entries.iter().zip(&resolved.agenda_files) {
328        match agenda_pattern(entry, &settings.roots) {
329            Some(pattern) => settings.agenda_files.push(pattern),
330            None => diagnostics.push(ImportDiagnostic {
331                status: ImportStatus::Unsupported,
332                key: original.clone(),
333                message: "agenda entry left out: outside the roots".to_string(),
334            }),
335        }
336    }
337    settings.agenda_files.dedup();
338
339    // Each sequence as its #+TODO: line; (type …) keywords read the same.
340    settings.todo_keywords = resolved
341        .todo_sequences
342        .iter()
343        .map(|sequence| sequence.join(" "))
344        .collect();
345    settings.tags_column = resolved.tags_column;
346    match imported_span(&resolved.agenda_span) {
347        Some(span) => settings.agenda_span = span,
348        None => diagnostics.push(ImportDiagnostic {
349            status: ImportStatus::Unsupported,
350            key: "org-agenda-span".to_string(),
351            message: format!(
352                "{} is not supported; agenda_span stays {}",
353                resolved.agenda_span, settings.agenda_span
354            ),
355        }),
356    }
357    settings.agenda_start_day = resolved.agenda_start_day.clone();
358    settings.week_start = resolved.week_start;
359    settings.deadline_warning_days = resolved.deadline_warning_days;
360    settings.skip_scheduled_if_done = resolved.skip_scheduled_if_done;
361    settings.skip_deadline_if_done = resolved.skip_deadline_if_done;
362    settings.skip_timestamp_if_done = resolved.skip_timestamp_if_done;
363    settings.sorting_strategy.clear();
364    for key in &resolved.sorting_strategy {
365        if SORTING_KEYS.contains(&key.as_str()) {
366            settings.sorting_strategy.push(key.clone());
367        } else {
368            diagnostics.push(ImportDiagnostic {
369                status: ImportStatus::Difference,
370                key: "sorting_strategy".to_string(),
371                message: format!("sorting key {key} is not supported and was left out"),
372            });
373        }
374    }
375    settings.highest_priority = resolved.highest_priority.clone();
376    settings.lowest_priority = resolved.lowest_priority.clone();
377    settings.default_priority = resolved.default_priority.clone();
378    settings.tag_inheritance_exclusions = resolved.tag_inheritance_exclusions.clone();
379    for (variable, value, mode) in [
380        ("org-log-done", &resolved.log_done, &mut settings.log_done),
381        (
382            "org-log-repeat",
383            &resolved.log_repeat,
384            &mut settings.log_repeat,
385        ),
386        (
387            "org-log-reschedule",
388            &resolved.log_reschedule,
389            &mut settings.log_reschedule,
390        ),
391        (
392            "org-log-redeadline",
393            &resolved.log_redeadline,
394            &mut settings.log_redeadline,
395        ),
396    ] {
397        match imported_log_mode(value) {
398            Some(imported) => *mode = imported,
399            None => diagnostics.push(ImportDiagnostic {
400                status: ImportStatus::Unsupported,
401                key: variable.to_string(),
402                message: format!("{value} is not nil, time or note; left out"),
403            }),
404        }
405    }
406    settings.log_into_drawer = resolved.log_into_drawer;
407
408    settings.captures = imported_captures(resolved, &settings.roots, &mut diagnostics);
409    // The defaults stay only when the editor defines no templates at all.
410    if resolved.capture.is_empty() {
411        settings.captures = base.captures.clone();
412    }
413
414    settings.views.clear();
415    for command in &resolved.commands {
416        let key = format!("view {}", command.key);
417        let unusable_match = command
418            .blocks
419            .iter()
420            .find_map(|block| crate::agenda::validate_view_block(block).err());
421        if let Some(error) = &unusable_match {
422            diagnostics.push(ImportDiagnostic {
423                status: ImportStatus::Unsupported,
424                key: key.clone(),
425                message: error.to_string(),
426            });
427        }
428        if command.enabled && unusable_match.is_none() {
429            settings.views.push(ViewSettings {
430                key: command.key.clone(),
431                desc: command.description.clone(),
432                blocks: command.blocks.clone(),
433            });
434        }
435        let status = if command.enabled {
436            ImportStatus::Difference
437        } else {
438            ImportStatus::Unsupported
439        };
440        for message in &command.diagnostics {
441            diagnostics.push(ImportDiagnostic {
442                status: status.clone(),
443                key: key.clone(),
444                message: message.clone(),
445            });
446        }
447    }
448    if resolved.commands.is_empty() {
449        settings.views = base.views.clone();
450    }
451
452    // The written config must load; only its roots may not exist yet.
453    crate::config::validate_values(&settings).map_err(|error| {
454        AgdError::new(
455            ErrorCode::InvalidImport,
456            format!("the imported settings would not load: {error}"),
457        )
458    })?;
459    Ok(ImportResult {
460        settings,
461        diagnostics,
462    })
463}
464
465/// The literal directory an agenda entry starts from: a directory itself, a
466/// file's parent, or a glob's components before its first wildcard.
467fn entry_base(entry: &Path) -> PathBuf {
468    let mut base = PathBuf::new();
469    for component in entry.components() {
470        if component
471            .as_os_str()
472            .to_string_lossy()
473            .contains(['*', '?', '['])
474        {
475            return base;
476        }
477        base.push(component);
478    }
479    if entry.is_dir() {
480        base
481    } else {
482        base.parent().map(Path::to_path_buf).unwrap_or(base)
483    }
484}
485
486/// org-directory if set, else each agenda entry's directory; nested
487/// directories collapse into their ancestor. Home-relative paths are written
488/// with `~`.
489fn derived_roots(org_directory: Option<&str>, entries: &[PathBuf]) -> Vec<String> {
490    let mut directories: Vec<PathBuf> = match org_directory {
491        Some(directory) => vec![PathBuf::from(shellexpand::tilde(directory).into_owned())],
492        None => entries.iter().map(|entry| entry_base(entry)).collect(),
493    };
494    directories.retain(|directory| directory.is_dir());
495    directories.sort();
496    directories.dedup();
497    let mut roots: Vec<PathBuf> = Vec::new();
498    for directory in directories {
499        if !roots.iter().any(|root| directory.starts_with(root)) {
500            roots.push(directory);
501        }
502    }
503    roots.iter().map(|root| home_relative(root)).collect()
504}
505
506/// `path` with the home directory written as `~`.
507fn home_relative(path: &Path) -> String {
508    let home = dirs::home_dir();
509    match home
510        .as_deref()
511        .and_then(|home| path.strip_prefix(home).ok())
512    {
513        Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
514        Some(rest) => format!("~/{}", rest.to_string_lossy()),
515        None => path.to_string_lossy().into_owned(),
516    }
517}
518
519/// The root an import writes for `agd -r DIR setup import`: DIR made
520/// absolute (symlinks kept, as the editor's paths keep them) with `~` for
521/// the home directory. DIR must exist, since agenda entries and capture
522/// files are placed under it.
523pub fn import_root(directory: &Path) -> Result<String, AgdError> {
524    let expanded = PathBuf::from(shellexpand::tilde(&directory.to_string_lossy()).into_owned());
525    let absolute = std::path::absolute(&expanded).map_err(|error| {
526        import_error(format!(
527            "cannot resolve root {}: {error}",
528            directory.display()
529        ))
530    })?;
531    if !absolute.is_dir() {
532        return Err(import_error(format!(
533            "root {} is not a directory",
534            directory.display()
535        )));
536    }
537    Ok(home_relative(&absolute))
538}
539
540/// An agenda entry as a pattern relative to the root that holds it: a file
541/// stays a file, a directory becomes its `*.org` files (Emacs does not
542/// recurse), and a glob keeps its wildcards. None when no root holds it.
543fn agenda_pattern(entry: &Path, roots: &[String]) -> Option<String> {
544    let root = holding_root(&entry_base(entry), roots)?;
545    let relative = entry
546        .strip_prefix(&root)
547        .ok()?
548        .to_string_lossy()
549        .into_owned();
550    if entry.is_dir() {
551        Some(if relative.is_empty() {
552            "*.org".to_string()
553        } else {
554            format!("{relative}/*.org")
555        })
556    } else {
557        Some(relative)
558    }
559}
560
561/// The root `path` is under, the deepest one when roots nest.
562fn holding_root(path: &Path, roots: &[String]) -> Option<PathBuf> {
563    roots
564        .iter()
565        .map(|root| PathBuf::from(shellexpand::tilde(root).into_owned()))
566        .filter(|root| path.starts_with(root))
567        .max_by_key(|root| root.components().count())
568}
569
570/// The editor's enabled capture templates, each file written relative to
571/// the root that holds it, as agenda entries are. Disabled templates, and
572/// templates whose file is outside the roots (agd could not write there),
573/// are left out and reported.
574fn imported_captures(
575    resolved: &ResolvedEditorConfig,
576    roots: &[String],
577    diagnostics: &mut Vec<ImportDiagnostic>,
578) -> Vec<CaptureTemplate> {
579    let mut captures = Vec::new();
580    for capture in &resolved.capture {
581        let left_out = |message: String| ImportDiagnostic {
582            status: ImportStatus::Unsupported,
583            key: format!("capture {}", capture.key),
584            message,
585        };
586        if !capture.enabled {
587            diagnostics.push(left_out(capture.diagnostics.join("; ")));
588            continue;
589        }
590        let file = match &capture.file {
591            Some(file) => {
592                let path = PathBuf::from(shellexpand::tilde(file).into_owned());
593                let relative = holding_root(&path, roots)
594                    .and_then(|root| path.strip_prefix(root).ok().map(Path::to_path_buf));
595                let Some(relative) = relative else {
596                    diagnostics.push(left_out(format!(
597                        "capture template left out: {file} is outside the roots"
598                    )));
599                    continue;
600                };
601                Some(relative.to_string_lossy().into_owned())
602            }
603            None => None,
604        };
605        captures.push(CaptureTemplate {
606            key: capture.key.clone(),
607            desc: capture.desc.clone(),
608            file,
609            heading: capture.heading.clone(),
610            olpath: capture.olpath.clone(),
611            id: capture.id.clone(),
612            datetree: capture.datetree,
613            template: capture.template.clone(),
614            template_file: capture.template_file.clone(),
615        });
616    }
617    captures
618}
619
620/// An org-log-* value as agd writes it: Emacs's nil is off and t is time.
621fn imported_log_mode(value: &str) -> Option<LogMode> {
622    match value {
623        "nil" => Some(LogMode::Off),
624        "t" | "time" => Some(LogMode::Time),
625        "note" => Some(LogMode::Note),
626        _ => None,
627    }
628}
629
630/// org-agenda-span as agd writes it: Org's `fortnight` is 14 days; None for
631/// a span agd has no form for, such as `year`.
632fn imported_span(value: &str) -> Option<AgendaSpan> {
633    match value {
634        "fortnight" => AgendaSpan::days(14).ok(),
635        value => value.parse().ok(),
636    }
637}
638
639fn validate_resolved(resolved: &ResolvedEditorConfig) -> Result<(), AgdError> {
640    if resolved.todo_sequences.is_empty()
641        || resolved
642            .todo_sequences
643            .iter()
644            .any(|sequence| sequence.is_empty())
645    {
646        return Err(AgdError::new(
647            ErrorCode::InvalidImport,
648            "editor returned no usable TODO sequence",
649        ));
650    }
651    Ok(())
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use std::fs;
658
659    #[test]
660    fn discovery_reads_entry_points_first_and_only_org_mentions() {
661        let temp = tempfile::tempdir().unwrap();
662        let root = temp.path();
663        fs::write(root.join("config.el"), "(setq org-log-done 'time)\n").unwrap();
664        fs::write(root.join("init.el"), "(setq org-directory \"~/org\")\n").unwrap();
665        fs::write(root.join("keys.el"), "(map! :n \"x\" #'ignore)\n").unwrap();
666        fs::create_dir_all(root.join(".local/straight")).unwrap();
667        fs::write(root.join(".local/straight/org.el"), "(setq org-x 1)\n").unwrap();
668        let sources = find_config_sources(&[root.to_path_buf()]).unwrap();
669        assert_eq!(sources.editor, ConfigEditor::Emacs);
670        assert_eq!(
671            sources.files,
672            vec![root.join("init.el"), root.join("config.el")]
673        );
674
675        fs::write(root.join("init.lua"), "require('orgmode').setup({})\n").unwrap();
676        let error = find_config_sources(&[root.to_path_buf()]).unwrap_err();
677        assert!(
678            error.to_string().contains("both Emacs and Neovim"),
679            "{error}"
680        );
681    }
682
683    #[test]
684    fn first_import_derives_roots_and_relative_patterns() {
685        let temp = tempfile::tempdir().unwrap();
686        let org = temp.path().join("org");
687        fs::create_dir_all(org.join("projects")).unwrap();
688        let entries = [
689            org.join("projects"),
690            org.join("**/*.org"),
691            org.join("inbox.org"),
692        ];
693        let roots = derived_roots(None, &entries);
694        assert_eq!(roots, vec![org.to_string_lossy().into_owned()]);
695        let patterns: Vec<Option<String>> = entries
696            .iter()
697            .map(|entry| agenda_pattern(entry, &roots))
698            .collect();
699        assert_eq!(
700            patterns,
701            vec![
702                Some("projects/*.org".to_string()),
703                Some("**/*.org".to_string()),
704                Some("inbox.org".to_string())
705            ]
706        );
707        assert_eq!(
708            agenda_pattern(&temp.path().join("elsewhere.org"), &roots),
709            None
710        );
711    }
712
713    #[test]
714    fn reimport_keeps_roots_and_agd_only_keys_and_drops_outside_entries() {
715        let temp = tempfile::tempdir().unwrap();
716        let base = Settings {
717            roots: vec![temp.path().to_string_lossy().into_owned()],
718            theme: "dark".to_string(),
719            ..Settings::default()
720        };
721        let resolved = resolved_with(serde_json::json!({
722            "org_directory": "/elsewhere",
723            "agenda_files": [temp.path().join("inbox.org"), "/elsewhere/private.org"],
724            "sorting_strategy": ["time-up"], "log_done": "time",
725        }));
726        let result = apply_import(&resolved, &base, false).unwrap();
727        assert_eq!(result.settings.roots, base.roots);
728        assert_eq!(result.settings.theme, "dark");
729        assert_eq!(result.settings.agenda_files, vec!["inbox.org"]);
730        assert!(
731            result
732                .diagnostics
733                .iter()
734                .any(|diagnostic| diagnostic.key == "/elsewhere/private.org")
735        );
736        assert_eq!(result.settings.captures, base.captures);
737    }
738
739    /// A reader payload with Org's defaults, `fields` replacing its keys.
740    fn resolved_with(fields: serde_json::Value) -> ResolvedEditorConfig {
741        let mut payload = serde_json::json!({
742            "editor": "emacs",
743            "agenda_files": [],
744            "todo_sequences": [["TODO", "|", "DONE"]],
745            "tags_column": -77,
746            "tag_inheritance_exclusions": [],
747            "highest_priority": "A", "lowest_priority": "C", "default_priority": "B",
748            "deadline_warning_days": 14, "agenda_span": "week", "agenda_start_day": null,
749            "week_start": "monday", "skip_scheduled_if_done": false,
750            "skip_deadline_if_done": false, "skip_timestamp_if_done": false,
751            "sorting_strategy": [], "log_done": "nil", "log_repeat": "time",
752            "log_reschedule": "nil", "log_redeadline": "nil", "log_into_drawer": false
753        });
754        for (key, value) in fields.as_object().unwrap() {
755            payload[key] = value.clone();
756        }
757        serde_json::from_value(payload).unwrap()
758    }
759
760    fn diagnostic<'a>(result: &'a ImportResult, key: &str) -> Option<&'a str> {
761        result
762            .diagnostics
763            .iter()
764            .find(|diagnostic| diagnostic.key == key)
765            .map(|diagnostic| diagnostic.message.as_str())
766    }
767
768    #[test]
769    fn emacs_log_values_import_as_off_time_or_note() {
770        let resolved = resolved_with(serde_json::json!({
771            "log_done": "nil", "log_repeat": "t", "log_reschedule": "note", "log_redeadline": "yes"
772        }));
773        let result = apply_import(&resolved, &Settings::default(), false).unwrap();
774        let settings = &result.settings;
775        assert_eq!(
776            [
777                settings.log_done,
778                settings.log_repeat,
779                settings.log_reschedule,
780                settings.log_redeadline
781            ],
782            [LogMode::Off, LogMode::Time, LogMode::Note, LogMode::Off]
783        );
784        assert_eq!(
785            diagnostic(&result, "org-log-redeadline"),
786            Some("yes is not nil, time or note; left out")
787        );
788        let rendered = crate::render_config(settings, "logs").unwrap();
789        assert!(rendered.contains("log_done = \"off\"\n"), "{rendered}");
790        assert!(!rendered.contains("nil"), "{rendered}");
791    }
792
793    #[test]
794    fn a_config_that_would_not_load_is_refused() {
795        let resolved = resolved_with(serde_json::json!({
796            "highest_priority": "1", "default_priority": "3", "lowest_priority": "5"
797        }));
798        let error = apply_import(&resolved, &Settings::default(), false).unwrap_err();
799        assert_eq!(
800            error.to_string(),
801            "the imported settings would not load: highest_priority is \"1\": numeric priorities are not supported; use letters A–Z"
802        );
803
804        let resolved = resolved_with(serde_json::json!({ "deadline_warning_days": -1 }));
805        let error = apply_import(&resolved, &Settings::default(), false).unwrap_err();
806        assert_eq!(error.code().as_str(), "invalid_import");
807        assert_eq!(
808            error.to_string(),
809            "the imported settings would not load: deadline_warning_days must be 0 or more, not -1"
810        );
811    }
812
813    #[test]
814    fn import_root_is_an_absolute_existing_directory() {
815        let temp = tempfile::tempdir().unwrap();
816        assert_eq!(
817            import_root(temp.path()).unwrap(),
818            temp.path().to_string_lossy()
819        );
820        let missing = temp.path().join("missing");
821        let error = import_root(&missing).unwrap_err();
822        assert_eq!(error.code().as_str(), "import_failed");
823        assert_eq!(
824            error.to_string(),
825            format!("root {} is not a directory", missing.display())
826        );
827    }
828
829    #[test]
830    fn capture_files_are_written_relative_to_their_root() {
831        let temp = tempfile::tempdir().unwrap();
832        let base = Settings {
833            roots: vec![temp.path().to_string_lossy().into_owned()],
834            ..Settings::default()
835        };
836        let capture = |key: &str, file: std::path::PathBuf| {
837            serde_json::json!({
838                "key": key, "desc": key, "file": file, "template": "* %?", "enabled": true
839            })
840        };
841        let resolved = resolved_with(serde_json::json!({
842            "capture": [
843                capture("t", temp.path().join("notes/inbox.org")),
844                capture("x", std::path::PathBuf::from("/elsewhere/inbox.org")),
845            ]
846        }));
847        let result = apply_import(&resolved, &base, false).unwrap();
848        let files: Vec<(&str, Option<&str>)> = result
849            .settings
850            .captures
851            .iter()
852            .map(|template| (template.key.as_str(), template.file.as_deref()))
853            .collect();
854        assert_eq!(files, [("t", Some("notes/inbox.org"))]);
855        assert_eq!(
856            diagnostic(&result, "capture x"),
857            Some("capture template left out: /elsewhere/inbox.org is outside the roots")
858        );
859    }
860
861    #[test]
862    fn emacs_spans_import_as_names_or_days() {
863        let span = |value: &str| {
864            let resolved = resolved_with(serde_json::json!({ "agenda_span": value }));
865            apply_import(&resolved, &Settings::default(), false).unwrap()
866        };
867        assert_eq!(span("10").settings.agenda_span, AgendaSpan::Days(10));
868        assert_eq!(span("fortnight").settings.agenda_span, AgendaSpan::Days(14));
869        assert_eq!(span("day").settings.agenda_span, AgendaSpan::Day);
870        let year = span("year");
871        assert_eq!(year.settings.agenda_span, AgendaSpan::Week);
872        assert_eq!(
873            diagnostic(&year, "org-agenda-span"),
874            Some("year is not supported; agenda_span stays week")
875        );
876    }
877}