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 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
124pub 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
143pub 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
152pub 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 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 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 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}