1pub 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#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
47pub struct AutotagConfig {
48 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 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#[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#[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 pub fn load() -> Result<Self> {
164 let cwd = std_env::current_dir()?;
165 Self::load_from(&cwd)
166 }
167
168 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#[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#[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#[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#[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#[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#[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#[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
378fn 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}