Skip to main content

gwm/
config_cli.rs

1use crate::config::{Config, CONFIG_FILE};
2use crate::error::{GwmError, Result};
3use crate::worktree;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9enum Index {
10  Number(usize),
11  Append,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15struct Segment {
16  name: String,
17  index: Option<Index>,
18}
19
20pub fn get(key: &str) -> Result<()> {
21  let root = repo_root()?;
22  let cfg = Config::load_for_repo(&root)?;
23  let value = resolved_value(&cfg, key)?;
24  println!("{}", format_get_value(&value));
25  Ok(())
26}
27
28pub fn set(key: &str, raw_value: Option<&str>) -> Result<()> {
29  let (key, raw_value) = split_set_args(key, raw_value)?;
30  let root = repo_root()?;
31  let path = config_path(&root);
32  let mut doc = load_document(&path)?;
33  let segments = parse_key(&key)?;
34  let value = parse_scalar(&raw_value);
35  let resolved_key = set_value(doc.as_table_mut(), &segments, value)?;
36  write_and_validate(&path, &doc)?;
37  let rendered = resolved_value(&Config::load_for_repo(&root)?, &resolved_key)?;
38  println!("{} = {}", resolved_key, crate::config::format_list_value(&rendered));
39  Ok(())
40}
41
42/// Layer-aware, silent variant of [`set`] for the in-TUI Settings panel
43/// (issue #279): set `key = value` in the TOML file at an EXPLICIT `path`
44/// (the repo `.gwm.toml` OR the user-global `config.toml`) rather than the
45/// discovered repo root, and return without printing so the TUI owns the
46/// feedback. Reuses the exact key-path parser, scalar coercion, surgical
47/// `toml_edit` write and post-write `Config` validation as `gwm config set`,
48/// so a write that round-trips through `gwm config` round-trips here too.
49///
50/// Creates the parent directory when missing so the user-global file can be
51/// written on its first use (the `~/.config/gwm/` dir may not exist yet).
52///
53/// `raw_value` is coerced with the same scalar heuristic as `gwm config set`
54/// (`123` → int, `true` → bool, else string). Use [`set_string_at`] for
55/// free-text settings that must stay strings regardless of their content.
56pub fn set_value_at(path: &Path, key: &str, raw_value: &str) -> Result<()> {
57  set_item_at(path, key, parse_scalar(raw_value))
58}
59
60/// String-forced variant of [`set_value_at`] for free-text Settings fields
61/// (issue #279 review P2): always writes the value as a TOML string, so a
62/// shell/editor command or worktree value like `123` / `true` is preserved
63/// as text rather than coerced to a number/bool by `parse_scalar` (which
64/// would then fail `Config` validation and, pre-fix, leave the file invalid).
65pub fn set_string_at(path: &Path, key: &str, raw_value: &str) -> Result<()> {
66  set_item_at(path, key, value(raw_value))
67}
68
69/// Array variant of [`set_value_at`] for the in-TUI Keys tab (issue #294):
70/// write `key = ["a", "b", …]` as a TOML array of strings at an explicit
71/// `path`. Backs the keymap rebind surface — a global action's chord list
72/// under `[tui.keys]`, or a modal verb's single-stroke list under
73/// `[tui.keys.modal.<context>]`. An empty `items` writes `key = []`, the
74/// legitimate "unbind" value. Reuses the same parent-dir creation, surgical
75/// `toml_edit` edit and validate-before-write as the scalar writers, so a
76/// rebind that produces a conflicting / prefix-colliding keymap is rejected
77/// before it can clobber a good file.
78pub fn set_array_at(path: &Path, key: &str, items: &[String]) -> Result<()> {
79  let mut arr = toml_edit::Array::new();
80  for item in items {
81    arr.push(item.as_str());
82  }
83  set_item_at(path, key, value(arr))
84}
85
86/// Remove `key` from the TOML file at an explicit `path` (issue #294): the
87/// layer-aware sibling of [`unset`], used by the in-TUI Keys tab to strip a
88/// pre-#290 alias when the canonical slug is rewritten. Tolerant — an absent
89/// file or missing key is a no-op (nothing to remove), so callers can clear a
90/// possible alias unconditionally. Validate-before-write like the setters.
91pub fn unset_at(path: &Path, key: &str) -> Result<()> {
92  if !path.exists() {
93    return Ok(());
94  }
95  let mut doc = load_document(path)?;
96  let segments = parse_key(key)?;
97  remove_value(doc.as_table_mut(), &segments)?;
98  write_and_validate(path, &doc)
99}
100
101/// Shared write path: ensure the parent dir exists, set `key` to `item` in
102/// the surgically-edited document, and validate-before-write so an invalid
103/// edit can never overwrite a good file.
104fn set_item_at(path: &Path, key: &str, item: Item) -> Result<()> {
105  if let Some(parent) = path.parent() {
106    if !parent.as_os_str().is_empty() {
107      std::fs::create_dir_all(parent)?;
108    }
109  }
110  let mut doc = load_document(path)?;
111  let segments = parse_key(key)?;
112  set_value(doc.as_table_mut(), &segments, item)?;
113  write_and_validate(path, &doc)
114}
115
116fn split_set_args(key: &str, raw_value: Option<&str>) -> Result<(String, String)> {
117  match (key.split_once('='), raw_value) {
118    (Some((key, value)), None) if !key.is_empty() => Ok((key.to_string(), value.to_string())),
119    (None, Some(value)) => Ok((key.to_string(), value.to_string())),
120    (Some(_), Some(_)) => Err(GwmError::Config(
121      "`gwm config set` accepts either `<key> <value>` or `<key=value>`, not both".into(),
122    )),
123    _ => Err(GwmError::Config(
124      "`gwm config set` requires a value (`<key> <value>` or `<key=value>`)".into(),
125    )),
126  }
127}
128
129pub fn unset(key: &str) -> Result<()> {
130  let root = repo_root()?;
131  let path = config_path(&root);
132  let mut doc = load_document(&path)?;
133  let segments = parse_key(key)?;
134  remove_value(doc.as_table_mut(), &segments)?;
135  write_and_validate(&path, &doc)?;
136  println!("unset {}", key);
137  Ok(())
138}
139
140pub fn list(prefix: Option<&str>) -> Result<()> {
141  let root = repo_root()?;
142  let cfg = Config::load_for_repo(&root)?;
143  let value = toml::Value::try_from(cfg).map_err(|e| GwmError::Config(e.to_string()))?;
144  let mut rows = Vec::new();
145  crate::config::flatten_value("", &value, &mut rows);
146  for (key, value) in rows {
147    if prefix
148      .map(|p| key == p || key.starts_with(&format!("{}.", p)) || key.starts_with(&format!("{}[", p)))
149      .unwrap_or(true)
150    {
151      // Issue #473: the *value* is already escaped by `format_list_value`'s
152      // `{:?}`, but the key is not, and a key is attacker-controlled wherever
153      // the schema is a map rather than fixed fields (`[aliases]`,
154      // `[forge_hosts]`, `[exec.profiles]`, `[clean.profiles]`, `[tui.keys]`).
155      // Sanitised at the print site rather than inside `flatten_value`, whose
156      // other callers compare keys across config layers to attribute a source
157      // and must keep seeing them byte-for-byte.
158      println!("{} = {}", crate::naming::sanitise_for_terminal(&key), value);
159    }
160  }
161  Ok(())
162}
163
164pub fn validate() -> Result<()> {
165  // Discover once: the warning below needs the repo *name* too (`{repo}` is
166  // a supported `branch_pattern` token and the verdict depends on it), and
167  // `repo_root` drops the handle it opened.
168  let repo = worktree::discover_repo(None)?;
169  let root = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
170  let path = config_path(&root);
171  let cfg = validate_file(&path)?;
172  println!("{} is valid", path.display());
173  // Issue #415: a `branch_pattern` the parser cannot read back is *valid*
174  // config — it just silently breaks everything keyed on the re-parsed
175  // segments. Stated on stderr so the exit code stays 0 and piped
176  // consumers of stdout are unaffected.
177  //
178  // Read the *effective* pattern, not the repo file's: `branch_pattern`
179  // set only in the user-level global config still applies at runtime
180  // through `merge_layered`, and validating `path` alone would stay quiet
181  // about it while `gwm doctor` (which sees the merged view) warns. A
182  // broken global layer is not this command's business — it reports on
183  // `path` — so fall back to the repo-only value it just validated.
184  let effective = Config::merge_layered(&root, crate::config::global_config_path().as_deref()).unwrap_or(cfg);
185  let types = effective.resolved_branch_types().types;
186  if let Some(warning) =
187    crate::naming::branch_pattern_warning(&effective.worktree.branch_pattern, &worktree::repo_name(&repo), &types)
188  {
189    // Issue #473: `branch_pattern_warning` already neutralises the pattern it
190    // quotes, but it also embeds the repo name, and this `eprintln!` bypasses
191    // the sink in `main` (it is a warning, not a returned error). One row, so
192    // the row variant.
193    eprintln!("warning: {}", crate::naming::sanitise_for_terminal(&warning));
194  }
195  Ok(())
196}
197
198pub fn path() -> Result<()> {
199  let root = repo_root()?;
200  println!("{}", config_path(&root).display());
201  Ok(())
202}
203
204pub fn edit() -> Result<()> {
205  let root = repo_root()?;
206  let path = config_path(&root);
207  if !path.exists() {
208    std::fs::write(&path, "")?;
209  }
210  let editor = std::env::var("EDITOR")
211    .map_err(|_| GwmError::Config("EDITOR is not set; set EDITOR or open `gwm config path` manually".into()))?;
212  let status = Command::new(&editor)
213    .arg(&path)
214    .status()
215    .map_err(|e| GwmError::CommandFailed(format!("{}: failed to spawn editor ({})", editor, e)))?;
216  if !status.success() {
217    return Err(GwmError::CommandFailed(format!("{} exited with {}", editor, status)));
218  }
219  validate_file(&path)?;
220  Ok(())
221}
222
223fn repo_root() -> Result<PathBuf> {
224  let repo = worktree::discover_repo(None)?;
225  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
226  Ok(workdir.to_path_buf())
227}
228
229fn config_path(root: &Path) -> PathBuf {
230  root.join(CONFIG_FILE)
231}
232
233fn load_document(path: &Path) -> Result<DocumentMut> {
234  if !path.exists() {
235    return Ok(DocumentMut::new());
236  }
237  let raw = std::fs::read_to_string(path)?;
238  raw
239    .parse::<DocumentMut>()
240    .map_err(|e| config_parse_error(path, &raw, e))
241}
242
243fn write_and_validate(path: &Path, doc: &DocumentMut) -> Result<()> {
244  let rendered = doc.to_string();
245  match validate_rendered(path, &rendered) {
246    // The edit is valid — write it.
247    Ok(_) => {
248      std::fs::write(path, rendered)?;
249      Ok(())
250    }
251    // The edit would produce an invalid Config. Only refuse the write when
252    // the existing on-disk file is VALID (or absent) — i.e. this edit would
253    // clobber a good file with a broken one (issue #279 review P2). If the
254    // file is ALREADY invalid, keep the historical write-then-error
255    // behaviour so `gwm config set` can still edit a broken file toward a
256    // fixed state rather than refusing every edit until it is hand-repaired
257    // (issue #281 — the validate-before-write chicken-and-egg).
258    Err(e) => {
259      if validate_file(path).is_ok() {
260        return Err(e);
261      }
262      std::fs::write(path, rendered)?;
263      Err(e)
264    }
265  }
266}
267
268/// Returns the validated `Config` so callers can inspect the resolved
269/// values without re-parsing the file — an absent config yields the
270/// defaults, which is exactly what the loader would have produced.
271fn validate_file(path: &Path) -> Result<Config> {
272  if !path.exists() {
273    return Ok(Config::default());
274  }
275  let raw = std::fs::read_to_string(path)?;
276  validate_rendered(path, &raw)
277}
278
279/// Validate `raw` as a complete `Config` (deserialization + the semantic
280/// checks `gwm config validate` runs). `path` is only used for error
281/// coordinates. Shared by [`validate_file`] (on-disk) and the
282/// validate-before-write path in [`write_and_validate`].
283fn validate_rendered(path: &Path, raw: &str) -> Result<Config> {
284  let cfg = toml::from_str::<Config>(raw).map_err(|e| config_de_error(path, raw, e))?;
285  cfg.validate_branch_types()?;
286  cfg.validate_bootstrap_paths()?;
287  cfg.validate_bootstrap_guards()?;
288  cfg.validate_labels()?;
289  cfg.validate_aliases()?;
290  // `[tui.keys]` / `[theme]` deserialize into raw tables resolved lazily, so a
291  // malformed keymap or theme passes `toml::from_str` cleanly. Run the same
292  // validators `Config::load_for_repo` does (issue #219 review) — otherwise
293  // `gwm config validate` / validate-before-write greenlights a config the
294  // loader will later reject.
295  cfg.validate_tui_keys()?;
296  cfg.validate_theme()?;
297  // `[exec.profiles]` / `[clean.profiles]` semantics (non-empty command, a
298  // worktree-relative single-name `dirs`) parse cleanly too, so run the same
299  // check `load_for_repo` does — otherwise `gwm config validate` greenlights a
300  // profile the loader and the new commands reject (issue #324 review).
301  cfg.validate_profiles()?;
302  Ok(cfg)
303}
304
305fn resolved_value(cfg: &Config, key: &str) -> Result<toml::Value> {
306  let value = toml::Value::try_from(cfg.clone()).map_err(|e| GwmError::Config(e.to_string()))?;
307  Ok(lookup_value(&value, &parse_key(key)?)?.clone())
308}
309
310fn lookup_value<'a>(value: &'a toml::Value, segments: &[Segment]) -> Result<&'a toml::Value> {
311  let mut current = value;
312  for segment in segments {
313    current = current
314      .get(&segment.name)
315      .ok_or_else(|| GwmError::Config(format!("unknown config key '{}'", render_segments(segments))))?;
316    if let Some(index) = &segment.index {
317      let array = current
318        .as_array()
319        .ok_or_else(|| GwmError::Config(format!("'{}' is not an array", segment.name)))?;
320      let Index::Number(i) = index else {
321        return Err(GwmError::Config("[+] is only valid for `config set`".into()));
322      };
323      current = array
324        .get(*i)
325        .ok_or_else(|| GwmError::Config(format!("array index out of bounds: {}[{}]", segment.name, i)))?;
326    }
327  }
328  Ok(current)
329}
330
331fn parse_key(key: &str) -> Result<Vec<Segment>> {
332  let mut segments = Vec::new();
333  for raw in key.split('.') {
334    if raw.is_empty() {
335      return Err(GwmError::Config(format!(
336        "invalid empty config key segment in '{}'",
337        key
338      )));
339    }
340    let (name, index) = if let Some(open) = raw.find('[') {
341      let close = raw
342        .strip_suffix(']')
343        .ok_or_else(|| GwmError::Config(format!("invalid array segment '{}'", raw)))?;
344      let name = &raw[..open];
345      let idx = &close[open + 1..];
346      let index = if idx == "+" {
347        Index::Append
348      } else {
349        Index::Number(
350          idx
351            .parse()
352            .map_err(|_| GwmError::Config(format!("invalid array index '{}'", idx)))?,
353        )
354      };
355      (name, Some(index))
356    } else {
357      (raw, None)
358    };
359    if name.is_empty() || !name.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) {
360      return Err(GwmError::Config(format!("invalid config key segment '{}'", raw)));
361    }
362    segments.push(Segment {
363      name: name.to_string(),
364      index,
365    });
366  }
367  Ok(segments)
368}
369
370fn parse_scalar(raw: &str) -> Item {
371  if let Ok(parsed) = raw.parse::<i64>() {
372    return value(parsed);
373  }
374  if let Ok(parsed) = raw.parse::<f64>() {
375    return value(parsed);
376  }
377  match raw {
378    "true" => value(true),
379    "false" => value(false),
380    _ => value(raw),
381  }
382}
383
384fn set_value(table: &mut Table, segments: &[Segment], new_value: Item) -> Result<String> {
385  let Some((head, tail)) = segments.split_first() else {
386    return Err(GwmError::Config("empty config key".into()));
387  };
388  if tail.is_empty() {
389    if head.index.is_some() {
390      return Err(GwmError::Config(
391        "array-table keys must name a field after the index".into(),
392      ));
393    }
394    table.insert(&head.name, new_value);
395    return Ok(render_segments(segments));
396  }
397
398  match &head.index {
399    None => {
400      let item = table.entry(&head.name).or_insert_with(|| Item::Table(Table::new()));
401      if item.is_none() {
402        *item = Item::Table(Table::new());
403      }
404      let child = item
405        .as_table_mut()
406        .ok_or_else(|| GwmError::Config(format!("'{}' is not a table", head.name)))?;
407      let tail_key = set_value(child, tail, new_value)?;
408      Ok(format!("{}.{}", head.name, tail_key))
409    }
410    Some(index) => {
411      let item = table
412        .entry(&head.name)
413        .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new()));
414      if item.is_none() {
415        *item = Item::ArrayOfTables(ArrayOfTables::new());
416      }
417      let array = item
418        .as_array_of_tables_mut()
419        .ok_or_else(|| GwmError::Config(format!("'{}' is not an array of tables", head.name)))?;
420      let actual = match index {
421        Index::Number(i) => {
422          while array.len() <= *i {
423            array.push(Table::new());
424          }
425          *i
426        }
427        Index::Append => {
428          array.push(Table::new());
429          array.len() - 1
430        }
431      };
432      let child = array
433        .get_mut(actual)
434        .ok_or_else(|| GwmError::Config(format!("array index out of bounds: {}[{}]", head.name, actual)))?;
435      let mut resolved = segments.to_vec();
436      resolved[0].index = Some(Index::Number(actual));
437      let tail_key = set_value(child, tail, new_value)?;
438      Ok(format!("{}.{}", render_segment(&resolved[0]), tail_key))
439    }
440  }
441}
442
443fn remove_value(table: &mut Table, segments: &[Segment]) -> Result<()> {
444  let Some((head, tail)) = segments.split_first() else {
445    return Err(GwmError::Config("empty config key".into()));
446  };
447  if tail.is_empty() {
448    if head.index.is_some() {
449      return Err(GwmError::Config(
450        "array-table keys must name a field after the index".into(),
451      ));
452    }
453    table.remove(&head.name);
454    return Ok(());
455  }
456  match &head.index {
457    None => {
458      let Some(item) = table.get_mut(&head.name) else {
459        return Ok(());
460      };
461      let Some(child) = item.as_table_mut() else {
462        return Ok(());
463      };
464      remove_value(child, tail)
465    }
466    Some(Index::Number(i)) => {
467      let Some(item) = table.get_mut(&head.name) else {
468        return Ok(());
469      };
470      let Some(array) = item.as_array_of_tables_mut() else {
471        return Ok(());
472      };
473      let Some(child) = array.get_mut(*i) else {
474        return Ok(());
475      };
476      remove_value(child, tail)
477    }
478    Some(Index::Append) => Err(GwmError::Config("[+] is only valid for `config set`".into())),
479  }
480}
481
482fn format_get_value(value: &toml::Value) -> String {
483  match value {
484    // Issue #473: `gwm config get` prints the string bare (that is its
485    // contract, the output is meant to be pipeable), so unlike the
486    // `format_list_value` path below it gets no incidental protection from
487    // `Debug`'s escaping. Neutralise the control bytes here instead.
488    toml::Value::String(s) => crate::naming::sanitise_for_terminal(s),
489    _ => crate::config::format_list_value(value),
490  }
491}
492
493fn render_segments(segments: &[Segment]) -> String {
494  segments.iter().map(render_segment).collect::<Vec<_>>().join(".")
495}
496
497fn render_segment(segment: &Segment) -> String {
498  match &segment.index {
499    Some(Index::Number(i)) => format!("{}[{}]", segment.name, i),
500    Some(Index::Append) => format!("{}[+]", segment.name),
501    None => segment.name.clone(),
502  }
503}
504
505fn config_de_error(path: &Path, raw: &str, err: toml::de::Error) -> GwmError {
506  let msg = enrich_schema_hint(err.to_string());
507  match err.span() {
508    Some(span) => GwmError::Config(format!(
509      "{}: error at line {}, col {}: {}",
510      path.display(),
511      line_col(raw, span.start).0,
512      line_col(raw, span.start).1,
513      msg
514    )),
515    None => GwmError::Config(format!("{}: {}", path.display(), msg)),
516  }
517}
518
519fn enrich_schema_hint(message: String) -> String {
520  if message.contains("fullscreem") {
521    format!("{} (did you mean 'fullscreen'?)", message)
522  } else {
523    message
524  }
525}
526
527fn config_parse_error(path: &Path, raw: &str, err: toml_edit::TomlError) -> GwmError {
528  match err.span() {
529    Some(span) => GwmError::Config(format!(
530      "{}: error at line {}, col {}: {}",
531      path.display(),
532      line_col(raw, span.start).0,
533      line_col(raw, span.start).1,
534      err
535    )),
536    None => GwmError::Config(format!("{}: {}", path.display(), err)),
537  }
538}
539
540fn line_col(raw: &str, offset: usize) -> (usize, usize) {
541  let mut line = 1;
542  let mut col = 1;
543  for (idx, ch) in raw.char_indices() {
544    if idx >= offset {
545      break;
546    }
547    if ch == '\n' {
548      line += 1;
549      col = 1;
550    } else {
551      col += 1;
552    }
553  }
554  (line, col)
555}