Skip to main content

agd_core/config/
model.rs

1//! The configuration's types: `Settings`, the view, capture and agent profile
2//! records, the value enums (`IdPolicy`, `WeekStart`, `LogMode`, `Notation`), the lists of
3//! accepted values (`SORTING_KEYS`, `VIEW_BLOCK_KINDS`, `VIEW_GROUP_BY`,
4//! `COLOR_NAMES`), the defaults, the built-in `n` view, and lookups such as
5//! `Settings::view`.
6//!
7//! The serde shapes are the config file format: field names are the keys.
8//! Checks the types cannot express live in `validation`.
9
10use std::collections::BTreeMap;
11use std::sync::LazyLock;
12
13use chrono::NaiveDate;
14use serde::{Deserialize, Serialize};
15
16use crate::{AgdError, AgendaSpan, ErrorCode};
17
18/// Everything agd reads from `config.toml`, one flat table: every setting is
19/// a top-level key, and only capture templates, views and agent profiles are
20/// lists of records (`[[capture]]`, `[[view]]`, `[[agent_profile]]`).
21/// Missing keys take the defaults below; unknown keys are errors.
22#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
23#[serde(default, deny_unknown_fields)]
24pub struct Settings {
25    /// The only directories agd reads or writes.
26    pub roots: Vec<String>,
27    /// org-agenda-files: files, directories (their `*.org` files) or globs.
28    /// Empty means every `.org` file under the roots.
29    pub agenda_files: Vec<String>,
30
31    /// org-todo-keywords, one `#+TODO:` line per sequence, such as
32    /// "TODO NEXT WAIT(w@/!) | DONE CANCELLED": read by the same code as a
33    /// file's own `#+TODO:` lines, which replace these. A line without `|`
34    /// makes its last keyword the done state, as in Org.
35    #[serde(deserialize_with = "deserialize_todo_lines")]
36    pub todo_keywords: Vec<String>,
37    /// org-todo-keyword-faces for the TUI: a color name (COLOR_NAMES) per
38    /// keyword. A view section takes the color its rows' keywords share.
39    /// The default colors the default workflow's WAIT yellow and CANCELLED
40    /// dim-red; a map of your own replaces it.
41    pub todo_keyword_colors: BTreeMap<String, String>,
42    /// "preserve" leaves headings alone; "add-on-write" adds an org-id :ID:
43    /// on a heading's first edit.
44    pub ids: IdPolicy,
45    /// org-tags-column: negative right-aligns tags to end at that column,
46    /// positive starts them there, as Emacs does when it rewrites a headline.
47    pub tags_column: i64,
48    pub tag_inheritance_exclusions: Vec<String>,
49    pub highest_priority: String,
50    pub lowest_priority: String,
51    pub default_priority: String,
52
53    /// org-agenda-span: "day", "week", "month" or a number of days
54    /// (`agenda_span = 10`).
55    pub agenda_span: AgendaSpan,
56    pub agenda_start_day: Option<String>,
57    /// org-agenda-start-on-weekday: "monday" or "sunday".
58    pub week_start: WeekStart,
59    pub deadline_warning_days: i64,
60    pub skip_scheduled_if_done: bool,
61    pub skip_deadline_if_done: bool,
62    pub skip_timestamp_if_done: bool,
63    /// org-agenda-sorting-strategy for agenda days, from SORTING_KEYS.
64    pub sorting_strategy: Vec<String>,
65
66    /// org-log-done, org-log-repeat, org-log-reschedule, org-log-redeadline:
67    /// "off" (Emacs's nil), "time" or "note". `#+STARTUP` options win per
68    /// file.
69    pub log_done: LogMode,
70    pub log_repeat: LogMode,
71    pub log_reschedule: LogMode,
72    pub log_redeadline: LogMode,
73    pub log_into_drawer: bool,
74
75    /// Program and arguments to open a file; `{file}`, `{line}` and `{col}`
76    /// are substituted. Run directly, never through a shell.
77    pub editor: Vec<String>,
78    /// auto (follow the terminal background), dark or light.
79    pub theme: String,
80    /// Round pill and chip caps with Nerd Font glyphs; turn on when your
81    /// terminal font has them (Ghostty, WezTerm and kitty bundle them).
82    pub nerd_font: bool,
83    /// How a capture line takes tags and a priority, and how agd shows
84    /// tags: "agd" (`#work`, `!A`) or "org" (`:work:`, `#A`).
85    pub notation: Notation,
86    /// The view key `agd` opens on, such as your dashboard or the built-in
87    /// `n`; empty opens Today.
88    pub start_view: String,
89    /// A `[[capture]]` key: every agent capture goes through that template
90    /// without dates, so agent work reaches your day only through triage.
91    /// Absent, agents choose a template.
92    pub agent_inbox: Option<String>,
93    pub someday_tag: String,
94    /// Someday also removes the dates that put the task on the agenda
95    /// (SCHEDULED, and an overdue DEADLINE), so it leaves the agenda.
96    pub someday_clears_dates: bool,
97    /// Triage suggests Someday after this many reschedules.
98    pub reschedule_nudge: usize,
99
100    #[serde(rename = "capture")]
101    pub captures: Vec<CaptureTemplate>,
102    /// The user's own views only; `all_views` and `view` add the built-in
103    /// ones.
104    #[serde(rename = "view")]
105    pub views: Vec<ViewSettings>,
106    #[serde(rename = "agent_profile")]
107    pub agent_profiles: Vec<AgentProfile>,
108}
109
110/// A saved agenda view, the declarative part of an org-agenda custom
111/// command: one block, or several shown one after another like a block
112/// agenda.
113#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
114#[serde(deny_unknown_fields)]
115pub struct ViewSettings {
116    pub key: String,
117    pub desc: String,
118    pub blocks: Vec<ViewBlock>,
119}
120
121#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
122#[serde(deny_unknown_fields)]
123pub struct ViewBlock {
124    /// agenda, todo, alltodo, tags or tags-todo, as in org-agenda.
125    pub kind: String,
126    /// Tags/TODO match; for `todo` blocks, TODO keywords such as `NEXT|WAIT`.
127    #[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
128    pub match_expression: Option<String>,
129    /// For agenda blocks: "day", "week", "month" or a number of days;
130    /// without one, `agenda_span`.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub span: Option<AgendaSpan>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub header: Option<String>,
135    /// The header's color in the TUI, one of COLOR_NAMES; by default it
136    /// follows the rows' TODO keywords.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub color: Option<String>,
139    /// org-super-agenda style groups over the block's rows.
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub groups: Vec<ViewGroup>,
142}
143
144/// One group of a view block, as an org-super-agenda group: each row goes
145/// to the first group, in list order, whose match it passes; `discard`
146/// drops those rows; `by` makes one group per category, parent heading,
147/// TODO keyword or priority cookie. Groups are shown by `order`, then list
148/// order, and rows no group takes come last as "Other items".
149#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
150#[serde(deny_unknown_fields)]
151pub struct ViewGroup {
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub name: Option<String>,
154    /// Tags/TODO match, as for a tags block; without one the group takes
155    /// every remaining row.
156    #[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
157    pub match_expression: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub by: Option<String>,
160    /// org-super-agenda's `:effort<` and `:effort>`: the heading's own
161    /// `:Effort:` is at most / at least this duration (`0:15`, `30`, `1h`).
162    /// A heading without an effort matches neither.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub effort_max: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub effort_min: Option<String>,
167    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
168    pub discard: bool,
169    #[serde(default, skip_serializing_if = "is_zero")]
170    pub order: i64,
171    /// As for a block; a `by` group's color applies to each group it makes.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub color: Option<String>,
174}
175
176fn is_zero(value: &i64) -> bool {
177    *value == 0
178}
179
180#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct CaptureTemplate {
183    pub key: String,
184    pub desc: String,
185    /// The target, as org-capture's: `id` alone, or `file` alone
186    /// (file), with `heading` (file+headline) or with `olpath`
187    /// (file+olp). `datetree` goes with `file` alone (file+datetree) or with
188    /// `olpath` (file+olp+datetree).
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub file: Option<String>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub heading: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub olpath: Option<Vec<String>>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub id: Option<String>,
197    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
198    pub datetree: bool,
199    /// The entry's text with org-capture escapes (`%?`, `%U`, `%^{prompt}`).
200    /// A template has exactly one of `template` and `template_file`; empty
201    /// is none. `load_settings` reads a `template_file` into `template`.
202    #[serde(default, skip_serializing_if = "String::is_empty")]
203    pub template: String,
204    /// A file holding the template text; a relative path is under the first
205    /// root, as Org's are under org-directory.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub template_file: Option<String>,
208}
209
210/// A named, filtered agenda an agent may read at session start.
211#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
212#[serde(deny_unknown_fields)]
213pub struct AgentProfile {
214    pub name: String,
215    #[serde(default)]
216    pub files: Vec<String>,
217    #[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
218    pub match_expression: Option<String>,
219}
220
221#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
222#[serde(rename_all = "kebab-case")]
223pub enum IdPolicy {
224    #[default]
225    Preserve,
226    AddOnWrite,
227}
228
229/// The day a week agenda starts on.
230#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
231#[serde(rename_all = "lowercase")]
232pub enum WeekStart {
233    #[default]
234    Monday,
235    Sunday,
236}
237
238/// How tags and a priority are written in a capture line, and how agd
239/// draws tags: `agd` is `#work` and `!A`; `org` is Org's own `:work:` (or
240/// `:a:b:`) and `#A`. Org's files, `--csv` and the agenda text are Org's
241/// either way.
242#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
243#[serde(rename_all = "lowercase")]
244pub enum Notation {
245    #[default]
246    Agd,
247    Org,
248}
249
250/// What a state or planning change records, as an org-log-* variable: no
251/// log, a timestamp, or a timestamp and a note.
252#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
253#[serde(rename_all = "lowercase")]
254pub enum LogMode {
255    Off,
256    Time,
257    Note,
258}
259
260/// Agenda sorting keys agd implements; habit-* and category-keep keep
261/// collection order.
262pub const SORTING_KEYS: [&str; 15] = [
263    "time-up",
264    "time-down",
265    "urgency-up",
266    "urgency-down",
267    "priority-up",
268    "priority-down",
269    "category-keep",
270    "category-up",
271    "category-down",
272    "alpha-up",
273    "alpha-down",
274    "todo-state-up",
275    "todo-state-down",
276    "habit-up",
277    "habit-down",
278];
279
280pub const VIEW_BLOCK_KINDS: [&str; 5] = ["agenda", "todo", "alltodo", "tags", "tags-todo"];
281pub const VIEW_GROUP_BY: [&str; 4] = ["category", "parent", "todo", "priority"];
282/// Colors a keyword or view section may name; the TUI maps them onto its
283/// light or dark palette.
284pub const COLOR_NAMES: [&str; 10] = [
285    "blue", "green", "yellow", "orange", "red", "dim-red", "purple", "pink", "teal", "grey",
286];
287
288impl Default for Settings {
289    fn default() -> Self {
290        Self {
291            roots: vec!["~/org".to_string()],
292            agenda_files: Vec::new(),
293            todo_keywords: strings(&["TODO NEXT WAIT | DONE CANCELLED"]),
294            todo_keyword_colors: BTreeMap::from([
295                ("WAIT".to_string(), "yellow".to_string()),
296                ("CANCELLED".to_string(), "dim-red".to_string()),
297            ]),
298            ids: IdPolicy::Preserve,
299            tags_column: -77,
300            tag_inheritance_exclusions: Vec::new(),
301            highest_priority: "A".to_string(),
302            lowest_priority: "C".to_string(),
303            default_priority: "B".to_string(),
304            agenda_span: AgendaSpan::Week,
305            agenda_start_day: None,
306            week_start: WeekStart::Monday,
307            deadline_warning_days: 14,
308            skip_scheduled_if_done: false,
309            skip_deadline_if_done: false,
310            skip_timestamp_if_done: false,
311            // Emacs's default for agenda days (Org 9.7+ names).
312            sorting_strategy: strings(&["habit-down", "time-up", "urgency-down", "category-keep"]),
313            log_done: LogMode::Time,
314            log_repeat: LogMode::Time,
315            log_reschedule: LogMode::Off,
316            log_redeadline: LogMode::Off,
317            log_into_drawer: false,
318            editor: vec![
319                std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()),
320                "+{line}".to_string(),
321                "{file}".to_string(),
322            ],
323            theme: "auto".to_string(),
324            nerd_font: false,
325            notation: Notation::Agd,
326            start_view: String::new(),
327            agent_inbox: None,
328            someday_tag: "someday".to_string(),
329            someday_clears_dates: true,
330            reschedule_nudge: 9,
331            captures: vec![CaptureTemplate::default()],
332            views: Vec::new(),
333            agent_profiles: Vec::new(),
334        }
335    }
336}
337
338/// Org's built-in agenda commands that agd runs, beside the user's
339/// `[[view]]`s: `n`, "Agenda and all TODOs". A `[[view]]` with the same key
340/// replaces one.
341static BUILTIN_VIEWS: LazyLock<Vec<ViewSettings>> = LazyLock::new(|| {
342    let block = |kind: &str| ViewBlock {
343        kind: kind.to_string(),
344        match_expression: None,
345        span: None,
346        header: None,
347        color: None,
348        groups: Vec::new(),
349    };
350    vec![ViewSettings {
351        key: "n".to_string(),
352        desc: "Agenda and all TODOs".to_string(),
353        blocks: vec![block("agenda"), block("alltodo")],
354    }]
355});
356
357/// The default `t` template: a TODO in inbox.org under Inbox, stamped with
358/// its creation time.
359impl Default for CaptureTemplate {
360    fn default() -> Self {
361        Self {
362            key: "t".to_string(),
363            desc: "Todo".to_string(),
364            file: Some("inbox.org".to_string()),
365            heading: Some("Inbox".to_string()),
366            olpath: None,
367            id: None,
368            datetree: false,
369            template: "* TODO %?\n:PROPERTIES:\n:CREATED: %U\n:END:".to_string(),
370            template_file: None,
371        }
372    }
373}
374
375pub(super) fn strings(values: &[&str]) -> Vec<String> {
376    values.iter().map(|value| value.to_string()).collect()
377}
378
379impl Settings {
380    /// The first agenda day: `agenda_start_day` from `today`, or today.
381    pub fn agenda_start(&self, today: NaiveDate) -> Result<NaiveDate, AgdError> {
382        match self.agenda_start_day.as_deref() {
383            Some(value) => parse_agenda_day(value, today),
384            None => Ok(today),
385        }
386    }
387
388    pub fn capture_template(&self, key: &str) -> Option<&CaptureTemplate> {
389        self.captures.iter().find(|template| template.key == key)
390    }
391
392    /// The template a capture goes through when none is named: the first
393    /// `[[capture]]`. (An agent's capture then goes to `agent_inbox` when
394    /// it is set, as every agent capture does.)
395    pub fn default_capture_template(&self) -> Result<&CaptureTemplate, AgdError> {
396        self.captures.first().ok_or_else(|| {
397            AgdError::new(
398                ErrorCode::UnknownTemplate,
399                "no [[capture]] template is configured",
400            )
401        })
402    }
403
404    /// How a capture line names its template, the one rule for every
405    /// surface: the first word is the template key when it names a
406    /// `[[capture]]` and more text follows; otherwise the whole line is the
407    /// text for the default template. Returns the template and the text.
408    pub fn capture_line_template<'a>(
409        &self,
410        line: &'a str,
411    ) -> Result<(&CaptureTemplate, &'a str), AgdError> {
412        let line = line.trim();
413        if let Some((key, text)) = line.split_once(char::is_whitespace)
414            && let Some(template) = self.capture_template(key)
415        {
416            return Ok((template, text.trim_start()));
417        }
418        Ok((self.default_capture_template()?, line))
419    }
420
421    /// The template `key` names, or an `unknown_template` error that lists
422    /// the templates there are.
423    pub fn find_capture_template(&self, key: &str) -> Result<&CaptureTemplate, AgdError> {
424        self.capture_template(key).ok_or_else(|| {
425            let keys = self.captures.iter().map(|template| template.key.as_str());
426            let known = if self.captures.is_empty() {
427                "no [[capture]] template is configured".to_string()
428            } else {
429                format!("the templates are {}", quoted_list(keys))
430            };
431            AgdError::new(
432                ErrorCode::UnknownTemplate,
433                format!("unknown capture template '{key}'; {known}"),
434            )
435        })
436    }
437
438    /// The view `key` runs, or an `unknown_view` error that lists every
439    /// view, the built-in ones too.
440    pub fn find_view(&self, key: &str) -> Result<&ViewSettings, AgdError> {
441        self.view(key).ok_or_else(|| {
442            AgdError::new(
443                ErrorCode::UnknownView,
444                format!("unknown view '{key}'; the views are {}", self.view_keys()),
445            )
446        })
447    }
448
449    /// The view `agd agenda --view` without a key runs: start_view.
450    pub fn find_start_view(&self) -> Result<&ViewSettings, AgdError> {
451        if self.start_view.is_empty() {
452            return Err(AgdError::new(
453                ErrorCode::UnknownView,
454                format!(
455                    "--view without a key runs start_view, which is not set; name one of {}",
456                    self.view_keys()
457                ),
458            ));
459        }
460        self.find_view(&self.start_view)
461    }
462
463    /// Every view's key, quoted, for messages: `'w' and 'n'`.
464    pub(super) fn view_keys(&self) -> String {
465        quoted_list(self.all_views().iter().map(|view| view.key.as_str()))
466    }
467
468    /// The view `key` runs: the user's `[[view]]`, else a built-in one.
469    pub fn view(&self, key: &str) -> Option<&ViewSettings> {
470        self.all_views().into_iter().find(|view| view.key == key)
471    }
472
473    /// Every view a key can run: the user's `[[view]]`s in config order,
474    /// then the built-in views they do not replace.
475    pub fn all_views(&self) -> Vec<&ViewSettings> {
476        let builtins = BUILTIN_VIEWS
477            .iter()
478            .filter(|builtin| self.views.iter().all(|view| view.key != builtin.key));
479        self.views.iter().chain(builtins).collect()
480    }
481
482    /// Whether `key` runs a built-in view rather than one of the user's.
483    pub fn is_builtin_view(&self, key: &str) -> bool {
484        self.views.iter().all(|view| view.key != key)
485            && BUILTIN_VIEWS.iter().any(|builtin| builtin.key == key)
486    }
487
488    /// Narrows these settings to `[[agent_profile]]` `name`, as `agd
489    /// agenda --profile` and MCP `agenda` do: its files replace
490    /// agenda_files, and its match is the match unless `match_expression`
491    /// gives one, which this returns.
492    pub fn apply_agent_profile(
493        &mut self,
494        name: &str,
495        match_expression: Option<String>,
496    ) -> Result<Option<String>, AgdError> {
497        let profile = self.find_agent_profile(name)?.clone();
498        if !profile.files.is_empty() {
499            self.agenda_files = profile.files;
500        }
501        Ok(match_expression.or(profile.match_expression))
502    }
503
504    /// The `[[agent_profile]]` `name` names, or an `unknown_profile` error
505    /// that lists the profiles there are.
506    pub fn find_agent_profile(&self, name: &str) -> Result<&AgentProfile, AgdError> {
507        let profile = self
508            .agent_profiles
509            .iter()
510            .find(|profile| profile.name == name);
511        profile.ok_or_else(|| {
512            let names = self
513                .agent_profiles
514                .iter()
515                .map(|profile| profile.name.as_str());
516            let known = if self.agent_profiles.is_empty() {
517                "no [[agent_profile]] is configured".to_string()
518            } else {
519                format!("the profiles are {}", quoted_list(names))
520            };
521            AgdError::new(
522                ErrorCode::UnknownProfile,
523                format!("unknown agent profile '{name}'; {known}"),
524            )
525        })
526    }
527
528    /// The editor program and its arguments with `{file}`, `{line}` and
529    /// `{col}` (both 1-based) substituted inside each argument. The result
530    /// goes straight to the process API, never to a shell, so file names
531    /// with spaces or quotes stay one argument.
532    pub fn editor_command(&self, file: &str, line: usize, col: usize) -> (String, Vec<String>) {
533        let mut parts = self.editor.iter().map(|argument| {
534            argument
535                .replace("{file}", file)
536                .replace("{line}", &line.to_string())
537                .replace("{col}", &col.to_string())
538        });
539        let program = parts.next().unwrap_or_else(|| "vi".to_string());
540        (program, parts.collect())
541    }
542}
543
544/// Names for a message, each in single quotes: `'a'`, `'a' and 'b'`,
545/// `'a', 'b' and 'c'`.
546pub(crate) fn quoted_list<'a>(names: impl IntoIterator<Item = &'a str>) -> String {
547    let quoted: Vec<String> = names.into_iter().map(|name| format!("'{name}'")).collect();
548    match quoted.split_last() {
549        None => String::new(),
550        Some((last, [])) => last.clone(),
551        Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
552    }
553}
554
555/// `todo_keywords` as strings. A list of words instead of a line gets an
556/// error that spells out the line to write.
557fn deserialize_todo_lines<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
558where
559    D: serde::Deserializer<'de>,
560{
561    #[derive(Deserialize)]
562    #[serde(untagged)]
563    enum TodoLine {
564        Line(String),
565        Words(Vec<String>),
566    }
567    Vec::<TodoLine>::deserialize(deserializer)?
568        .into_iter()
569        .map(|line| match line {
570            TodoLine::Line(line) => Ok(line),
571            TodoLine::Words(words) => Err(serde::de::Error::custom(format!(
572                "todo_keywords takes one #+TODO: line per sequence; write \"{}\"",
573                words.join(" ")
574            ))),
575        })
576        .collect()
577}
578
579/// A day as org-agenda-start-day and `agd agenda --from` take it: a date,
580/// an offset from `today` such as -1d or +2w, or what `parse_date_input`
581/// reads, such as "fri".
582pub fn parse_agenda_day(value: &str, today: NaiveDate) -> Result<NaiveDate, AgdError> {
583    crate::date::parse_org_date(&crate::parse_date_input(value, today)?.date)
584}
585
586#[cfg(test)]
587mod tests {
588    use std::path::PathBuf;
589
590    use super::*;
591    use crate::config::test_support::{load_body, load_error};
592    use crate::render_config;
593
594    #[test]
595    fn editor_command_substitutes_every_placeholder_per_argument() {
596        let settings = Settings {
597            editor: strings(&["emacsclient", "-n", "+{line}:{col}", "{file}"]),
598            ..Settings::default()
599        };
600        assert_eq!(
601            settings.editor_command("/org/my tasks.org", 42, 1),
602            (
603                "emacsclient".to_string(),
604                strings(&["-n", "+42:1", "/org/my tasks.org"])
605            )
606        );
607    }
608
609    #[test]
610    fn a_capture_line_names_its_template_only_with_text_after_the_key() {
611        let settings = Settings {
612            captures: vec![
613                CaptureTemplate::default(),
614                CaptureTemplate {
615                    key: "n".to_string(),
616                    ..CaptureTemplate::default()
617                },
618            ],
619            ..Settings::default()
620        };
621        let read = |line: &str| {
622            let (template, text) = settings.capture_line_template(line).unwrap();
623            (template.key.clone(), text.to_string())
624        };
625        assert_eq!(
626            read("n Call back"),
627            ("n".to_string(), "Call back".to_string())
628        );
629        assert_eq!(
630            read("Renew passport"),
631            ("t".to_string(), "Renew passport".to_string())
632        );
633        assert_eq!(read("n"), ("t".to_string(), "n".to_string()));
634        assert_eq!(
635            read("x -1 point"),
636            ("t".to_string(), "x -1 point".to_string())
637        );
638    }
639
640    #[test]
641    fn unknown_names_list_what_is_configured_or_say_nothing_is() {
642        let settings = Settings {
643            agent_profiles: vec![AgentProfile {
644                name: "coding".to_string(),
645                ..AgentProfile::default()
646            }],
647            ..Settings::default()
648        };
649        let message = |error: AgdError| (error.code(), error.to_string());
650        assert_eq!(
651            message(settings.find_view("x").unwrap_err()),
652            (
653                ErrorCode::UnknownView,
654                "unknown view 'x'; the views are 'n'".to_string()
655            )
656        );
657        assert_eq!(
658            message(settings.find_start_view().unwrap_err()).1,
659            "--view without a key runs start_view, which is not set; name one of 'n'"
660        );
661        assert_eq!(
662            message(settings.find_agent_profile("x").unwrap_err()).1,
663            "unknown agent profile 'x'; the profiles are 'coding'"
664        );
665        assert_eq!(
666            message(Settings::default().find_agent_profile("x").unwrap_err()).1,
667            "unknown agent profile 'x'; no [[agent_profile]] is configured"
668        );
669        let none = Settings {
670            captures: Vec::new(),
671            ..Settings::default()
672        };
673        assert_eq!(
674            message(none.find_capture_template("t").unwrap_err()),
675            (
676                ErrorCode::UnknownTemplate,
677                "unknown capture template 't'; no [[capture]] template is configured".to_string()
678            )
679        );
680        assert_eq!(quoted_list(["a", "b", "c"]), "'a', 'b' and 'c'");
681    }
682
683    #[test]
684    fn builtin_n_stays_beside_user_views_until_replaced() {
685        let defaults = Settings::default();
686        assert!(defaults.views.is_empty());
687        assert_eq!(defaults.view("n").unwrap().desc, "Agenda and all TODOs");
688        assert!(defaults.is_builtin_view("n"));
689
690        let settings = load_body(concat!(
691            "start_view = \"n\"\n",
692            "[[view]]\nkey = \"w\"\ndesc = \"Work\"\n",
693            "blocks = [{ kind = \"tags-todo\", match = \"work\" }]\n",
694        ))
695        .unwrap();
696        let keys: Vec<&str> = settings
697            .all_views()
698            .iter()
699            .map(|view| view.key.as_str())
700            .collect();
701        assert_eq!(keys, ["w", "n"]);
702        assert!(!settings.is_builtin_view("w"));
703        let rendered = render_config(&settings, "views").unwrap();
704        assert!(rendered.contains("key = \"w\""), "{rendered}");
705        assert!(!rendered.contains("key = \"n\""), "{rendered}");
706
707        let replaced = load_body(concat!(
708            "[[view]]\nkey = \"n\"\ndesc = \"Next\"\n",
709            "blocks = [{ kind = \"todo\", match = \"NEXT\" }]\n",
710        ))
711        .unwrap();
712        assert_eq!(replaced.all_views().len(), 1);
713        assert_eq!(replaced.view("n").unwrap().desc, "Next");
714        assert!(!replaced.is_builtin_view("n"));
715    }
716
717    #[test]
718    fn week_start_is_monday_or_sunday() {
719        let settings = load_body("week_start = \"sunday\"\n").unwrap();
720        assert_eq!(settings.week_start, WeekStart::Sunday);
721        assert_eq!(
722            load_error("week_start = \"friday\"\n"),
723            concat!(
724                "cannot parse CONFIG: TOML parse error at line 2, column 14\n",
725                "  |\n",
726                "2 | week_start = \"friday\"\n",
727                "  |              ^^^^^^^^\n",
728                "unknown variant `friday`, expected `monday` or `sunday`\n",
729            )
730        );
731    }
732
733    #[test]
734    fn notation_is_agd_or_org() {
735        assert_eq!(load_body("").unwrap().notation, Notation::Agd);
736        assert_eq!(
737            load_body("notation = \"org\"\n").unwrap().notation,
738            Notation::Org
739        );
740        assert_eq!(
741            load_error("notation = \"emacs\"\n"),
742            concat!(
743                "cannot parse CONFIG: TOML parse error at line 2, column 12\n",
744                "  |\n",
745                "2 | notation = \"emacs\"\n",
746                "  |            ^^^^^^^\n",
747                "unknown variant `emacs`, expected `agd` or `org`\n",
748            )
749        );
750    }
751
752    #[test]
753    fn log_settings_are_off_time_or_note() {
754        let settings = load_body("log_done = \"note\"\nlog_repeat = \"off\"\n").unwrap();
755        assert_eq!(
756            (settings.log_done, settings.log_repeat),
757            (LogMode::Note, LogMode::Off)
758        );
759        assert_eq!(
760            load_error("log_reschedule = \"yes\"\n"),
761            concat!(
762                "cannot parse CONFIG: TOML parse error at line 2, column 18\n",
763                "  |\n",
764                "2 | log_reschedule = \"yes\"\n",
765                "  |                  ^^^^^\n",
766                "unknown variant `yes`, expected one of `off`, `time`, `note`\n",
767            )
768        );
769        assert!(
770            load_error("log_done = \"nil\"\n")
771                .ends_with("unknown variant `nil`, expected one of `off`, `time`, `note`\n")
772        );
773    }
774
775    #[test]
776    fn todo_keyword_lines_read_like_a_files_todo_lines() {
777        let settings = Settings {
778            todo_keywords: strings(&["TODO(t) WAIT(w@/!) | DONE(d!)", "IDEA DROPPED"]),
779            ..Settings::default()
780        };
781        let document = crate::IndexedDocument::parse(
782            PathBuf::from("tasks.org"),
783            b"* WAIT Reply\n".to_vec(),
784            &settings,
785        )
786        .unwrap();
787        let keywords: Vec<(&str, bool, usize)> = document
788            .todo_keywords()
789            .iter()
790            .map(|keyword| (keyword.name.as_str(), keyword.done, keyword.sequence))
791            .collect();
792        assert_eq!(
793            keywords,
794            [
795                ("TODO", false, 0),
796                ("WAIT", false, 0),
797                ("DONE", true, 0),
798                ("IDEA", false, 1),
799                ("DROPPED", true, 1)
800            ]
801        );
802        let wait = &document.todo_keywords()[1];
803        assert_eq!(
804            (wait.log_enter, wait.log_leave),
805            (Some(crate::LogKind::Note), Some(crate::LogKind::Time))
806        );
807    }
808}