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!("{} = {}", 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 Ok(()) => {
215 std::fs::write(path, rendered)?;
216 Ok(())
217 }
218 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
243fn 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 cfg.validate_tui_keys()?;
260 cfg.validate_theme()?;
261 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}