1use 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
53fn 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
63pub 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
82fn 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
111fn 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
140const 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
195pub 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 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
246fn 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
318fn 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 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}