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 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
117pub 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
136pub 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
145pub 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 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}