1use 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#[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#[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 #[serde(default)]
78 pub org_directory: Option<String>,
79 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 #[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#[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;
140const 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];
155const 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
166pub 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
273fn 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#[derive(Clone, Debug)]
296pub struct ImportResult {
297 pub settings: Settings,
298 pub diagnostics: Vec<ImportDiagnostic>,
299}
300
301pub 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 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 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 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
465fn 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
486fn 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
506fn 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
519pub 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
540fn 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
561fn 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
570fn 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
620fn 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
630fn 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 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}