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/// Return a human-readable hint for a config key, listing allowed values when
180/// the field is an enum or constrained type. Derived from the Config struct
181/// rather than a hand-maintained map.
182pub fn field_hint(key: &str) -> Option<String> {
183    let defaults = serde_json::to_value(Config::default()).ok()?;
184    // Try navigating with the original key; if not found (e.g. optional fields
185    // omitted by skip_serializing_if), fall back to probing directly.
186    let current = navigate_json(&defaults, key);
187
188    // Probe for enum variants regardless of whether the field is in defaults
189    let candidates = probe_string_field(key);
190    if !candidates.is_empty() {
191        return Some(format!("allowed values: {}", candidates.join(", ")));
192    }
193
194    if let Some(current) = current {
195        return match current {
196            serde_json::Value::Bool(_) => Some("expected: true or false".to_string()),
197            serde_json::Value::Number(_) => Some("expected: a number".to_string()),
198            serde_json::Value::String(_) => Some("expected: a string".to_string()),
199            _ => None,
200        };
201    }
202
203    None
204}
205
206fn navigate_json<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
207    let mut current = value;
208    for part in key.split('.') {
209        // Try as-is first, then with hyphens/underscores swapped (YAML uses
210        // hyphens, serde_json serializes Rust field names with underscores).
211        current = current
212            .get(part)
213            .or_else(|| current.get(part.replace('-', "_")))
214            .or_else(|| current.get(part.replace('_', "-")))?;
215    }
216    Some(current)
217}
218
219/// Try setting a config field to various string values to discover which ones
220/// the schema accepts -- this reveals enum variants without hard-coding them.
221/// Validates via YAML round-trip to correctly handle hyphen/underscore key
222/// variants and optional fields.
223fn probe_string_field(key: &str) -> Vec<String> {
224    const PROBES: &[&str] = &[
225        "auto",
226        "always",
227        "never",
228        "none",
229        "true",
230        "false",
231        "yes",
232        "no",
233        "on",
234        "add",
235        "commit",
236        "push",
237        "off",
238        "list",
239        "board",
240        "calendar",
241        "all",
242        "tech",
243        "science",
244        "humor",
245        "low",
246        "medium",
247        "high",
248        "critical",
249        "autonomous",
250        "supervised",
251        "collaborative",
252        "interactive",
253        "pairing",
254    ];
255
256    let mut accepted = Vec::new();
257    for &candidate in PROBES {
258        // Build a minimal YAML snippet with the candidate value and try
259        // deserializing as Config. This uses the same path as load_config,
260        // so hyphen/underscore handling matches real behavior.
261        let yaml = build_yaml_for_key(key, candidate);
262        let defaults_yaml = serde_yaml_ng::to_string(&Config::default()).unwrap_or_default();
263        let Ok(mut base): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&defaults_yaml)
264        else {
265            continue;
266        };
267        let Ok(overlay): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&yaml) else {
268            continue;
269        };
270        crate::store::deep_merge_value(&mut base, &overlay);
271        if serde_json::from_value::<Config>(base).is_ok() {
272            accepted.push(candidate.to_string());
273        }
274    }
275    accepted
276}
277
278/// Build a nested YAML string from a dotted key and value.
279/// e.g. "output.color" + "auto" -> "output:\n  color: auto\n"
280fn build_yaml_for_key(key: &str, value: &str) -> String {
281    let parts: Vec<&str> = key.split('.').collect();
282    let mut yaml = String::new();
283    for (i, part) in parts.iter().enumerate() {
284        for _ in 0..i {
285            yaml.push_str("  ");
286        }
287        if i == parts.len() - 1 {
288            yaml.push_str(&format!("{part}: {value}\n"));
289        } else {
290            yaml.push_str(&format!("{part}:\n"));
291        }
292    }
293    yaml
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn default_config_roundtrip() {
302        let config = Config::default();
303        let yaml = serde_yaml_ng::to_string(&config).unwrap();
304        let parsed: Config = serde_yaml_ng::from_str(&yaml).unwrap();
305        assert_eq!(config, parsed);
306    }
307
308    #[test]
309    fn default_config_snapshot() {
310        let config = Config::default();
311        let yaml = serde_yaml_ng::to_string(&config).unwrap();
312        insta::assert_snapshot!(yaml);
313    }
314
315    #[test]
316    fn modes_config_get_default() {
317        let config = Config::default();
318        assert_eq!(config.modes.default, InteractionLevel::Collaborative);
319    }
320
321    #[test]
322    fn modes_config_set_default() {
323        let yaml = "modes:\n  default: pairing\n";
324        let mut base = serde_json::to_value(Config::default()).unwrap();
325        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
326        crate::store::deep_merge_value(&mut base, &overlay);
327        let config: Config = serde_json::from_value(base).unwrap();
328        assert_eq!(config.modes.default, InteractionLevel::Pairing);
329    }
330
331    #[test]
332    fn old_agents_key_does_not_deserialize_to_modes() {
333        let yaml = "agents:\n  default:\n    mode: pairing\n";
334        let mut base = serde_json::to_value(Config::default()).unwrap();
335        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
336        crate::store::deep_merge_value(&mut base, &overlay);
337        let config: Config = serde_json::from_value(base).unwrap();
338        // modes.default should still be the default, not pairing
339        assert_eq!(config.modes.default, InteractionLevel::Collaborative);
340    }
341
342    #[test]
343    fn field_hint_modes_default() {
344        let hint = field_hint("modes.default");
345        assert!(hint.is_some());
346        let values = hint.unwrap();
347        assert!(values.contains("collaborative"));
348        assert!(values.contains("pairing"));
349    }
350
351    #[test]
352    fn old_agents_key_has_no_effect_on_modes() {
353        // Even if agents key is present in YAML, it should not affect modes
354        let yaml = "agents:\n  default:\n    mode: pairing\nmodes:\n  default: interactive\n";
355        let mut base = serde_json::to_value(Config::default()).unwrap();
356        let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
357        crate::store::deep_merge_value(&mut base, &overlay);
358        let config: Config = serde_json::from_value(base).unwrap();
359        // modes.default takes the explicit value, agents is ignored
360        assert_eq!(config.modes.default, InteractionLevel::Interactive);
361    }
362}