Skip to main content

agd_core/config/
file.rs

1//! `config.toml` on disk: where it lives, reading it into `Settings`
2//! (`load_settings`, `parse_config_file`), explaining TOML errors such as a
3//! top-level key written below a record, reading each `template_file`, and
4//! writing settings back as a commented file (`render_config`).
5//!
6//! Loading validates before it returns, so a caller never holds settings that
7//! fail validation. Rendering leaves out values equal to the defaults, so the
8//! file shows only what differs. `CONFIG_GROUPS` is the one list of top-level
9//! keys, shared by both directions.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use serde::Serialize;
15
16use crate::{AgdError, AgendaSpan, ErrorCode};
17
18use super::model::{AgentProfile, CaptureTemplate, Settings, ViewGroup, ViewSettings};
19use super::validation::{validate_roots, validate_settings};
20
21pub fn default_config_path() -> Result<PathBuf, AgdError> {
22    Ok(crate::path::config_dir()?.join("config.toml"))
23}
24
25pub fn load_settings(
26    path: Option<&Path>,
27    root_override: Option<&Path>,
28) -> Result<Settings, AgdError> {
29    let config_path = match path {
30        Some(path) => path.to_path_buf(),
31        None => default_config_path()?,
32    };
33    let mut settings = if config_path.exists() {
34        parse_config_file(&config_path)?
35    } else if path.is_some() {
36        return Err(config_error(format!(
37            "configuration file does not exist: {}",
38            config_path.display()
39        )));
40    } else {
41        Settings::default()
42    };
43    if let Some(root) = root_override {
44        settings.roots = vec![root.to_string_lossy().into_owned()];
45    } else if !config_path.exists() && validate_roots(&settings.roots).is_err() {
46        return Err(first_run_error(&config_path, &settings.roots));
47    }
48    validate_settings(&settings)?;
49    read_template_files(&mut settings.captures, &settings.roots)?;
50    Ok(settings)
51}
52
53/// With neither a config file nor the default root there is nothing to
54/// read, so the error names both and the ways to start.
55fn first_run_error(config_path: &Path, roots: &[String]) -> AgdError {
56    let root = roots.join(", ");
57    config_error(format!(
58        "no config at {} and no {root} to read. Start with one of:\n  mkdir {root}    agd then reads it with its defaults\n  agd setup import <your Emacs or Neovim config directory>\n  agd -r DIR    read another directory for this run",
59        config_path.display()
60    ))
61}
62
63/// Parses a config file without validating roots, so `agd setup import` can
64/// read one whose roots do not exist yet.
65pub fn parse_config_file(path: &Path) -> Result<Settings, AgdError> {
66    let source = fs::read_to_string(path).map_err(|error| {
67        AgdError::with_source(
68            ErrorCode::IoFailed,
69            format!("cannot read {}: {error}", path.display()),
70            error,
71        )
72    })?;
73    toml::from_str(&source).map_err(|error| {
74        config_error(format!(
75            "cannot parse {}: {}",
76            path.display(),
77            explain_parse_error(&error)
78        ))
79    })
80}
81
82/// The TOML error with its location. TOML puts every key below a
83/// `[[capture]]`, `[[view]]` or `[[agent_profile]]` line into that record,
84/// so a top-level setting written there is explained instead of reported as
85/// an unknown record field.
86fn explain_parse_error(error: &toml::de::Error) -> String {
87    let text = error.to_string();
88    let field = error
89        .message()
90        .strip_prefix("unknown field `")
91        .and_then(|rest| rest.split('`').next());
92    match field {
93        Some(field) if is_top_level_key(field) => text.replace(
94            error.message(),
95            &format!(
96                "`{field}` is a top-level setting; move it above the first [[capture]], [[view]] or [[agent_profile]] record"
97            ),
98        ),
99        _ => text,
100    }
101}
102
103fn is_top_level_key(name: &str) -> bool {
104    CONFIG_GROUPS.iter().any(|(_, keys)| keys.contains(&name))
105}
106
107pub(super) fn config_error(message: impl Into<String>) -> AgdError {
108    AgdError::new(ErrorCode::InvalidConfig, message)
109}
110
111/// Reads each `template_file` into its template's `template`, so a capture
112/// reads no file but its target. Relative paths are under `roots[0]`.
113fn read_template_files(captures: &mut [CaptureTemplate], roots: &[String]) -> Result<(), AgdError> {
114    for template in captures {
115        let Some(file) = &template.template_file else {
116            continue;
117        };
118        let path = PathBuf::from(shellexpand::tilde(file).into_owned());
119        let path = match roots.first() {
120            Some(root) if path.is_relative() => {
121                PathBuf::from(shellexpand::tilde(root).into_owned()).join(path)
122            }
123            _ => path,
124        };
125        template.template = fs::read_to_string(&path).map_err(|error| {
126            AgdError::with_source(
127                ErrorCode::InvalidConfig,
128                format!(
129                    "capture template '{}': cannot read template_file {}: {error}",
130                    template.key,
131                    path.display()
132                ),
133                error,
134            )
135        })?;
136    }
137    Ok(())
138}
139
140/// Top-level keys in the order `render_config` writes them, under these
141/// comments.
142const CONFIG_GROUPS: [(&str, &[&str]); 6] = [
143    ("Files agd may read and write", &["roots", "agenda_files"]),
144    (
145        "Org syntax, as in your editor",
146        &[
147            "todo_keywords",
148            "todo_keyword_colors",
149            "ids",
150            "tags_column",
151            "tag_inheritance_exclusions",
152            "highest_priority",
153            "lowest_priority",
154            "default_priority",
155        ],
156    ),
157    (
158        "Agenda",
159        &[
160            "agenda_span",
161            "agenda_start_day",
162            "week_start",
163            "deadline_warning_days",
164            "skip_scheduled_if_done",
165            "skip_deadline_if_done",
166            "skip_timestamp_if_done",
167            "sorting_strategy",
168        ],
169    ),
170    (
171        "Logging; #+STARTUP options in a file win",
172        &[
173            "log_done",
174            "log_repeat",
175            "log_reschedule",
176            "log_redeadline",
177            "log_into_drawer",
178        ],
179    ),
180    (
181        "Editor and terminal",
182        &["editor", "theme", "nerd_font", "notation", "start_view"],
183    ),
184    (
185        "Agents and triage",
186        &[
187            "agent_inbox",
188            "someday_tag",
189            "someday_clears_dates",
190            "reschedule_nudge",
191        ],
192    ),
193];
194
195/// Writes `settings` as a config file: grouped, commented top-level keys,
196/// then `[[capture]]`, `[[view]]` and `[[agent_profile]]` records. Values
197/// equal to the defaults are left out, so the file shows only what differs.
198pub fn render_config(settings: &Settings, header: &str) -> Result<String, AgdError> {
199    let encode = |value: &Settings| {
200        toml::Value::try_from(value)
201            .map_err(|error| config_error(format!("cannot encode settings: {error}")))
202    };
203    let current = encode(settings)?;
204    let defaults = encode(&Settings::default())?;
205    let (Some(current), Some(defaults)) = (current.as_table(), defaults.as_table()) else {
206        return Err(config_error("settings did not encode as a table"));
207    };
208    let mut output = String::new();
209    for line in header.lines() {
210        output.push_str(&format!("# {line}\n").replace("# \n", "#\n"));
211    }
212    for (comment, keys) in CONFIG_GROUPS {
213        let lines: Vec<String> = keys
214            .iter()
215            .filter_map(|key| {
216                let value = current.get(*key)?;
217                (defaults.get(*key) != Some(value)).then(|| render_key(key, value))
218            })
219            .collect();
220        if !lines.is_empty() {
221            output.push_str(&format!("\n# {comment}\n"));
222            output.extend(lines);
223        }
224    }
225    // Records are encoded from the structs, not the sorted table above, so
226    // each keeps its declared field order with `key` first.
227    let defaults = Settings::default();
228    let records = Records {
229        capture: (settings.captures != defaults.captures).then_some(&settings.captures),
230        agent_profile: (!settings.agent_profiles.is_empty()).then_some(&settings.agent_profiles),
231    };
232    let records = toml::to_string_pretty(&records)
233        .map_err(|error| config_error(format!("cannot encode records: {error}")))?;
234    if !records.is_empty() {
235        output.push('\n');
236        output.push_str(&records);
237    }
238    if settings.views != defaults.views {
239        for view in &settings.views {
240            output.push_str(&render_view(view));
241        }
242    }
243    Ok(output)
244}
245
246/// A `[[view]]` record with its blocks as one inline table per line, so the
247/// file has no nested `[[view.blocks]]` tables.
248fn render_view(view: &ViewSettings) -> String {
249    let quote = |value: &str| toml::Value::String(value.to_string()).to_string();
250    let blocks: String = view
251        .blocks
252        .iter()
253        .map(|block| {
254            let mut fields = vec![format!("kind = {}", quote(&block.kind))];
255            if let Some(expression) = &block.match_expression {
256                fields.push(format!("match = {}", quote(expression)));
257            }
258            match block.span {
259                Some(AgendaSpan::Days(days)) => fields.push(format!("span = {days}")),
260                Some(span) => fields.push(format!("span = {}", quote(&span.to_string()))),
261                None => {}
262            }
263            for (name, value) in [("header", &block.header), ("color", &block.color)] {
264                if let Some(value) = value {
265                    fields.push(format!("{name} = {}", quote(value)));
266                }
267            }
268            if block.groups.is_empty() {
269                return format!("  {{ {} }},\n", fields.join(", "));
270            }
271            let groups: String = block
272                .groups
273                .iter()
274                .map(|group| format!("    {{ {} }},\n", render_group(group).join(", ")))
275                .collect();
276            format!("  {{ {}, groups = [\n{groups}  ] }},\n", fields.join(", "))
277        })
278        .collect();
279    format!(
280        "\n[[view]]\nkey = {}\ndesc = {}\nblocks = [\n{blocks}]\n",
281        quote(&view.key),
282        quote(&view.desc)
283    )
284}
285
286fn render_group(group: &ViewGroup) -> Vec<String> {
287    let quote = |value: &str| toml::Value::String(value.to_string()).to_string();
288    let mut fields = Vec::new();
289    for (name, value) in [
290        ("name", &group.name),
291        ("match", &group.match_expression),
292        ("by", &group.by),
293        ("effort_max", &group.effort_max),
294        ("effort_min", &group.effort_min),
295        ("color", &group.color),
296    ] {
297        if let Some(value) = value {
298            fields.push(format!("{name} = {}", quote(value)));
299        }
300    }
301    if group.discard {
302        fields.push("discard = true".to_string());
303    }
304    if group.order != 0 {
305        fields.push(format!("order = {}", group.order));
306    }
307    fields
308}
309
310#[derive(Serialize)]
311struct Records<'a> {
312    #[serde(skip_serializing_if = "Option::is_none")]
313    capture: Option<&'a Vec<CaptureTemplate>>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    agent_profile: Option<&'a Vec<AgentProfile>>,
316}
317
318/// `key = value`, with a list that does not fit in 80 columns written one
319/// item per line.
320fn render_key(key: &str, value: &toml::Value) -> String {
321    let line = format!("{key} = {value}\n");
322    match value.as_array() {
323        Some(items) if line.len() > 80 && !items.is_empty() => {
324            let items: String = items.iter().map(|item| format!("  {item},\n")).collect();
325            format!("{key} = [\n{items}]\n")
326        }
327        _ => line,
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use std::collections::BTreeMap;
334
335    use super::*;
336    use crate::ViewBlock;
337    use crate::config::model::strings;
338    use crate::config::test_support::{load_body, load_error};
339
340    #[test]
341    fn flat_keys_and_records_load_and_unknown_keys_are_errors() {
342        let temp = tempfile::tempdir().unwrap();
343        let config = temp.path().join("config.toml");
344        fs::write(
345            &config,
346            format!(
347                concat!(
348                    "roots = [{root}]\n",
349                    "deadline_warning_days = 9\n",
350                    "todo_keywords = [\"TODO | DONE\"]\n",
351                    "[[capture]]\n",
352                    "key = \"n\"\n",
353                    "desc = \"Note\"\n",
354                    "file = \"notes.org\"\n",
355                    "template = \"* %?\"\n",
356                    "[[view]]\n",
357                    "key = \"w\"\n",
358                    "desc = \"Work\"\n",
359                    "blocks = [{{ kind = \"tags-todo\", match = \"work\" }}]\n",
360                ),
361                root = toml::Value::String(temp.path().to_string_lossy().into_owned())
362            ),
363        )
364        .unwrap();
365        let settings = load_settings(Some(&config), None).unwrap();
366        assert_eq!(settings.deadline_warning_days, 9);
367        assert_eq!(settings.captures.len(), 1);
368        assert_eq!(settings.capture_template("n").unwrap().desc, "Note");
369        assert_eq!(settings.view("w").unwrap().blocks[0].kind, "tags-todo");
370
371        fs::write(
372            &config,
373            format!(
374                "roots = [{}]\n[agenda]\nspan = \"day\"\n",
375                toml::Value::String(temp.path().to_string_lossy().into_owned())
376            ),
377        )
378        .unwrap();
379        let error = load_settings(Some(&config), None).unwrap_err();
380        assert!(error.to_string().contains("agenda"), "{error}");
381    }
382
383    #[test]
384    fn rendered_config_round_trips_and_omits_defaults() {
385        let settings = Settings {
386            roots: strings(&["~/notes"]),
387            deadline_warning_days: 5,
388            todo_keyword_colors: BTreeMap::from([
389                ("NEXT".to_string(), "teal".to_string()),
390                ("WAIT".to_string(), "yellow".to_string()),
391            ]),
392            captures: vec![CaptureTemplate {
393                key: "j".to_string(),
394                desc: "Journal".to_string(),
395                file: Some("journal.org".to_string()),
396                heading: None,
397                olpath: None,
398                id: None,
399                datetree: true,
400                template: "* %?".to_string(),
401                template_file: None,
402            }],
403            ..Settings::default()
404        };
405        let rendered = render_config(&settings, "written by a test").unwrap();
406        assert!(rendered.starts_with("# written by a test\n"), "{rendered}");
407        assert!(rendered.contains("deadline_warning_days = 5"), "{rendered}");
408        assert!(
409            rendered.contains("todo_keyword_colors = { NEXT = \"teal\", WAIT = \"yellow\" }\n"),
410            "{rendered}"
411        );
412        assert!(!rendered.contains("agenda_span"), "{rendered}");
413        assert!(!rendered.contains("[[view]]"), "{rendered}");
414        let parsed: Settings = toml::from_str(&rendered).unwrap();
415        assert_eq!(parsed, settings);
416    }
417
418    #[test]
419    fn rendered_view_groups_round_trip() {
420        let settings = Settings {
421            views: vec![ViewSettings {
422                key: "d".to_string(),
423                desc: "Day".to_string(),
424                blocks: vec![ViewBlock {
425                    kind: "alltodo".to_string(),
426                    match_expression: None,
427                    span: None,
428                    header: Some("Backlog".to_string()),
429                    color: Some("orange".to_string()),
430                    groups: vec![
431                        ViewGroup {
432                            match_expression: Some("TODO=\"HOLD\"".to_string()),
433                            discard: true,
434                            ..ViewGroup::default()
435                        },
436                        ViewGroup {
437                            name: Some("Next".to_string()),
438                            match_expression: Some("/NEXT".to_string()),
439                            order: -1,
440                            ..ViewGroup::default()
441                        },
442                        ViewGroup {
443                            by: Some("category".to_string()),
444                            order: 9,
445                            ..ViewGroup::default()
446                        },
447                    ],
448                }],
449            }],
450            ..Settings::default()
451        };
452        let rendered = render_config(&settings, "groups").unwrap();
453        assert!(
454            rendered.contains("color = \"orange\", groups = ["),
455            "{rendered}"
456        );
457        let mut unknown = settings.clone();
458        unknown.roots = vec![std::env::temp_dir().to_string_lossy().into_owned()];
459        unknown.views[0].blocks[0].groups[1].color = Some("crimson".to_string());
460        let error = validate_settings(&unknown).unwrap_err().to_string();
461        assert!(
462            error.contains("color 'crimson'; use one of blue"),
463            "{error}"
464        );
465        assert!(
466            rendered.contains("    { name = \"Next\", match = \"/NEXT\", order = -1 },\n"),
467            "{rendered}"
468        );
469        let parsed: Settings = toml::from_str(&rendered).unwrap();
470        assert_eq!(parsed, settings);
471    }
472
473    #[test]
474    fn removed_keys_are_unknown() {
475        for (key, value) in [
476            ("tag_alist", "[\"work\"]"),
477            ("persistent_tags", "[\"work\"]"),
478            ("agent_capture_to", "\"inbox\""),
479            ("agent_inbox_template", "\"t\""),
480        ] {
481            let error = load_error(&format!("{key} = {value}\n"));
482            assert!(error.contains(&format!("unknown field `{key}`")), "{error}");
483        }
484    }
485
486    #[test]
487    fn a_setting_below_a_record_is_explained_with_its_location() {
488        let error = load_error(concat!(
489            "[[capture]]\nkey = \"t\"\ndesc = \"Todo\"\nfile = \"inbox.org\"\n",
490            "template = \"* %?\"\n",
491            "start_view = \"n\"\n",
492        ));
493        assert_eq!(
494            error,
495            concat!(
496                "cannot parse CONFIG: TOML parse error at line 7, column 1\n",
497                "  |\n",
498                "7 | start_view = \"n\"\n",
499                "  | ^^^^^^^^^^\n",
500                "`start_view` is a top-level setting; move it above the first [[capture]], [[view]] or [[agent_profile]] record\n",
501            )
502        );
503        // A key that is no setting at all keeps TOML's own message.
504        let error = load_error("[[agent_profile]]\nname = \"a\"\nfils = []\n");
505        assert!(
506            error.contains("unknown field `fils`, expected one of `name`, `files`, `match`"),
507            "{error}"
508        );
509    }
510
511    #[test]
512    fn every_top_level_setting_is_rendered_and_explained() {
513        let settings = Settings {
514            agenda_start_day: Some("-1d".to_string()),
515            agent_inbox: Some("t".to_string()),
516            ..Settings::default()
517        };
518        let encoded = toml::Value::try_from(&settings).unwrap();
519        for key in encoded.as_table().unwrap().keys() {
520            if !["capture", "view", "agent_profile"].contains(&key.as_str()) {
521                assert!(is_top_level_key(key), "{key} is missing from CONFIG_GROUPS");
522            }
523        }
524    }
525
526    #[test]
527    fn template_file_is_read_at_load_relative_to_the_first_root() {
528        let temp = tempfile::tempdir().unwrap();
529        fs::write(temp.path().join("journal.txt"), "* %U %?\n").unwrap();
530        let config = temp.path().join("config.toml");
531        let root = toml::Value::String(temp.path().to_string_lossy().into_owned());
532        fs::write(
533            &config,
534            format!(
535                "roots = [{root}]\n[[capture]]\nkey = \"j\"\ndesc = \"Journal\"\nfile = \"j.org\"\ntemplate_file = \"journal.txt\"\n"
536            ),
537        )
538        .unwrap();
539        let settings = load_settings(Some(&config), None).unwrap();
540        assert_eq!(
541            settings.capture_template("j").unwrap().template,
542            "* %U %?\n"
543        );
544
545        fs::remove_file(temp.path().join("journal.txt")).unwrap();
546        let error = load_settings(Some(&config), None).unwrap_err().to_string();
547        assert!(
548            error.starts_with(&format!(
549                "capture template 'j': cannot read template_file {}: ",
550                temp.path().join("journal.txt").display()
551            )),
552            "{error}"
553        );
554    }
555
556    #[test]
557    fn spans_take_a_name_or_a_toml_number_of_days() {
558        let settings = load_body(concat!(
559            "agenda_span = 10\n",
560            "[[view]]\n",
561            "key = \"f\"\n",
562            "desc = \"Fortnight\"\n",
563            "blocks = [{ kind = \"agenda\", span = 14 }, { kind = \"agenda\", span = \"day\" }]\n",
564        ))
565        .unwrap();
566        assert_eq!(settings.agenda_span, AgendaSpan::Days(10));
567        let spans: Vec<_> = settings.views[0]
568            .blocks
569            .iter()
570            .map(|block| block.span)
571            .collect();
572        assert_eq!(spans, [Some(AgendaSpan::Days(14)), Some(AgendaSpan::Day)]);
573        let rendered = render_config(&settings, "spans").unwrap();
574        assert!(rendered.contains("agenda_span = 10\n"), "{rendered}");
575        assert!(
576            rendered.contains("{ kind = \"agenda\", span = 14 },\n"),
577            "{rendered}"
578        );
579        assert_eq!(toml::from_str::<Settings>(&rendered).unwrap(), settings);
580
581        assert_eq!(
582            load_error("agenda_span = \"10\"\n"),
583            concat!(
584                "cannot parse CONFIG: TOML parse error at line 2, column 15\n",
585                "  |\n",
586                "2 | agenda_span = \"10\"\n",
587                "  |               ^^^^\n",
588                "write a number of days without quotes: 10, not \"10\"\n",
589            )
590        );
591        assert!(
592            load_error("agenda_span = \"wek\"\n")
593                .ends_with("expected day, week, month or a number of days, not \"wek\"\n")
594        );
595        assert!(load_error("agenda_span = 0\n").ends_with("a span is at least 1 day, not 0\n"));
596        assert!(
597            load_error(concat!(
598                "[[view]]\nkey = \"x\"\ndesc = \"X\"\n",
599                "blocks = [{ kind = \"agenda\", span = \"fortnightly\" }]\n",
600            ))
601            .ends_with("expected day, week, month or a number of days, not \"fortnightly\"\n")
602        );
603    }
604
605    #[test]
606    fn explicit_missing_config_file_is_an_error() {
607        let temp = tempfile::tempdir().unwrap();
608        let error = load_settings(Some(&temp.path().join("missing.toml")), None).unwrap_err();
609        assert_eq!(error.code().as_str(), "invalid_config");
610    }
611
612    #[test]
613    fn cli_root_override_has_highest_precedence() {
614        let temp = tempfile::tempdir().unwrap();
615        let configured = temp.path().join("configured");
616        let overridden = temp.path().join("overridden");
617        fs::create_dir(&configured).unwrap();
618        fs::create_dir(&overridden).unwrap();
619        let config = temp.path().join("config.toml");
620        fs::write(
621            &config,
622            format!(
623                "roots = [{}]\n",
624                toml::Value::String(configured.to_string_lossy().into_owned())
625            ),
626        )
627        .unwrap();
628
629        let settings = load_settings(Some(&config), Some(&overridden)).unwrap();
630        assert_eq!(settings.roots, vec![overridden.to_string_lossy()]);
631    }
632}