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