use std::collections::BTreeMap;
use std::sync::LazyLock;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::{AgdError, AgendaSpan, ErrorCode};
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub roots: Vec<String>,
pub agenda_files: Vec<String>,
#[serde(deserialize_with = "deserialize_todo_lines")]
pub todo_keywords: Vec<String>,
pub todo_keyword_colors: BTreeMap<String, String>,
pub ids: IdPolicy,
pub tags_column: i64,
pub tag_inheritance_exclusions: Vec<String>,
pub highest_priority: String,
pub lowest_priority: String,
pub default_priority: String,
pub agenda_span: AgendaSpan,
pub agenda_start_day: Option<String>,
pub week_start: WeekStart,
pub deadline_warning_days: i64,
pub skip_scheduled_if_done: bool,
pub skip_deadline_if_done: bool,
pub skip_timestamp_if_done: bool,
pub sorting_strategy: Vec<String>,
pub log_done: LogMode,
pub log_repeat: LogMode,
pub log_reschedule: LogMode,
pub log_redeadline: LogMode,
pub log_into_drawer: bool,
pub editor: Vec<String>,
pub theme: String,
pub nerd_font: bool,
pub notation: Notation,
pub start_view: String,
pub agent_inbox: Option<String>,
pub someday_tag: String,
pub someday_clears_dates: bool,
pub reschedule_nudge: usize,
#[serde(rename = "capture")]
pub captures: Vec<CaptureTemplate>,
#[serde(rename = "view")]
pub views: Vec<ViewSettings>,
#[serde(rename = "agent_profile")]
pub agent_profiles: Vec<AgentProfile>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ViewSettings {
pub key: String,
pub desc: String,
pub blocks: Vec<ViewBlock>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ViewBlock {
pub kind: String,
#[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
pub match_expression: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<AgendaSpan>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<ViewGroup>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ViewGroup {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
pub match_expression: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort_max: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort_min: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub discard: bool,
#[serde(default, skip_serializing_if = "is_zero")]
pub order: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
fn is_zero(value: &i64) -> bool {
*value == 0
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureTemplate {
pub key: String,
pub desc: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub heading: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub olpath: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub datetree: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub template: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_file: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentProfile {
pub name: String,
#[serde(default)]
pub files: Vec<String>,
#[serde(default, rename = "match", skip_serializing_if = "Option::is_none")]
pub match_expression: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum IdPolicy {
#[default]
Preserve,
AddOnWrite,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WeekStart {
#[default]
Monday,
Sunday,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Notation {
#[default]
Agd,
Org,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LogMode {
Off,
Time,
Note,
}
pub const SORTING_KEYS: [&str; 15] = [
"time-up",
"time-down",
"urgency-up",
"urgency-down",
"priority-up",
"priority-down",
"category-keep",
"category-up",
"category-down",
"alpha-up",
"alpha-down",
"todo-state-up",
"todo-state-down",
"habit-up",
"habit-down",
];
pub const VIEW_BLOCK_KINDS: [&str; 5] = ["agenda", "todo", "alltodo", "tags", "tags-todo"];
pub const VIEW_GROUP_BY: [&str; 4] = ["category", "parent", "todo", "priority"];
pub const COLOR_NAMES: [&str; 10] = [
"blue", "green", "yellow", "orange", "red", "dim-red", "purple", "pink", "teal", "grey",
];
impl Default for Settings {
fn default() -> Self {
Self {
roots: vec!["~/org".to_string()],
agenda_files: Vec::new(),
todo_keywords: strings(&["TODO NEXT WAIT | DONE CANCELLED"]),
todo_keyword_colors: BTreeMap::from([
("WAIT".to_string(), "yellow".to_string()),
("CANCELLED".to_string(), "dim-red".to_string()),
]),
ids: IdPolicy::Preserve,
tags_column: -77,
tag_inheritance_exclusions: Vec::new(),
highest_priority: "A".to_string(),
lowest_priority: "C".to_string(),
default_priority: "B".to_string(),
agenda_span: AgendaSpan::Week,
agenda_start_day: None,
week_start: WeekStart::Monday,
deadline_warning_days: 14,
skip_scheduled_if_done: false,
skip_deadline_if_done: false,
skip_timestamp_if_done: false,
sorting_strategy: strings(&["habit-down", "time-up", "urgency-down", "category-keep"]),
log_done: LogMode::Time,
log_repeat: LogMode::Time,
log_reschedule: LogMode::Off,
log_redeadline: LogMode::Off,
log_into_drawer: false,
editor: vec![
std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()),
"+{line}".to_string(),
"{file}".to_string(),
],
theme: "auto".to_string(),
nerd_font: false,
notation: Notation::Agd,
start_view: String::new(),
agent_inbox: None,
someday_tag: "someday".to_string(),
someday_clears_dates: true,
reschedule_nudge: 9,
captures: vec![CaptureTemplate::default()],
views: Vec::new(),
agent_profiles: Vec::new(),
}
}
}
static BUILTIN_VIEWS: LazyLock<Vec<ViewSettings>> = LazyLock::new(|| {
let block = |kind: &str| ViewBlock {
kind: kind.to_string(),
match_expression: None,
span: None,
header: None,
color: None,
groups: Vec::new(),
};
vec![ViewSettings {
key: "n".to_string(),
desc: "Agenda and all TODOs".to_string(),
blocks: vec![block("agenda"), block("alltodo")],
}]
});
impl Default for CaptureTemplate {
fn default() -> Self {
Self {
key: "t".to_string(),
desc: "Todo".to_string(),
file: Some("inbox.org".to_string()),
heading: Some("Inbox".to_string()),
olpath: None,
id: None,
datetree: false,
template: "* TODO %?\n:PROPERTIES:\n:CREATED: %U\n:END:".to_string(),
template_file: None,
}
}
}
pub(super) fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
impl Settings {
pub fn agenda_start(&self, today: NaiveDate) -> Result<NaiveDate, AgdError> {
match self.agenda_start_day.as_deref() {
Some(value) => parse_agenda_day(value, today),
None => Ok(today),
}
}
pub fn capture_template(&self, key: &str) -> Option<&CaptureTemplate> {
self.captures.iter().find(|template| template.key == key)
}
pub fn default_capture_template(&self) -> Result<&CaptureTemplate, AgdError> {
self.captures.first().ok_or_else(|| {
AgdError::new(
ErrorCode::UnknownTemplate,
"no [[capture]] template is configured",
)
})
}
pub fn capture_line_template<'a>(
&self,
line: &'a str,
) -> Result<(&CaptureTemplate, &'a str), AgdError> {
let line = line.trim();
if let Some((key, text)) = line.split_once(char::is_whitespace)
&& let Some(template) = self.capture_template(key)
{
return Ok((template, text.trim_start()));
}
Ok((self.default_capture_template()?, line))
}
pub fn find_capture_template(&self, key: &str) -> Result<&CaptureTemplate, AgdError> {
self.capture_template(key).ok_or_else(|| {
let keys = self.captures.iter().map(|template| template.key.as_str());
let known = if self.captures.is_empty() {
"no [[capture]] template is configured".to_string()
} else {
format!("the templates are {}", quoted_list(keys))
};
AgdError::new(
ErrorCode::UnknownTemplate,
format!("unknown capture template '{key}'; {known}"),
)
})
}
pub fn find_view(&self, key: &str) -> Result<&ViewSettings, AgdError> {
self.view(key).ok_or_else(|| {
AgdError::new(
ErrorCode::UnknownView,
format!("unknown view '{key}'; the views are {}", self.view_keys()),
)
})
}
pub fn find_start_view(&self) -> Result<&ViewSettings, AgdError> {
if self.start_view.is_empty() {
return Err(AgdError::new(
ErrorCode::UnknownView,
format!(
"--view without a key runs start_view, which is not set; name one of {}",
self.view_keys()
),
));
}
self.find_view(&self.start_view)
}
pub(super) fn view_keys(&self) -> String {
quoted_list(self.all_views().iter().map(|view| view.key.as_str()))
}
pub fn view(&self, key: &str) -> Option<&ViewSettings> {
self.all_views().into_iter().find(|view| view.key == key)
}
pub fn all_views(&self) -> Vec<&ViewSettings> {
let builtins = BUILTIN_VIEWS
.iter()
.filter(|builtin| self.views.iter().all(|view| view.key != builtin.key));
self.views.iter().chain(builtins).collect()
}
pub fn is_builtin_view(&self, key: &str) -> bool {
self.views.iter().all(|view| view.key != key)
&& BUILTIN_VIEWS.iter().any(|builtin| builtin.key == key)
}
pub fn apply_agent_profile(
&mut self,
name: &str,
match_expression: Option<String>,
) -> Result<Option<String>, AgdError> {
let profile = self.find_agent_profile(name)?.clone();
if !profile.files.is_empty() {
self.agenda_files = profile.files;
}
Ok(match_expression.or(profile.match_expression))
}
pub fn find_agent_profile(&self, name: &str) -> Result<&AgentProfile, AgdError> {
let profile = self
.agent_profiles
.iter()
.find(|profile| profile.name == name);
profile.ok_or_else(|| {
let names = self
.agent_profiles
.iter()
.map(|profile| profile.name.as_str());
let known = if self.agent_profiles.is_empty() {
"no [[agent_profile]] is configured".to_string()
} else {
format!("the profiles are {}", quoted_list(names))
};
AgdError::new(
ErrorCode::UnknownProfile,
format!("unknown agent profile '{name}'; {known}"),
)
})
}
pub fn editor_command(&self, file: &str, line: usize, col: usize) -> (String, Vec<String>) {
let mut parts = self.editor.iter().map(|argument| {
argument
.replace("{file}", file)
.replace("{line}", &line.to_string())
.replace("{col}", &col.to_string())
});
let program = parts.next().unwrap_or_else(|| "vi".to_string());
(program, parts.collect())
}
}
pub(crate) fn quoted_list<'a>(names: impl IntoIterator<Item = &'a str>) -> String {
let quoted: Vec<String> = names.into_iter().map(|name| format!("'{name}'")).collect();
match quoted.split_last() {
None => String::new(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
fn deserialize_todo_lines<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum TodoLine {
Line(String),
Words(Vec<String>),
}
Vec::<TodoLine>::deserialize(deserializer)?
.into_iter()
.map(|line| match line {
TodoLine::Line(line) => Ok(line),
TodoLine::Words(words) => Err(serde::de::Error::custom(format!(
"todo_keywords takes one #+TODO: line per sequence; write \"{}\"",
words.join(" ")
))),
})
.collect()
}
pub fn parse_agenda_day(value: &str, today: NaiveDate) -> Result<NaiveDate, AgdError> {
crate::date::parse_org_date(&crate::parse_date_input(value, today)?.date)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::config::test_support::{load_body, load_error};
use crate::render_config;
#[test]
fn editor_command_substitutes_every_placeholder_per_argument() {
let settings = Settings {
editor: strings(&["emacsclient", "-n", "+{line}:{col}", "{file}"]),
..Settings::default()
};
assert_eq!(
settings.editor_command("/org/my tasks.org", 42, 1),
(
"emacsclient".to_string(),
strings(&["-n", "+42:1", "/org/my tasks.org"])
)
);
}
#[test]
fn a_capture_line_names_its_template_only_with_text_after_the_key() {
let settings = Settings {
captures: vec![
CaptureTemplate::default(),
CaptureTemplate {
key: "n".to_string(),
..CaptureTemplate::default()
},
],
..Settings::default()
};
let read = |line: &str| {
let (template, text) = settings.capture_line_template(line).unwrap();
(template.key.clone(), text.to_string())
};
assert_eq!(
read("n Call back"),
("n".to_string(), "Call back".to_string())
);
assert_eq!(
read("Renew passport"),
("t".to_string(), "Renew passport".to_string())
);
assert_eq!(read("n"), ("t".to_string(), "n".to_string()));
assert_eq!(
read("x -1 point"),
("t".to_string(), "x -1 point".to_string())
);
}
#[test]
fn unknown_names_list_what_is_configured_or_say_nothing_is() {
let settings = Settings {
agent_profiles: vec![AgentProfile {
name: "coding".to_string(),
..AgentProfile::default()
}],
..Settings::default()
};
let message = |error: AgdError| (error.code(), error.to_string());
assert_eq!(
message(settings.find_view("x").unwrap_err()),
(
ErrorCode::UnknownView,
"unknown view 'x'; the views are 'n'".to_string()
)
);
assert_eq!(
message(settings.find_start_view().unwrap_err()).1,
"--view without a key runs start_view, which is not set; name one of 'n'"
);
assert_eq!(
message(settings.find_agent_profile("x").unwrap_err()).1,
"unknown agent profile 'x'; the profiles are 'coding'"
);
assert_eq!(
message(Settings::default().find_agent_profile("x").unwrap_err()).1,
"unknown agent profile 'x'; no [[agent_profile]] is configured"
);
let none = Settings {
captures: Vec::new(),
..Settings::default()
};
assert_eq!(
message(none.find_capture_template("t").unwrap_err()),
(
ErrorCode::UnknownTemplate,
"unknown capture template 't'; no [[capture]] template is configured".to_string()
)
);
assert_eq!(quoted_list(["a", "b", "c"]), "'a', 'b' and 'c'");
}
#[test]
fn builtin_n_stays_beside_user_views_until_replaced() {
let defaults = Settings::default();
assert!(defaults.views.is_empty());
assert_eq!(defaults.view("n").unwrap().desc, "Agenda and all TODOs");
assert!(defaults.is_builtin_view("n"));
let settings = load_body(concat!(
"start_view = \"n\"\n",
"[[view]]\nkey = \"w\"\ndesc = \"Work\"\n",
"blocks = [{ kind = \"tags-todo\", match = \"work\" }]\n",
))
.unwrap();
let keys: Vec<&str> = settings
.all_views()
.iter()
.map(|view| view.key.as_str())
.collect();
assert_eq!(keys, ["w", "n"]);
assert!(!settings.is_builtin_view("w"));
let rendered = render_config(&settings, "views").unwrap();
assert!(rendered.contains("key = \"w\""), "{rendered}");
assert!(!rendered.contains("key = \"n\""), "{rendered}");
let replaced = load_body(concat!(
"[[view]]\nkey = \"n\"\ndesc = \"Next\"\n",
"blocks = [{ kind = \"todo\", match = \"NEXT\" }]\n",
))
.unwrap();
assert_eq!(replaced.all_views().len(), 1);
assert_eq!(replaced.view("n").unwrap().desc, "Next");
assert!(!replaced.is_builtin_view("n"));
}
#[test]
fn week_start_is_monday_or_sunday() {
let settings = load_body("week_start = \"sunday\"\n").unwrap();
assert_eq!(settings.week_start, WeekStart::Sunday);
assert_eq!(
load_error("week_start = \"friday\"\n"),
concat!(
"cannot parse CONFIG: TOML parse error at line 2, column 14\n",
" |\n",
"2 | week_start = \"friday\"\n",
" | ^^^^^^^^\n",
"unknown variant `friday`, expected `monday` or `sunday`\n",
)
);
}
#[test]
fn notation_is_agd_or_org() {
assert_eq!(load_body("").unwrap().notation, Notation::Agd);
assert_eq!(
load_body("notation = \"org\"\n").unwrap().notation,
Notation::Org
);
assert_eq!(
load_error("notation = \"emacs\"\n"),
concat!(
"cannot parse CONFIG: TOML parse error at line 2, column 12\n",
" |\n",
"2 | notation = \"emacs\"\n",
" | ^^^^^^^\n",
"unknown variant `emacs`, expected `agd` or `org`\n",
)
);
}
#[test]
fn log_settings_are_off_time_or_note() {
let settings = load_body("log_done = \"note\"\nlog_repeat = \"off\"\n").unwrap();
assert_eq!(
(settings.log_done, settings.log_repeat),
(LogMode::Note, LogMode::Off)
);
assert_eq!(
load_error("log_reschedule = \"yes\"\n"),
concat!(
"cannot parse CONFIG: TOML parse error at line 2, column 18\n",
" |\n",
"2 | log_reschedule = \"yes\"\n",
" | ^^^^^\n",
"unknown variant `yes`, expected one of `off`, `time`, `note`\n",
)
);
assert!(
load_error("log_done = \"nil\"\n")
.ends_with("unknown variant `nil`, expected one of `off`, `time`, `note`\n")
);
}
#[test]
fn todo_keyword_lines_read_like_a_files_todo_lines() {
let settings = Settings {
todo_keywords: strings(&["TODO(t) WAIT(w@/!) | DONE(d!)", "IDEA DROPPED"]),
..Settings::default()
};
let document = crate::IndexedDocument::parse(
PathBuf::from("tasks.org"),
b"* WAIT Reply\n".to_vec(),
&settings,
)
.unwrap();
let keywords: Vec<(&str, bool, usize)> = document
.todo_keywords()
.iter()
.map(|keyword| (keyword.name.as_str(), keyword.done, keyword.sequence))
.collect();
assert_eq!(
keywords,
[
("TODO", false, 0),
("WAIT", false, 0),
("DONE", true, 0),
("IDEA", false, 1),
("DROPPED", true, 1)
]
);
let wait = &document.todo_keywords()[1];
assert_eq!(
(wait.log_enter, wait.log_leave),
(Some(crate::LogKind::Note), Some(crate::LogKind::Time))
);
}
}