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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum ConfigFormat {
15 Json,
16 Toml,
17 Yaml,
18}
19
20impl ConfigFormat {
21 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
34pub 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
63pub 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
89pub 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
120pub 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
139pub 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
148pub 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 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}