Skip to main content

doing_config/
lib.rs

1//! Configuration loading and types for the doing CLI.
2//!
3//! This crate handles discovering, parsing, merging, and deserializing the
4//! doing configuration from multiple sources:
5//!
6//! 1. **Global config** — `$DOING_CONFIG`, XDG config home, or `~/.doingrc`.
7//! 2. **Local configs** — `.doingrc` files walked from filesystem root to `$CWD`
8//!    (each layer deep-merges over the previous).
9//! 3. **Environment variables** — `DOING_FILE`, `DOING_BACKUP_DIR`, `DOING_EDITOR`,
10//!    and others override individual fields (see [`env`]).
11//!
12//! Config files may be YAML, TOML, or JSON (with comments). The merged result is
13//! deserialized into [`Config`], which provides typed access to all settings with
14//! sensible defaults.
15//!
16//! # Usage
17//!
18//! ```no_run
19//! let config = doing_config::Config::load().unwrap();
20//! println!("doing file: {}", config.doing_file.display());
21//! ```
22
23pub mod env;
24pub mod loader;
25pub mod paths;
26
27use std::{
28  collections::HashMap,
29  env as std_env,
30  fmt::{Display, Formatter},
31  path::PathBuf,
32};
33
34use doing_error::{Error, Result};
35pub use doing_time::ShortdateFormatConfig;
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39use crate::paths::expand_tilde;
40
41/// Autotag configuration for automatic tag assignment.
42///
43/// Supports both structured format (synonyms/transform/whitelist) and Ruby-style
44/// simple key-value mappings where `word = "tag"` means: if "word" appears in the
45/// title, add `@tag`.
46#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
47pub struct AutotagConfig {
48  /// Ruby-style simple mappings: word -> tag name
49  pub mappings: HashMap<String, String>,
50  pub synonyms: HashMap<String, Vec<String>>,
51  pub transform: Vec<String>,
52  pub whitelist: Vec<String>,
53}
54
55impl<'de> serde::Deserialize<'de> for AutotagConfig {
56  fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
57  where
58    D: serde::Deserializer<'de>,
59  {
60    let value: serde_json::Value = serde::Deserialize::deserialize(deserializer)?;
61
62    let obj = match value.as_object() {
63      Some(obj) => obj,
64      None => return Ok(Self::default()),
65    };
66
67    let mut config = Self::default();
68
69    for (key, val) in obj {
70      match key.as_str() {
71        "synonyms" => {
72          if let Ok(v) = serde_json::from_value(val.clone()) {
73            config.synonyms = v;
74          }
75        }
76        "transform" => {
77          if let Ok(v) = serde_json::from_value(val.clone()) {
78            config.transform = v;
79          }
80        }
81        "whitelist" => {
82          if let Ok(v) = serde_json::from_value(val.clone()) {
83            config.whitelist = v;
84          }
85        }
86        _ => {
87          // Ruby-style mapping: word = "tag"
88          if let Some(tag) = val.as_str() {
89            config.mappings.insert(key.clone(), tag.to_string());
90          }
91        }
92      }
93    }
94
95    Ok(config)
96  }
97}
98
99/// Configuration for the byday plugin.
100#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
101#[serde(default)]
102pub struct BydayPluginConfig {
103  pub item_width: u32,
104}
105
106impl Default for BydayPluginConfig {
107  fn default() -> Self {
108    Self {
109      item_width: 60,
110    }
111  }
112}
113
114/// Top-level configuration for the doing application.
115#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
116#[serde(default)]
117pub struct Config {
118  pub autotag: AutotagConfig,
119  pub backup_dir: PathBuf,
120  pub budgets: HashMap<String, String>,
121  pub current_section: String,
122  pub date_tags: Vec<String>,
123  pub default_tags: Vec<String>,
124  pub disabled_commands: Vec<String>,
125  pub doing_file: PathBuf,
126  pub doing_file_sort: SortOrder,
127  pub editors: EditorsConfig,
128  pub export_templates: HashMap<String, Option<TemplateConfig>>,
129  pub history_size: u32,
130  pub include_notes: bool,
131  pub interaction: InteractionConfig,
132  pub interval_format: String,
133  pub marker_color: String,
134  pub marker_tag: String,
135  pub never_finish: Vec<String>,
136  pub never_time: Vec<String>,
137  pub order: SortOrder,
138  pub paginate: bool,
139  pub plugins: PluginsConfig,
140  pub search: SearchConfig,
141  pub shortdate_format: ShortdateFormatConfig,
142  pub tag_sort: String,
143  pub tags_color: Option<String>,
144  pub template_path: PathBuf,
145  pub templates: HashMap<String, TemplateConfig>,
146  pub timer_format: String,
147  pub totals_format: String,
148  pub views: HashMap<String, ViewConfig>,
149}
150
151impl Config {
152  /// Load the fully resolved configuration.
153  ///
154  /// Discovery order:
155  /// 1. Parse global config file (env var -> XDG -> `~/.doingrc`).
156  /// 2. Parse local `.doingrc` files (walked root-to-leaf from CWD).
157  /// 3. Deep-merge all layers (local overrides global).
158  /// 4. Apply environment variable overrides.
159  /// 5. Deserialize into `Config` (serde fills defaults for missing keys).
160  /// 6. Expand `~` in path fields.
161  ///
162  /// Missing config files produce defaults, not errors.
163  pub fn load() -> Result<Self> {
164    let cwd = std_env::current_dir()?;
165    Self::load_from(&cwd)
166  }
167
168  /// Load configuration using a specific directory for local config discovery.
169  pub fn load_from(start_dir: &std::path::Path) -> Result<Self> {
170    let global_config = loader::discover_global_config();
171    let mut merged = match &global_config {
172      Some(path) => loader::parse_file(path)?,
173      None => Value::Object(serde_json::Map::new()),
174    };
175
176    for local_path in loader::discover_local_configs_with_global(start_dir, global_config.as_deref()) {
177      let local = loader::parse_file(&local_path)?;
178      merged = loader::deep_merge(&merged, &local);
179    }
180
181    merged = apply_env_overrides(merged);
182
183    let mut config: Config =
184      serde_json::from_value(merged).map_err(|e| Error::Config(format!("deserialization error: {e}")))?;
185
186    config.expand_paths()?;
187    Ok(config)
188  }
189
190  fn expand_paths(&mut self) -> Result<()> {
191    self.backup_dir = expand_tilde(&self.backup_dir)?;
192    self.doing_file = expand_tilde(&self.doing_file)?;
193    self.plugins.command_path = expand_tilde(&self.plugins.command_path)?;
194    self.plugins.plugin_path = expand_tilde(&self.plugins.plugin_path)?;
195    self.template_path = expand_tilde(&self.template_path)?;
196    Ok(())
197  }
198}
199
200impl Default for Config {
201  fn default() -> Self {
202    let config_dir = dir_spec::config_home().unwrap_or_else(|| PathBuf::from(".config"));
203    let data_dir = dir_spec::data_home().unwrap_or_else(|| PathBuf::from(".local/share"));
204    Self {
205      autotag: AutotagConfig::default(),
206      backup_dir: data_dir.join("doing/doing_backup"),
207      budgets: HashMap::new(),
208      current_section: "Currently".into(),
209      date_tags: vec!["done".into(), "defer(?:red)?".into(), "waiting".into()],
210      default_tags: Vec::new(),
211      disabled_commands: Vec::new(),
212      doing_file: data_dir.join("doing/what_was_i_doing.md"),
213      doing_file_sort: SortOrder::Desc,
214      editors: EditorsConfig::default(),
215      export_templates: HashMap::new(),
216      history_size: 15,
217      include_notes: true,
218      interaction: InteractionConfig::default(),
219      interval_format: "clock".into(),
220      marker_color: "red".into(),
221      marker_tag: "flagged".into(),
222      never_finish: Vec::new(),
223      never_time: Vec::new(),
224      order: SortOrder::Asc,
225      paginate: false,
226      plugins: PluginsConfig::default(),
227      search: SearchConfig::default(),
228      shortdate_format: ShortdateFormatConfig::default(),
229      tag_sort: "name".into(),
230      tags_color: None,
231      template_path: config_dir.join("doing/templates"),
232      templates: HashMap::new(),
233      timer_format: "text".into(),
234      totals_format: String::new(),
235      views: HashMap::new(),
236    }
237  }
238}
239
240/// Editor configuration for various contexts.
241#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
242#[serde(default)]
243pub struct EditorsConfig {
244  pub config: Option<String>,
245  pub default: Option<String>,
246  pub doing_file: Option<String>,
247  pub pager: Option<String>,
248}
249
250/// Interaction settings for user prompts.
251#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
252#[serde(default)]
253pub struct InteractionConfig {
254  pub confirm_longer_than: String,
255}
256
257impl Default for InteractionConfig {
258  fn default() -> Self {
259    Self {
260      confirm_longer_than: "5h".into(),
261    }
262  }
263}
264
265/// Plugin paths and plugin-specific settings.
266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267#[serde(default)]
268pub struct PluginsConfig {
269  pub byday: BydayPluginConfig,
270  pub command_path: PathBuf,
271  pub plugin_path: PathBuf,
272}
273
274impl Default for PluginsConfig {
275  fn default() -> Self {
276    let config_dir = dir_spec::config_home().unwrap_or_else(|| PathBuf::from(".config"));
277    Self {
278      byday: BydayPluginConfig::default(),
279      command_path: config_dir.join("doing/commands"),
280      plugin_path: config_dir.join("doing/plugins"),
281    }
282  }
283}
284
285/// Search behavior settings.
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287#[serde(default)]
288pub struct SearchConfig {
289  pub case: String,
290  pub distance: u32,
291  pub highlight: bool,
292  pub matching: String,
293}
294
295impl Default for SearchConfig {
296  fn default() -> Self {
297    Self {
298      case: "smart".into(),
299      distance: 3,
300      highlight: false,
301      matching: "pattern".into(),
302    }
303  }
304}
305
306/// The order in which items are sorted.
307#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
308#[serde(rename_all = "lowercase")]
309pub enum SortOrder {
310  #[default]
311  Asc,
312  Desc,
313}
314
315impl Display for SortOrder {
316  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
317    match self {
318      Self::Asc => write!(f, "asc"),
319      Self::Desc => write!(f, "desc"),
320    }
321  }
322}
323
324/// A named display template.
325#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
326#[serde(default)]
327pub struct TemplateConfig {
328  pub count: Option<u32>,
329  pub date_format: String,
330  pub order: Option<SortOrder>,
331  pub template: String,
332  pub wrap_width: u32,
333}
334
335impl Default for TemplateConfig {
336  fn default() -> Self {
337    Self {
338      count: None,
339      date_format: "%Y-%m-%d %H:%M".into(),
340      order: None,
341      template:
342        "%boldwhite%-10shortdate %boldcyan║ %boldwhite%title%reset  %interval  %cyan[%10section]%reset%cyan%note%reset"
343          .into(),
344      wrap_width: 0,
345    }
346  }
347}
348
349/// A named custom view.
350#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
351#[serde(default)]
352pub struct ViewConfig {
353  pub count: u32,
354  pub date_format: String,
355  pub order: SortOrder,
356  pub section: String,
357  pub tags: String,
358  pub tags_bool: String,
359  pub template: String,
360  pub wrap_width: u32,
361}
362
363impl Default for ViewConfig {
364  fn default() -> Self {
365    Self {
366      count: 0,
367      date_format: String::new(),
368      order: SortOrder::Asc,
369      section: String::new(),
370      tags: String::new(),
371      tags_bool: "OR".into(),
372      template: String::new(),
373      wrap_width: 0,
374    }
375  }
376}
377
378/// Apply environment variable overrides to a config value tree.
379fn apply_env_overrides(mut value: Value) -> Value {
380  let obj = match value.as_object_mut() {
381    Some(obj) => obj,
382    None => return value,
383  };
384
385  if let Ok(backup_dir) = env::DOING_BACKUP_DIR.value() {
386    obj.insert("backup_dir".into(), Value::String(backup_dir));
387  }
388
389  if let Ok(doing_file) = env::DOING_FILE.value() {
390    obj.insert("doing_file".into(), Value::String(doing_file));
391  }
392
393  if let Ok(editor) = env::DOING_EDITOR.value() {
394    let editors = obj
395      .entry("editors")
396      .or_insert_with(|| Value::Object(serde_json::Map::new()));
397    if let Some(editors_obj) = editors.as_object_mut() {
398      editors_obj.insert("default".into(), Value::String(editor));
399    }
400  }
401
402  value
403}
404
405#[cfg(test)]
406mod test {
407  use std::fs;
408
409  use super::*;
410
411  mod load {
412    use super::*;
413
414    #[test]
415    fn it_succeeds_when_current_dir_is_valid() {
416      let result = Config::load();
417
418      assert!(result.is_ok());
419    }
420  }
421
422  mod load_from {
423    use pretty_assertions::assert_eq;
424
425    use super::*;
426
427    #[test]
428    fn it_expands_tilde_in_paths() {
429      let dir = tempfile::tempdir().unwrap();
430      fs::write(
431        dir.path().join(".doingrc"),
432        "doing_file: ~/my_doing.md\nbackup_dir: ~/backups\n",
433      )
434      .unwrap();
435
436      let config = Config::load_from(dir.path()).unwrap();
437
438      assert!(config.doing_file.is_absolute());
439      assert!(config.doing_file.ends_with("my_doing.md"));
440      assert!(config.backup_dir.is_absolute());
441      assert!(config.backup_dir.ends_with("backups"));
442    }
443
444    #[test]
445    fn it_handles_explicit_null_values_in_config() {
446      let dir = tempfile::tempdir().unwrap();
447      fs::write(dir.path().join(".doingrc"), "search:\ncurrent_section: Working\n").unwrap();
448
449      let config = Config::load_from(dir.path()).unwrap();
450
451      assert_eq!(config.current_section, "Working");
452      assert_eq!(config.search, SearchConfig::default());
453    }
454
455    #[test]
456    fn it_loads_from_local_doingrc() {
457      let dir = tempfile::tempdir().unwrap();
458      fs::write(
459        dir.path().join(".doingrc"),
460        "current_section: Working\nhistory_size: 30\n",
461      )
462      .unwrap();
463
464      let config = Config::load_from(dir.path()).unwrap();
465
466      assert_eq!(config.current_section, "Working");
467      assert_eq!(config.history_size, 30);
468    }
469
470    #[test]
471    fn it_merges_nested_local_configs() {
472      let dir = tempfile::tempdir().unwrap();
473      let root = dir.path();
474      let child = root.join("projects/myapp");
475      fs::create_dir_all(&child).unwrap();
476      fs::write(root.join(".doingrc"), "current_section: Root\nhistory_size: 50\n").unwrap();
477      fs::write(child.join(".doingrc"), "current_section: Child\n").unwrap();
478
479      let config = Config::load_from(&child).unwrap();
480
481      assert_eq!(config.current_section, "Child");
482      assert_eq!(config.history_size, 50);
483    }
484
485    #[test]
486    fn it_preserves_defaults_for_missing_keys() {
487      let dir = tempfile::tempdir().unwrap();
488      fs::write(dir.path().join(".doingrc"), "history_size: 99\n").unwrap();
489
490      let config = Config::load_from(dir.path()).unwrap();
491
492      assert_eq!(config.history_size, 99);
493      assert_eq!(config.current_section, "Currently");
494      assert_eq!(config.marker_tag, "flagged");
495      assert_eq!(config.search.matching, "pattern");
496    }
497
498    #[test]
499    fn it_returns_defaults_when_no_config_exists() {
500      let dir = tempfile::tempdir().unwrap();
501
502      let config = Config::load_from(dir.path()).unwrap();
503
504      assert_eq!(config.current_section, "Currently");
505      assert_eq!(config.history_size, 15);
506      assert_eq!(config.order, SortOrder::Asc);
507    }
508  }
509}