1use std::io::{stdout, IsTerminal};
28use std::path::{Path, PathBuf};
29
30use ratatui::backend::CrosstermBackend;
31use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
32use ratatui::crossterm::execute;
33use ratatui::crossterm::terminal::{
34 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
35};
36use ratatui::layout::{Constraint, Direction, Layout, Rect};
37use ratatui::style::{Modifier, Style};
38use ratatui::text::{Line, Span};
39use ratatui::widgets::{Block, Borders, Clear, Paragraph};
40use ratatui::Terminal;
41use serde_json::Value;
42
43use crate::error::{CwError, Result};
44use crate::git;
45use crate::operations::config_ops::{
46 global_path, lookup_value, parse_value_for, read_json_or_empty, set_value, unset_value,
47 write_json, ConfigKey, Scope,
48};
49
50pub fn run() -> Result<()> {
52 if !stdout().is_terminal() {
53 return Err(CwError::Config(
54 "gw config edit needs a TTY (run it in an interactive terminal)".to_string(),
55 ));
56 }
57 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
58 let global = global_path();
59 let repo = git::get_repo_root(Some(&cwd))
60 .ok()
61 .map(|r| r.join(".cwconfig.json"));
62
63 let app_state = AppState::load(&global, repo.as_deref())?;
64 let outcome = run_loop(app_state)?;
65 if let Some(saved) = outcome {
66 for (label, count) in saved.summary() {
67 if count > 0 {
68 println!(" {}: {} key(s) updated", label, count);
69 }
70 }
71 }
72 Ok(())
73}
74
75#[derive(Debug)]
80struct AppState {
81 global_value: Value,
83 repo_value: Option<Value>,
85
86 global_path: PathBuf,
87 repo_path: Option<PathBuf>,
88
89 cursor: usize,
90 active: Scope,
91 dirty_global: bool,
92 dirty_repo: bool,
93 status: String,
94 editing: Option<EditState>,
96 quit: bool,
97}
98
99#[derive(Debug)]
100struct EditState {
101 key: ConfigKey,
102 scope: Scope,
103 buffer: String,
104 error: Option<String>,
105}
106
107impl AppState {
108 fn load(global: &Path, repo: Option<&Path>) -> Result<Self> {
109 let global_value = read_json_or_empty(global)?;
110 let repo_value = match repo {
111 Some(p) => Some(read_json_or_empty(p)?),
112 None => None,
113 };
114 Ok(Self {
117 global_value,
118 repo_value,
119 global_path: global.to_path_buf(),
120 repo_path: repo.map(Path::to_path_buf),
121 cursor: 0,
122 active: Scope::Global,
123 dirty_global: false,
124 dirty_repo: false,
125 status: String::from("Tab: swap scope · Enter: edit · s: save · q: quit"),
126 editing: None,
127 quit: false,
128 })
129 }
130
131 fn keys() -> &'static [ConfigKey] {
132 ConfigKey::ALL
133 }
134
135 fn active_value(&self) -> Option<&Value> {
136 match self.active {
137 Scope::Global => Some(&self.global_value),
138 Scope::Repo => self.repo_value.as_ref(),
139 }
140 }
141
142 fn current_key(&self) -> ConfigKey {
143 Self::keys()[self.cursor]
144 }
145
146 fn move_cursor(&mut self, delta: i32) {
147 let len = Self::keys().len() as i32;
148 if len == 0 {
149 return;
150 }
151 let next = (self.cursor as i32 + delta).rem_euclid(len);
152 self.cursor = next as usize;
153 }
154
155 fn toggle_scope(&mut self) {
156 if self.repo_value.is_none() {
157 self.status = "Not inside a git repo — repo scope unavailable".to_string();
158 return;
159 }
160 self.active = self.active.other();
161 }
162
163 fn begin_edit(&mut self) {
165 if matches!(self.active, Scope::Repo) && self.repo_value.is_none() {
166 self.status = "Not inside a git repo — cannot edit repo scope".to_string();
167 return;
168 }
169 let key = self.current_key();
170 let buffer = match self.active {
171 Scope::Global => lookup_value(&self.global_value, key)
172 .map(value_to_input_string)
173 .unwrap_or_default(),
174 Scope::Repo => self
175 .repo_value
176 .as_ref()
177 .and_then(|v| lookup_value(v, key))
178 .map(value_to_input_string)
179 .unwrap_or_default(),
180 };
181 self.editing = Some(EditState {
182 key,
183 scope: self.active,
184 buffer,
185 error: None,
186 });
187 }
188
189 fn commit_edit(&mut self) {
191 let edit = match self.editing.take() {
194 Some(e) => e,
195 None => return,
196 };
197 let trimmed = edit.buffer.trim();
198 let result = if trimmed.is_empty() {
199 self.apply_unset(edit.key, edit.scope);
201 Ok(())
202 } else {
203 parse_value_for(edit.key, &edit.buffer)
204 .and_then(|v| self.apply_set(edit.key, edit.scope, v))
205 };
206 match result {
207 Ok(()) => {
208 self.status = format!(
209 "Updated {} in [{}] (unsaved)",
210 edit.key.name(),
211 edit.scope.label()
212 );
213 }
214 Err(e) => {
215 self.editing = Some(EditState {
217 error: Some(format!("{e}")),
218 ..edit
219 });
220 }
221 }
222 }
223
224 fn cancel_edit(&mut self) {
225 self.editing = None;
226 }
227
228 fn apply_set(&mut self, key: ConfigKey, scope: Scope, value: Value) -> Result<()> {
229 match scope {
230 Scope::Global => {
231 set_value(&mut self.global_value, key, value)?;
232 self.dirty_global = true;
233 }
234 Scope::Repo => {
235 let v = self.repo_value.as_mut().ok_or_else(|| {
236 CwError::Config("repo scope unavailable (not in a git repo)".to_string())
237 })?;
238 set_value(v, key, value)?;
239 self.dirty_repo = true;
240 }
241 }
242 Ok(())
243 }
244
245 fn apply_unset(&mut self, key: ConfigKey, scope: Scope) {
249 match scope {
250 Scope::Global => {
251 if lookup_value(&self.global_value, key).is_some() {
252 unset_value(&mut self.global_value, key);
253 self.dirty_global = true;
254 }
255 }
256 Scope::Repo => {
257 if let Some(v) = self.repo_value.as_mut() {
258 if lookup_value(v, key).is_some() {
259 unset_value(v, key);
260 self.dirty_repo = true;
261 }
262 }
263 }
264 }
265 }
266
267 fn save(&mut self) -> Result<SaveSummary> {
268 let mut summary = SaveSummary::default();
269 if self.dirty_global {
270 write_json(&self.global_path, &self.global_value)?;
271 summary.global = count_set_keys(&self.global_value);
272 self.dirty_global = false;
273 }
274 if self.dirty_repo {
275 if let (Some(p), Some(v)) = (self.repo_path.as_ref(), self.repo_value.as_ref()) {
276 write_json(p, v)?;
277 summary.repo = count_set_keys(v);
278 self.dirty_repo = false;
279 }
280 }
281 self.status = "Saved.".to_string();
282 Ok(summary)
283 }
284}
285
286fn count_set_keys(root: &Value) -> usize {
289 ConfigKey::ALL
290 .iter()
291 .filter(|k| lookup_value(root, **k).is_some())
292 .count()
293}
294
295fn value_to_input_string(v: &Value) -> String {
299 match v {
300 Value::Null => String::new(),
301 Value::String(s) => s.clone(),
302 Value::Bool(b) => b.to_string(),
303 Value::Number(n) => n.to_string(),
304 Value::Array(a) => {
305 if a.iter().all(|e| e.is_string()) {
306 a.iter()
307 .map(|e| e.as_str().unwrap_or("").to_string())
308 .collect::<Vec<_>>()
309 .join(" ")
310 } else {
311 v.to_string()
312 }
313 }
314 Value::Object(_) => v.to_string(),
315 }
316}
317
318#[derive(Debug, Default)]
323pub struct SaveSummary {
324 pub global: usize,
325 pub repo: usize,
326}
327
328impl SaveSummary {
329 fn summary(&self) -> [(&'static str, usize); 2] {
330 [("global", self.global), ("repo", self.repo)]
331 }
332}
333
334fn run_loop(mut state: AppState) -> Result<Option<SaveSummary>> {
339 enable_raw_mode().map_err(io_err)?;
340 let mut out = stdout();
341 if let Err(e) = execute!(out, EnterAlternateScreen) {
342 let _ = disable_raw_mode();
345 return Err(io_err(e));
346 }
347 crate::tui::mark_ratatui_active();
348
349 let backend = CrosstermBackend::new(out);
350 let mut terminal = match Terminal::new(backend) {
351 Ok(t) => t,
352 Err(e) => {
353 let _ = disable_raw_mode();
356 let _ = execute!(stdout(), LeaveAlternateScreen);
357 crate::tui::mark_ratatui_inactive();
358 return Err(io_err(e));
359 }
360 };
361
362 let result = (|| -> Result<Option<SaveSummary>> {
363 let mut last_save: Option<SaveSummary> = None;
364 loop {
365 terminal.draw(|f| render(f, &state)).map_err(io_err)?;
366 if state.quit {
367 break;
368 }
369 let evt = event::read().map_err(io_err)?;
370 if let Event::Key(k) = evt {
371 if let Some(s) = handle_key(&mut state, k)? {
372 last_save = Some(s);
373 }
374 }
375 }
376 Ok(last_save)
377 })();
378
379 let _ = disable_raw_mode();
381 let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
382 drop(terminal);
383 crate::tui::mark_ratatui_inactive();
384
385 result
386}
387
388fn io_err(e: std::io::Error) -> CwError {
389 CwError::Other(format!("terminal io: {}", e))
390}
391
392fn handle_key(state: &mut AppState, key: KeyEvent) -> Result<Option<SaveSummary>> {
393 if state.editing.is_some() {
394 handle_edit_key(state, key);
395 return Ok(None);
396 }
397 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
399 state.quit = true;
400 return Ok(None);
401 }
402 match key.code {
403 KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
404 KeyCode::Up | KeyCode::Char('k') => state.move_cursor(-1),
405 KeyCode::Down | KeyCode::Char('j') => state.move_cursor(1),
406 KeyCode::Tab => state.toggle_scope(),
407 KeyCode::Enter => state.begin_edit(),
408 KeyCode::Char('s') => {
409 let s = state.save()?;
410 return Ok(Some(s));
411 }
412 _ => {}
413 }
414 Ok(None)
415}
416
417fn handle_edit_key(state: &mut AppState, key: KeyEvent) {
418 match key.code {
419 KeyCode::Esc => state.cancel_edit(),
420 KeyCode::Enter => state.commit_edit(),
421 KeyCode::Backspace => {
422 if let Some(e) = state.editing.as_mut() {
423 e.buffer.pop();
424 e.error = None;
425 }
426 }
427 KeyCode::Char(c) => {
428 if key.modifiers.contains(KeyModifiers::CONTROL) && (c == 'c' || c == 'C') {
431 state.cancel_edit();
432 return;
433 }
434 if let Some(e) = state.editing.as_mut() {
435 e.buffer.push(c);
436 e.error = None;
437 }
438 }
439 _ => {}
440 }
441}
442
443fn render(f: &mut ratatui::Frame, state: &AppState) {
448 let chunks = Layout::default()
449 .direction(Direction::Vertical)
450 .constraints([
451 Constraint::Length(3),
452 Constraint::Min(5),
453 Constraint::Length(2),
454 ])
455 .split(f.area());
456
457 render_header(f, chunks[0], state);
458 render_body(f, chunks[1], state);
459 render_footer(f, chunks[2], state);
460
461 if let Some(edit) = &state.editing {
462 render_edit_modal(f, edit);
463 }
464}
465
466fn render_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
467 let dim_when_unavailable = state.repo_value.is_none();
468 let active_global = matches!(state.active, Scope::Global);
469 let active_repo = matches!(state.active, Scope::Repo);
470
471 let global_label = if active_global {
472 Span::styled("● global", Style::new().add_modifier(Modifier::BOLD))
473 } else {
474 Span::raw("○ global")
475 };
476 let repo_label = if dim_when_unavailable {
477 Span::styled("○ repo (n/a)", Style::new().add_modifier(Modifier::DIM))
478 } else if active_repo {
479 Span::styled("● repo", Style::new().add_modifier(Modifier::BOLD))
480 } else {
481 Span::raw("○ repo")
482 };
483
484 let line = Line::from(vec![
485 Span::raw("Scope: "),
486 global_label,
487 Span::raw(" "),
488 repo_label,
489 Span::raw(" "),
490 Span::styled(
491 current_path_hint(state),
492 Style::new().add_modifier(Modifier::DIM),
493 ),
494 ]);
495 let block = Block::default()
496 .borders(Borders::ALL)
497 .title(" gw config edit ");
498 let para = Paragraph::new(line).block(block);
499 f.render_widget(para, area);
500}
501
502fn current_path_hint(state: &AppState) -> String {
503 let p = match state.active {
504 Scope::Global => Some(state.global_path.as_path()),
505 Scope::Repo => state.repo_path.as_deref(),
506 };
507 p.map(|p| p.display().to_string()).unwrap_or_default()
508}
509
510fn render_body(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
511 let keys = AppState::keys();
512 let key_col = keys.iter().map(|k| k.name().len()).max().unwrap_or(0);
513
514 let mut lines: Vec<Line> = Vec::with_capacity(keys.len());
515 for (i, key) in keys.iter().enumerate() {
516 let v = match state.active_value() {
517 Some(root) => lookup_value(root, *key),
518 None => None,
519 };
520 let value_str = match v {
521 Some(v) => value_to_input_string(v),
522 None => "(unset)".to_string(),
523 };
524 let key_text = format!("{key:<key_col$}", key = key.name());
525 let marker = if i == state.cursor { "▶ " } else { " " };
526 let style = if i == state.cursor {
527 Style::new().add_modifier(Modifier::REVERSED)
528 } else {
529 Style::new()
530 };
531 lines.push(Line::from(vec![
532 Span::raw(marker),
533 Span::styled(format!("{key_text} {value_str}"), style),
534 ]));
535 }
536
537 let title = format!(" {} ", state.active.label());
538 let block = Block::default().borders(Borders::ALL).title(title);
539 f.render_widget(Paragraph::new(lines).block(block), area);
540}
541
542fn render_footer(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
543 let dirty = state.dirty_global || state.dirty_repo;
544 let mut spans = vec![Span::raw(state.status.as_str())];
545 if dirty {
546 spans.push(Span::raw(" "));
547 spans.push(Span::styled(
548 "[unsaved]",
549 Style::new().add_modifier(Modifier::BOLD),
550 ));
551 }
552 let line = Line::from(spans);
553 f.render_widget(Paragraph::new(line), area);
554}
555
556fn render_edit_modal(f: &mut ratatui::Frame, edit: &EditState) {
557 let area = centered_rect(60, 7, f.area());
558 f.render_widget(Clear, area);
559
560 let title = format!(" edit {} [{}] ", edit.key.name(), edit.scope.label());
561 let mut lines = vec![
562 Line::from(Span::raw(format!("{}_", edit.buffer))),
563 Line::from(Span::styled(
564 "Enter: commit · Esc: cancel · empty = unset",
565 Style::new().add_modifier(Modifier::DIM),
566 )),
567 ];
568 if let Some(err) = &edit.error {
569 lines.push(Line::from(Span::styled(
570 err.clone(),
571 Style::new().add_modifier(Modifier::BOLD),
572 )));
573 }
574 let block = Block::default().borders(Borders::ALL).title(title);
575 f.render_widget(Paragraph::new(lines).block(block), area);
576}
577
578fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
579 let w = width.min(area.width.saturating_sub(2));
580 let h = height.min(area.height.saturating_sub(2));
581 let x = area.x + area.width.saturating_sub(w) / 2;
582 let y = area.y + area.height.saturating_sub(h) / 2;
583 Rect {
584 x,
585 y,
586 width: w,
587 height: h,
588 }
589}
590
591#[cfg(test)]
596mod tests {
597 use super::*;
598 use serde_json::json;
599 use tempfile::TempDir;
600
601 fn fresh_state() -> AppState {
602 AppState {
605 global_value: Value::Object(serde_json::Map::new()),
606 repo_value: None,
607 global_path: PathBuf::from("/tmp/none-global.json"),
608 repo_path: None,
609 cursor: 0,
610 active: Scope::Global,
611 dirty_global: false,
612 dirty_repo: false,
613 status: String::new(),
614 editing: None,
615 quit: false,
616 }
617 }
618
619 #[test]
620 fn move_cursor_wraps_in_both_directions() {
621 let mut s = fresh_state();
622 s.move_cursor(-1);
623 assert_eq!(s.cursor, ConfigKey::ALL.len() - 1);
624 s.move_cursor(1);
625 assert_eq!(s.cursor, 0);
626 }
627
628 #[test]
629 fn toggle_scope_blocked_outside_repo() {
630 let mut s = fresh_state();
631 s.toggle_scope();
632 assert_eq!(s.active, Scope::Global);
633 let lc = s.status.to_lowercase();
634 assert!(
635 lc.contains("not inside") && lc.contains("repo"),
636 "unexpected status: {:?}",
637 s.status
638 );
639 }
640
641 #[test]
642 fn begin_edit_seeds_buffer_with_current_value() {
643 let mut s = fresh_state();
644 s.global_value = json!({"ai_tool": {"command": "codex"}});
645 s.begin_edit();
647 let edit = s.editing.as_ref().unwrap();
648 assert_eq!(edit.buffer, "codex");
649 assert_eq!(edit.scope, Scope::Global);
650 }
651
652 #[test]
653 fn commit_edit_updates_value_and_marks_dirty() {
654 let mut s = fresh_state();
655 s.begin_edit();
656 s.editing.as_mut().unwrap().buffer = "codex".to_string();
657 s.commit_edit();
658 assert!(s.dirty_global);
659 assert_eq!(
660 lookup_value(&s.global_value, ConfigKey::AiToolCommand).unwrap(),
661 &Value::String("codex".into())
662 );
663 assert!(s.editing.is_none());
664 }
665
666 #[test]
667 fn commit_edit_with_empty_buffer_unsets() {
668 let mut s = fresh_state();
669 s.global_value = json!({"ai_tool": {"command": "codex"}});
670 s.begin_edit();
671 s.editing.as_mut().unwrap().buffer = " ".to_string();
672 s.commit_edit();
673 assert!(s.dirty_global);
674 assert!(lookup_value(&s.global_value, ConfigKey::AiToolCommand).is_none());
675 }
676
677 #[test]
678 fn commit_edit_invalid_value_keeps_editing_open() {
679 let mut s = fresh_state();
680 s.cursor = 2;
682 s.begin_edit();
683 s.editing.as_mut().unwrap().buffer = "definitely-not-bool".to_string();
684 s.commit_edit();
685 assert!(s.editing.is_some(), "should remain in edit mode on error");
686 assert!(s.editing.as_ref().unwrap().error.is_some());
687 assert!(!s.dirty_global);
688 }
689
690 #[test]
691 fn save_persists_global_changes() {
692 let tmp = TempDir::new().unwrap();
693 let p = tmp.path().join("config.json");
694 let mut s = fresh_state();
695 s.global_path = p.clone();
696 s.global_value = json!({"ai_tool": {"command": "codex"}});
697 s.dirty_global = true;
698
699 let summary = s.save().unwrap();
700 assert_eq!(summary.global, 1);
701 assert!(!s.dirty_global);
702
703 let read = std::fs::read_to_string(&p).unwrap();
704 assert!(read.contains("\"command\""));
705 assert!(read.contains("\"codex\""));
706 }
707
708 #[test]
709 fn save_skips_repo_when_no_repo_loaded() {
710 let tmp = TempDir::new().unwrap();
711 let p = tmp.path().join("config.json");
712 let mut s = fresh_state();
713 s.global_path = p;
714 s.dirty_repo = true;
715
716 let summary = s.save().unwrap();
718 assert_eq!(summary.repo, 0);
719 }
720}