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