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