Skip to main content

jot_core/
config.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! User configuration for jot.
5//!
6//! Layout mirrors joy-cli's config: YAML files at two locations, merged
7//! at load time with a deep-merge. Strict schema via `deny_unknown_fields`
8//! at every level so that typos in `jot config set` are reported instead
9//! of silently written.
10//!
11//! Layers, highest precedence last:
12//!   1. code defaults (`Config::default()`)
13//!   2. global personal: `$XDG_CONFIG_HOME/jot/config.yaml`
14//!   3. project-local personal: `<root>/.jot/config.yaml`
15
16use 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
71/// `$XDG_CONFIG_HOME/jot/config.yaml`, falling back to `~/.config/jot/config.yaml`.
72pub 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
85/// `<root>/.jot/config.yaml`.
86pub 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
94/// Recursively merge `overlay` into `base`. Mapping keys are merged; all
95/// other values are replaced.
96pub 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
119/// Fully resolved, typed config: defaults merged with global + local.
120///
121/// If no project root exists, defaults and global are still honoured.
122/// A malformed file is reported on stderr and the offending layer is
123/// skipped, falling through to lower-precedence layers.
124pub 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
135/// Untyped merged view across all layers, suitable for `jot config` display.
136pub 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
151/// Only the user-set overrides (global + local), without defaults. Used to
152/// mark which values are at their default vs. set by the user.
153pub 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
168/// Returns cwd if it contains a `.jot/` directory, else `None`.
169///
170/// jot's task model is per-cwd (no walking up), and config follows the
171/// same rule for consistency: the local config layer corresponds to
172/// exactly the `.jot/` directory a user sees when they `ls` their cwd.
173pub 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
182/// Navigate a dotted key. Accepts both `fortune-category` and
183/// `fortune_category` (serde renames vs. Rust field names).
184pub 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
195/// Set a value at a dotted key path, creating intermediate maps as needed.
196pub 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
224/// Render a short "expected X" hint for a dotted key, derived from the
225/// schema rather than a hand-maintained list. Returns `None` for unknown
226/// keys.
227pub fn field_hint(key: &str) -> Option<String> {
228    let defaults = serde_json::to_value(Config::default()).ok()?;
229
230    // Probe enum variants by trying string values against the full
231    // Config round-trip. This surfaces Category variants without
232    // hard-coding them here.
233    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}