1use std::path::{Path, PathBuf};
22
23use clap::ValueEnum;
24use console::style;
25use serde_json::{json, Value};
26
27use crate::config::{get_config_path, Config};
28use crate::error::{CwError, Result};
29use crate::git;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
40#[value(rename_all = "kebab-case")]
41pub enum ConfigKey {
42 #[value(name = "ai-tool.command")]
43 AiToolCommand,
44 #[value(name = "ai-tool.args")]
45 AiToolArgs,
46 #[value(name = "ai-tool.guard")]
47 AiToolGuard,
48 #[value(name = "launch.method")]
49 LaunchMethod,
50 #[value(name = "launch.tmux-session-prefix")]
51 LaunchTmuxSessionPrefix,
52 #[value(name = "launch.wezterm-ready-timeout")]
53 LaunchWeztermReadyTimeout,
54 #[value(name = "update.auto-check")]
55 UpdateAutoCheck,
56 #[value(name = "hooks.post-new")]
57 HooksPostNew,
58 #[value(name = "hooks.pre-rm")]
59 HooksPreRm,
60}
61
62impl ConfigKey {
63 pub const ALL: &'static [ConfigKey] = &[
64 ConfigKey::AiToolCommand,
65 ConfigKey::AiToolArgs,
66 ConfigKey::AiToolGuard,
67 ConfigKey::LaunchMethod,
68 ConfigKey::LaunchTmuxSessionPrefix,
69 ConfigKey::LaunchWeztermReadyTimeout,
70 ConfigKey::UpdateAutoCheck,
71 ConfigKey::HooksPostNew,
72 ConfigKey::HooksPreRm,
73 ];
74
75 pub fn name(self) -> &'static str {
77 match self {
78 ConfigKey::AiToolCommand => "ai-tool.command",
79 ConfigKey::AiToolArgs => "ai-tool.args",
80 ConfigKey::AiToolGuard => "ai-tool.guard",
81 ConfigKey::LaunchMethod => "launch.method",
82 ConfigKey::LaunchTmuxSessionPrefix => "launch.tmux-session-prefix",
83 ConfigKey::LaunchWeztermReadyTimeout => "launch.wezterm-ready-timeout",
84 ConfigKey::UpdateAutoCheck => "update.auto-check",
85 ConfigKey::HooksPostNew => "hooks.post-new",
86 ConfigKey::HooksPreRm => "hooks.pre-rm",
87 }
88 }
89
90 fn json_path(self) -> &'static [&'static str] {
96 match self {
97 ConfigKey::AiToolCommand => &["ai_tool", "command"],
98 ConfigKey::AiToolArgs => &["ai_tool", "args"],
99 ConfigKey::AiToolGuard => &["ai_tool", "guard"],
100 ConfigKey::LaunchMethod => &["launch", "method"],
101 ConfigKey::LaunchTmuxSessionPrefix => &["launch", "tmux_session_prefix"],
102 ConfigKey::LaunchWeztermReadyTimeout => &["launch", "wezterm_ready_timeout"],
103 ConfigKey::UpdateAutoCheck => &["update", "auto_check"],
104 ConfigKey::HooksPostNew => &["hooks", "post_new"],
105 ConfigKey::HooksPreRm => &["hooks", "pre_rm"],
106 }
107 }
108}
109
110pub fn lookup_value(root: &Value, key: ConfigKey) -> Option<&Value> {
117 let mut cur = root;
118 for seg in key.json_path() {
119 cur = cur.get(*seg)?;
120 if cur.is_null() {
121 return None;
122 }
123 }
124 Some(cur)
125}
126
127pub fn parse_value_for(key: ConfigKey, input: &str) -> Result<Value> {
134 let trimmed = input.trim();
135 match key {
136 ConfigKey::AiToolGuard | ConfigKey::UpdateAutoCheck => match trimmed {
137 "true" | "1" | "yes" | "on" => Ok(Value::Bool(true)),
138 "false" | "0" | "no" | "off" => Ok(Value::Bool(false)),
139 other => Err(CwError::Config(format!(
140 "{} expects a boolean (true/false), got: {}",
141 key.name(),
142 other
143 ))),
144 },
145 ConfigKey::LaunchWeztermReadyTimeout => trimmed
146 .parse::<f64>()
147 .map_err(|e| {
148 CwError::Config(format!(
149 "{} expects a number, got {:?}: {}",
150 key.name(),
151 trimmed,
152 e
153 ))
154 })
155 .and_then(|n| {
156 serde_json::Number::from_f64(n)
160 .map(Value::Number)
161 .ok_or_else(|| {
162 CwError::Config(format!(
163 "{} expects a finite number (got {})",
164 key.name(),
165 n
166 ))
167 })
168 }),
169 ConfigKey::AiToolArgs => {
170 if trimmed.starts_with('[') {
174 serde_json::from_str(trimmed).map_err(|e| {
175 CwError::Config(format!("{} got malformed JSON array: {}", key.name(), e))
176 })
177 } else if trimmed.is_empty() {
178 Ok(json!([]))
179 } else {
180 Ok(Value::Array(
181 trimmed
182 .split_whitespace()
183 .map(|s| Value::String(s.to_string()))
184 .collect(),
185 ))
186 }
187 }
188 _ => Ok(Value::String(trimmed.to_string())),
189 }
190}
191
192pub fn set_value(root: &mut Value, key: ConfigKey, value: Value) -> Result<()> {
196 let path = key.json_path();
197 let (last, parents) = path.split_last().expect("ConfigKey paths are non-empty");
198
199 if !root.is_object() {
200 *root = Value::Object(serde_json::Map::new());
201 }
202 let mut cur = root;
203 for seg in parents {
204 let map = cur.as_object_mut().expect("ensured object above");
205 let entry = map
206 .entry((*seg).to_string())
207 .or_insert_with(|| Value::Object(serde_json::Map::new()));
208 if !entry.is_object() {
209 return Err(CwError::Config(format!(
210 "config field `{}` is not an object — refusing to overwrite \
211 (run `gw config edit` to fix manually)",
212 seg
213 )));
214 }
215 cur = entry;
216 }
217 let map = cur.as_object_mut().expect("parent is an object");
218 map.insert((*last).to_string(), value);
219 Ok(())
220}
221
222pub fn unset_value(root: &mut Value, key: ConfigKey) {
226 let path = key.json_path();
227 let (last, parents) = match path.split_last() {
228 Some(p) => p,
229 None => return,
230 };
231 let mut cur = root;
232 for seg in parents {
233 cur = match cur.get_mut(*seg) {
234 Some(v) if v.is_object() => v,
235 _ => return,
236 };
237 }
238 if let Some(map) = cur.as_object_mut() {
239 map.remove(*last);
240 }
241}
242
243pub fn global_path() -> PathBuf {
249 get_config_path()
250}
251
252pub fn repo_path(cwd: &Path) -> Result<PathBuf> {
257 let repo = git::get_repo_root(Some(cwd)).map_err(|_| {
258 CwError::Config("--repo / repo scope requires running inside a git repository".to_string())
259 })?;
260 Ok(repo.join(".cwconfig.json"))
261}
262
263pub fn read_json_or_empty(path: &Path) -> Result<Value> {
266 if !path.exists() {
267 return Ok(Value::Object(serde_json::Map::new()));
268 }
269 let content = std::fs::read_to_string(path)
270 .map_err(|e| CwError::Config(format!("failed to read {}: {}", path.display(), e)))?;
271 if content.trim().is_empty() {
272 return Ok(Value::Object(serde_json::Map::new()));
273 }
274 serde_json::from_str(&content)
275 .map_err(|e| CwError::Config(format!("failed to parse {}: {}", path.display(), e)))
276}
277
278pub fn write_json(path: &Path, value: &Value) -> Result<()> {
280 if let Some(parent) = path.parent() {
281 std::fs::create_dir_all(parent).map_err(|e| {
282 CwError::Config(format!("failed to create {}: {}", parent.display(), e))
283 })?;
284 }
285 let pretty = serde_json::to_string_pretty(value)
286 .map_err(|e| CwError::Config(format!("failed to serialize config: {}", e)))?;
287 let with_nl = format!("{}\n", pretty);
289 std::fs::write(path, with_nl)
290 .map_err(|e| CwError::Config(format!("failed to write {}: {}", path.display(), e)))
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum Scope {
299 Global,
300 Repo,
301}
302
303impl Scope {
304 pub fn label(self) -> &'static str {
305 match self {
306 Scope::Global => "global",
307 Scope::Repo => "repo",
308 }
309 }
310
311 pub fn other(self) -> Self {
313 match self {
314 Scope::Global => Scope::Repo,
315 Scope::Repo => Scope::Global,
316 }
317 }
318}
319
320pub fn list_cmd() -> Result<()> {
327 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
328 let global_v = read_json_or_empty(&global_path())?;
329 let repo_path_opt = git::get_repo_root(Some(&cwd))
330 .ok()
331 .map(|r| r.join(".cwconfig.json"));
332 let repo_v = match &repo_path_opt {
333 Some(p) => read_json_or_empty(p)?,
334 None => Value::Object(serde_json::Map::new()),
335 };
336
337 let default_v = serde_json::to_value(Config::default())
338 .map_err(|e| CwError::Config(format!("default config not serializable: {}", e)))?;
339
340 println!();
341 println!(
342 " {} {}",
343 style("global:").dim(),
344 style(global_path().display()).cyan()
345 );
346 match &repo_path_opt {
347 Some(p) => println!(
348 " {} {}",
349 style("repo:").dim(),
350 style(p.display()).cyan()
351 ),
352 None => println!(
353 " {} {}",
354 style("repo:").dim(),
355 style("(not in a git repo)").dim()
356 ),
357 }
358 println!();
359
360 let key_col = ConfigKey::ALL
364 .iter()
365 .map(|k| k.name().len())
366 .max()
367 .unwrap_or(0);
368
369 for key in ConfigKey::ALL {
370 let g = lookup_value(&global_v, *key);
371 let r = lookup_value(&repo_v, *key);
372 let d = lookup_value(&default_v, *key);
373 let (value, tag) = match (r, g, d) {
374 (Some(rv), Some(_), _) => (rv.clone(), style("[override: repo]").yellow()),
375 (Some(rv), None, _) => (rv.clone(), style("[repo]").yellow()),
376 (None, Some(gv), _) => (gv.clone(), style("[global]").green()),
377 (None, None, Some(dv)) => (dv.clone(), style("[default]").dim()),
378 (None, None, None) => (Value::Null, style("[unset]").dim()),
379 };
380 println!(
381 " {key:<key_col$} {value} {tag}",
382 key = key.name(),
383 value = render_value(&value),
384 );
385 }
386 println!();
387 Ok(())
388}
389
390fn render_value(v: &Value) -> String {
392 match v {
393 Value::Null => "(unset)".to_string(),
394 Value::String(s) => s.clone(),
395 Value::Bool(b) => b.to_string(),
396 Value::Number(n) => n.to_string(),
397 Value::Array(a) => {
398 let inner: Vec<String> = a.iter().map(render_value).collect();
399 format!("[{}]", inner.join(", "))
400 }
401 Value::Object(_) => v.to_string(),
402 }
403}
404
405pub fn get_cmd(key: ConfigKey) -> Result<()> {
409 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
410 let global_v = read_json_or_empty(&global_path())?;
411 let repo_v = git::get_repo_root(Some(&cwd))
412 .ok()
413 .map(|r| read_json_or_empty(&r.join(".cwconfig.json")))
414 .transpose()?
415 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
416
417 if let Some(v) = lookup_value(&repo_v, key) {
418 println!("{}", render_value(v));
419 return Ok(());
420 }
421 if let Some(v) = lookup_value(&global_v, key) {
422 println!("{}", render_value(v));
423 return Ok(());
424 }
425 let default_v = serde_json::to_value(Config::default())
426 .map_err(|e| CwError::Config(format!("default config not serializable: {}", e)))?;
427 if let Some(v) = lookup_value(&default_v, key) {
428 println!("{}", render_value(v));
429 return Ok(());
430 }
431 Err(CwError::ExitCode(1))
432}
433
434pub fn set_cmd(key: ConfigKey, value: &str, scope: Scope) -> Result<()> {
439 let parsed = parse_value_for(key, value)?;
440 let target = match scope {
441 Scope::Global => global_path(),
442 Scope::Repo => {
443 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
444 repo_path(&cwd)?
445 }
446 };
447 let mut root = read_json_or_empty(&target)?;
448 set_value(&mut root, key, parsed.clone())?;
449 write_json(&target, &root)?;
450 println!(
451 "{} {} = {} {}",
452 style("set").green().bold(),
453 key.name(),
454 render_value(&parsed),
455 style(format!("[{}]", scope.label())).dim(),
456 );
457 Ok(())
458}
459
460#[cfg(test)]
465mod tests {
466 use super::*;
467
468 fn empty() -> Value {
469 Value::Object(serde_json::Map::new())
470 }
471
472 #[test]
473 fn config_key_all_matches_name() {
474 for k in ConfigKey::ALL {
475 assert!(k.name().contains('.'));
476 assert!(!k.json_path().is_empty());
477 }
478 }
479
480 #[test]
481 fn set_then_lookup_string() {
482 let mut root = empty();
483 set_value(
484 &mut root,
485 ConfigKey::AiToolCommand,
486 Value::String("codex".into()),
487 )
488 .unwrap();
489 let got = lookup_value(&root, ConfigKey::AiToolCommand).unwrap();
490 assert_eq!(got, &Value::String("codex".into()));
491 }
492
493 #[test]
494 fn set_creates_intermediate_objects() {
495 let mut root = empty();
496 set_value(
497 &mut root,
498 ConfigKey::HooksPostNew,
499 Value::String("echo hi".into()),
500 )
501 .unwrap();
502 assert_eq!(
504 lookup_value(&root, ConfigKey::HooksPostNew).unwrap(),
505 &Value::String("echo hi".into())
506 );
507 assert!(lookup_value(&root, ConfigKey::HooksPreRm).is_none());
508 }
509
510 #[test]
511 fn unset_removes_key_only() {
512 let mut root = json!({"hooks": {"post_new": "x", "pre_rm": "y"}});
513 unset_value(&mut root, ConfigKey::HooksPostNew);
514 assert!(lookup_value(&root, ConfigKey::HooksPostNew).is_none());
515 assert_eq!(
516 lookup_value(&root, ConfigKey::HooksPreRm).unwrap(),
517 &Value::String("y".into())
518 );
519 }
520
521 #[test]
522 fn unset_missing_key_noop() {
523 let mut root = empty();
524 unset_value(&mut root, ConfigKey::HooksPostNew);
525 assert!(lookup_value(&root, ConfigKey::HooksPostNew).is_none());
526 }
527
528 #[test]
529 fn parse_bool_variants() {
530 assert_eq!(
531 parse_value_for(ConfigKey::AiToolGuard, "true").unwrap(),
532 Value::Bool(true)
533 );
534 assert_eq!(
535 parse_value_for(ConfigKey::AiToolGuard, "no").unwrap(),
536 Value::Bool(false)
537 );
538 assert!(parse_value_for(ConfigKey::AiToolGuard, "maybe").is_err());
539 }
540
541 #[test]
542 fn parse_number_for_timeout() {
543 let v = parse_value_for(ConfigKey::LaunchWeztermReadyTimeout, "7.5").unwrap();
544 assert!(v.is_number());
545 assert!((v.as_f64().unwrap() - 7.5).abs() < 1e-9);
546 }
547
548 #[test]
549 fn parse_number_rejects_non_finite() {
550 for input in ["inf", "-inf", "NaN"] {
553 let err = parse_value_for(ConfigKey::LaunchWeztermReadyTimeout, input).unwrap_err();
554 let msg = format!("{err}");
555 assert!(
556 msg.contains("finite") || msg.contains("expects a number"),
557 "unexpected error for {input:?}: {msg}"
558 );
559 }
560 }
561
562 #[test]
563 fn parse_empty_string_value_is_empty_string() {
564 let v = parse_value_for(ConfigKey::AiToolCommand, "").unwrap();
569 assert_eq!(v, Value::String(String::new()));
570 }
571
572 #[test]
573 fn parse_args_whitespace_form() {
574 let v = parse_value_for(ConfigKey::AiToolArgs, "--continue --resume").unwrap();
575 let arr = v.as_array().unwrap();
576 assert_eq!(arr.len(), 2);
577 assert_eq!(arr[0], Value::String("--continue".into()));
578 assert_eq!(arr[1], Value::String("--resume".into()));
579 }
580
581 #[test]
582 fn parse_args_json_form() {
583 let v = parse_value_for(ConfigKey::AiToolArgs, r#"["a","b c"]"#).unwrap();
584 let arr = v.as_array().unwrap();
585 assert_eq!(arr.len(), 2);
586 assert_eq!(arr[1], Value::String("b c".into()));
587 }
588
589 #[test]
590 fn parse_args_empty_is_empty_array() {
591 let v = parse_value_for(ConfigKey::AiToolArgs, " ").unwrap();
592 assert!(v.as_array().unwrap().is_empty());
593 }
594
595 #[test]
596 fn set_rejects_non_object_intermediate() {
597 let mut root = json!({"hooks": "oops"});
599 let err = set_value(
600 &mut root,
601 ConfigKey::HooksPostNew,
602 Value::String("x".into()),
603 )
604 .unwrap_err();
605 assert!(format!("{err}").contains("not an object"));
606 }
607}