1use std::io;
13use std::time::Duration;
14
15use anyhow::Result;
16use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
17use ratatui::prelude::*;
18use ratatui::widgets::*;
19
20use crate::tui::Tui;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Control {
29 Toggle,
31 Number,
33 Adapters,
35}
36
37#[derive(Debug, Clone)]
39pub struct ConfigRow {
40 pub key: &'static str,
41 pub help: &'static str,
42 pub control: Control,
43 pub value: String,
45 pub original: String,
47 pub is_new: bool,
49}
50
51impl ConfigRow {
52 pub fn changed(&self) -> bool {
53 self.value != self.original
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct DeclarationLine {
60 pub mark: char,
63 pub subject: String,
64 pub state: String,
65}
66
67pub enum Outcome {
69 Save(Vec<ConfigRow>),
71 KeepAll,
73 Cancelled,
75}
76
77pub struct ConfigSession<'a> {
79 pub declaration: Vec<DeclarationLine>,
81 pub standing: String,
83 pub rows: Vec<ConfigRow>,
84 pub adapters: &'a [&'static str],
86 pub opt_in_adapters: &'a [&'static str],
88 pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
91 pub title: &'a str,
93}
94
95fn opening_index(rows: &[ConfigRow]) -> usize {
99 rows.iter().position(|r| r.is_new).unwrap_or(0)
100}
101
102#[derive(Debug, PartialEq, Eq, Clone, Copy)]
103enum Screen {
104 Declaration,
105 Settings,
106 Adapters,
107 Summary,
108}
109
110struct State<'a> {
111 session: ConfigSession<'a>,
112 screen: Screen,
113 list: ListState,
114 editing: Option<String>,
116 error: Option<String>,
118 picker_active: Vec<bool>,
120 picker_list: ListState,
121 decl_list: ListState,
123}
124
125pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
127 if session.rows.is_empty() {
128 return Ok(Outcome::KeepAll);
129 }
130
131 let mut list = ListState::default();
132 list.select(Some(opening_index(&session.rows)));
133
134 let mut picker_list = ListState::default();
135 picker_list.select(Some(0));
136
137 let mut decl_list = ListState::default();
138 decl_list.select(Some(0));
139
140 let mut state = State {
141 picker_active: vec![true; session.adapters.len()],
142 session,
143 screen: Screen::Declaration,
144 list,
145 editing: None,
146 error: None,
147 picker_list,
148 decl_list,
149 };
150
151 let mut tui = Tui::new()?;
154 tui.drain_stale_input(Duration::from_millis(300));
155 event_loop(&mut tui.terminal, &mut state)
156}
157
158fn event_loop(
159 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
160 state: &mut State<'_>,
161) -> Result<Outcome> {
162 loop {
163 terminal.draw(|frame| render(frame, state))?;
164
165 if !event::poll(Duration::from_millis(100))? {
166 continue;
167 }
168 let Event::Key(key) = event::read()? else {
169 continue;
170 };
171 if key.kind == KeyEventKind::Release {
174 continue;
175 }
176 if key.modifiers.contains(KeyModifiers::CONTROL)
179 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
180 {
181 return Ok(Outcome::Cancelled);
182 }
183
184 if let Some(outcome) = handle_key(state, key.code) {
185 return Ok(outcome);
186 }
187 }
188}
189
190fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
192 match state.screen {
193 Screen::Declaration => declaration_key(state, code),
194 Screen::Settings => settings_key(state, code),
195 Screen::Adapters => adapters_key(state, code),
196 Screen::Summary => summary_key(state, code),
197 }
198}
199
200fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
201 let len = state.session.declaration.len().max(1);
202 let current = state.decl_list.selected().unwrap_or(0);
203 match code {
204 KeyCode::Up | KeyCode::Char('k') => {
206 state.decl_list.select(Some(current.saturating_sub(1)));
207 None
208 }
209 KeyCode::Down | KeyCode::Char('j') => {
210 state.decl_list.select(Some((current + 1).min(len - 1)));
211 None
212 }
213 KeyCode::Char('y') | KeyCode::Char('Y') => Some(Outcome::KeepAll),
216 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
217 state.screen = Screen::Settings;
218 None
219 }
220 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
221 _ => None,
222 }
223}
224
225fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
226 if state.editing.is_some() {
228 return number_edit_key(state, code);
229 }
230
231 let len = state.session.rows.len();
232 let current = state.list.selected().unwrap_or(0);
233 match code {
234 KeyCode::Up | KeyCode::Char('k') => {
235 state.error = None;
236 state
237 .list
238 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
239 }
240 KeyCode::Down | KeyCode::Char('j') => {
241 state.error = None;
242 state
243 .list
244 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
245 }
246 KeyCode::Home | KeyCode::Char('g') => state.list.select(Some(0)),
247 KeyCode::End | KeyCode::Char('G') => state.list.select(Some(len - 1)),
248 KeyCode::Char(' ') | KeyCode::Enter => activate(state, current),
249 KeyCode::Char('r') | KeyCode::Char('R') => {
250 state.error = None;
251 let row = &mut state.session.rows[current];
252 row.value = row.original.clone();
253 }
254 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') | KeyCode::Char('S') => {
255 state.screen = Screen::Summary;
256 }
257 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
258 _ => {}
259 }
260 None
261}
262
263fn activate(state: &mut State<'_>, index: usize) {
265 state.error = None;
266 match state.session.rows[index].control {
267 Control::Toggle => {
268 let row = &mut state.session.rows[index];
269 row.value = if row.value == "true" {
270 "false".to_string()
271 } else {
272 "true".to_string()
273 };
274 }
275 Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
276 Control::Adapters => {
277 let disabled = parse_list(&state.session.rows[index].value);
278 state.picker_active = state
279 .session
280 .adapters
281 .iter()
282 .map(|name| !disabled.iter().any(|d| d == name))
283 .collect();
284 state.picker_list.select(Some(0));
285 state.screen = Screen::Adapters;
286 }
287 }
288}
289
290fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
291 let index = state.list.selected().unwrap_or(0);
292 match code {
293 KeyCode::Char(c) if c.is_ascii_digit() => {
294 if let Some(buf) = state.editing.as_mut() {
295 buf.push(c);
296 }
297 }
298 KeyCode::Backspace => {
299 if let Some(buf) = state.editing.as_mut() {
300 buf.pop();
301 }
302 }
303 KeyCode::Enter => {
304 let typed = state.editing.clone().unwrap_or_default();
305 let key = state.session.rows[index].key;
306 match (state.session.validate)(key, typed.trim()) {
307 Ok(()) => {
308 state.session.rows[index].value = typed.trim().to_string();
309 state.editing = None;
310 state.error = None;
311 }
312 Err(why) => state.error = Some(why),
315 }
316 }
317 KeyCode::Esc => {
318 state.editing = None;
319 state.error = None;
320 }
321 _ => {}
322 }
323 None
324}
325
326fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
327 let len = state.session.adapters.len();
328 let current = state.picker_list.selected().unwrap_or(0);
329 match code {
330 KeyCode::Up | KeyCode::Char('k') => {
331 state
332 .picker_list
333 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
334 }
335 KeyCode::Down | KeyCode::Char('j') => {
336 state
337 .picker_list
338 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
339 }
340 KeyCode::Char(' ') => state.picker_active[current] = !state.picker_active[current],
341 KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
342 KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
343 KeyCode::Enter => {
344 let disabled: Vec<&str> = state
345 .session
346 .adapters
347 .iter()
348 .zip(state.picker_active.iter())
349 .filter(|(_, active)| !**active)
350 .map(|(name, _)| *name)
351 .collect();
352 let index = state.list.selected().unwrap_or(0);
353 state.session.rows[index].value = if disabled.is_empty() {
356 "(none)".to_string()
357 } else {
358 disabled.join(",")
359 };
360 state.screen = Screen::Settings;
361 }
362 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
363 _ => {}
364 }
365 None
366}
367
368fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
369 match code {
370 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') => {
371 let changed: Vec<ConfigRow> = state
372 .session
373 .rows
374 .iter()
375 .filter(|r| r.changed())
376 .cloned()
377 .collect();
378 if changed.is_empty() {
379 Some(Outcome::KeepAll)
380 } else {
381 Some(Outcome::Save(changed))
382 }
383 }
384 KeyCode::Esc | KeyCode::Backspace => {
385 state.screen = Screen::Settings;
386 None
387 }
388 KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
389 _ => None,
390 }
391}
392
393fn parse_list(value: &str) -> Vec<String> {
395 if value.trim().eq_ignore_ascii_case("(none)") {
396 return Vec::new();
397 }
398 value
399 .split(',')
400 .map(|s| s.trim().to_lowercase())
401 .filter(|s| !s.is_empty())
402 .collect()
403}
404
405fn render(frame: &mut Frame, state: &mut State<'_>) {
410 match state.screen {
411 Screen::Declaration => render_declaration(frame, state),
412 Screen::Settings => render_settings(frame, state),
413 Screen::Adapters => render_adapters(frame, state),
414 Screen::Summary => render_summary(frame, state),
415 }
416}
417
418fn dim() -> Style {
419 Style::default().fg(Color::DarkGray)
420}
421
422fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
423 Paragraph::new(vec![
424 Line::from(Span::styled(
425 title.to_string(),
426 Style::default()
427 .fg(Color::Cyan)
428 .add_modifier(Modifier::BOLD),
429 )),
430 Line::from(Span::styled(subtitle.to_string(), dim())),
431 ])
432}
433
434fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
435 let mut spans = Vec::new();
436 for (i, (key, what)) in keys.iter().enumerate() {
437 if i > 0 {
438 spans.push(Span::styled(" ", dim()));
439 }
440 spans.push(Span::styled(
441 key.to_string(),
442 Style::default().add_modifier(Modifier::BOLD),
443 ));
444 spans.push(Span::styled(format!(" {what}"), dim()));
445 }
446 Paragraph::new(Line::from(spans))
447 .block(Block::default().borders(Borders::TOP).border_style(dim()))
448}
449
450fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
451 let chunks = Layout::vertical([
452 Constraint::Length(3),
453 Constraint::Min(5),
454 Constraint::Length(3),
455 Constraint::Length(2),
456 ])
457 .split(frame.area());
458
459 frame.render_widget(
460 header(
461 state.session.title,
462 "What this tool is allowed to do on this machine, before it does any of it.",
463 ),
464 chunks[0],
465 );
466
467 let items: Vec<ListItem> = state
468 .session
469 .declaration
470 .iter()
471 .map(|d| {
472 if d.mark == '#' {
473 return ListItem::new(Line::from(Span::styled(
474 format!(" {}", d.subject),
475 Style::default()
476 .fg(Color::Cyan)
477 .add_modifier(Modifier::BOLD),
478 )));
479 }
480 let (mark_style, symbol) = match d.mark {
481 '!' => (Style::default().fg(Color::Yellow), "!"),
482 '+' => (Style::default().fg(Color::Green), "✓"),
483 _ => (dim(), " "),
484 };
485 ListItem::new(Line::from(vec![
486 Span::styled(format!(" {symbol} "), mark_style),
487 Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
488 Span::styled(d.state.clone(), dim()),
489 ]))
490 })
491 .collect();
492
493 frame.render_stateful_widget(
496 List::new(items).block(
497 Block::default()
498 .title(" Declaration ")
499 .borders(Borders::ALL)
500 .border_style(dim()),
501 ),
502 chunks[1],
503 &mut state.decl_list,
504 );
505
506 frame.render_widget(
507 Paragraph::new(Line::from(Span::styled(
508 format!(" {}", state.session.standing),
509 Style::default().fg(Color::Green),
510 )))
511 .block(Block::default().borders(Borders::ALL).border_style(dim())),
512 chunks[2],
513 );
514
515 frame.render_widget(
516 footer(&[
517 ("↑↓", "read"),
518 ("y", "keep all defaults and go"),
519 ("Enter", "configure"),
520 ("q", "cancel"),
521 ]),
522 chunks[3],
523 );
524}
525
526fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
527 let chunks = Layout::vertical([
528 Constraint::Length(3),
529 Constraint::Min(5),
530 Constraint::Length(4),
531 Constraint::Length(2),
532 ])
533 .split(frame.area());
534
535 let changed = state.session.rows.iter().filter(|r| r.changed()).count();
536 let new = state.session.rows.iter().filter(|r| r.is_new).count();
537 let subtitle = match (changed, new) {
538 (0, 0) => "Nothing changed yet.".to_string(),
539 (c, 0) => format!("{c} changed."),
540 (0, n) => format!("{n} new in this version."),
541 (c, n) => format!("{c} changed, {n} new in this version."),
542 };
543 frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
544
545 let selected = state.list.selected();
546 let items: Vec<ListItem> = state
547 .session
548 .rows
549 .iter()
550 .enumerate()
551 .map(|(i, row)| {
552 let control = match row.control {
553 Control::Toggle if row.value == "true" => Span::styled(
554 "[x] ",
555 Style::default()
556 .fg(Color::Green)
557 .add_modifier(Modifier::BOLD),
558 ),
559 Control::Toggle => Span::styled("[ ] ", dim()),
560 Control::Number => Span::styled("123 ", dim()),
561 Control::Adapters => Span::styled("••• ", dim()),
562 };
563
564 let shown = if state.editing.is_some() && selected == Some(i) {
565 format!("{}_", state.editing.clone().unwrap_or_default())
566 } else {
567 row.value.clone()
568 };
569
570 let mut spans = vec![
571 control,
572 Span::styled(
573 crate::output::pad_display(row.key, 28),
574 if selected == Some(i) {
575 Style::default().fg(Color::White)
576 } else {
577 Style::default()
578 },
579 ),
580 Span::styled(
581 crate::output::pad_display(&shown, 20),
582 if row.changed() {
583 Style::default().fg(Color::Yellow)
584 } else {
585 Style::default().fg(Color::Cyan)
586 },
587 ),
588 ];
589 if row.is_new {
590 spans.push(Span::styled(
591 "NEW ",
592 Style::default()
593 .fg(Color::Magenta)
594 .add_modifier(Modifier::BOLD),
595 ));
596 }
597 if row.changed() {
598 spans.push(Span::styled(format!("was {}", row.original), dim()));
599 }
600 ListItem::new(Line::from(spans))
601 })
602 .collect();
603
604 let list = List::new(items)
605 .block(
606 Block::default()
607 .title(" Settings ")
608 .borders(Borders::ALL)
609 .border_style(dim()),
610 )
611 .highlight_style(
612 Style::default()
613 .bg(Color::Rgb(30, 40, 60))
614 .add_modifier(Modifier::BOLD),
615 )
616 .highlight_symbol("▶ ");
617 frame.render_stateful_widget(list, chunks[1], &mut state.list);
618
619 let index = selected.unwrap_or(0);
622 let row = &state.session.rows[index];
623 let mut detail = vec![Line::from(Span::styled(format!(" {}", row.help), dim()))];
624 if row.is_new {
625 detail.push(Line::from(Span::styled(
626 " New in this version — it has been applying its default since the upgrade.",
627 Style::default().fg(Color::Magenta),
628 )));
629 }
630 if let Some(why) = &state.error {
631 detail.push(Line::from(Span::styled(
632 format!(" {why}"),
633 Style::default().fg(Color::Red),
634 )));
635 }
636 frame.render_widget(
637 Paragraph::new(detail)
638 .wrap(Wrap { trim: true })
639 .block(Block::default().borders(Borders::ALL).border_style(dim())),
640 chunks[2],
641 );
642
643 let keys: &[(&str, &str)] = if state.editing.is_some() {
644 &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
645 } else {
646 &[
647 ("↑↓", "move"),
648 ("Space", "change"),
649 ("r", "reset"),
650 ("y", "done"),
651 ("q", "cancel"),
652 ]
653 };
654 frame.render_widget(footer(keys), chunks[3]);
655}
656
657fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
658 let chunks = Layout::vertical([
659 Constraint::Length(3),
660 Constraint::Min(5),
661 Constraint::Length(2),
662 ])
663 .split(frame.area());
664
665 let off = state.picker_active.iter().filter(|a| !**a).count();
666 frame.render_widget(
667 header(
668 "Adapters",
669 &format!(
670 "Unchecked adapters are left alone entirely — not scanned, not counted, \
671 not pruned. {off} off.",
672 ),
673 ),
674 chunks[0],
675 );
676
677 let items: Vec<ListItem> = state
678 .session
679 .adapters
680 .iter()
681 .zip(state.picker_active.iter())
682 .map(|(name, active)| {
683 let mut spans = vec![
684 if *active {
685 Span::styled(
686 "[x] ",
687 Style::default()
688 .fg(Color::Green)
689 .add_modifier(Modifier::BOLD),
690 )
691 } else {
692 Span::styled("[ ] ", dim())
693 },
694 Span::styled(crate::output::pad_display(name, 16), Style::default()),
695 ];
696 if state.session.opt_in_adapters.contains(name) {
697 spans.push(Span::styled(
700 format!("opt-in — also needs enable_{name}"),
701 dim(),
702 ));
703 }
704 ListItem::new(Line::from(spans))
705 })
706 .collect();
707
708 let list = List::new(items)
709 .block(
710 Block::default()
711 .title(" Checked adapters stay active ")
712 .borders(Borders::ALL)
713 .border_style(dim()),
714 )
715 .highlight_style(
716 Style::default()
717 .bg(Color::Rgb(30, 40, 60))
718 .add_modifier(Modifier::BOLD),
719 )
720 .highlight_symbol("▶ ");
721 frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
722
723 frame.render_widget(
724 footer(&[
725 ("↑↓", "move"),
726 ("Space", "toggle"),
727 ("a", "all on"),
728 ("n", "all off"),
729 ("Enter", "accept"),
730 ("Esc", "back"),
731 ]),
732 chunks[2],
733 );
734}
735
736fn render_summary(frame: &mut Frame, state: &State<'_>) {
737 let chunks = Layout::vertical([
738 Constraint::Length(3),
739 Constraint::Min(5),
740 Constraint::Length(2),
741 ])
742 .split(frame.area());
743
744 let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
745 frame.render_widget(
746 header(
747 "Summary",
748 if changed.is_empty() {
749 "Nothing changed. The defaults stay in place."
750 } else {
751 "These are the only values that will be written."
752 },
753 ),
754 chunks[0],
755 );
756
757 let mut lines: Vec<Line> = changed
758 .iter()
759 .map(|row| {
760 Line::from(vec![
761 Span::styled(
762 format!(" {}", crate::output::pad_display(row.key, 28)),
763 Style::default(),
764 ),
765 Span::styled(row.original.clone(), dim()),
766 Span::styled(" → ", dim()),
767 Span::styled(
768 row.value.clone(),
769 Style::default()
770 .fg(Color::Yellow)
771 .add_modifier(Modifier::BOLD),
772 ),
773 ])
774 })
775 .collect();
776 if lines.is_empty() {
777 lines.push(Line::from(Span::styled(
778 " Every setting is still at the value it had when this opened.",
779 dim(),
780 )));
781 }
782 lines.push(Line::from(""));
783 lines.push(Line::from(Span::styled(
784 format!(" {}", state.session.standing),
785 Style::default().fg(Color::Green),
786 )));
787
788 frame.render_widget(
789 Paragraph::new(lines).wrap(Wrap { trim: true }).block(
790 Block::default()
791 .title(" About to be saved ")
792 .borders(Borders::ALL)
793 .border_style(dim()),
794 ),
795 chunks[1],
796 );
797
798 frame.render_widget(
799 footer(&[
800 ("Enter", "save"),
801 ("Esc", "back"),
802 ("q", "discard everything"),
803 ]),
804 chunks[2],
805 );
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811
812 fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
813 ConfigRow {
814 key,
815 help: "help",
816 control,
817 value: value.to_string(),
818 original: value.to_string(),
819 is_new: false,
820 }
821 }
822
823 fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
824 ConfigSession {
825 declaration: Vec::new(),
826 standing: String::new(),
827 rows,
828 adapters,
829 opt_in_adapters: &[],
830 validate: &|_, v| {
831 v.parse::<u64>()
832 .map(|_| ())
833 .map_err(|_| "not a number".to_string())
834 },
835 title: "test",
836 }
837 }
838
839 fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
840 let mut list = ListState::default();
841 list.select(Some(0));
842 let mut picker_list = ListState::default();
843 picker_list.select(Some(0));
844 State {
845 picker_active: vec![true; s.adapters.len()],
846 session: s,
847 screen: Screen::Settings,
848 list,
849 editing: None,
850 error: None,
851 picker_list,
852 decl_list: ListState::default(),
853 }
854 }
855
856 fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
861 st.screen = screen;
862 let mut terminal =
863 Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
864 terminal.draw(|frame| render(frame, st)).expect("draw");
865 terminal
866 .backend()
867 .buffer()
868 .content()
869 .iter()
870 .map(|cell| cell.symbol())
871 .collect()
872 }
873
874 #[test]
875 fn every_screen_draws() {
876 let adapters: &[&'static str] = &["npm", "cargo"];
877 let mut st = state(session(
878 vec![
879 row("idle_days", Control::Number, "14"),
880 row("disabled_adapters", Control::Adapters, "(none)"),
881 ],
882 adapters,
883 ));
884 st.session.declaration.push(DeclarationLine {
885 mark: '+',
886 subject: "Lockfile verification".to_string(),
887 state: "Required before every delete".to_string(),
888 });
889 st.session.standing = "Nothing has been deleted.".to_string();
890
891 let decl = screenshot(&mut st, Screen::Declaration);
892 assert!(decl.contains("Lockfile verification"));
893 assert!(decl.contains("Nothing has been deleted."));
894
895 let settings = screenshot(&mut st, Screen::Settings);
896 assert!(settings.contains("idle_days"));
897
898 let picker = screenshot(&mut st, Screen::Adapters);
899 assert!(picker.contains("cargo"));
900
901 let summary = screenshot(&mut st, Screen::Summary);
904 assert!(summary.contains("still at the value"));
905 }
906
907 #[test]
908 fn y_on_the_declaration_still_means_yes_to_everything() {
909 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
912 st.screen = Screen::Declaration;
913 assert!(matches!(
914 handle_key(&mut st, KeyCode::Char('y')),
915 Some(Outcome::KeepAll)
916 ));
917 }
918
919 #[test]
920 fn a_refused_value_is_not_stored() {
921 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
922 handle_key(&mut st, KeyCode::Enter); handle_key(&mut st, KeyCode::Backspace);
924 handle_key(&mut st, KeyCode::Backspace); handle_key(&mut st, KeyCode::Enter);
926 assert_eq!(st.session.rows[0].value, "14");
927 assert!(st.error.is_some(), "the reason was not shown");
928 assert!(st.editing.is_some(), "the editor closed on a refusal");
929 }
930
931 #[test]
932 fn an_accepted_value_replaces_the_old_one() {
933 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
934 handle_key(&mut st, KeyCode::Enter);
935 handle_key(&mut st, KeyCode::Backspace);
936 handle_key(&mut st, KeyCode::Backspace);
937 handle_key(&mut st, KeyCode::Char('3'));
938 handle_key(&mut st, KeyCode::Char('0'));
939 handle_key(&mut st, KeyCode::Enter);
940 assert_eq!(st.session.rows[0].value, "30");
941 assert!(st.session.rows[0].changed());
942 }
943
944 #[test]
945 fn unchecking_an_adapter_writes_it_to_the_deny_list() {
946 let adapters: &[&'static str] = &["npm", "cargo", "go"];
947 let mut st = state(session(
948 vec![row("disabled_adapters", Control::Adapters, "(none)")],
949 adapters,
950 ));
951 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.screen, Screen::Adapters);
953 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
955 handle_key(&mut st, KeyCode::Enter);
956 assert_eq!(st.session.rows[0].value, "cargo");
957 assert_eq!(st.screen, Screen::Settings);
958 }
959
960 #[test]
961 fn the_checklist_opens_showing_what_is_already_disabled() {
962 let adapters: &[&'static str] = &["npm", "cargo", "go"];
965 let mut st = state(session(
966 vec![row("disabled_adapters", Control::Adapters, "go")],
967 adapters,
968 ));
969 handle_key(&mut st, KeyCode::Enter);
970 assert_eq!(st.picker_active, vec![true, true, false]);
971 handle_key(&mut st, KeyCode::Enter);
972 assert_eq!(st.session.rows[0].value, "go");
973 }
974
975 #[test]
976 fn cancelling_reports_cancelled_rather_than_an_empty_save() {
977 let mut st = state(session(
980 vec![row("auto_update", Control::Toggle, "false")],
981 &[],
982 ));
983 assert!(matches!(
984 handle_key(&mut st, KeyCode::Char('q')),
985 Some(Outcome::Cancelled)
986 ));
987 }
988
989 #[test]
990 fn only_changed_rows_are_saved() {
991 let mut st = state(session(
992 vec![
993 row("auto_update", Control::Toggle, "false"),
994 row("auto_config", Control::Toggle, "false"),
995 ],
996 &[],
997 ));
998 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('y')); let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
1001 panic!("expected a save");
1002 };
1003 assert_eq!(changed.len(), 1);
1004 assert_eq!(changed[0].key, "auto_update");
1005 assert_eq!(changed[0].value, "true");
1006 }
1007
1008 #[test]
1009 fn reset_puts_a_row_back_without_touching_the_others() {
1010 let mut st = state(session(
1011 vec![
1012 row("auto_update", Control::Toggle, "false"),
1013 row("auto_config", Control::Toggle, "true"),
1014 ],
1015 &[],
1016 ));
1017 handle_key(&mut st, KeyCode::Char(' '));
1018 assert!(st.session.rows[0].changed());
1019 handle_key(&mut st, KeyCode::Char('r'));
1020 assert!(!st.session.rows[0].changed());
1021 assert_eq!(st.session.rows[1].value, "true");
1022 }
1023
1024 #[test]
1025 fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
1026 let mut rows = [
1027 row("idle_days", Control::Number, "14"),
1028 row("auto_update", Control::Toggle, "false"),
1029 row("auto_config", Control::Toggle, "false"),
1030 ];
1031 assert_eq!(
1032 opening_index(&rows),
1033 0,
1034 "with nothing new, start at the top"
1035 );
1036 rows[2].is_new = true;
1037 assert_eq!(opening_index(&rows), 2);
1038 }
1039}