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}
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct WorkflowConfig {
24    #[serde(rename = "auto-assign", default = "default_true")]
25    pub auto_assign: bool,
26    #[serde(rename = "auto-git", default)]
27    pub auto_git: AutoGit,
28}
29
30impl Default for WorkflowConfig {
31    fn default() -> Self {
32        Self {
33            auto_assign: true,
34            auto_git: AutoGit::default(),
35        }
36    }
37}
38
39/// Controls automatic git operations after Joy writes versioned files.
40/// Each level implies the previous: Push = Add + Commit + Push.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum AutoGit {
44    Off,
45    #[default]
46    Add,
47    Commit,
48    Push,
49}
50
51impl AutoGit {
52    pub fn should_add(self) -> bool {
53        matches!(self, Self::Add | Self::Commit | Self::Push)
54    }
55
56    pub fn should_commit(self) -> bool {
57        matches!(self, Self::Commit | Self::Push)
58    }
59
60    pub fn should_push(self) -> bool {
61        matches!(self, Self::Push)
62    }
63}
64
65fn default_true() -> bool {
66    true
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
70pub struct ModesConfig {
71    #[serde(default)]
72    pub default: InteractionLevel,
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum InteractionLevel {
78    Autonomous,
79    Supervised,
80    #[default]
81    Collaborative,
82    Interactive,
83    Pairing,
84}
85
86impl std::fmt::Display for InteractionLevel {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::Autonomous => write!(f, "autonomous"),
90            Self::Supervised => write!(f, "supervised"),
91            Self::Collaborative => write!(f, "collaborative"),
92            Self::Interactive => write!(f, "interactive"),
93            Self::Pairing => write!(f, "pairing"),
94        }
95    }
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct SyncConfig {
100    pub remote: String,
101    pub auto: bool,
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct OutputConfig {
106    pub color: ColorMode,
107    pub emoji: bool,
108    #[serde(default)]
109    pub short: bool,
110    #[serde(default = "default_fortune")]
111    pub fortune: bool,
112    #[serde(
113        rename = "fortune-category",
114        default,
115        skip_serializing_if = "Option::is_none"
116    )]
117    pub fortune_category: Option<Category>,
118}
119
120fn default_fortune() -> bool {
121    true
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum ColorMode {
127    Auto,
128    Always,
129    Never,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct AiConfig {
134    pub tool: String,
135    pub command: String,
136    pub model: String,
137    pub max_cost_per_job: f64,
138    pub currency: String,
139}
140
141impl Default for Config {
142    fn default() -> Self {
143        Self {
144            version: 1,
145            sync: None,
146            output: OutputConfig::default(),
147            ai: None,
148            workflow: WorkflowConfig::default(),
149            modes: ModesConfig::default(),
150        }
151    }
152}
153
154impl Default for OutputConfig {
155    fn default() -> Self {
156        Self {
157            color: ColorMode::Auto,
158            emoji: true,
159            short: false,
160            fortune: true,
161            fortune_category: None,
162        }
163    }
164}
165
166/// Return a human-readable hint for a config key, listing allowed values when
167/// the field is an enum or constrained type. Derived from the Config struct
168/// rather than a hand-maintained map.
169pub fn field_hint(key: &str) -> Option<String> {
170    let defaults = serde_json::to_value(Config::default()).ok()?;
171    // Try navigating with the original key; if not found (e.g. optional fields
172    // omitted by skip_serializing_if), fall back to probing directly.
173    let current = navigate_json(&defaults, key);
174
175    // Probe for enum variants regardless of whether the field is in defaults
176    let candidates = probe_string_field(key);
177    if !candidates.is_empty() {
178        return Some(format!("allowed values: {}", candidates.join(", ")));
179    }
180
181    if let Some(current) = current {
182        return match current {
183            serde_json::Value::Bool(_) => Some("expected: true or false".to_string()),
184            serde_json::Value::Number(_) => Some("expected: a number".to_string()),
185            serde_json::Value::String(_) => Some("expected: a string".to_string()),
186            _ => None,
187        };
188    }
189
190    None
191}
192
193fn navigate_json<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
194    let mut current = value;
195    for part in key.split('.') {
196        // Try as-is first, then with hyphens/underscores swapped (YAML uses
197        // hyphens, serde_json serializes Rust field names with underscores).
198        current = current
199            .get(part)
200            .or_else(|| current.get(part.replace('-', "_")))
201            .or_else(|| current.get(part.replace('_', "-")))?;
202    }
203    Some(current)
204}
205
206/// Try setting a config field to various string values to discover which ones
207/// the schema accepts -- this reveals enum variants without hard-coding them.
208/// Validates via YAML round-trip to correctly handle hyphen/underscore key
209/// variants and optional fields.
210fn probe_string_field(key: &str) -> Vec<String> {
211    const PROBES: &[&str] = &[
212        "auto",
213        "always",
214        "never",
215        "none",
216        "true",
217        "false",
218        "yes",
219        "no",
220        "on",
221        "add",
222        "commit",
223        "push",
224        "off",
225        "list",
226        "board",
227        "calendar",
228        "all",
229        "tech",
230        "science",
231        "humor",
232        "low",
233        "medium",
234        "high",
235        "critical",
236        "autonomous",
237        "supervised",
238        "collaborative",
239        "interactive",
240        "pairing",
241    ];
242
243    let mut accepted = Vec::new();
244    for &candidate in PROBES {
245        // Build a minimal YAML snippet with the candidate value and try
246        // deserializing as Config. This uses the same path as load_config,
247        // so hyphen/underscore handling matches real behavior.
248        let yaml = build_yaml_for_key(key, candidate);
249        let defaults_yaml = serde_yaml_ng::to_string(&Config::default()).unwrap_or_default();
250        let Ok(mut base): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&defaults_yaml)
251        else {
252            continue;
253        };
254        let Ok(overlay): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&yaml) else {
255            continue;
256        };
257        crate::store::deep_merge_value(&mut base, &overlay);
258        if serde_json::from_value::<Config>(base).is_ok() {
259            accepted.push(candidate.to_string());
260        }
261    }
262    accepted
263}
264
265/// Build a nested YAML string from a dotted key and value.
266/// e.g. "output.color" + "auto" -> "output:\n  color: auto\n"
267fn build_yaml_for_key(key: &str, value: &str) -> String {
268    let parts: Vec<&str> = key.split('.').collect();
269    let mut yaml = String::new();
270    for (i, part) in parts.iter().enumerate() {
271        for _ in 0..i {
272            yaml.push_str("  ");
273        }
274        if i == parts.len() - 1 {
275            yaml.push_str(&format!("{part}: {value}\n"));
276        } else {
277            yaml.push_str(&format!("{part}:\n"));
278        }
279    }
280    yaml
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn default_config_roundtrip() {
289        let config = Config::default();
290        let yaml = serde_yaml_ng::to_string(&config).unwrap();
291        let parsed: Config = serde_yaml_ng::from_str(&yaml).unwrap();
292        assert_eq!(config, parsed);
293    }
294
295    #[test]
296    fn default_config_snapshot() {
297        let config = Config::default();
298        let yaml = serde_yaml_ng::to_string(&config).unwrap();
299        insta::assert_snapshot!(yaml);
300    }
301
302    #[test]
303    fn modes_config_get_default() {
304        let config = Config::default();
305        assert_eq!(config.modes.default, InteractionLevel::Collaborative);
306    }
307
308    #[test]
309    fn modes_config_set_default() {
310        let yaml = "modes:\n  default: pairing\n";
311        let mut base = serde_json::to_value(Config::default()).unwrap();
312        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
313        crate::store::deep_merge_value(&mut base, &overlay);
314        let config: Config = serde_json::from_value(base).unwrap();
315        assert_eq!(config.modes.default, InteractionLevel::Pairing);
316    }
317
318    #[test]
319    fn old_agents_key_does_not_deserialize_to_modes() {
320        let yaml = "agents:\n  default:\n    mode: pairing\n";
321        let mut base = serde_json::to_value(Config::default()).unwrap();
322        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
323        crate::store::deep_merge_value(&mut base, &overlay);
324        let config: Config = serde_json::from_value(base).unwrap();
325        // modes.default should still be the default, not pairing
326        assert_eq!(config.modes.default, InteractionLevel::Collaborative);
327    }
328
329    #[test]
330    fn field_hint_modes_default() {
331        let hint = field_hint("modes.default");
332        assert!(hint.is_some());
333        let values = hint.unwrap();
334        assert!(values.contains("collaborative"));
335        assert!(values.contains("pairing"));
336    }
337
338    #[test]
339    fn old_agents_key_has_no_effect_on_modes() {
340        // Even if agents key is present in YAML, it should not affect modes
341        let yaml = "agents:\n  default:\n    mode: pairing\nmodes:\n  default: interactive\n";
342        let mut base = serde_json::to_value(Config::default()).unwrap();
343        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
344        crate::store::deep_merge_value(&mut base, &overlay);
345        let config: Config = serde_json::from_value(base).unwrap();
346        // modes.default takes the explicit value, agents is ignored
347        assert_eq!(config.modes.default, InteractionLevel::Interactive);
348    }
349}