Skip to main content

dev_prune/tui/
config_view.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// The interactive configurator, used by the first-run walkthrough and by
5// `devp config wizard`.
6//
7// One view serves both because they are the same question asked at two different
8// moments: "here is everything this tool will do to your machine — change any of it
9// before it starts." The line-by-line prompt in `commands::config` stays as the fallback
10// for terminals this cannot run in, and as the path an agent or a script drives.
11
12use 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/// The control a setting is edited with.
23///
24/// Mirrors `commands::config::Kind`, which is private to that module. Kept as its own
25/// type so the view depends on nothing but its own inputs, and so a new control can be
26/// added here without the settings table knowing how it is drawn.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Control {
29    /// Flipped in place with Space.
30    Toggle,
31    /// Typed into an inline field.
32    Number,
33    /// Opens the adapter checklist.
34    Adapters,
35}
36
37/// One setting, as the view needs it.
38#[derive(Debug, Clone)]
39pub struct ConfigRow {
40    pub key: &'static str,
41    pub help: &'static str,
42    pub control: Control,
43    /// The value, spelled the way `devp config set` would take it.
44    pub value: String,
45    /// What it was when the view opened, so the summary shows only real changes.
46    pub original: String,
47    /// Introduced in a release newer than the one this machine last reviewed at.
48    pub is_new: bool,
49}
50
51impl ConfigRow {
52    pub fn changed(&self) -> bool {
53        self.value != self.original
54    }
55}
56
57/// One line of the declaration shown before anything is configurable.
58#[derive(Debug, Clone)]
59pub struct DeclarationLine {
60    /// `+` for a guarantee or a safe reading, `!` for something widened, `#` for a
61    /// section heading, ` ` for a plain fact.
62    pub mark: char,
63    pub subject: String,
64    pub state: String,
65}
66
67/// What the user decided.
68pub enum Outcome {
69    /// Write these values back.
70    Save(Vec<ConfigRow>),
71    /// Everything stays as it is, and the settings count as reviewed.
72    KeepAll,
73    /// Escape hatch: change nothing, and do not count as reviewed either.
74    Cancelled,
75}
76
77/// Everything the view needs that it cannot work out for itself.
78pub struct ConfigSession<'a> {
79    /// Shown above the settings; in practice the `devp trust` report.
80    pub declaration: Vec<DeclarationLine>,
81    /// A one-line summary of what has and has not happened yet.
82    pub standing: String,
83    pub rows: Vec<ConfigRow>,
84    /// Every adapter name, in registry order, for the checklist.
85    pub adapters: &'a [&'static str],
86    /// Adapter names that need their own `enable_*` switch as well.
87    pub opt_in_adapters: &'a [&'static str],
88    /// Round-trips one value through the setter that owns it. `Err` is shown in place
89    /// and the edit is refused, so validation lives in exactly one place.
90    pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
91    /// Title bar text — the walkthrough and `config wizard` arrive here differently.
92    pub title: &'a str,
93}
94
95/// Where the cursor starts: the first setting the user has never been shown, when there
96/// is one. After an upgrade that setting is the only reason this screen is in front of
97/// them, and making them hunt for it down a list of twenty is how it gets skipped.
98fn 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    /// Buffer for an in-progress `Number` edit; `None` when not editing.
115    editing: Option<String>,
116    /// The last refused edit, shown until the next keypress that changes anything.
117    error: Option<String>,
118    /// Adapter checklist state: `true` means the adapter stays active.
119    picker_active: Vec<bool>,
120    picker_list: ListState,
121    /// Scroll position of the declaration, which is longer than most terminals are tall.
122    decl_list: ListState,
123}
124
125/// Run the configurator. Returns what the user decided; writing is the caller's job.
126pub 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    // The guard owns raw mode, the alternate screen and the panic hook, and puts all
152    // three back on every exit path — including the `?` below.
153    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        // Windows consoles deliver a release for every press; acting on both would
172        // toggle every setting twice.
173        if key.kind == KeyEventKind::Release {
174            continue;
175        }
176        // Raw mode delivers Ctrl-C as a key event rather than a signal, so without this
177        // the one key everybody reaches for to escape does nothing.
178        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
190/// Apply one keypress. `Some` ends the view.
191fn 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        // A promise the reader cannot scroll to is not a promise they have been shown.
205        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        // `y` has meant "yes, all of it, carry on" at this prompt since 1.0.0, and it
214        // still does — this screen must not turn a habit into a detour.
215        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    // An in-progress number edit owns the keyboard until it is committed or abandoned.
227    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
263/// Space or Enter on a row: flip it, open its editor, or open its checklist.
264fn 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                // Refused in place rather than accepted and rejected on save: the
313                // reason belongs next to the field that caused it.
314                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            // `(none)` rather than an empty string, so what the row shows is exactly
354            // what `devp config get disabled_adapters` prints.
355            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
393/// Split a stored deny-list back into names. `(none)` is the empty list.
394fn 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
405// ---------------------------------------------------------------------------
406// Rendering
407// ---------------------------------------------------------------------------
408
409fn 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    // A `List` rather than a `Paragraph` only for the scrolling: no highlight symbol and
494    // no highlight style, because nothing on this screen is selectable.
495    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    // The help for the highlighted row, and any refusal, in the same place: a message
620    // about a field belongs next to the field.
621    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                // Two switches govern these, and someone who ticks this box and sees
698                // nothing happen deserves to know which other one to look at.
699                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    /// Draw one screen into an off-screen buffer and return it as text.
857    ///
858    /// The layouts are the one part of this file a keypress test cannot reach, and a
859    /// constraint that does not fit its area panics rather than clipping.
860    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        // The summary must say so when there is nothing to say, rather than draw an
902        // empty box that reads as a rendering failure.
903        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        // The prompt this replaced was `Keep all of these? [Y/n]`. Anyone who has typed
910        // `y` at it once will type `y` at this, and must get the same result.
911        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); // open the editor
923        handle_key(&mut st, KeyCode::Backspace);
924        handle_key(&mut st, KeyCode::Backspace); // buffer now empty, which will not parse
925        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); // open the checklist
952        assert_eq!(st.screen, Screen::Adapters);
953        handle_key(&mut st, KeyCode::Down); // cargo
954        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        // Opening with everything ticked would silently re-enable an adapter the user
963        // turned off, the first time they visited the screen for any other reason.
964        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        // The difference matters: `KeepAll` marks the settings reviewed and `Cancelled`
978        // does not, so an escape must not be mistaken for an answer.
979        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(' ')); // flip the first
999        handle_key(&mut st, KeyCode::Char('y')); // to the summary
1000        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}