1use std::collections::BTreeMap;
11use std::sync::LazyLock;
12
13use chrono::NaiveDate;
14use serde::{Deserialize, Serialize};
15
16use crate::{AgdError, AgendaSpan, ErrorCode};
17
18#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
23#[serde(default, deny_unknown_fields)]
24pub struct Settings {
25 pub roots: Vec<String>,
27 pub agenda_files: Vec<String>,
30
31 #[serde(deserialize_with = "deserialize_todo_lines")]
36 pub todo_keywords: Vec<String>,
37 pub todo_keyword_colors: BTreeMap<String, String>,
42 pub ids: IdPolicy,
45 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 pub agenda_span: AgendaSpan,
56 pub agenda_start_day: Option<String>,
57 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 pub sorting_strategy: Vec<String>,
65
66 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 pub editor: Vec<String>,
78 pub theme: String,
80 pub nerd_font: bool,
83 pub notation: Notation,
86 pub start_view: String,
89 pub agent_inbox: Option<String>,
93 pub someday_tag: String,
94 pub someday_clears_dates: bool,
97 pub reschedule_nudge: usize,
99
100 #[serde(rename = "capture")]
101 pub captures: Vec<CaptureTemplate>,
102 #[serde(rename = "view")]
105 pub views: Vec<ViewSettings>,
106 #[serde(rename = "agent_profile")]
107 pub agent_profiles: Vec<AgentProfile>,
108}
109
110#[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 pub kind: String,
126 #[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
128 pub match_expression: Option<String>,
129 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub color: Option<String>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub groups: Vec<ViewGroup>,
142}
143
144#[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 #[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 #[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 #[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 #[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 #[serde(default, skip_serializing_if = "String::is_empty")]
203 pub template: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub template_file: Option<String>,
208}
209
210#[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#[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#[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
253#[serde(rename_all = "lowercase")]
254pub enum LogMode {
255 Off,
256 Time,
257 Note,
258}
259
260pub 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"];
282pub 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 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
338static 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
357impl 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 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 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 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 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 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 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 pub(super) fn view_keys(&self) -> String {
465 quoted_list(self.all_views().iter().map(|view| view.key.as_str()))
466 }
467
468 pub fn view(&self, key: &str) -> Option<&ViewSettings> {
470 self.all_views().into_iter().find(|view| view.key == key)
471 }
472
473 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 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 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 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 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
544pub(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
555fn 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
579pub 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}