1use std::path::{Path, PathBuf};
17
18use joy_core::fortune::Category;
19use serde::{Deserialize, Serialize};
20
21pub const CONFIG_FILE: &str = "config.yaml";
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct Config {
26 #[serde(default = "default_version")]
27 pub version: u32,
28 #[serde(default)]
29 pub output: OutputConfig,
30}
31
32impl Default for Config {
33 fn default() -> Self {
34 Self {
35 version: 1,
36 output: OutputConfig::default(),
37 }
38 }
39}
40
41fn default_version() -> u32 {
42 1
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct OutputConfig {
48 #[serde(default = "default_fortune")]
49 pub fortune: bool,
50 #[serde(
51 rename = "fortune-category",
52 default,
53 skip_serializing_if = "Option::is_none"
54 )]
55 pub fortune_category: Option<Category>,
56}
57
58impl Default for OutputConfig {
59 fn default() -> Self {
60 Self {
61 fortune: true,
62 fortune_category: None,
63 }
64 }
65}
66
67fn default_fortune() -> bool {
68 true
69}
70
71pub fn global_config_path() -> PathBuf {
73 global_config_path_from(
74 std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from),
75 home_dir(),
76 )
77}
78
79fn global_config_path_from(xdg: Option<PathBuf>, home: Option<PathBuf>) -> PathBuf {
80 let config_dir =
81 xdg.unwrap_or_else(|| home.unwrap_or_else(|| PathBuf::from(".")).join(".config"));
82 config_dir.join("jot").join(CONFIG_FILE)
83}
84
85pub fn local_config_path(root: &Path) -> PathBuf {
87 crate::storage::jot_dir(root).join(CONFIG_FILE)
88}
89
90fn home_dir() -> Option<PathBuf> {
91 std::env::var("HOME").ok().map(PathBuf::from)
92}
93
94pub fn deep_merge_value(base: &mut serde_json::Value, overlay: &serde_json::Value) {
97 if let (Some(base_map), Some(overlay_map)) = (base.as_object_mut(), overlay.as_object()) {
98 for (key, value) in overlay_map {
99 if let Some(existing) = base_map.get_mut(key) {
100 deep_merge_value(existing, value);
101 } else {
102 base_map.insert(key.clone(), value.clone());
103 }
104 }
105 } else {
106 *base = overlay.clone();
107 }
108}
109
110fn read_yaml_value(path: &Path) -> Option<serde_json::Value> {
111 let content = std::fs::read_to_string(path).ok()?;
112 let value: serde_json::Value = serde_yaml_ng::from_str(&content).ok()?;
113 if value.is_null() {
114 return None;
115 }
116 Some(value)
117}
118
119pub fn load_config() -> Config {
125 let merged = load_config_value();
126 match serde_json::from_value(merged) {
127 Ok(config) => config,
128 Err(e) => {
129 eprintln!("Warning: config has invalid values, using defaults: {e}");
130 Config::default()
131 }
132 }
133}
134
135pub fn load_config_value() -> serde_json::Value {
137 let mut merged: serde_json::Value = serde_json::to_value(Config::default()).unwrap_or_default();
138
139 if let Some(global) = read_yaml_value(&global_config_path()) {
140 deep_merge_value(&mut merged, &global);
141 }
142 if let Some(root) = current_project_root() {
143 if let Some(local) = read_yaml_value(&local_config_path(&root)) {
144 deep_merge_value(&mut merged, &local);
145 }
146 }
147
148 merged
149}
150
151pub fn load_personal_config_value() -> serde_json::Value {
154 let mut merged = serde_json::json!({});
155
156 if let Some(global) = read_yaml_value(&global_config_path()) {
157 deep_merge_value(&mut merged, &global);
158 }
159 if let Some(root) = current_project_root() {
160 if let Some(local) = read_yaml_value(&local_config_path(&root)) {
161 deep_merge_value(&mut merged, &local);
162 }
163 }
164
165 merged
166}
167
168pub fn current_project_root() -> Option<PathBuf> {
174 let cwd = std::env::current_dir().ok()?;
175 if crate::storage::jot_dir(&cwd).is_dir() {
176 Some(cwd)
177 } else {
178 None
179 }
180}
181
182pub fn navigate<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
185 let mut current = value;
186 for part in key.split('.') {
187 current = current
188 .get(part)
189 .or_else(|| current.get(part.replace('-', "_")))
190 .or_else(|| current.get(part.replace('_', "-")))?;
191 }
192 Some(current)
193}
194
195pub fn set_nested(
197 value: &mut serde_json::Value,
198 key: &str,
199 new_val: serde_json::Value,
200) -> Result<(), String> {
201 let parts: Vec<&str> = key.split('.').collect();
202 let mut current = value;
203
204 for (i, part) in parts.iter().enumerate() {
205 if i == parts.len() - 1 {
206 current
207 .as_object_mut()
208 .ok_or_else(|| format!("cannot set '{key}': parent is not an object"))?
209 .insert(part.to_string(), new_val.clone());
210 return Ok(());
211 }
212 if !current.get(*part).is_some_and(|v| v.is_object()) {
213 current
214 .as_object_mut()
215 .ok_or_else(|| format!("cannot set '{key}': parent is not an object"))?
216 .insert(part.to_string(), serde_json::json!({}));
217 }
218 current = current.get_mut(*part).unwrap();
219 }
220
221 Ok(())
222}
223
224pub fn field_hint(key: &str) -> Option<String> {
228 let defaults = serde_json::to_value(Config::default()).ok()?;
229
230 let candidates = probe_string_field(key);
234 if !candidates.is_empty() {
235 return Some(format!("allowed values: {}", candidates.join(", ")));
236 }
237
238 match navigate(&defaults, key)? {
239 serde_json::Value::Bool(_) => Some("expected: true or false".to_string()),
240 serde_json::Value::Number(_) => Some("expected: a number".to_string()),
241 serde_json::Value::String(_) => Some("expected: a string".to_string()),
242 _ => None,
243 }
244}
245
246fn probe_string_field(key: &str) -> Vec<String> {
247 const PROBES: &[&str] = &["tech", "science", "humor", "all"];
248
249 let mut accepted = Vec::new();
250 for &candidate in PROBES {
251 let yaml = build_yaml_for_key(key, candidate);
252 let defaults_yaml = match serde_yaml_ng::to_string(&Config::default()) {
253 Ok(s) => s,
254 Err(_) => continue,
255 };
256 let mut base: serde_json::Value = match serde_yaml_ng::from_str(&defaults_yaml) {
257 Ok(v) => v,
258 Err(_) => continue,
259 };
260 let overlay: serde_json::Value = match serde_yaml_ng::from_str(&yaml) {
261 Ok(v) => v,
262 Err(_) => continue,
263 };
264 deep_merge_value(&mut base, &overlay);
265 if serde_json::from_value::<Config>(base).is_ok() {
266 accepted.push(candidate.to_string());
267 }
268 }
269 accepted
270}
271
272fn build_yaml_for_key(key: &str, value: &str) -> String {
273 let parts: Vec<&str> = key.split('.').collect();
274 let mut yaml = String::new();
275 for (i, part) in parts.iter().enumerate() {
276 for _ in 0..i {
277 yaml.push_str(" ");
278 }
279 if i == parts.len() - 1 {
280 yaml.push_str(&format!("{part}: {value}\n"));
281 } else {
282 yaml.push_str(&format!("{part}:\n"));
283 }
284 }
285 yaml
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn default_config_roundtrip() {
294 let config = Config::default();
295 let yaml = serde_yaml_ng::to_string(&config).unwrap();
296 let parsed: Config = serde_yaml_ng::from_str(&yaml).unwrap();
297 assert_eq!(config, parsed);
298 }
299
300 #[test]
301 fn default_output_fortune_is_true() {
302 assert!(Config::default().output.fortune);
303 }
304
305 #[test]
306 fn unknown_top_level_key_is_rejected() {
307 let yaml = "version: 1\nunknown_key: foo\n";
308 let err = serde_yaml_ng::from_str::<Config>(yaml).unwrap_err();
309 assert!(err.to_string().contains("unknown"));
310 }
311
312 #[test]
313 fn unknown_nested_key_is_rejected() {
314 let yaml = "output:\n not_a_field: true\n";
315 let err = serde_yaml_ng::from_str::<Config>(yaml).unwrap_err();
316 assert!(err.to_string().contains("unknown"));
317 }
318
319 #[test]
320 fn missing_version_defaults_to_1() {
321 let yaml = "output:\n fortune: false\n";
322 let parsed: Config = serde_yaml_ng::from_str(yaml).unwrap();
323 assert_eq!(parsed.version, 1);
324 assert!(!parsed.output.fortune);
325 }
326
327 #[test]
328 fn fortune_category_parses() {
329 let yaml = "output:\n fortune-category: tech\n";
330 let parsed: Config = serde_yaml_ng::from_str(yaml).unwrap();
331 assert_eq!(parsed.output.fortune_category, Some(Category::Tech));
332 }
333
334 #[test]
335 fn deep_merge_replaces_scalars_and_merges_maps() {
336 let mut base = serde_json::json!({ "a": 1, "b": { "c": 2, "d": 3 } });
337 let overlay = serde_json::json!({ "b": { "c": 99 }, "e": 5 });
338 deep_merge_value(&mut base, &overlay);
339 assert_eq!(
340 base,
341 serde_json::json!({ "a": 1, "b": { "c": 99, "d": 3 }, "e": 5 })
342 );
343 }
344
345 #[test]
346 fn set_nested_creates_intermediate_maps() {
347 let mut v = serde_json::json!({});
348 set_nested(&mut v, "output.fortune", serde_json::json!(false)).unwrap();
349 assert_eq!(v, serde_json::json!({ "output": { "fortune": false } }));
350 }
351
352 #[test]
353 fn navigate_handles_hyphen_and_underscore_variants() {
354 let v = serde_json::json!({ "output": { "fortune-category": "tech" } });
355 assert_eq!(
356 navigate(&v, "output.fortune-category").unwrap(),
357 &serde_json::json!("tech")
358 );
359 assert_eq!(
360 navigate(&v, "output.fortune_category").unwrap(),
361 &serde_json::json!("tech")
362 );
363 }
364
365 #[test]
366 fn field_hint_for_bool() {
367 let hint = field_hint("output.fortune").unwrap();
368 assert_eq!(hint, "expected: true or false");
369 }
370
371 #[test]
372 fn field_hint_for_category_lists_variants() {
373 let hint = field_hint("output.fortune-category").unwrap();
374 assert!(hint.contains("tech"));
375 assert!(hint.contains("humor"));
376 assert!(hint.contains("science"));
377 assert!(hint.contains("all"));
378 }
379
380 #[test]
381 fn global_config_path_uses_xdg_when_set() {
382 let p = global_config_path_from(
383 Some(PathBuf::from("/tmp/xdg")),
384 Some(PathBuf::from("/home/user")),
385 );
386 assert_eq!(p, PathBuf::from("/tmp/xdg/jot/config.yaml"));
387 }
388
389 #[test]
390 fn global_config_path_falls_back_to_home_dot_config() {
391 let p = global_config_path_from(None, Some(PathBuf::from("/home/user")));
392 assert_eq!(p, PathBuf::from("/home/user/.config/jot/config.yaml"));
393 }
394
395 #[test]
396 fn global_config_path_falls_back_to_cwd_without_home() {
397 let p = global_config_path_from(None, None);
398 assert_eq!(p, PathBuf::from("./.config/jot/config.yaml"));
399 }
400
401 #[test]
402 fn local_config_path_is_under_dot_jot() {
403 let root = Path::new("/some/project");
404 assert_eq!(
405 local_config_path(root),
406 Path::new("/some/project/.jot/config.yaml")
407 );
408 }
409}