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  let global = discover_global_config();
96  let mut configs = Vec::new();
97  let mut dir = start_dir.to_path_buf();
98
99  loop {
100    let candidate = dir.join(".doingrc");
101    if candidate.exists() {
102      let dominated_by_global = global.as_ref().is_some_and(|g| *g == candidate);
103      if !dominated_by_global {
104        configs.push(candidate);
105      }
106    }
107
108    if !dir.pop() {
109      break;
110    }
111  }
112
113  configs.reverse();
114  configs
115}
116
117/// Parse a config file into a generic JSON [`Value`] tree.
118///
119/// The format is detected from the file extension. Files with no recognized
120/// extension are tried as YAML first (the default config format), then TOML.
121///
122/// Empty or whitespace-only files are treated as empty config objects.
123pub fn parse_file(path: &Path) -> Result<Value> {
124  let content = fs::read_to_string(path).map_err(|e| Error::Config(format!("{path}: {e}", path = path.display())))?;
125
126  if content.trim().is_empty() {
127    return Ok(Value::Object(serde_json::Map::new()));
128  }
129
130  match ConfigFormat::from_extension(path) {
131    Some(format) => parse_str(&content, format),
132    None => try_parse_unknown(&content, path),
133  }
134}
135
136/// Parse a string in the given format into a [`Value`].
137pub fn parse_str(content: &str, format: ConfigFormat) -> Result<Value> {
138  match format {
139    ConfigFormat::Json => parse_json(content),
140    ConfigFormat::Toml => parse_toml(content),
141    ConfigFormat::Yaml => parse_yaml(content),
142  }
143}
144
145/// Return the path to the global config file for editing.
146///
147/// Uses the same discovery order as [`discover_global_config`], but falls back to
148/// the XDG config path when no existing file is found.
149pub fn resolve_global_config_path() -> PathBuf {
150  discover_global_config().unwrap_or_else(|| {
151    dir_spec::config_home()
152      .unwrap_or_else(|| PathBuf::from(".config"))
153      .join("doing/config.toml")
154  })
155}
156
157fn env_config_path() -> Option<PathBuf> {
158  let raw = DOING_CONFIG.value().ok()?;
159  let path = expand_tilde(Path::new(&raw)).ok()?;
160  if path.exists() { Some(path) } else { None }
161}
162
163fn parse_json(content: &str) -> Result<Value> {
164  let mut stripped = String::new();
165  json_comments::StripComments::new(content.as_bytes())
166    .read_to_string(&mut stripped)
167    .map_err(|e| Error::Config(format!("failed to strip JSON comments: {e}")))?;
168
169  serde_json::from_str(&stripped).map_err(|e| Error::Config(format!("invalid JSON: {e}")))
170}
171
172fn parse_toml(content: &str) -> Result<Value> {
173  let toml_value: toml::Table = toml::from_str(content).map_err(|e| Error::Config(format!("invalid TOML: {e}")))?;
174  serde_json::to_value(toml_value).map_err(|e| Error::Config(format!("TOML conversion error: {e}")))
175}
176
177fn parse_yaml(content: &str) -> Result<Value> {
178  yaml_serde::from_str(content).map_err(|e| Error::Config(format!("invalid YAML: {e}")))
179}
180
181fn try_parse_unknown(content: &str, path: &Path) -> Result<Value> {
182  parse_yaml(content).or_else(|_| {
183    parse_toml(content).map_err(|_| Error::Config(format!("{}: unrecognized config format", path.display())))
184  })
185}
186
187#[cfg(test)]
188mod test {
189  use super::*;
190
191  mod deep_merge {
192    use pretty_assertions::assert_eq;
193    use serde_json::json;
194
195    #[test]
196    fn it_adds_new_keys() {
197      let base = json!({"order": "asc"});
198      let overlay = json!({"marker_tag": "flagged"});
199
200      let result = super::deep_merge(&base, &overlay);
201
202      assert_eq!(result, json!({"order": "asc", "marker_tag": "flagged"}));
203    }
204
205    #[test]
206    fn it_concatenates_arrays() {
207      let base = json!({"tags": ["done", "waiting"]});
208      let overlay = json!({"tags": ["custom"]});
209
210      let result = super::deep_merge(&base, &overlay);
211
212      assert_eq!(result, json!({"tags": ["done", "waiting", "custom"]}));
213    }
214
215    #[test]
216    fn it_handles_nested_objects_with_arrays() {
217      let base = json!({"autotag": {"whitelist": ["work"], "synonyms": {}}});
218      let overlay = json!({"autotag": {"whitelist": ["play"]}});
219
220      let result = super::deep_merge(&base, &overlay);
221
222      assert_eq!(
223        result,
224        json!({"autotag": {"whitelist": ["work", "play"], "synonyms": {}}})
225      );
226    }
227
228    #[test]
229    fn it_ignores_null_fields_within_objects() {
230      let base = json!({"search": {"case": "smart", "distance": 3}});
231      let overlay = json!({"search": {"case": null, "distance": 5}});
232
233      let result = super::deep_merge(&base, &overlay);
234
235      assert_eq!(result, json!({"search": {"case": "smart", "distance": 5}}));
236    }
237
238    #[test]
239    fn it_ignores_null_overlay_values() {
240      let base = json!({"search": {"case": "smart", "distance": 3}});
241      let overlay = json!({"search": null});
242
243      let result = super::deep_merge(&base, &overlay);
244
245      assert_eq!(result, json!({"search": {"case": "smart", "distance": 3}}));
246    }
247
248    #[test]
249    fn it_merges_objects_recursively() {
250      let base = json!({"search": {"case": "smart", "distance": 3}});
251      let overlay = json!({"search": {"distance": 5}});
252
253      let result = super::deep_merge(&base, &overlay);
254
255      assert_eq!(result, json!({"search": {"case": "smart", "distance": 5}}));
256    }
257
258    #[test]
259    fn it_overwrites_scalars() {
260      let base = json!({"order": "asc", "paginate": false});
261      let overlay = json!({"order": "desc"});
262
263      let result = super::deep_merge(&base, &overlay);
264
265      assert_eq!(result, json!({"order": "desc", "paginate": false}));
266    }
267
268    #[test]
269    fn it_replaces_scalar_with_object() {
270      let base = json!({"editors": "vim"});
271      let overlay = json!({"editors": {"default": "nvim"}});
272
273      let result = super::deep_merge(&base, &overlay);
274
275      assert_eq!(result, json!({"editors": {"default": "nvim"}}));
276    }
277
278    #[test]
279    fn it_skips_null_for_new_keys() {
280      let base = json!({"order": "asc"});
281      let overlay = json!({"search": null});
282
283      let result = super::deep_merge(&base, &overlay);
284
285      assert_eq!(result, json!({"order": "asc"}));
286    }
287  }
288
289  mod discover_local_configs {
290    use pretty_assertions::assert_eq;
291
292    use super::*;
293
294    #[test]
295    fn it_excludes_global_config_path() {
296      // If a .doingrc happens to be the global config, it should not appear
297      // as a local config. This is difficult to test without mocking the
298      // global discovery, so we just verify the function doesn't panic on
299      // deeply nested paths.
300      let dir = tempfile::tempdir().unwrap();
301      let deep = dir.path().join("a/b/c/d/e");
302      fs::create_dir_all(&deep).unwrap();
303
304      let configs = discover_local_configs(&deep);
305
306      assert!(configs.is_empty());
307    }
308
309    #[test]
310    fn it_finds_doingrc_in_ancestors() {
311      let dir = tempfile::tempdir().unwrap();
312      let root = dir.path();
313      let child = root.join("projects/myapp");
314      fs::create_dir_all(&child).unwrap();
315      fs::write(root.join(".doingrc"), "order: asc\n").unwrap();
316      fs::write(child.join(".doingrc"), "order: desc\n").unwrap();
317
318      let configs = discover_local_configs(&child);
319
320      assert_eq!(configs.len(), 2);
321      assert_eq!(configs[0], root.join(".doingrc"));
322      assert_eq!(configs[1], child.join(".doingrc"));
323    }
324
325    #[test]
326    fn it_returns_empty_when_none_found() {
327      let dir = tempfile::tempdir().unwrap();
328
329      let configs = discover_local_configs(dir.path());
330
331      assert!(configs.is_empty());
332    }
333  }
334
335  mod from_extension {
336    use pretty_assertions::assert_eq;
337
338    use super::*;
339
340    #[test]
341    fn it_detects_json() {
342      assert_eq!(
343        ConfigFormat::from_extension(Path::new("config.json")),
344        Some(ConfigFormat::Json)
345      );
346    }
347
348    #[test]
349    fn it_detects_jsonc() {
350      assert_eq!(
351        ConfigFormat::from_extension(Path::new("config.jsonc")),
352        Some(ConfigFormat::Json)
353      );
354    }
355
356    #[test]
357    fn it_detects_toml() {
358      assert_eq!(
359        ConfigFormat::from_extension(Path::new("config.toml")),
360        Some(ConfigFormat::Toml)
361      );
362    }
363
364    #[test]
365    fn it_detects_yaml() {
366      assert_eq!(
367        ConfigFormat::from_extension(Path::new("config.yaml")),
368        Some(ConfigFormat::Yaml)
369      );
370    }
371
372    #[test]
373    fn it_detects_yml() {
374      assert_eq!(
375        ConfigFormat::from_extension(Path::new("config.yml")),
376        Some(ConfigFormat::Yaml)
377      );
378    }
379
380    #[test]
381    fn it_returns_none_for_no_extension() {
382      assert_eq!(ConfigFormat::from_extension(Path::new(".doingrc")), None);
383    }
384
385    #[test]
386    fn it_returns_none_for_unknown() {
387      assert_eq!(ConfigFormat::from_extension(Path::new("config.txt")), None);
388    }
389  }
390
391  mod parse_file {
392    use pretty_assertions::assert_eq;
393
394    use super::*;
395
396    #[test]
397    fn it_falls_back_to_yaml_for_unknown_extension() {
398      let dir = tempfile::tempdir().unwrap();
399      let path = dir.path().join(".doingrc");
400      fs::write(&path, "current_section: Working\n").unwrap();
401
402      let value = parse_file(&path).unwrap();
403
404      assert_eq!(value["current_section"], "Working");
405    }
406
407    #[test]
408    fn it_parses_json_file() {
409      let dir = tempfile::tempdir().unwrap();
410      let path = dir.path().join("config.json");
411      fs::write(&path, r#"{"current_section": "Working", "history_size": 25}"#).unwrap();
412
413      let value = parse_file(&path).unwrap();
414
415      assert_eq!(value["current_section"], "Working");
416      assert_eq!(value["history_size"], 25);
417    }
418
419    #[test]
420    fn it_parses_toml_file() {
421      let dir = tempfile::tempdir().unwrap();
422      let path = dir.path().join("config.toml");
423      fs::write(&path, "current_section = \"Working\"\nhistory_size = 25\n").unwrap();
424
425      let value = parse_file(&path).unwrap();
426
427      assert_eq!(value["current_section"], "Working");
428      assert_eq!(value["history_size"], 25);
429    }
430
431    #[test]
432    fn it_parses_yaml_file() {
433      let dir = tempfile::tempdir().unwrap();
434      let path = dir.path().join("config.yml");
435      fs::write(&path, "current_section: Working\nhistory_size: 25\n").unwrap();
436
437      let value = parse_file(&path).unwrap();
438
439      assert_eq!(value["current_section"], "Working");
440      assert_eq!(value["history_size"], 25);
441    }
442
443    #[test]
444    fn it_returns_empty_object_for_empty_file() {
445      let dir = tempfile::tempdir().unwrap();
446      let path = dir.path().join(".doingrc");
447      fs::write(&path, "").unwrap();
448
449      let value = parse_file(&path).unwrap();
450
451      assert_eq!(value, serde_json::Value::Object(serde_json::Map::new()));
452    }
453
454    #[test]
455    fn it_returns_empty_object_for_whitespace_only_file() {
456      let dir = tempfile::tempdir().unwrap();
457      let path = dir.path().join("config.yml");
458      fs::write(&path, "  \n  \n").unwrap();
459
460      let value = parse_file(&path).unwrap();
461
462      assert_eq!(value, serde_json::Value::Object(serde_json::Map::new()));
463    }
464
465    #[test]
466    fn it_returns_error_for_missing_file() {
467      let result = parse_file(Path::new("/nonexistent/config.yml"));
468
469      assert!(result.is_err());
470    }
471
472    #[test]
473    fn it_strips_json_comments() {
474      let dir = tempfile::tempdir().unwrap();
475      let path = dir.path().join("config.jsonc");
476      fs::write(
477        &path,
478        "{\n  // this is a comment\n  \"current_section\": \"Working\"\n}\n",
479      )
480      .unwrap();
481
482      let value = parse_file(&path).unwrap();
483
484      assert_eq!(value["current_section"], "Working");
485    }
486  }
487
488  mod parse_str {
489    use pretty_assertions::assert_eq;
490
491    use super::*;
492
493    #[test]
494    fn it_roundtrips_json() {
495      let json = r#"{"order": "desc", "paginate": true}"#;
496
497      let value = parse_str(json, ConfigFormat::Json).unwrap();
498
499      assert_eq!(value["order"], "desc");
500      assert_eq!(value["paginate"], true);
501    }
502
503    #[test]
504    fn it_roundtrips_toml() {
505      let toml_str = "order = \"desc\"\npaginate = true\n";
506
507      let value = parse_str(toml_str, ConfigFormat::Toml).unwrap();
508
509      assert_eq!(value["order"], "desc");
510      assert_eq!(value["paginate"], true);
511    }
512
513    #[test]
514    fn it_roundtrips_yaml() {
515      let yaml = "order: desc\npaginate: true\n";
516
517      let value = parse_str(yaml, ConfigFormat::Yaml).unwrap();
518
519      assert_eq!(value["order"], "desc");
520      assert_eq!(value["paginate"], true);
521    }
522  }
523}