Skip to main content

git_worktree_manager/operations/
config_ops.rs

1//! `gw config` command implementation.
2//!
3//! Surface (intentionally small):
4//!   - `gw config list`              — merged view + scope annotation
5//!   - `gw config get <key>`         — resolved value (single line)
6//!   - `gw config set <key> <value>` — write to global, `--repo` to override
7//!   - `gw config edit`              — TUI editor switching global ↔ repo
8//!
9//! Scope model:
10//!   - **global**: `~/.config/git-worktree-manager/config.json`
11//!   - **repo**:   `<repo-root>/.cwconfig.json`
12//!
13//! Resolved value follows the same precedence as runtime
14//! `load_effective_config`: defaults < global < repo.
15//!
16//! The settable surface is the [`ConfigKey`] enum — clap derives the value
17//! parser from it, so `gw config set <key>` auto-completes / errors against
18//! the same list of keys the code understands. New keys go here and become
19//! settable everywhere at once.
20
21use 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/// Every key gw exposes through `gw config get/set`.
32///
33/// Keys here mirror the JSON paths in [`crate::config::Config`]. The
34/// `ValueEnum` derive gives clap a closed enumeration so a typo at the CLI
35/// fails fast instead of silently writing a key the code never reads.
36///
37/// Order is the order users see in `gw config list` — keep related keys
38/// adjacent.
39#[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    /// Dotted user-facing key (matches the `#[value(name=...)]` form).
76    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    /// JSON Pointer path into the serialized [`Config`] tree.
91    ///
92    /// Kept separate from [`Self::name`] because the on-disk schema uses
93    /// snake_case (matching `#[derive(Serialize)]` field names) while the
94    /// CLI surface uses kebab-case for consistency with the rest of clap.
95    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
110// ---------------------------------------------------------------------------
111// Value get / set on a serde_json::Value
112// ---------------------------------------------------------------------------
113
114/// Look up a [`ConfigKey`] in a serialized config tree. Returns `None` when
115/// any segment along the path is missing or null.
116pub 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
127/// Parse a user-typed string into the JSON value [`ConfigKey`] expects.
128///
129/// Strings stay strings; booleans accept `true`/`false`; numbers parse via
130/// `serde_json`; `args` accepts a JSON array literal or a space-separated
131/// shell-ish list. Empty string means "unset" — the caller decides whether
132/// to remove the field or leave it default.
133pub 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                // `Number::from_f64` returns None for inf/-inf/NaN — JSON has
157                // no representation for non-finite floats. Surface that as an
158                // error rather than silently writing `null`.
159                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            // Accept a JSON array literal first (lets users pass exact tokens
171            // including ones with spaces). Fall back to whitespace splitting,
172            // which matches what users naturally type.
173            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
192/// Set `key` to `value` inside `root`, creating intermediate objects as
193/// needed. Errors only if an intermediate path collides with a non-object
194/// (the only way this can happen is if the file was hand-edited).
195pub 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
222/// Remove `key` from `root`. No-op if the path doesn't exist. Empty parent
223/// objects are NOT pruned — we want a stable file shape and `serde` fills
224/// missing fields with defaults on load anyway.
225pub 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
243// ---------------------------------------------------------------------------
244// File I/O for each scope
245// ---------------------------------------------------------------------------
246
247/// Where the global config file lives (delegates to [`crate::config`]).
248pub fn global_path() -> PathBuf {
249    get_config_path()
250}
251
252/// Resolve the repo-local `.cwconfig.json` path for the given cwd. Returns
253/// `Err` when cwd is not inside a git repository — `--repo` is a no-op
254/// outside one and we surface that as an error rather than silently
255/// writing under the user's home.
256pub 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
263/// Read a JSON file as a `Value`, returning `Value::Object({})` when the
264/// file does not exist. Parse errors propagate.
265pub 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
278/// Write `value` to `path` pretty-printed, creating parent directories.
279pub 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    // Trailing newline so the file plays nicely with editors / `cat`.
288    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// ---------------------------------------------------------------------------
294// Scope abstraction
295// ---------------------------------------------------------------------------
296
297#[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    /// Toggle between the two scopes. Used by the edit TUI's Tab key.
312    pub fn other(self) -> Self {
313        match self {
314            Scope::Global => Scope::Repo,
315            Scope::Repo => Scope::Global,
316        }
317    }
318}
319
320// ---------------------------------------------------------------------------
321// Commands: list / get / set
322// ---------------------------------------------------------------------------
323
324/// `gw config list` — render every known key with its resolved value and the
325/// scope that supplied it.
326pub 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    // Two-column-ish formatting: key (left), value + scope (right). Keys
361    // top out at 30 chars in the current schema; pad to the longest name
362    // so columns line up without a tabulate dependency.
363    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
390/// Render a config value compactly for `list` / `get`.
391fn 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
405/// `gw config get <key>` — print the resolved value (repo > global > default).
406/// Exits non-zero with no output when nothing is set, matching `git config`'s
407/// convention for script consumers.
408pub 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
434/// `gw config set <key> <value> [--repo]`.
435///
436/// Default scope is global, matching `git config` semantics — `--repo`
437/// writes to `<repo-root>/.cwconfig.json`.
438pub 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// ---------------------------------------------------------------------------
461// Tests
462// ---------------------------------------------------------------------------
463
464#[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        // hooks.post_new exists; hooks.pre_rm path is absent (no extra fill).
503        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        // serde_json::Number cannot represent inf/NaN; we surface that as an
551        // error instead of silently writing `null` to the file.
552        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        // Documenting the divergence between `gw config set <key> ""` (writes
565        // an empty string for non-array keys) and the TUI's empty buffer (which
566        // calls `unset_value` instead). Two surfaces, two semantics — captured
567        // here so a future change is a conscious one.
568        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        // hooks is a string here instead of an object — should refuse.
598        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}