1use 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 #[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#[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
179pub fn field_hint(key: &str) -> Option<String> {
183 let defaults = serde_json::to_value(Config::default()).ok()?;
184 let current = navigate_json(&defaults, key);
187
188 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 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
219fn 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 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
278fn 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 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 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 assert_eq!(config.modes.default, InteractionLevel::Interactive);
361 }
362}