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      println!("{} = {}", key, value);
152    }
153  }
154  Ok(())
155}
156
157pub fn validate() -> Result<()> {
158  let root = repo_root()?;
159  let path = config_path(&root);
160  validate_file(&path)?;
161  println!("{} is valid", path.display());
162  Ok(())
163}
164
165pub fn path() -> Result<()> {
166  let root = repo_root()?;
167  println!("{}", config_path(&root).display());
168  Ok(())
169}
170
171pub fn edit() -> Result<()> {
172  let root = repo_root()?;
173  let path = config_path(&root);
174  if !path.exists() {
175    std::fs::write(&path, "")?;
176  }
177  let editor = std::env::var("EDITOR")
178    .map_err(|_| GwmError::Config("EDITOR is not set; set EDITOR or open `gwm config path` manually".into()))?;
179  let status = Command::new(&editor)
180    .arg(&path)
181    .status()
182    .map_err(|e| GwmError::CommandFailed(format!("{}: failed to spawn editor ({})", editor, e)))?;
183  if !status.success() {
184    return Err(GwmError::CommandFailed(format!("{} exited with {}", editor, status)));
185  }
186  validate_file(&path)?;
187  Ok(())
188}
189
190fn repo_root() -> Result<PathBuf> {
191  let repo = worktree::discover_repo(None)?;
192  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
193  Ok(workdir.to_path_buf())
194}
195
196fn config_path(root: &Path) -> PathBuf {
197  root.join(CONFIG_FILE)
198}
199
200fn load_document(path: &Path) -> Result<DocumentMut> {
201  if !path.exists() {
202    return Ok(DocumentMut::new());
203  }
204  let raw = std::fs::read_to_string(path)?;
205  raw
206    .parse::<DocumentMut>()
207    .map_err(|e| config_parse_error(path, &raw, e))
208}
209
210fn write_and_validate(path: &Path, doc: &DocumentMut) -> Result<()> {
211  let rendered = doc.to_string();
212  match validate_rendered(path, &rendered) {
213    // The edit is valid — write it.
214    Ok(()) => {
215      std::fs::write(path, rendered)?;
216      Ok(())
217    }
218    // The edit would produce an invalid Config. Only refuse the write when
219    // the existing on-disk file is VALID (or absent) — i.e. this edit would
220    // clobber a good file with a broken one (issue #279 review P2). If the
221    // file is ALREADY invalid, keep the historical write-then-error
222    // behaviour so `gwm config set` can still edit a broken file toward a
223    // fixed state rather than refusing every edit until it is hand-repaired
224    // (issue #281 — the validate-before-write chicken-and-egg).
225    Err(e) => {
226      if validate_file(path).is_ok() {
227        return Err(e);
228      }
229      std::fs::write(path, rendered)?;
230      Err(e)
231    }
232  }
233}
234
235fn validate_file(path: &Path) -> Result<()> {
236  if !path.exists() {
237    return Ok(());
238  }
239  let raw = std::fs::read_to_string(path)?;
240  validate_rendered(path, &raw)
241}
242
243/// Validate `raw` as a complete `Config` (deserialization + the semantic
244/// checks `gwm config validate` runs). `path` is only used for error
245/// coordinates. Shared by [`validate_file`] (on-disk) and the
246/// validate-before-write path in [`write_and_validate`].
247fn validate_rendered(path: &Path, raw: &str) -> Result<()> {
248  let cfg = toml::from_str::<Config>(raw).map_err(|e| config_de_error(path, raw, e))?;
249  cfg.validate_branch_types()?;
250  cfg.validate_bootstrap_paths()?;
251  cfg.validate_bootstrap_guards()?;
252  cfg.validate_labels()?;
253  cfg.validate_aliases()?;
254  // `[tui.keys]` / `[theme]` deserialize into raw tables resolved lazily, so a
255  // malformed keymap or theme passes `toml::from_str` cleanly. Run the same
256  // validators `Config::load_for_repo` does (issue #219 review) — otherwise
257  // `gwm config validate` / validate-before-write greenlights a config the
258  // loader will later reject.
259  cfg.validate_tui_keys()?;
260  cfg.validate_theme()?;
261  // `[exec.profiles]` / `[clean.profiles]` semantics (non-empty command, a
262  // worktree-relative single-name `dirs`) parse cleanly too, so run the same
263  // check `load_for_repo` does — otherwise `gwm config validate` greenlights a
264  // profile the loader and the new commands reject (issue #324 review).
265  cfg.validate_profiles()?;
266  Ok(())
267}
268
269fn resolved_value(cfg: &Config, key: &str) -> Result<toml::Value> {
270  let value = toml::Value::try_from(cfg.clone()).map_err(|e| GwmError::Config(e.to_string()))?;
271  Ok(lookup_value(&value, &parse_key(key)?)?.clone())
272}
273
274fn lookup_value<'a>(value: &'a toml::Value, segments: &[Segment]) -> Result<&'a toml::Value> {
275  let mut current = value;
276  for segment in segments {
277    current = current
278      .get(&segment.name)
279      .ok_or_else(|| GwmError::Config(format!("unknown config key '{}'", render_segments(segments))))?;
280    if let Some(index) = &segment.index {
281      let array = current
282        .as_array()
283        .ok_or_else(|| GwmError::Config(format!("'{}' is not an array", segment.name)))?;
284      let Index::Number(i) = index else {
285        return Err(GwmError::Config("[+] is only valid for `config set`".into()));
286      };
287      current = array
288        .get(*i)
289        .ok_or_else(|| GwmError::Config(format!("array index out of bounds: {}[{}]", segment.name, i)))?;
290    }
291  }
292  Ok(current)
293}
294
295fn parse_key(key: &str) -> Result<Vec<Segment>> {
296  let mut segments = Vec::new();
297  for raw in key.split('.') {
298    if raw.is_empty() {
299      return Err(GwmError::Config(format!(
300        "invalid empty config key segment in '{}'",
301        key
302      )));
303    }
304    let (name, index) = if let Some(open) = raw.find('[') {
305      let close = raw
306        .strip_suffix(']')
307        .ok_or_else(|| GwmError::Config(format!("invalid array segment '{}'", raw)))?;
308      let name = &raw[..open];
309      let idx = &close[open + 1..];
310      let index = if idx == "+" {
311        Index::Append
312      } else {
313        Index::Number(
314          idx
315            .parse()
316            .map_err(|_| GwmError::Config(format!("invalid array index '{}'", idx)))?,
317        )
318      };
319      (name, Some(index))
320    } else {
321      (raw, None)
322    };
323    if name.is_empty() || !name.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) {
324      return Err(GwmError::Config(format!("invalid config key segment '{}'", raw)));
325    }
326    segments.push(Segment {
327      name: name.to_string(),
328      index,
329    });
330  }
331  Ok(segments)
332}
333
334fn parse_scalar(raw: &str) -> Item {
335  if let Ok(parsed) = raw.parse::<i64>() {
336    return value(parsed);
337  }
338  if let Ok(parsed) = raw.parse::<f64>() {
339    return value(parsed);
340  }
341  match raw {
342    "true" => value(true),
343    "false" => value(false),
344    _ => value(raw),
345  }
346}
347
348fn set_value(table: &mut Table, segments: &[Segment], new_value: Item) -> Result<String> {
349  let Some((head, tail)) = segments.split_first() else {
350    return Err(GwmError::Config("empty config key".into()));
351  };
352  if tail.is_empty() {
353    if head.index.is_some() {
354      return Err(GwmError::Config(
355        "array-table keys must name a field after the index".into(),
356      ));
357    }
358    table.insert(&head.name, new_value);
359    return Ok(render_segments(segments));
360  }
361
362  match &head.index {
363    None => {
364      let item = table.entry(&head.name).or_insert_with(|| Item::Table(Table::new()));
365      if item.is_none() {
366        *item = Item::Table(Table::new());
367      }
368      let child = item
369        .as_table_mut()
370        .ok_or_else(|| GwmError::Config(format!("'{}' is not a table", head.name)))?;
371      let tail_key = set_value(child, tail, new_value)?;
372      Ok(format!("{}.{}", head.name, tail_key))
373    }
374    Some(index) => {
375      let item = table
376        .entry(&head.name)
377        .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new()));
378      if item.is_none() {
379        *item = Item::ArrayOfTables(ArrayOfTables::new());
380      }
381      let array = item
382        .as_array_of_tables_mut()
383        .ok_or_else(|| GwmError::Config(format!("'{}' is not an array of tables", head.name)))?;
384      let actual = match index {
385        Index::Number(i) => {
386          while array.len() <= *i {
387            array.push(Table::new());
388          }
389          *i
390        }
391        Index::Append => {
392          array.push(Table::new());
393          array.len() - 1
394        }
395      };
396      let child = array
397        .get_mut(actual)
398        .ok_or_else(|| GwmError::Config(format!("array index out of bounds: {}[{}]", head.name, actual)))?;
399      let mut resolved = segments.to_vec();
400      resolved[0].index = Some(Index::Number(actual));
401      let tail_key = set_value(child, tail, new_value)?;
402      Ok(format!("{}.{}", render_segment(&resolved[0]), tail_key))
403    }
404  }
405}
406
407fn remove_value(table: &mut Table, segments: &[Segment]) -> Result<()> {
408  let Some((head, tail)) = segments.split_first() else {
409    return Err(GwmError::Config("empty config key".into()));
410  };
411  if tail.is_empty() {
412    if head.index.is_some() {
413      return Err(GwmError::Config(
414        "array-table keys must name a field after the index".into(),
415      ));
416    }
417    table.remove(&head.name);
418    return Ok(());
419  }
420  match &head.index {
421    None => {
422      let Some(item) = table.get_mut(&head.name) else {
423        return Ok(());
424      };
425      let Some(child) = item.as_table_mut() else {
426        return Ok(());
427      };
428      remove_value(child, tail)
429    }
430    Some(Index::Number(i)) => {
431      let Some(item) = table.get_mut(&head.name) else {
432        return Ok(());
433      };
434      let Some(array) = item.as_array_of_tables_mut() else {
435        return Ok(());
436      };
437      let Some(child) = array.get_mut(*i) else {
438        return Ok(());
439      };
440      remove_value(child, tail)
441    }
442    Some(Index::Append) => Err(GwmError::Config("[+] is only valid for `config set`".into())),
443  }
444}
445
446fn format_get_value(value: &toml::Value) -> String {
447  match value {
448    toml::Value::String(s) => s.clone(),
449    _ => crate::config::format_list_value(value),
450  }
451}
452
453fn render_segments(segments: &[Segment]) -> String {
454  segments.iter().map(render_segment).collect::<Vec<_>>().join(".")
455}
456
457fn render_segment(segment: &Segment) -> String {
458  match &segment.index {
459    Some(Index::Number(i)) => format!("{}[{}]", segment.name, i),
460    Some(Index::Append) => format!("{}[+]", segment.name),
461    None => segment.name.clone(),
462  }
463}
464
465fn config_de_error(path: &Path, raw: &str, err: toml::de::Error) -> GwmError {
466  let msg = enrich_schema_hint(err.to_string());
467  match err.span() {
468    Some(span) => GwmError::Config(format!(
469      "{}: error at line {}, col {}: {}",
470      path.display(),
471      line_col(raw, span.start).0,
472      line_col(raw, span.start).1,
473      msg
474    )),
475    None => GwmError::Config(format!("{}: {}", path.display(), msg)),
476  }
477}
478
479fn enrich_schema_hint(message: String) -> String {
480  if message.contains("fullscreem") {
481    format!("{} (did you mean 'fullscreen'?)", message)
482  } else {
483    message
484  }
485}
486
487fn config_parse_error(path: &Path, raw: &str, err: toml_edit::TomlError) -> GwmError {
488  match err.span() {
489    Some(span) => GwmError::Config(format!(
490      "{}: error at line {}, col {}: {}",
491      path.display(),
492      line_col(raw, span.start).0,
493      line_col(raw, span.start).1,
494      err
495    )),
496    None => GwmError::Config(format!("{}: {}", path.display(), err)),
497  }
498}
499
500fn line_col(raw: &str, offset: usize) -> (usize, usize) {
501  let mut line = 1;
502  let mut col = 1;
503  for (idx, ch) in raw.char_indices() {
504    if idx >= offset {
505      break;
506    }
507    if ch == '\n' {
508      line += 1;
509      col = 1;
510    } else {
511      col += 1;
512    }
513  }
514  (line, col)
515}