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 describe_value(key: &str, value: &serde_json::Value) -> Option<String> {
184 let s = value.as_str();
185 let b = value.as_bool();
186 let text = match (key, s, b) {
187 ("modes.default", Some("autonomous"), _) => {
188 "work independently, stop only at governance gates"
189 }
190 ("modes.default", Some("supervised"), _) => "confirm before irreversible actions",
191 ("modes.default", Some("collaborative"), _) => {
192 "propose approach, proceed after confirmation"
193 }
194 ("modes.default", Some("interactive"), _) => {
195 "present options with rationale, wait for decision"
196 }
197 ("modes.default", Some("pairing"), _) => "step by step, question by question",
198
199 ("workflow.auto-git", Some("off"), _) => "never stage, commit, or push automatically",
200 ("workflow.auto-git", Some("add"), _) => "git add changed files after each write",
201 ("workflow.auto-git", Some("commit"), _) => "add + commit after each write",
202 ("workflow.auto-git", Some("push"), _) => "add + commit + push after each write",
203
204 ("output.color", Some("auto"), _) => "color on TTY, plain when piped",
205 ("output.color", Some("always"), _) => "force color even when output is piped",
206 ("output.color", Some("never"), _) => "plain output, no ANSI escapes",
207
208 ("workflow.auto-assign", _, Some(true)) => "assign yourself when running `joy start`",
209 ("workflow.auto-assign", _, Some(false)) => "leave assignment unchanged on `joy start`",
210
211 ("auto-sync", _, Some(true)) => "reassert hooks/instructions on every joy invocation",
212 ("auto-sync", _, Some(false)) => "skip auto-sync of hooks/instructions",
213
214 ("output.emoji", _, Some(true)) => "use emoji glyphs in styled output",
215 ("output.emoji", _, Some(false)) => "no emoji in output",
216
217 ("output.short", _, Some(true)) => "compact listings (single line per item)",
218 ("output.short", _, Some(false)) => "verbose listings (multi-line per item)",
219
220 ("output.fortune", _, Some(true)) => "show a short fortune after init and on idle",
221 ("output.fortune", _, Some(false)) => "no fortune banners",
222
223 _ => return None,
224 };
225 Some(text.to_string())
226}
227
228pub fn flatten_under(value: &serde_json::Value, prefix: &str) -> Vec<(String, serde_json::Value)> {
233 let mut out = Vec::new();
234 let start = if prefix.is_empty() {
235 Some(value)
236 } else {
237 navigate_json(value, prefix)
238 };
239 if let Some(start) = start {
240 walk(prefix, start, &mut out);
241 }
242 out.sort_by(|a, b| a.0.cmp(&b.0));
243 out
244}
245
246fn walk(prefix: &str, value: &serde_json::Value, out: &mut Vec<(String, serde_json::Value)>) {
247 match value {
248 serde_json::Value::Object(map) => {
249 for (k, v) in map {
250 let next = if prefix.is_empty() {
251 k.clone()
252 } else {
253 format!("{prefix}.{k}")
254 };
255 walk(&next, v, out);
256 }
257 }
258 scalar => out.push((prefix.to_string(), scalar.clone())),
259 }
260}
261
262pub fn field_hint(key: &str) -> Option<String> {
266 let defaults = serde_json::to_value(Config::default()).ok()?;
267 let current = navigate_json(&defaults, key);
270
271 let candidates = probe_string_field(key);
273 if !candidates.is_empty() {
274 return Some(format!("allowed values: {}", candidates.join(", ")));
275 }
276
277 if let Some(current) = current {
278 return match current {
279 serde_json::Value::Bool(_) => Some("expected: true or false".to_string()),
280 serde_json::Value::Number(_) => Some("expected: a number".to_string()),
281 serde_json::Value::String(_) => Some("expected: a string".to_string()),
282 _ => None,
283 };
284 }
285
286 None
287}
288
289fn navigate_json<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
290 let mut current = value;
291 for part in key.split('.') {
292 current = current
295 .get(part)
296 .or_else(|| current.get(part.replace('-', "_")))
297 .or_else(|| current.get(part.replace('_', "-")))?;
298 }
299 Some(current)
300}
301
302fn probe_string_field(key: &str) -> Vec<String> {
307 const PROBES: &[&str] = &[
308 "auto",
309 "always",
310 "never",
311 "none",
312 "true",
313 "false",
314 "yes",
315 "no",
316 "on",
317 "add",
318 "commit",
319 "push",
320 "off",
321 "list",
322 "board",
323 "calendar",
324 "all",
325 "tech",
326 "science",
327 "humor",
328 "low",
329 "medium",
330 "high",
331 "critical",
332 "autonomous",
333 "supervised",
334 "collaborative",
335 "interactive",
336 "pairing",
337 ];
338
339 let mut accepted = Vec::new();
340 for &candidate in PROBES {
341 let yaml = build_yaml_for_key(key, candidate);
345 let defaults_yaml = serde_yaml_ng::to_string(&Config::default()).unwrap_or_default();
346 let Ok(mut base): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&defaults_yaml)
347 else {
348 continue;
349 };
350 let Ok(overlay): Result<serde_json::Value, _> = serde_yaml_ng::from_str(&yaml) else {
351 continue;
352 };
353 crate::store::deep_merge_value(&mut base, &overlay);
354 if serde_json::from_value::<Config>(base).is_ok() {
355 accepted.push(candidate.to_string());
356 }
357 }
358 accepted
359}
360
361fn build_yaml_for_key(key: &str, value: &str) -> String {
364 let parts: Vec<&str> = key.split('.').collect();
365 let mut yaml = String::new();
366 for (i, part) in parts.iter().enumerate() {
367 for _ in 0..i {
368 yaml.push_str(" ");
369 }
370 if i == parts.len() - 1 {
371 yaml.push_str(&format!("{part}: {value}\n"));
372 } else {
373 yaml.push_str(&format!("{part}:\n"));
374 }
375 }
376 yaml
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn default_config_roundtrip() {
385 let config = Config::default();
386 let yaml = serde_yaml_ng::to_string(&config).unwrap();
387 let parsed: Config = serde_yaml_ng::from_str(&yaml).unwrap();
388 assert_eq!(config, parsed);
389 }
390
391 #[test]
392 fn default_config_snapshot() {
393 let config = Config::default();
394 let yaml = serde_yaml_ng::to_string(&config).unwrap();
395 insta::assert_snapshot!(yaml);
396 }
397
398 #[test]
399 fn modes_config_get_default() {
400 let config = Config::default();
401 assert_eq!(config.modes.default, InteractionLevel::Collaborative);
402 }
403
404 #[test]
405 fn modes_config_set_default() {
406 let yaml = "modes:\n default: pairing\n";
407 let mut base = serde_json::to_value(Config::default()).unwrap();
408 let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
409 crate::store::deep_merge_value(&mut base, &overlay);
410 let config: Config = serde_json::from_value(base).unwrap();
411 assert_eq!(config.modes.default, InteractionLevel::Pairing);
412 }
413
414 #[test]
415 fn old_agents_key_does_not_deserialize_to_modes() {
416 let yaml = "agents:\n default:\n mode: pairing\n";
417 let mut base = serde_json::to_value(Config::default()).unwrap();
418 let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
419 crate::store::deep_merge_value(&mut base, &overlay);
420 let config: Config = serde_json::from_value(base).unwrap();
421 assert_eq!(config.modes.default, InteractionLevel::Collaborative);
423 }
424
425 #[test]
426 fn describe_value_modes_default() {
427 let v = serde_json::Value::String("collaborative".to_string());
428 let d = describe_value("modes.default", &v).expect("known variant");
429 assert!(d.contains("propose"));
430 let unknown = serde_json::Value::String("zzz".to_string());
431 assert!(describe_value("modes.default", &unknown).is_none());
432 }
433
434 #[test]
435 fn flatten_under_modes_returns_default() {
436 let cfg = serde_json::to_value(Config::default()).unwrap();
437 let leaves = flatten_under(&cfg, "modes");
438 let keys: Vec<&str> = leaves.iter().map(|(k, _)| k.as_str()).collect();
439 assert!(keys.contains(&"modes.default"));
440 }
441
442 #[test]
443 fn flatten_under_output_lists_scalars_only() {
444 let cfg = serde_json::to_value(Config::default()).unwrap();
445 let leaves = flatten_under(&cfg, "output");
446 assert!(leaves.iter().all(|(_, v)| !v.is_object()));
447 assert!(leaves.iter().any(|(k, _)| k == "output.color"));
448 }
449
450 #[test]
451 fn field_hint_modes_default() {
452 let hint = field_hint("modes.default");
453 assert!(hint.is_some());
454 let values = hint.unwrap();
455 assert!(values.contains("collaborative"));
456 assert!(values.contains("pairing"));
457 }
458
459 #[test]
460 fn old_agents_key_has_no_effect_on_modes() {
461 let yaml = "agents:\n default:\n mode: pairing\nmodes:\n default: interactive\n";
463 let mut base = serde_json::to_value(Config::default()).unwrap();
464 let overlay: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
465 crate::store::deep_merge_value(&mut base, &overlay);
466 let config: Config = serde_json::from_value(base).unwrap();
467 assert_eq!(config.modes.default, InteractionLevel::Interactive);
469 }
470}