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
42pub fn set_value_at(path: &Path, key: &str, raw_value: &str) -> Result<()> {
57 set_item_at(path, key, parse_scalar(raw_value))
58}
59
60pub fn set_string_at(path: &Path, key: &str, raw_value: &str) -> Result<()> {
66 set_item_at(path, key, value(raw_value))
67}
68
69pub 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
86pub 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
101fn 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!("{} = {}", crate::naming::sanitise_for_terminal(&key), value);
159 }
160 }
161 Ok(())
162}
163
164pub fn validate() -> Result<()> {
165 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 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 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 Ok(_) => {
248 std::fs::write(path, rendered)?;
249 Ok(())
250 }
251 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
268fn 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
279fn 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 cfg.validate_tui_keys()?;
296 cfg.validate_theme()?;
297 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 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}