Skip to main content

doing_config/
loader.rs

1use std::{
2  fs,
3  io::Read,
4  path::{Path, PathBuf},
5};
6
7use doing_error::{Error, Result};
8use serde_json::Value;
9
10use crate::{env::DOING_CONFIG, paths::expand_tilde};
11
12/// Supported configuration file formats.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum ConfigFormat {
15  Json,
16  Toml,
17  Yaml,
18}
19
20impl ConfigFormat {
21  /// Detect format from a file extension.
22  ///
23  /// Returns `None` for unrecognized extensions.
24  pub fn from_extension(path: &Path) -> Option<Self> {
25    match path.extension()?.to_str()? {
26      "json" | "jsonc" => Some(Self::Json),
27      "toml" => Some(Self::Toml),
28      "yaml" | "yml" => Some(Self::Yaml),
29      _ => None,
30    }
31  }
32}
33
34/// Deep-merge two JSON [`Value`] trees.
35///
36/// - Objects merge recursively: keys from `overlay` are applied on top of `base`.
37/// - Arrays concatenate: `overlay` elements are appended to `base` elements.
38/// - All other types: `overlay` wins.
39pub fn deep_merge(base: &Value, overlay: &Value) -> Value {
40  match (base, overlay) {
41    (_, Value::Null) => base.clone(),
42    (Value::Object(base_map), Value::Object(overlay_map)) => {
43      let mut merged = base_map.clone();
44      for (key, overlay_val) in overlay_map {
45        let merged_val = match merged.get(key) {
46          Some(base_val) => deep_merge(base_val, overlay_val),
47          None if overlay_val.is_null() => continue,
48          None => overlay_val.clone(),
49        };
50        merged.insert(key.clone(), merged_val);
51      }
52      Value::Object(merged)
53    }
54    (Value::Array(base_arr), Value::Array(overlay_arr)) => {
55      let mut merged = base_arr.clone();
56      merged.extend(overlay_arr.iter().cloned());
57      Value::Array(merged)
58    }
59    (_, overlay) => overlay.clone(),
60  }
61}
62
63/// Discover the global config file path.
64///
65/// Searches in order:
66/// 1. `DOING_CONFIG` environment variable
67/// 2. XDG config path (`$XDG_CONFIG_HOME/doing/config.yml`)
68/// 3. `~/.doingrc` fallback
69///
70/// Returns `None` if no config file exists at any location.
71pub fn discover_global_config() -> Option<PathBuf> {
72  if let Some(env_path) = env_config_path() {
73    return Some(env_path);
74  }
75
76  let xdg_path = dir_spec::config_home()?.join("doing/config.yml");
77  if xdg_path.exists() {
78    return Some(xdg_path);
79  }
80
81  let home_rc = dir_spec::home()?.join(".doingrc");
82  if home_rc.exists() {
83    return Some(home_rc);
84  }
85
86  None
87}
88
89/// Discover local `.doingrc` files by walking from `start_dir` upward.
90///
91/// Returns paths ordered root-to-leaf (outermost ancestor first) so they
92/// can be merged in precedence order -- each successive file overrides the
93/// previous.
94pub fn discover_local_configs(start_dir: &Path) -> Vec<PathBuf> {
95  discover_local_configs_with_global(start_dir, discover_global_config().as_deref())
96}
97
98pub fn discover_local_configs_with_global(start_dir: &Path, global: Option<&Path>) -> Vec<PathBuf> {
99  const MAX_DEPTH: usize = 20;
100
101  let mut configs = Vec::new();
102  let mut dir = start_dir.to_path_buf();
103  let mut depth = 0;
104
105  loop {
106    let candidate = dir.join(".doingrc");
107    if candidate.exists() {
108      let dominated_by_global = global.is_some_and(|g| g == candidate);
109      if !dominated_by_global {
110        configs.push(candidate);
111      }
112    }
113
114    depth += 1;
115    if depth >= MAX_DEPTH || !dir.pop() {
116      break;
117    }
118  }
119
120  configs.reverse();
121  configs
122}
123
124/// Parse a config file into a generic JSON [`Value`] tree.
125///
126/// The format is detected from the file extension. Files with no recognized
127/// extension are tried as YAML first (the default config format), then TOML.
128///
129/// Empty or whitespace-only files are treated as empty config objects.
130pub fn parse_file(path: &Path) -> Result<Value> {
131  let content = fs::read_to_string(path).map_err(|e| Error::Config(format!("{path}: {e}", path = path.display())))?;
132
133  if content.trim().is_empty() {
134    return Ok(Value::Object(serde_json::Map::new()));
135  }
136
137  match ConfigFormat::from_extension(path) {
138    Some(format) => parse_str(&content, format),
139    None => try_parse_unknown(&content, path),
140  }
141}
142
143/// Parse a string in the given format into a [`Value`].
144pub fn parse_str(content: &str, format: ConfigFormat) -> Result<Value> {
145  match format {
146    ConfigFormat::Json => parse_json(content),
147    ConfigFormat::Toml => parse_toml(content),
148    ConfigFormat::Yaml => parse_yaml(content),
149  }
150}
151
152/// Return the path to the global config file for editing.
153///
154/// Uses the same discovery order as [`discover_global_config`], but falls back to
155/// the XDG config path when no existing file is found.
156pub fn resolve_global_config_path() -> PathBuf {
157  discover_global_config().unwrap_or_else(|| {
158    dir_spec::config_home()
159      .unwrap_or_else(|| PathBuf::from(".config"))
160      .join("doing/config.toml")
161  })
162}
163
164fn env_config_path() -> Option<PathBuf> {
165  let raw = DOING_CONFIG.value().ok()?;
166  let path = expand_tilde(Path::new(&raw)).ok()?;
167  if path.exists() { Some(path) } else { None }
168}
169
170fn parse_json(content: &str) -> Result<Value> {
171  let mut stripped = String::new();
172  json_comments::StripComments::new(content.as_bytes())
173    .read_to_string(&mut stripped)
174    .map_err(|e| Error::Config(format!("failed to strip JSON comments: {e}")))?;
175
176  serde_json::from_str(&stripped).map_err(|e| Error::Config(format!("invalid JSON: {e}")))
177}
178
179fn parse_toml(content: &str) -> Result<Value> {
180  let toml_value: toml::Table = toml::from_str(content).map_err(|e| Error::Config(format!("invalid TOML: {e}")))?;
181  serde_json::to_value(toml_value).map_err(|e| Error::Config(format!("TOML conversion error: {e}")))
182}
183
184fn parse_yaml(content: &str) -> Result<Value> {
185  yaml_serde::from_str(content).map_err(|e| Error::Config(format!("invalid YAML: {e}")))
186}
187
188fn try_parse_unknown(content: &str, path: &Path) -> Result<Value> {
189  parse_yaml(content).or_else(|_| {
190    parse_toml(content).map_err(|_| Error::Config(format!("{}: unrecognized config format", path.display())))
191  })
192}
193
194#[cfg(test)]
195mod test {
196  use super::*;
197
198  mod deep_merge {
199    use pretty_assertions::assert_eq;
200    use serde_json::json;
201
202    #[test]
203    fn it_adds_new_keys() {
204      let base = json!({"order": "asc"});
205      let overlay = json!({"marker_tag": "flagged"});
206
207      let result = super::deep_merge(&base, &overlay);
208
209      assert_eq!(result, json!({"order": "asc", "marker_tag": "flagged"}));
210    }
211
212    #[test]
213    fn it_concatenates_arrays() {
214      let base = json!({"tags": ["done", "waiting"]});
215      let overlay = json!({"tags": ["custom"]});
216
217      let result = super::deep_merge(&base, &overlay);
218
219      assert_eq!(result, json!({"tags": ["done", "waiting", "custom"]}));
220    }
221
222    #[test]
223    fn it_handles_nested_objects_with_arrays() {
224      let base = json!({"autotag": {"whitelist": ["work"], "synonyms": {}}});
225      let overlay = json!({"autotag": {"whitelist": ["play"]}});
226
227      let result = super::deep_merge(&base, &overlay);
228
229      assert_eq!(
230        result,
231        json!({"autotag": {"whitelist": ["work", "play"], "synonyms": {}}})
232      );
233    }
234
235    #[test]
236    fn it_ignores_null_fields_within_objects() {
237      let base = json!({"search": {"case": "smart", "distance": 3}});
238      let overlay = json!({"search": {"case": null, "distance": 5}});
239
240      let result = super::deep_merge(&base, &overlay);
241
242      assert_eq!(result, json!({"search": {"case": "smart", "distance": 5}}));
243    }
244
245    #[test]
246    fn it_ignores_null_overlay_values() {
247      let base = json!({"search": {"case": "smart", "distance": 3}});
248      let overlay = json!({"search": null});
249
250      let result = super::deep_merge(&base, &overlay);
251
252      assert_eq!(result, json!({"search": {"case": "smart", "distance": 3}}));
253    }
254
255    #[test]
256    fn it_merges_objects_recursively() {
257      let base = json!({"search": {"case": "smart", "distance": 3}});
258      let overlay = json!({"search": {"distance": 5}});
259
260      let result = super::deep_merge(&base, &overlay);
261
262      assert_eq!(result, json!({"search": {"case": "smart", "distance": 5}}));
263    }
264
265    #[test]
266    fn it_overwrites_scalars() {
267      let base = json!({"order": "asc", "paginate": false});
268      let overlay = json!({"order": "desc"});
269
270      let result = super::deep_merge(&base, &overlay);
271
272      assert_eq!(result, json!({"order": "desc", "paginate": false}));
273    }
274
275    #[test]
276    fn it_replaces_scalar_with_object() {
277      let base = json!({"editors": "vim"});
278      let overlay = json!({"editors": {"default": "nvim"}});
279
280      let result = super::deep_merge(&base, &overlay);
281
282      assert_eq!(result, json!({"editors": {"default": "nvim"}}));
283    }
284
285    #[test]
286    fn it_skips_null_for_new_keys() {
287      let base = json!({"order": "asc"});
288      let overlay = json!({"search": null});
289
290      let result = super::deep_merge(&base, &overlay);
291
292      assert_eq!(result, json!({"order": "asc"}));
293    }
294  }
295
296  mod discover_local_configs {
297    use pretty_assertions::assert_eq;
298
299    use super::*;
300
301    #[test]
302    fn it_excludes_global_config_path() {
303      // If a .doingrc happens to be the global config, it should not appear
304      // as a local config. This is difficult to test without mocking the
305      // global discovery, so we just verify the function doesn't panic on
306      // deeply nested paths.
307      let dir = tempfile::tempdir().unwrap();
308      let deep = dir.path().join("a/b/c/d/e");
309      fs::create_dir_all(&deep).unwrap();
310
311      let configs = discover_local_configs(&deep);
312
313      assert!(configs.is_empty());
314    }
315
316    #[test]
317    fn it_finds_doingrc_in_ancestors() {
318      let dir = tempfile::tempdir().unwrap();
319      let root = dir.path();
320      let child = root.join("projects/myapp");
321      fs::create_dir_all(&child).unwrap();
322      fs::write(root.join(".doingrc"), "order: asc\n").unwrap();
323      fs::write(child.join(".doingrc"), "order: desc\n").unwrap();
324
325      let configs = discover_local_configs(&child);
326
327      assert_eq!(configs.len(), 2);
328      assert_eq!(configs[0], root.join(".doingrc"));
329      assert_eq!(configs[1], child.join(".doingrc"));
330    }
331
332    #[test]
333    fn it_returns_empty_when_none_found() {
334      let dir = tempfile::tempdir().unwrap();
335
336      let configs = discover_local_configs(dir.path());
337
338      assert!(configs.is_empty());
339    }
340
341    #[test]
342    fn it_stops_walking_at_max_depth() {
343      let dir = tempfile::tempdir().unwrap();
344      let root = dir.path();
345      // Build a path 25 levels deep — beyond MAX_DEPTH (20)
346      let mut deep = root.to_path_buf();
347      for i in 0..25 {
348        deep = deep.join(format!("d{i}"));
349      }
350      fs::create_dir_all(&deep).unwrap();
351      // Place a .doingrc at the root — it should NOT be found
352      fs::write(root.join(".doingrc"), "order: asc\n").unwrap();
353
354      let configs = discover_local_configs_with_global(&deep, None);
355
356      assert!(configs.is_empty());
357    }
358  }
359
360  mod from_extension {
361    use pretty_assertions::assert_eq;
362
363    use super::*;
364
365    #[test]
366    fn it_detects_json() {
367      assert_eq!(
368        ConfigFormat::from_extension(Path::new("config.json")),
369        Some(ConfigFormat::Json)
370      );
371    }
372
373    #[test]
374    fn it_detects_jsonc() {
375      assert_eq!(
376        ConfigFormat::from_extension(Path::new("config.jsonc")),
377        Some(ConfigFormat::Json)
378      );
379    }
380
381    #[test]
382    fn it_detects_toml() {
383      assert_eq!(
384        ConfigFormat::from_extension(Path::new("config.toml")),
385        Some(ConfigFormat::Toml)
386      );
387    }
388
389    #[test]
390    fn it_detects_yaml() {
391      assert_eq!(
392        ConfigFormat::from_extension(Path::new("config.yaml")),
393        Some(ConfigFormat::Yaml)
394      );
395    }
396
397    #[test]
398    fn it_detects_yml() {
399      assert_eq!(
400        ConfigFormat::from_extension(Path::new("config.yml")),
401        Some(ConfigFormat::Yaml)
402      );
403    }
404
405    #[test]
406    fn it_returns_none_for_no_extension() {
407      assert_eq!(ConfigFormat::from_extension(Path::new(".doingrc")), None);
408    }
409
410    #[test]
411    fn it_returns_none_for_unknown() {
412      assert_eq!(ConfigFormat::from_extension(Path::new("config.txt")), None);
413    }
414  }
415
416  mod parse_file {
417    use pretty_assertions::assert_eq;
418
419    use super::*;
420
421    #[test]
422    fn it_falls_back_to_yaml_for_unknown_extension() {
423      let dir = tempfile::tempdir().unwrap();
424      let path = dir.path().join(".doingrc");
425      fs::write(&path, "current_section: Working\n").unwrap();
426
427      let value = parse_file(&path).unwrap();
428
429      assert_eq!(value["current_section"], "Working");
430    }
431
432    #[test]
433    fn it_parses_json_file() {
434      let dir = tempfile::tempdir().unwrap();
435      let path = dir.path().join("config.json");
436      fs::write(&path, r#"{"current_section": "Working", "history_size": 25}"#).unwrap();
437
438      let value = parse_file(&path).unwrap();
439
440      assert_eq!(value["current_section"], "Working");
441      assert_eq!(value["history_size"], 25);
442    }
443
444    #[test]
445    fn it_parses_toml_file() {
446      let dir = tempfile::tempdir().unwrap();
447      let path = dir.path().join("config.toml");
448      fs::write(&path, "current_section = \"Working\"\nhistory_size = 25\n").unwrap();
449
450      let value = parse_file(&path).unwrap();
451
452      assert_eq!(value["current_section"], "Working");
453      assert_eq!(value["history_size"], 25);
454    }
455
456    #[test]
457    fn it_parses_yaml_file() {
458      let dir = tempfile::tempdir().unwrap();
459      let path = dir.path().join("config.yml");
460      fs::write(&path, "current_section: Working\nhistory_size: 25\n").unwrap();
461
462      let value = parse_file(&path).unwrap();
463
464      assert_eq!(value["current_section"], "Working");
465      assert_eq!(value["history_size"], 25);
466    }
467
468    #[test]
469    fn it_returns_empty_object_for_empty_file() {
470      let dir = tempfile::tempdir().unwrap();
471      let path = dir.path().join(".doingrc");
472      fs::write(&path, "").unwrap();
473
474      let value = parse_file(&path).unwrap();
475
476      assert_eq!(value, serde_json::Value::Object(serde_json::Map::new()));
477    }
478
479    #[test]
480    fn it_returns_empty_object_for_whitespace_only_file() {
481      let dir = tempfile::tempdir().unwrap();
482      let path = dir.path().join("config.yml");
483      fs::write(&path, "  \n  \n").unwrap();
484
485      let value = parse_file(&path).unwrap();
486
487      assert_eq!(value, serde_json::Value::Object(serde_json::Map::new()));
488    }
489
490    #[test]
491    fn it_returns_error_for_missing_file() {
492      let result = parse_file(Path::new("/nonexistent/config.yml"));
493
494      assert!(result.is_err());
495    }
496
497    #[test]
498    fn it_strips_json_comments() {
499      let dir = tempfile::tempdir().unwrap();
500      let path = dir.path().join("config.jsonc");
501      fs::write(
502        &path,
503        "{\n  // this is a comment\n  \"current_section\": \"Working\"\n}\n",
504      )
505      .unwrap();
506
507      let value = parse_file(&path).unwrap();
508
509      assert_eq!(value["current_section"], "Working");
510    }
511  }
512
513  mod parse_str {
514    use pretty_assertions::assert_eq;
515
516    use super::*;
517
518    #[test]
519    fn it_roundtrips_json() {
520      let json = r#"{"order": "desc", "paginate": true}"#;
521
522      let value = parse_str(json, ConfigFormat::Json).unwrap();
523
524      assert_eq!(value["order"], "desc");
525      assert_eq!(value["paginate"], true);
526    }
527
528    #[test]
529    fn it_roundtrips_toml() {
530      let toml_str = "order = \"desc\"\npaginate = true\n";
531
532      let value = parse_str(toml_str, ConfigFormat::Toml).unwrap();
533
534      assert_eq!(value["order"], "desc");
535      assert_eq!(value["paginate"], true);
536    }
537
538    #[test]
539    fn it_roundtrips_yaml() {
540      let yaml = "order: desc\npaginate: true\n";
541
542      let value = parse_str(yaml, ConfigFormat::Yaml).unwrap();
543
544      assert_eq!(value["order"], "desc");
545      assert_eq!(value["paginate"], true);
546    }
547  }
548}