Skip to main content

joy_core/model/
config.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use crate::fortune::Category;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct Config {
9    pub version: u32,
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub sync: Option<SyncConfig>,
12    #[serde(default)]
13    pub output: OutputConfig,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub ai: Option<AiConfig>,
16    #[serde(default)]
17    pub workflow: WorkflowConfig,
18    #[serde(default)]
19    pub modes: ModesConfig,
20    #[serde(default = "default_auto_sync", rename = "auto-sync")]
21    pub auto_sync: bool,
22    /// Editor invoked when a Joy command needs free-form input (e.g.
23    /// joy comment without TEXT). Takes precedence over $VISUAL /
24    /// $EDITOR; the value is run via `sh -c`, so it can carry flags.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub editor: Option<String>,
27}
28
29fn default_auto_sync() -> bool {
30    true
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct WorkflowConfig {
35    #[serde(rename = "auto-assign", default = "default_true")]
36    pub auto_assign: bool,
37    #[serde(rename = "auto-git", default)]
38    pub auto_git: AutoGit,
39}
40
41impl Default for WorkflowConfig {
42    fn default() -> Self {
43        Self {
44            auto_assign: true,
45            auto_git: AutoGit::default(),
46        }
47    }
48}
49
50/// Controls automatic git operations after Joy writes versioned files.
51/// Each level implies the previous: Push = Add + Commit + Push.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum AutoGit {
55    Off,
56    #[default]
57    Add,
58    Commit,
59    Push,
60}
61
62impl AutoGit {
63    pub fn should_add(self) -> bool {
64        matches!(self, Self::Add | Self::Commit | Self::Push)
65    }
66
67    pub fn should_commit(self) -> bool {
68        matches!(self, Self::Commit | Self::Push)
69    }
70
71    pub fn should_push(self) -> bool {
72        matches!(self, Self::Push)
73    }
74}
75
76fn default_true() -> bool {
77    true
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
81pub struct ModesConfig {
82    #[serde(default)]
83    pub default: InteractionLevel,
84}
85
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum InteractionLevel {
89    Autonomous,
90    Supervised,
91    #[default]
92    Collaborative,
93    Interactive,
94    Pairing,
95}
96
97impl std::fmt::Display for InteractionLevel {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::Autonomous => write!(f, "autonomous"),
101            Self::Supervised => write!(f, "supervised"),
102            Self::Collaborative => write!(f, "collaborative"),
103            Self::Interactive => write!(f, "interactive"),
104            Self::Pairing => write!(f, "pairing"),
105        }
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct SyncConfig {
111    pub remote: String,
112    pub auto: bool,
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct OutputConfig {
117    pub color: ColorMode,
118    pub emoji: bool,
119    #[serde(default)]
120    pub short: bool,
121    #[serde(default = "default_fortune")]
122    pub fortune: bool,
123    #[serde(
124        rename = "fortune-category",
125        default,
126        skip_serializing_if = "Option::is_none"
127    )]
128    pub fortune_category: Option<Category>,
129}
130
131fn default_fortune() -> bool {
132    true
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum ColorMode {
138    Auto,
139    Always,
140    Never,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct AiConfig {
145    pub tool: String,
146    pub command: String,
147    pub model: String,
148    pub max_cost_per_job: f64,
149    pub currency: String,
150}
151
152impl Default for Config {
153    fn default() -> Self {
154        Self {
155            version: 1,
156            sync: None,
157            output: OutputConfig::default(),
158            ai: None,
159            workflow: WorkflowConfig::default(),
160            modes: ModesConfig::default(),
161            auto_sync: default_auto_sync(),
162            editor: None,
163        }
164    }
165}
166
167impl Default for OutputConfig {
168    fn default() -> Self {
169        Self {
170            color: ColorMode::Auto,
171            emoji: false,
172            short: true,
173            fortune: true,
174            fortune_category: None,
175        }
176    }
177}
178
179/// One-line description for a config (key, value) pair. Returned by
180/// `joy config get --describe` so the CLI is the single source of truth
181/// for the semantics of each setting. New keys/values should be added
182/// here when introduced so the help surface stays complete.
183pub fn describe_value(key: &str, value: &serde_json::Value) -> Option<String> {
184    let s = value.as_str();
185    let b = value.as_bool();
186    let text = match (key, s, b) {
187        ("modes.default", Some("autonomous"), _) => {
188            "work independently, stop only at governance gates"
189        }
190        ("modes.default", Some("supervised"), _) => "confirm before irreversible actions",
191        ("modes.default", Some("collaborative"), _) => {
192            "propose approach, proceed after confirmation"
193        }
194        ("modes.default", Some("interactive"), _) => {
195            "present options with rationale, wait for decision"
196        }
197        ("modes.default", Some("pairing"), _) => "step by step, question by question",
198
199        ("workflow.auto-git", Some("off"), _) => "never stage, commit, or push automatically",
200        ("workflow.auto-git", Some("add"), _) => "git add changed files after each write",
201        ("workflow.auto-git", Some("commit"), _) => "add + commit after each write",
202        ("workflow.auto-git", Some("push"), _) => "add + commit + push after each write",
203
204        ("output.color", Some("auto"), _) => "color on TTY, plain when piped",
205        ("output.color", Some("always"), _) => "force color even when output is piped",
206        ("output.color", Some("never"), _) => "plain output, no ANSI escapes",
207
208        ("workflow.auto-assign", _, Some(true)) => "assign yourself when running `joy start`",
209        ("workflow.auto-assign", _, Some(false)) => "leave assignment unchanged on `joy start`",
210
211        ("auto-sync", _, Some(true)) => "reassert hooks/instructions on every joy invocation",
212        ("auto-sync", _, Some(false)) => "skip auto-sync of hooks/instructions",
213
214        ("output.emoji", _, Some(true)) => "use emoji glyphs in styled output",
215        ("output.emoji", _, Some(false)) => "no emoji in output",
216
217        ("output.short", _, Some(true)) => "compact listings (single line per item)",
218        ("output.short", _, Some(false)) => "verbose listings (multi-line per item)",
219
220        ("output.fortune", _, Some(true)) => "show a short fortune after init and on idle",
221        ("output.fortune", _, Some(false)) => "no fortune banners",
222
223        _ => return None,
224    };
225    Some(text.to_string())
226}
227
228/// Flatten the nested config tree under `prefix` into a list of
229/// `(dotted_key, leaf_value)` pairs. The prefix itself is included in
230/// the emitted keys so callers can render them verbatim. Used by
231/// `joy config get <prefix>.*`.
232pub fn flatten_under(value: &serde_json::Value, prefix: &str) -> Vec<(String, serde_json::Value)> {
233    let mut out = Vec::new();
234    let start = if prefix.is_empty() {
235        Some(value)
236    } else {
237        navigate_json(value, prefix)
238    };
239    if let Some(start) = start {
240        walk(prefix, start, &mut out);
241    }
242    out.sort_by(|a, b| a.0.cmp(&b.0));
243    out
244}
245
246fn walk(prefix: &str, value: &serde_json::Value, out: &mut Vec<(String, serde_json::Value)>) {
247    match value {
248        serde_json::Value::Object(map) => {
249            for (k, v) in map {
250                let next = if prefix.is_empty() {
251                    k.clone()
252                } else {
253                    format!("{prefix}.{k}")
254                };
255                walk(&next, v, out);
256            }
257        }
258        scalar => out.push((prefix.to_string(), scalar.clone())),
259    }
260}
261
262/// Return a human-readable hint for a config key, listing allowed values when
263/// the field is an enum or constrained type. Derived from the Config struct
264/// rather than a hand-maintained map.
265pub fn field_hint(key: &str) -> Option<String> {
266    let defaults = serde_json::to_value(Config::default()).ok()?;
267    // Try navigating with the original key; if not found (e.g. optional fields
268    // omitted by skip_serializing_if), fall back to probing directly.
269    let current = navigate_json(&defaults, key);
270
271    // Probe for enum variants regardless of whether the field is in defaults
272    let candidates = probe_string_field(key);
273    if !candidates.is_empty() {
274        return Some(format!("allowed values: {}", candidates.join(", ")));
275    }
276
277    if let Some(current) = current {
278        return match current {
279            serde_json::Value::Bool(_) => Some("expected: true or false".to_string()),
280            serde_json::Value::Number(_) => Some("expected: a number".to_string()),
281            serde_json::Value::String(_) => Some("expected: a string".to_string()),
282            _ => None,
283        };
284    }
285
286    None
287}
288
289fn navigate_json<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
290    let mut current = value;
291    for part in key.split('.') {
292        // Try as-is first, then with hyphens/underscores swapped (YAML uses
293        // hyphens, serde_json serializes Rust field names with underscores).
294        current = current
295            .get(part)
296            .or_else(|| current.get(part.replace('-', "_")))
297            .or_else(|| current.get(part.replace('_', "-")))?;
298    }
299    Some(current)
300}
301
302/// Try setting a config field to various string values to discover which ones
303/// the schema accepts -- this reveals enum variants without hard-coding them.
304/// Validates via YAML round-trip to correctly handle hyphen/underscore key
305/// variants and optional fields.
306fn probe_string_field(key: &str) -> Vec<String> {
307    const PROBES: &[&str] = &[
308        "auto",
309        "always",
310        "never",
311        "none",
312        "true",
313        "false",
314        "yes",
315        "no",
316        "on",
317        "add",
318        "commit",
319        "push",
320        "off",
321        "list",
322        "board",
323        "calendar",
324        "all",
325        "tech",
326        "science",
327        "humor",
328        "low",
329        "medium",
330        "high",
331        "critical",
332        "autonomous",
333        "supervised",
334        "collaborative",
335        "interactive",
336        "pairing",
337    ];
338
339    let mut accepted = Vec::new();
340    for &candidate in PROBES {
341        // Build a minimal YAML snippet with the candidate value and try
342        // deserializing as Config. This uses the same path as load_config,
343        // so hyphen/underscore handling matches real behavior.
344        let yaml = build_yaml_for_key(key, candidate);
345        let defaults_yaml = serde_yaml_ng::to_string(&Config::default()).unwrap_or_default();
346        let Ok(mut base): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&defaults_yaml)
347        else {
348            continue;
349        };
350        let Ok(overlay): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&yaml) else {
351            continue;
352        };
353        crate::store::deep_merge_value(&mut base, &overlay);
354        if serde_json::from_value::<Config>(base).is_ok() {
355            accepted.push(candidate.to_string());
356        }
357    }
358    accepted
359}
360
361/// Build a nested YAML string from a dotted key and value.
362/// e.g. "output.color" + "auto" -> "output:\n  color: auto\n"
363fn build_yaml_for_key(key: &str, value: &str) -> String {
364    let parts: Vec<&str> = key.split('.').collect();
365    let mut yaml = String::new();
366    for (i, part) in parts.iter().enumerate() {
367        for _ in 0..i {
368            yaml.push_str("  ");
369        }
370        if i == parts.len() - 1 {
371            yaml.push_str(&format!("{part}: {value}\n"));
372        } else {
373            yaml.push_str(&format!("{part}:\n"));
374        }
375    }
376    yaml
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn default_config_roundtrip() {
385        let config = Config::default();
386        let yaml = serde_yaml_ng::to_string(&config).unwrap();
387        let parsed: Config = serde_yaml_ng::from_str(&yaml).unwrap();
388        assert_eq!(config, parsed);
389    }
390
391    #[test]
392    fn default_config_snapshot() {
393        let config = Config::default();
394        let yaml = serde_yaml_ng::to_string(&config).unwrap();
395        insta::assert_snapshot!(yaml);
396    }
397
398    #[test]
399    fn modes_config_get_default() {
400        let config = Config::default();
401        assert_eq!(config.modes.default, InteractionLevel::Collaborative);
402    }
403
404    #[test]
405    fn modes_config_set_default() {
406        let yaml = "modes:\n  default: pairing\n";
407        let mut base = serde_json::to_value(Config::default()).unwrap();
408        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
409        crate::store::deep_merge_value(&mut base, &overlay);
410        let config: Config = serde_json::from_value(base).unwrap();
411        assert_eq!(config.modes.default, InteractionLevel::Pairing);
412    }
413
414    #[test]
415    fn old_agents_key_does_not_deserialize_to_modes() {
416        let yaml = "agents:\n  default:\n    mode: pairing\n";
417        let mut base = serde_json::to_value(Config::default()).unwrap();
418        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
419        crate::store::deep_merge_value(&mut base, &overlay);
420        let config: Config = serde_json::from_value(base).unwrap();
421        // modes.default should still be the default, not pairing
422        assert_eq!(config.modes.default, InteractionLevel::Collaborative);
423    }
424
425    #[test]
426    fn describe_value_modes_default() {
427        let v = serde_json::Value::String("collaborative".to_string());
428        let d = describe_value("modes.default", &v).expect("known variant");
429        assert!(d.contains("propose"));
430        let unknown = serde_json::Value::String("zzz".to_string());
431        assert!(describe_value("modes.default", &unknown).is_none());
432    }
433
434    #[test]
435    fn flatten_under_modes_returns_default() {
436        let cfg = serde_json::to_value(Config::default()).unwrap();
437        let leaves = flatten_under(&cfg, "modes");
438        let keys: Vec<&str> = leaves.iter().map(|(k, _)| k.as_str()).collect();
439        assert!(keys.contains(&"modes.default"));
440    }
441
442    #[test]
443    fn flatten_under_output_lists_scalars_only() {
444        let cfg = serde_json::to_value(Config::default()).unwrap();
445        let leaves = flatten_under(&cfg, "output");
446        assert!(leaves.iter().all(|(_, v)| !v.is_object()));
447        assert!(leaves.iter().any(|(k, _)| k == "output.color"));
448    }
449
450    #[test]
451    fn field_hint_modes_default() {
452        let hint = field_hint("modes.default");
453        assert!(hint.is_some());
454        let values = hint.unwrap();
455        assert!(values.contains("collaborative"));
456        assert!(values.contains("pairing"));
457    }
458
459    #[test]
460    fn old_agents_key_has_no_effect_on_modes() {
461        // Even if agents key is present in YAML, it should not affect modes
462        let yaml = "agents:\n  default:\n    mode: pairing\nmodes:\n  default: interactive\n";
463        let mut base = serde_json::to_value(Config::default()).unwrap();
464        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
465        crate::store::deep_merge_value(&mut base, &overlay);
466        let config: Config = serde_json::from_value(base).unwrap();
467        // modes.default takes the explicit value, agents is ignored
468        assert_eq!(config.modes.default, InteractionLevel::Interactive);
469    }
470}