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}
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#[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: false,
159 short: true,
160 fortune: true,
161 fortune_category: None,
162 }
163 }
164}
165
166pub fn field_hint(key: &str) -> Option<String> {
170 let defaults = serde_json::to_value(Config::default()).ok()?;
171 let current = navigate_json(&defaults, key);
174
175 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 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
206fn 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 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
265fn 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 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 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 assert_eq!(config.modes.default, InteractionLevel::Interactive);
348 }
349}