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}
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#[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
173pub fn field_hint(key: &str) -> Option<String> {
177 let defaults = serde_json::to_value(Config::default()).ok()?;
178 let current = navigate_json(&defaults, key);
181
182 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 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
213fn 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 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
272fn 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 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 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 assert_eq!(config.modes.default, InteractionLevel::Interactive);
355 }
356}