Skip to main content

org_core/
settings.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::AgdError;
8
9/// Everything agd reads from `config.toml`, one flat table: every setting is
10/// a top-level key, and only capture templates, views and agent profiles are
11/// lists of records (`[[capture]]`, `[[view]]`, `[[agent_profile]]`).
12/// Missing keys take the defaults below; unknown keys are errors.
13#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
14#[serde(default, deny_unknown_fields)]
15pub struct Settings {
16    /// The only directories agd reads or writes.
17    pub roots: Vec<String>,
18    /// org-agenda-files: files, directories (their `*.org` files) or globs.
19    /// Empty means every `.org` file under the roots.
20    pub agenda_files: Vec<String>,
21
22    /// org-todo-keywords: one list per sequence, with exactly one "|".
23    pub todo_keywords: Vec<Vec<String>>,
24    /// "preserve" leaves headings alone; "add-on-write" adds an org-id :ID:
25    /// on a heading's first edit.
26    pub ids: IdPolicy,
27    pub tag_alist: Vec<String>,
28    pub persistent_tags: Vec<String>,
29    /// org-tags-column: negative right-aligns tags to end at that column,
30    /// positive starts them there, as Emacs does when it rewrites a headline.
31    pub tags_column: i64,
32    pub tag_inheritance_exclusions: Vec<String>,
33    pub highest_priority: String,
34    pub lowest_priority: String,
35    pub default_priority: String,
36
37    /// day, week, month or a number of days.
38    pub agenda_span: String,
39    pub agenda_start_day: Option<String>,
40    /// monday or sunday.
41    pub week_start: String,
42    pub deadline_warning_days: i64,
43    pub skip_scheduled_if_done: bool,
44    pub skip_deadline_if_done: bool,
45    pub skip_timestamp_if_done: bool,
46    /// org-agenda-sorting-strategy for agenda days, from SORTING_KEYS.
47    pub sorting_strategy: Vec<String>,
48
49    /// org-log-done, org-log-repeat, org-log-reschedule, org-log-redeadline:
50    /// nil, time or note. `#+STARTUP` options win per file.
51    pub log_done: String,
52    pub log_repeat: String,
53    pub log_reschedule: String,
54    pub log_redeadline: String,
55    pub log_into_drawer: bool,
56
57    /// Program and arguments to open a file; `{file}`, `{line}` and `{col}`
58    /// are substituted. Run directly, never through a shell.
59    pub editor: Vec<String>,
60    /// auto (follow the terminal background), dark or light.
61    pub theme: String,
62    /// "template" captures through the agent's chosen template; "inbox" sends
63    /// every agent capture through `agent_inbox_template` without dates.
64    pub agent_capture_to: String,
65    pub agent_inbox_template: String,
66    pub someday_tag: String,
67    /// Someday also removes SCHEDULED, so the task leaves the agenda.
68    pub someday_clears_scheduled: bool,
69    /// Triage suggests Someday after this many reschedules.
70    pub reschedule_nudge: usize,
71
72    #[serde(rename = "capture")]
73    pub captures: Vec<CaptureTemplate>,
74    #[serde(rename = "view")]
75    pub views: Vec<ViewSettings>,
76    #[serde(rename = "agent_profile")]
77    pub agent_profiles: Vec<AgentProfile>,
78}
79
80/// A saved agenda view, the declarative part of an org-agenda custom
81/// command: one block, or several shown one after another like a block
82/// agenda.
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84#[serde(deny_unknown_fields)]
85pub struct ViewSettings {
86    pub key: String,
87    pub desc: String,
88    pub blocks: Vec<ViewBlock>,
89}
90
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92#[serde(deny_unknown_fields)]
93pub struct ViewBlock {
94    /// agenda, todo, alltodo, tags or tags-todo, as in org-agenda.
95    pub kind: String,
96    /// Tags/TODO match; for `todo` blocks, TODO keywords such as `NEXT|WAIT`.
97    #[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
98    pub match_expression: Option<String>,
99    /// For agenda blocks: day, week, month or a number of days.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub span: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub header: Option<String>,
104}
105
106#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
107#[serde(deny_unknown_fields)]
108pub struct CaptureTemplate {
109    pub key: String,
110    pub desc: String,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub file: Option<String>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub heading: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub outline_path: Option<Vec<String>>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub id: Option<String>,
119    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120    pub datetree: bool,
121    pub template: String,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub template_file: Option<String>,
124}
125
126/// A named, filtered agenda an agent may read at session start.
127#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct AgentProfile {
130    pub name: String,
131    #[serde(default)]
132    pub files: Vec<String>,
133    #[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
134    pub match_expression: Option<String>,
135}
136
137#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum IdPolicy {
140    #[default]
141    Preserve,
142    AddOnWrite,
143}
144
145/// Agenda sorting keys agd implements; habit-* and category-keep keep
146/// collection order.
147pub const SORTING_KEYS: [&str; 15] = [
148    "time-up",
149    "time-down",
150    "urgency-up",
151    "urgency-down",
152    "priority-up",
153    "priority-down",
154    "category-keep",
155    "category-up",
156    "category-down",
157    "alpha-up",
158    "alpha-down",
159    "todo-state-up",
160    "todo-state-down",
161    "habit-up",
162    "habit-down",
163];
164
165pub const VIEW_BLOCK_KINDS: [&str; 5] = ["agenda", "todo", "alltodo", "tags", "tags-todo"];
166
167#[derive(Clone, Debug)]
168pub struct LoadedSettings {
169    pub settings: Settings,
170    pub config_path: PathBuf,
171}
172
173impl Default for Settings {
174    fn default() -> Self {
175        Self {
176            roots: vec!["~/org".to_string()],
177            agenda_files: Vec::new(),
178            todo_keywords: vec![strings(&["TODO", "NEXT", "WAIT", "|", "DONE", "CANCELLED"])],
179            ids: IdPolicy::Preserve,
180            tag_alist: Vec::new(),
181            persistent_tags: Vec::new(),
182            tags_column: -77,
183            tag_inheritance_exclusions: Vec::new(),
184            highest_priority: "A".to_string(),
185            lowest_priority: "C".to_string(),
186            default_priority: "B".to_string(),
187            agenda_span: "week".to_string(),
188            agenda_start_day: None,
189            week_start: "monday".to_string(),
190            deadline_warning_days: 14,
191            skip_scheduled_if_done: false,
192            skip_deadline_if_done: false,
193            skip_timestamp_if_done: false,
194            // Emacs's default for agenda days (Org 9.7+ names).
195            sorting_strategy: strings(&["habit-down", "time-up", "urgency-down", "category-keep"]),
196            log_done: "time".to_string(),
197            log_repeat: "time".to_string(),
198            log_reschedule: "nil".to_string(),
199            log_redeadline: "nil".to_string(),
200            log_into_drawer: false,
201            editor: vec![
202                std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()),
203                "+{line}".to_string(),
204                "{file}".to_string(),
205            ],
206            theme: "auto".to_string(),
207            agent_capture_to: "template".to_string(),
208            agent_inbox_template: "t".to_string(),
209            someday_tag: "someday".to_string(),
210            someday_clears_scheduled: true,
211            reschedule_nudge: 3,
212            captures: vec![CaptureTemplate::default()],
213            // Org's built-in `n` command: "Agenda and all TODOs".
214            views: vec![ViewSettings {
215                key: "n".to_string(),
216                desc: "Agenda and all TODOs".to_string(),
217                blocks: vec![
218                    ViewBlock {
219                        kind: "agenda".to_string(),
220                        match_expression: None,
221                        span: None,
222                        header: None,
223                    },
224                    ViewBlock {
225                        kind: "alltodo".to_string(),
226                        match_expression: None,
227                        span: None,
228                        header: None,
229                    },
230                ],
231            }],
232            agent_profiles: Vec::new(),
233        }
234    }
235}
236
237/// The default `t` template: a TODO in inbox.org under Inbox, stamped with
238/// its creation time.
239impl Default for CaptureTemplate {
240    fn default() -> Self {
241        Self {
242            key: "t".to_string(),
243            desc: "Todo".to_string(),
244            file: Some("inbox.org".to_string()),
245            heading: Some("Inbox".to_string()),
246            outline_path: None,
247            id: None,
248            datetree: false,
249            template: "* TODO %?\n:PROPERTIES:\n:CREATED: %U\n:END:".to_string(),
250            template_file: None,
251        }
252    }
253}
254
255fn strings(values: &[&str]) -> Vec<String> {
256    values.iter().map(|value| value.to_string()).collect()
257}
258
259impl Settings {
260    pub fn capture_template(&self, key: &str) -> Option<&CaptureTemplate> {
261        self.captures.iter().find(|template| template.key == key)
262    }
263
264    pub fn view(&self, key: &str) -> Option<&ViewSettings> {
265        self.views.iter().find(|view| view.key == key)
266    }
267
268    pub fn agent_profile(&self, name: &str) -> Option<&AgentProfile> {
269        self.agent_profiles
270            .iter()
271            .find(|profile| profile.name == name)
272    }
273
274    /// The editor program and its arguments with `{file}`, `{line}` and
275    /// `{col}` (both 1-based) substituted inside each argument. The result
276    /// goes straight to the process API, never to a shell, so file names
277    /// with spaces or quotes stay one argument.
278    pub fn editor_command(&self, file: &str, line: usize, col: usize) -> (String, Vec<String>) {
279        let mut parts = self.editor.iter().map(|argument| {
280            argument
281                .replace("{file}", file)
282                .replace("{line}", &line.to_string())
283                .replace("{col}", &col.to_string())
284        });
285        let program = parts.next().unwrap_or_else(|| "vi".to_string());
286        (program, parts.collect())
287    }
288}
289
290pub fn default_config_path() -> Result<PathBuf, AgdError> {
291    Ok(crate::paths::config_dir()?.join("config.toml"))
292}
293
294pub fn load_settings(
295    path: Option<&Path>,
296    root_override: Option<&Path>,
297) -> Result<LoadedSettings, AgdError> {
298    let config_path = path
299        .map(Path::to_path_buf)
300        .unwrap_or(default_config_path()?);
301    let mut settings = if config_path.exists() {
302        parse_config_file(&config_path)?
303    } else if path.is_some() {
304        return Err(config_error(format!(
305            "configuration file does not exist: {}",
306            config_path.display()
307        )));
308    } else {
309        Settings::default()
310    };
311    if let Some(root) = root_override {
312        settings.roots = vec![root.to_string_lossy().into_owned()];
313    }
314    validate_settings(&settings)?;
315    Ok(LoadedSettings {
316        settings,
317        config_path,
318    })
319}
320
321/// Parses a config file without validating roots, so `agd setup import` can
322/// read one whose roots do not exist yet.
323pub fn parse_config_file(path: &Path) -> Result<Settings, AgdError> {
324    let source = fs::read_to_string(path).map_err(|error| {
325        AgdError::with_source(
326            "io",
327            format!("cannot read {}: {error}", path.display()),
328            error,
329        )
330    })?;
331    toml::from_str(&source)
332        .map_err(|error| config_error(format!("cannot parse {}: {error}", path.display())))
333}
334
335fn config_error(message: impl Into<String>) -> AgdError {
336    AgdError::new("config", message)
337}
338
339/// Top-level keys in the order `render_config` writes them, under these
340/// comments.
341const CONFIG_GROUPS: [(&str, &[&str]); 6] = [
342    ("Files agd may read and write", &["roots", "agenda_files"]),
343    (
344        "Org syntax, as in your editor",
345        &[
346            "todo_keywords",
347            "ids",
348            "tag_alist",
349            "persistent_tags",
350            "tags_column",
351            "tag_inheritance_exclusions",
352            "highest_priority",
353            "lowest_priority",
354            "default_priority",
355        ],
356    ),
357    (
358        "Agenda",
359        &[
360            "agenda_span",
361            "agenda_start_day",
362            "week_start",
363            "deadline_warning_days",
364            "skip_scheduled_if_done",
365            "skip_deadline_if_done",
366            "skip_timestamp_if_done",
367            "sorting_strategy",
368        ],
369    ),
370    (
371        "Logging; #+STARTUP options in a file win",
372        &[
373            "log_done",
374            "log_repeat",
375            "log_reschedule",
376            "log_redeadline",
377            "log_into_drawer",
378        ],
379    ),
380    ("Editor and terminal", &["editor", "theme"]),
381    (
382        "Agents and triage",
383        &[
384            "agent_capture_to",
385            "agent_inbox_template",
386            "someday_tag",
387            "someday_clears_scheduled",
388            "reschedule_nudge",
389        ],
390    ),
391];
392
393/// Writes `settings` as a config file: grouped, commented top-level keys,
394/// then `[[capture]]`, `[[view]]` and `[[agent_profile]]` records. Values
395/// equal to the defaults are left out, so the file shows only what differs.
396pub fn render_config(settings: &Settings, header: &str) -> Result<String, AgdError> {
397    let encode = |value: &Settings| {
398        toml::Value::try_from(value)
399            .map_err(|error| config_error(format!("cannot encode settings: {error}")))
400    };
401    let current = encode(settings)?;
402    let defaults = encode(&Settings::default())?;
403    let (Some(current), Some(defaults)) = (current.as_table(), defaults.as_table()) else {
404        return Err(config_error("settings did not encode as a table"));
405    };
406    let mut output = String::new();
407    for line in header.lines() {
408        output.push_str(&format!("# {line}\n").replace("# \n", "#\n"));
409    }
410    for (comment, keys) in CONFIG_GROUPS {
411        let lines: Vec<String> = keys
412            .iter()
413            .filter_map(|key| {
414                let value = current.get(*key)?;
415                (defaults.get(*key) != Some(value)).then(|| render_key(key, value))
416            })
417            .collect();
418        if !lines.is_empty() {
419            output.push_str(&format!("\n# {comment}\n"));
420            output.extend(lines);
421        }
422    }
423    // Records are encoded from the structs, not the sorted table above, so
424    // each keeps its declared field order with `key` first.
425    let defaults = Settings::default();
426    let records = Records {
427        capture: (settings.captures != defaults.captures).then_some(&settings.captures),
428        agent_profile: (!settings.agent_profiles.is_empty()).then_some(&settings.agent_profiles),
429    };
430    let records = toml::to_string_pretty(&records)
431        .map_err(|error| config_error(format!("cannot encode records: {error}")))?;
432    if !records.is_empty() {
433        output.push('\n');
434        output.push_str(&records);
435    }
436    if settings.views != defaults.views {
437        for view in &settings.views {
438            output.push_str(&render_view(view));
439        }
440    }
441    Ok(output)
442}
443
444/// A `[[view]]` record with its blocks as one inline table per line, so the
445/// file has no nested `[[view.blocks]]` tables.
446fn render_view(view: &ViewSettings) -> String {
447    let quote = |value: &str| toml::Value::String(value.to_string()).to_string();
448    let blocks: String = view
449        .blocks
450        .iter()
451        .map(|block| {
452            let mut fields = vec![format!("kind = {}", quote(&block.kind))];
453            for (name, value) in [
454                ("match", &block.match_expression),
455                ("span", &block.span),
456                ("header", &block.header),
457            ] {
458                if let Some(value) = value {
459                    fields.push(format!("{name} = {}", quote(value)));
460                }
461            }
462            format!("  {{ {} }},\n", fields.join(", "))
463        })
464        .collect();
465    format!(
466        "\n[[view]]\nkey = {}\ndesc = {}\nblocks = [\n{blocks}]\n",
467        quote(&view.key),
468        quote(&view.desc)
469    )
470}
471
472#[derive(Serialize)]
473struct Records<'a> {
474    #[serde(skip_serializing_if = "Option::is_none")]
475    capture: Option<&'a Vec<CaptureTemplate>>,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    agent_profile: Option<&'a Vec<AgentProfile>>,
478}
479
480/// `key = value`, with a list that does not fit in 80 columns written one
481/// item per line.
482fn render_key(key: &str, value: &toml::Value) -> String {
483    let line = format!("{key} = {value}\n");
484    match value.as_array() {
485        Some(items) if line.len() > 80 && !items.is_empty() => {
486            let items: String = items.iter().map(|item| format!("  {item},\n")).collect();
487            format!("{key} = [\n{items}]\n")
488        }
489        _ => line,
490    }
491}
492
493fn validate_settings(settings: &Settings) -> Result<(), AgdError> {
494    if settings.roots.is_empty() {
495        return Err(config_error("roots must contain at least one directory"));
496    }
497    let mut roots = BTreeSet::new();
498    for root in &settings.roots {
499        let expanded = shellexpand::tilde(root).into_owned();
500        let canonical = PathBuf::from(&expanded)
501            .canonicalize()
502            .map_err(|error| config_error(format!("cannot resolve root '{root}': {error}")))?;
503        if !canonical.is_dir() {
504            return Err(config_error(format!("root is not a directory: {root}")));
505        }
506        if !roots.insert(canonical) {
507            return Err(config_error(format!("duplicate root: {root}")));
508        }
509    }
510    for sequence in &settings.todo_keywords {
511        if sequence.iter().filter(|word| word.as_str() == "|").count() != 1 {
512            return Err(config_error(
513                "every todo_keywords sequence must contain exactly one '|' separator",
514            ));
515        }
516    }
517    if settings.editor.is_empty() {
518        return Err(config_error("editor needs at least the program to run"));
519    }
520    match settings.agent_capture_to.as_str() {
521        "template" => {}
522        "inbox" => {
523            if settings
524                .capture_template(&settings.agent_inbox_template)
525                .is_none()
526            {
527                return Err(config_error(format!(
528                    "agent_capture_to = \"inbox\" needs capture template '{}' (agent_inbox_template)",
529                    settings.agent_inbox_template
530                )));
531            }
532        }
533        other => {
534            return Err(config_error(format!(
535                "agent_capture_to must be \"template\" or \"inbox\", not \"{other}\""
536            )));
537        }
538    }
539    if !["auto", "dark", "light"].contains(&settings.theme.as_str()) {
540        return Err(config_error(format!(
541            "theme must be auto, dark or light, not '{}'",
542            settings.theme
543        )));
544    }
545    for key in &settings.sorting_strategy {
546        if !SORTING_KEYS.contains(&key.as_str()) {
547            return Err(config_error(format!(
548                "sorting_strategy key '{key}' is not supported; use {}",
549                SORTING_KEYS.join(", ")
550            )));
551        }
552    }
553    let mut view_keys = BTreeSet::new();
554    for view in &settings.views {
555        if !view_keys.insert(view.key.as_str()) {
556            return Err(config_error(format!("duplicate view key '{}'", view.key)));
557        }
558        if view.blocks.is_empty() {
559            return Err(config_error(format!(
560                "view '{}' needs at least one block",
561                view.key
562            )));
563        }
564        for block in &view.blocks {
565            if !VIEW_BLOCK_KINDS.contains(&block.kind.as_str()) {
566                return Err(config_error(format!(
567                    "view '{}' has unsupported block kind '{}'; use one of {}",
568                    view.key,
569                    block.kind,
570                    VIEW_BLOCK_KINDS.join(", ")
571                )));
572            }
573            crate::agenda_engine::validate_view_block(block)
574                .map_err(|error| config_error(format!("view '{}': {error}", view.key)))?;
575        }
576    }
577    let mut capture_keys = BTreeSet::new();
578    for template in &settings.captures {
579        let key = &template.key;
580        if key.is_empty() || key.chars().any(char::is_whitespace) {
581            return Err(config_error(format!(
582                "invalid capture template key '{key}'"
583            )));
584        }
585        if !capture_keys.insert(key.as_str()) {
586            return Err(config_error(format!(
587                "duplicate capture template key '{key}'"
588            )));
589        }
590        if template.file.is_none() && template.id.is_none() {
591            return Err(config_error(format!(
592                "capture template '{key}' needs a file or ID target"
593            )));
594        }
595    }
596    let mut profile_names = BTreeSet::new();
597    for profile in &settings.agent_profiles {
598        if !profile_names.insert(profile.name.as_str()) {
599            return Err(config_error(format!(
600                "duplicate agent_profile name '{}'",
601                profile.name
602            )));
603        }
604    }
605    Ok(())
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn editor_command_substitutes_every_placeholder_per_argument() {
614        let settings = Settings {
615            editor: strings(&["emacsclient", "-n", "+{line}:{col}", "{file}"]),
616            ..Settings::default()
617        };
618        assert_eq!(
619            settings.editor_command("/org/my tasks.org", 42, 1),
620            (
621                "emacsclient".to_string(),
622                strings(&["-n", "+42:1", "/org/my tasks.org"])
623            )
624        );
625    }
626
627    #[test]
628    fn flat_keys_and_records_load_and_unknown_keys_are_errors() {
629        let temp = tempfile::tempdir().unwrap();
630        let config = temp.path().join("config.toml");
631        fs::write(
632            &config,
633            format!(
634                concat!(
635                    "roots = [{root}]\n",
636                    "deadline_warning_days = 9\n",
637                    "todo_keywords = [[\"TODO\", \"|\", \"DONE\"]]\n",
638                    "[[capture]]\n",
639                    "key = \"n\"\n",
640                    "desc = \"Note\"\n",
641                    "file = \"notes.org\"\n",
642                    "template = \"* %?\"\n",
643                    "[[view]]\n",
644                    "key = \"w\"\n",
645                    "desc = \"Work\"\n",
646                    "blocks = [{{ kind = \"tags-todo\", match = \"work\" }}]\n",
647                ),
648                root = toml::Value::String(temp.path().to_string_lossy().into_owned())
649            ),
650        )
651        .unwrap();
652        let loaded = load_settings(Some(&config), None).unwrap();
653        assert_eq!(loaded.settings.deadline_warning_days, 9);
654        assert_eq!(loaded.settings.captures.len(), 1);
655        assert_eq!(loaded.settings.capture_template("n").unwrap().desc, "Note");
656        assert_eq!(
657            loaded.settings.view("w").unwrap().blocks[0].kind,
658            "tags-todo"
659        );
660
661        fs::write(
662            &config,
663            format!(
664                "roots = [{}]\n[agenda]\nspan = \"day\"\n",
665                toml::Value::String(temp.path().to_string_lossy().into_owned())
666            ),
667        )
668        .unwrap();
669        let error = load_settings(Some(&config), None).unwrap_err();
670        assert!(error.to_string().contains("agenda"), "{error}");
671    }
672
673    #[test]
674    fn rendered_config_round_trips_and_omits_defaults() {
675        let settings = Settings {
676            roots: strings(&["~/notes"]),
677            deadline_warning_days: 5,
678            captures: vec![CaptureTemplate {
679                key: "j".to_string(),
680                desc: "Journal".to_string(),
681                file: Some("journal.org".to_string()),
682                heading: None,
683                outline_path: None,
684                id: None,
685                datetree: true,
686                template: "* %?".to_string(),
687                template_file: None,
688            }],
689            ..Settings::default()
690        };
691        let rendered = render_config(&settings, "written by a test").unwrap();
692        assert!(rendered.starts_with("# written by a test\n"), "{rendered}");
693        assert!(rendered.contains("deadline_warning_days = 5"), "{rendered}");
694        assert!(!rendered.contains("agenda_span"), "{rendered}");
695        assert!(!rendered.contains("[[view]]"), "{rendered}");
696        let parsed: Settings = toml::from_str(&rendered).unwrap();
697        assert_eq!(parsed, settings);
698    }
699
700    #[test]
701    fn explicit_missing_config_file_is_an_error() {
702        let temp = tempfile::tempdir().unwrap();
703        let error = load_settings(Some(&temp.path().join("missing.toml")), None).unwrap_err();
704        assert_eq!(error.code(), "config");
705    }
706
707    #[test]
708    fn cli_root_override_has_highest_precedence() {
709        let temp = tempfile::tempdir().unwrap();
710        let configured = temp.path().join("configured");
711        let overridden = temp.path().join("overridden");
712        fs::create_dir(&configured).unwrap();
713        fs::create_dir(&overridden).unwrap();
714        let config = temp.path().join("config.toml");
715        fs::write(
716            &config,
717            format!(
718                "roots = [{}]\n",
719                toml::Value::String(configured.to_string_lossy().into_owned())
720            ),
721        )
722        .unwrap();
723
724        let loaded = load_settings(Some(&config), Some(&overridden)).unwrap();
725        assert_eq!(loaded.settings.roots, vec![overridden.to_string_lossy()]);
726    }
727}