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    /// Opens the same adapter checklist, on the idle-window column.
36    ///
37    /// Which adapters run and how long each one waits are one decision made twice, so
38    /// they are edited on one screen. The row exists separately only because the
39    /// settings table stores them as two keys.
40    AdapterDays,
41}
42
43/// One setting, as the view needs it.
44#[derive(Debug, Clone)]
45pub struct ConfigRow {
46    pub key: &'static str,
47    pub help: &'static str,
48    pub control: Control,
49    /// The value, spelled the way `devp config set` would take it.
50    pub value: String,
51    /// What it was when the view opened, so the summary shows only real changes.
52    pub original: String,
53    /// Introduced in a release newer than the one this machine last reviewed at.
54    pub is_new: bool,
55}
56
57impl ConfigRow {
58    pub fn changed(&self) -> bool {
59        self.value != self.original
60    }
61}
62
63/// One line of the declaration shown before anything is configurable.
64#[derive(Debug, Clone)]
65pub struct DeclarationLine {
66    /// `+` for a guarantee or a safe reading, `!` for something widened, `#` for a
67    /// section heading, ` ` for a plain fact.
68    pub mark: char,
69    pub subject: String,
70    pub state: String,
71}
72
73/// What the user decided.
74pub enum Outcome {
75    /// Write these values back.
76    Save(Vec<ConfigRow>),
77    /// Everything stays as it is, and the settings count as reviewed.
78    KeepAll,
79    /// Escape hatch: change nothing, and do not count as reviewed either.
80    Cancelled,
81}
82
83/// Everything the view needs that it cannot work out for itself.
84pub struct ConfigSession<'a> {
85    /// Shown above the settings; in practice the `devp trust` report.
86    pub declaration: Vec<DeclarationLine>,
87    /// A one-line summary of what has and has not happened yet.
88    pub standing: String,
89    pub rows: Vec<ConfigRow>,
90    /// Every adapter name, in registry order, for the checklist.
91    pub adapters: &'a [&'static str],
92    /// Adapter names that need their own `enable_*` switch as well.
93    pub opt_in_adapters: &'a [&'static str],
94    /// The language groups the adapters are shown under, in display order. Anything
95    /// not named by a group is collected under a trailing "Other".
96    pub groups: &'a [(&'static str, &'static [&'static str])],
97    /// Round-trips one value through the setter that owns it. `Err` is shown in place
98    /// and the edit is refused, so validation lives in exactly one place.
99    pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
100    /// Title bar text — the walkthrough and `config wizard` arrive here differently.
101    pub title: &'a str,
102}
103
104/// Where the cursor starts: the first setting the user has never been shown, when there
105/// is one. After an upgrade that setting is the only reason this screen is in front of
106/// them, and making them hunt for it down a list of twenty is how it gets skipped.
107fn opening_index(rows: &[ConfigRow]) -> usize {
108    rows.iter().position(|r| r.is_new).unwrap_or(0)
109}
110
111#[derive(Debug, PartialEq, Eq, Clone, Copy)]
112enum Screen {
113    Declaration,
114    Settings,
115    Adapters,
116    Summary,
117}
118
119/// One drawn line of the adapter checklist.
120#[derive(Debug, Clone, PartialEq, Eq)]
121enum PickerEntry {
122    /// A language heading, carrying the indices of every adapter under it so that one
123    /// keypress on the heading reaches all of them.
124    Group {
125        label: &'static str,
126        members: Vec<usize>,
127    },
128    /// An adapter, by its index into `session.adapters`.
129    Adapter(usize),
130}
131
132/// Lay the adapters out under their language headings.
133///
134/// Order comes from the group table rather than the adapter registry: someone looking
135/// for "the Python ones" is looking for a heading, not for four names that happen to be
136/// adjacent. An adapter no group claims still has to appear — a checklist that silently
137/// omits an adapter is a checklist that cannot turn it off.
138fn build_entries(
139    adapters: &[&'static str],
140    groups: &[(&'static str, &'static [&'static str])],
141) -> Vec<PickerEntry> {
142    let mut entries = Vec::new();
143    let mut placed = vec![false; adapters.len()];
144
145    for (label, names) in groups {
146        let members: Vec<usize> = names
147            .iter()
148            .filter_map(|name| adapters.iter().position(|a| a == name))
149            .collect();
150        if members.is_empty() {
151            continue;
152        }
153        for &i in &members {
154            placed[i] = true;
155        }
156        entries.push(PickerEntry::Group {
157            label,
158            members: members.clone(),
159        });
160        entries.extend(members.into_iter().map(PickerEntry::Adapter));
161    }
162
163    let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
164    if !rest.is_empty() {
165        entries.push(PickerEntry::Group {
166            label: "Other",
167            members: rest.clone(),
168        });
169        entries.extend(rest.into_iter().map(PickerEntry::Adapter));
170    }
171    entries
172}
173
174/// The value of one row, by key.
175fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
176    rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
177}
178
179/// Write one row by key, ignoring a key the settings table does not carry.
180fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
181    if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
182        row.value = value;
183    }
184}
185
186struct State<'a> {
187    session: ConfigSession<'a>,
188    screen: Screen,
189    list: ListState,
190    /// Buffer for an in-progress `Number` edit; `None` when not editing.
191    editing: Option<String>,
192    /// The last refused edit, shown until the next keypress that changes anything.
193    error: Option<String>,
194    /// Adapter checklist state: `true` means the adapter stays active.
195    picker_active: Vec<bool>,
196    /// Per-adapter idle window in days, `None` when the adapter follows the global one.
197    picker_days: Vec<Option<u64>>,
198    /// The checklist as it is drawn: group headings interleaved with their adapters.
199    /// Rebuilt when the screen opens, because it depends on nothing that changes while
200    /// it is open.
201    picker_entries: Vec<PickerEntry>,
202    /// Buffer for an in-progress idle-window edit on the checklist.
203    picker_editing: Option<String>,
204    picker_list: ListState,
205    /// Scroll position of the declaration, which is longer than most terminals are tall.
206    decl_list: ListState,
207}
208
209/// Run the configurator. Returns what the user decided; writing is the caller's job.
210pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
211    if session.rows.is_empty() {
212        return Ok(Outcome::KeepAll);
213    }
214
215    let mut list = ListState::default();
216    list.select(Some(opening_index(&session.rows)));
217
218    let mut picker_list = ListState::default();
219    picker_list.select(Some(0));
220
221    let mut decl_list = ListState::default();
222    decl_list.select(Some(0));
223
224    let mut state = State {
225        picker_active: vec![true; session.adapters.len()],
226        picker_days: vec![None; session.adapters.len()],
227        picker_entries: Vec::new(),
228        picker_editing: None,
229        session,
230        screen: Screen::Declaration,
231        list,
232        editing: None,
233        error: None,
234        picker_list,
235        decl_list,
236    };
237
238    // The guard owns raw mode, the alternate screen and the panic hook, and puts all
239    // three back on every exit path — including the `?` below.
240    let mut tui = Tui::new()?;
241    tui.drain_stale_input(Duration::from_millis(300));
242    event_loop(&mut tui.terminal, &mut state)
243}
244
245fn event_loop(
246    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
247    state: &mut State<'_>,
248) -> Result<Outcome> {
249    loop {
250        terminal.draw(|frame| render(frame, state))?;
251
252        if !event::poll(Duration::from_millis(100))? {
253            continue;
254        }
255        let Event::Key(key) = event::read()? else {
256            continue;
257        };
258        // Windows consoles deliver a release for every press; acting on both would
259        // toggle every setting twice.
260        if key.kind == KeyEventKind::Release {
261            continue;
262        }
263        // Raw mode delivers Ctrl-C as a key event rather than a signal, so without this
264        // the one key everybody reaches for to escape does nothing.
265        if key.modifiers.contains(KeyModifiers::CONTROL)
266            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
267        {
268            return Ok(Outcome::Cancelled);
269        }
270
271        if let Some(outcome) = handle_key(state, key.code) {
272            return Ok(outcome);
273        }
274    }
275}
276
277/// Apply one keypress. `Some` ends the view.
278fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
279    match state.screen {
280        Screen::Declaration => declaration_key(state, code),
281        Screen::Settings => settings_key(state, code),
282        Screen::Adapters => adapters_key(state, code),
283        Screen::Summary => summary_key(state, code),
284    }
285}
286
287fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
288    let len = state.session.declaration.len().max(1);
289    let current = state.decl_list.selected().unwrap_or(0);
290    match code {
291        // A promise the reader cannot scroll to is not a promise they have been shown.
292        KeyCode::Up | KeyCode::Char('k') => {
293            state.decl_list.select(Some(current.saturating_sub(1)));
294            None
295        }
296        KeyCode::Down | KeyCode::Char('j') => {
297            state.decl_list.select(Some((current + 1).min(len - 1)));
298            None
299        }
300        // `y` has meant "yes, all of it, carry on" at this prompt since 1.0.0, and it
301        // still does — this screen must not turn a habit into a detour.
302        KeyCode::Char('y') | KeyCode::Char('Y') => Some(Outcome::KeepAll),
303        KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
304            state.screen = Screen::Settings;
305            None
306        }
307        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
308        _ => None,
309    }
310}
311
312fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
313    // An in-progress number edit owns the keyboard until it is committed or abandoned.
314    if state.editing.is_some() {
315        return number_edit_key(state, code);
316    }
317
318    let len = state.session.rows.len();
319    let current = state.list.selected().unwrap_or(0);
320    match code {
321        KeyCode::Up | KeyCode::Char('k') => {
322            state.error = None;
323            state
324                .list
325                .select(Some(if current == 0 { len - 1 } else { current - 1 }));
326        }
327        KeyCode::Down | KeyCode::Char('j') => {
328            state.error = None;
329            state
330                .list
331                .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
332        }
333        KeyCode::Home | KeyCode::Char('g') => state.list.select(Some(0)),
334        KeyCode::End | KeyCode::Char('G') => state.list.select(Some(len - 1)),
335        KeyCode::Char(' ') | KeyCode::Enter => activate(state, current),
336        KeyCode::Char('r') | KeyCode::Char('R') => {
337            state.error = None;
338            let row = &mut state.session.rows[current];
339            row.value = row.original.clone();
340        }
341        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') | KeyCode::Char('S') => {
342            state.screen = Screen::Summary;
343        }
344        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
345        _ => {}
346    }
347    None
348}
349
350/// Space or Enter on a row: flip it, open its editor, or open its checklist.
351fn activate(state: &mut State<'_>, index: usize) {
352    state.error = None;
353    match state.session.rows[index].control {
354        Control::Toggle => {
355            let row = &mut state.session.rows[index];
356            row.value = if row.value == "true" {
357                "false".to_string()
358            } else {
359                "true".to_string()
360            };
361        }
362        Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
363        Control::Adapters | Control::AdapterDays => open_picker(state),
364    }
365}
366
367/// Seed the checklist from the rows it will write back to.
368///
369/// Opening with everything ticked would silently re-enable an adapter the user turned
370/// off, the first time they visited this screen for any other reason — so all three
371/// rows that govern an adapter are read back here, not just the deny-list.
372fn open_picker(state: &mut State<'_>) {
373    let rows = &state.session.rows;
374    let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
375    let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
376
377    state.picker_active = state
378        .session
379        .adapters
380        .iter()
381        .map(|name| {
382            if disabled.iter().any(|d| d == name) {
383                return false;
384            }
385            // An opt-in adapter is active only if its own switch is on: it is off by
386            // default and absent from the deny-list, and showing it ticked would
387            // promise a prune that never happens.
388            if state.session.opt_in_adapters.contains(name) {
389                return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
390            }
391            true
392        })
393        .collect();
394    state.picker_days = state
395        .session
396        .adapters
397        .iter()
398        .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
399        .collect();
400
401    state.picker_entries = build_entries(state.session.adapters, state.session.groups);
402    state.picker_editing = None;
403    state.picker_list.select(Some(0));
404    state.screen = Screen::Adapters;
405}
406
407fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
408    let index = state.list.selected().unwrap_or(0);
409    match code {
410        KeyCode::Char(c) if c.is_ascii_digit() => {
411            if let Some(buf) = state.editing.as_mut() {
412                buf.push(c);
413            }
414        }
415        KeyCode::Backspace => {
416            if let Some(buf) = state.editing.as_mut() {
417                buf.pop();
418            }
419        }
420        KeyCode::Enter => {
421            let typed = state.editing.clone().unwrap_or_default();
422            let key = state.session.rows[index].key;
423            match (state.session.validate)(key, typed.trim()) {
424                Ok(()) => {
425                    state.session.rows[index].value = typed.trim().to_string();
426                    state.editing = None;
427                    state.error = None;
428                }
429                // Refused in place rather than accepted and rejected on save: the
430                // reason belongs next to the field that caused it.
431                Err(why) => state.error = Some(why),
432            }
433        }
434        KeyCode::Esc => {
435            state.editing = None;
436            state.error = None;
437        }
438        _ => {}
439    }
440    None
441}
442
443fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
444    if state.picker_editing.is_some() {
445        return picker_days_key(state, code);
446    }
447
448    let len = state.picker_entries.len().max(1);
449    let current = state.picker_list.selected().unwrap_or(0);
450    match code {
451        KeyCode::Up | KeyCode::Char('k') => {
452            state.error = None;
453            state
454                .picker_list
455                .select(Some(if current == 0 { len - 1 } else { current - 1 }));
456        }
457        KeyCode::Down | KeyCode::Char('j') => {
458            state.error = None;
459            state
460                .picker_list
461                .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
462        }
463        // On a heading, one keypress governs the whole language: off if any of them is
464        // on, so "turn Python off" never needs four presses and a count.
465        KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
466            Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
467            Some(PickerEntry::Group { members, .. }) => {
468                let target = !members.iter().any(|&i| state.picker_active[i]);
469                for i in members {
470                    state.picker_active[i] = target;
471                }
472            }
473            None => {}
474        },
475        KeyCode::Char('d') | KeyCode::Char('D') => {
476            state.error = None;
477            let seed = match state.picker_entries.get(current) {
478                Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
479                // A group seeds from the window its members already share; a group of
480                // disagreeing values seeds empty rather than picking one of them.
481                Some(PickerEntry::Group { members, .. }) => {
482                    let first = members.first().and_then(|&i| state.picker_days[i]);
483                    if members.iter().all(|&i| state.picker_days[i] == first) {
484                        first
485                    } else {
486                        None
487                    }
488                }
489                None => None,
490            };
491            state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
492        }
493        KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
494        KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
495        KeyCode::Enter => commit_picker(state),
496        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
497        _ => {}
498    }
499    None
500}
501
502/// The inline idle-window editor on the checklist. An empty buffer clears the window,
503/// which is the only way back to "follow the global one" once a number is set.
504fn picker_days_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
505    let current = state.picker_list.selected().unwrap_or(0);
506    match code {
507        KeyCode::Char(c) if c.is_ascii_digit() => {
508            if let Some(buf) = state.picker_editing.as_mut() {
509                buf.push(c);
510            }
511        }
512        KeyCode::Backspace => {
513            if let Some(buf) = state.picker_editing.as_mut() {
514                buf.pop();
515            }
516        }
517        KeyCode::Enter => {
518            let typed = state.picker_editing.clone().unwrap_or_default();
519            let typed = typed.trim().to_string();
520            let targets: Vec<usize> = match state.picker_entries.get(current) {
521                Some(PickerEntry::Adapter(i)) => vec![*i],
522                Some(PickerEntry::Group { members, .. }) => members.clone(),
523                None => Vec::new(),
524            };
525            let value = if typed.is_empty() {
526                None
527            } else {
528                let Some(&first) = targets.first() else {
529                    state.picker_editing = None;
530                    return None;
531                };
532                let probe = format!("{}={typed}", state.session.adapters[first]);
533                // Through the real setter, so the checklist cannot store a number
534                // `devp config set` would refuse.
535                if let Err(why) = (state.session.validate)("adapter_idle_days", &probe) {
536                    state.error = Some(why);
537                    return None;
538                }
539                typed.parse::<u64>().ok()
540            };
541            for i in targets {
542                state.picker_days[i] = value;
543            }
544            state.picker_editing = None;
545            state.error = None;
546        }
547        KeyCode::Esc => {
548            state.picker_editing = None;
549            state.error = None;
550        }
551        _ => {}
552    }
553    None
554}
555
556/// Fold the checklist back into the rows that store it.
557///
558/// An opt-in adapter is governed by its own `enable_*` switch rather than by the
559/// deny-list: two ways to say the same "off" would leave the settings screen showing a
560/// contradiction, and unticking it here should read back there as the switch being off.
561fn commit_picker(state: &mut State<'_>) {
562    let adapters = state.session.adapters;
563    let opt_in = state.session.opt_in_adapters;
564
565    let disabled: Vec<&str> = adapters
566        .iter()
567        .enumerate()
568        .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
569        .map(|(_, name)| *name)
570        .collect();
571    // `(none)` rather than an empty string, so what the row shows is exactly what
572    // `devp config get disabled_adapters` prints.
573    let disabled = if disabled.is_empty() {
574        "(none)".to_string()
575    } else {
576        disabled.join(",")
577    };
578
579    let days: Vec<String> = adapters
580        .iter()
581        .enumerate()
582        .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
583        .collect();
584    let days = if days.is_empty() {
585        "(none)".to_string()
586    } else {
587        days.join(",")
588    };
589
590    let switches: Vec<(String, String)> = adapters
591        .iter()
592        .enumerate()
593        .filter(|(_, name)| opt_in.contains(name))
594        .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
595        .collect();
596
597    let rows = &mut state.session.rows;
598    set_row(rows, "disabled_adapters", disabled);
599    set_row(rows, "adapter_idle_days", days);
600    for (key, value) in switches {
601        set_row(rows, &key, value);
602    }
603    state.screen = Screen::Settings;
604}
605
606fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
607    match code {
608        KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') => {
609            let changed: Vec<ConfigRow> = state
610                .session
611                .rows
612                .iter()
613                .filter(|r| r.changed())
614                .cloned()
615                .collect();
616            if changed.is_empty() {
617                Some(Outcome::KeepAll)
618            } else {
619                Some(Outcome::Save(changed))
620            }
621        }
622        KeyCode::Esc | KeyCode::Backspace => {
623            state.screen = Screen::Settings;
624            None
625        }
626        KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
627        _ => None,
628    }
629}
630
631/// Split a stored deny-list back into names. `(none)` is the empty list.
632fn parse_list(value: &str) -> Vec<String> {
633    if value.trim().eq_ignore_ascii_case("(none)") {
634        return Vec::new();
635    }
636    value
637        .split(',')
638        .map(|s| s.trim().to_lowercase())
639        .filter(|s| !s.is_empty())
640        .collect()
641}
642
643/// Split a stored `name=days` map back into pairs. `(none)` is the empty map.
644///
645/// Anything malformed is dropped rather than refused: this parses a value the setter
646/// already accepted, and a checklist that will not open is worse than one that opens
647/// with a window missing.
648fn parse_days(value: &str) -> Vec<(String, u64)> {
649    if value.trim().eq_ignore_ascii_case("(none)") {
650        return Vec::new();
651    }
652    value
653        .split(',')
654        .filter_map(|entry| {
655            let (name, days) = entry.trim().split_once('=')?;
656            Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
657        })
658        .collect()
659}
660
661// ---------------------------------------------------------------------------
662// Rendering
663// ---------------------------------------------------------------------------
664
665fn render(frame: &mut Frame, state: &mut State<'_>) {
666    match state.screen {
667        Screen::Declaration => render_declaration(frame, state),
668        Screen::Settings => render_settings(frame, state),
669        Screen::Adapters => render_adapters(frame, state),
670        Screen::Summary => render_summary(frame, state),
671    }
672}
673
674fn dim() -> Style {
675    Style::default().fg(Color::DarkGray)
676}
677
678fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
679    Paragraph::new(vec![
680        Line::from(Span::styled(
681            title.to_string(),
682            Style::default()
683                .fg(Color::Cyan)
684                .add_modifier(Modifier::BOLD),
685        )),
686        Line::from(Span::styled(subtitle.to_string(), dim())),
687    ])
688}
689
690fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
691    let mut spans = Vec::new();
692    for (i, (key, what)) in keys.iter().enumerate() {
693        if i > 0 {
694            spans.push(Span::styled("   ", dim()));
695        }
696        spans.push(Span::styled(
697            key.to_string(),
698            Style::default().add_modifier(Modifier::BOLD),
699        ));
700        spans.push(Span::styled(format!(" {what}"), dim()));
701    }
702    Paragraph::new(Line::from(spans))
703        .block(Block::default().borders(Borders::TOP).border_style(dim()))
704}
705
706fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
707    let chunks = Layout::vertical([
708        Constraint::Length(3),
709        Constraint::Min(5),
710        Constraint::Length(3),
711        Constraint::Length(2),
712    ])
713    .split(frame.area());
714
715    frame.render_widget(
716        header(
717            state.session.title,
718            "What this tool is allowed to do on this machine, before it does any of it.",
719        ),
720        chunks[0],
721    );
722
723    let items: Vec<ListItem> = state
724        .session
725        .declaration
726        .iter()
727        .map(|d| {
728            if d.mark == '#' {
729                return ListItem::new(Line::from(Span::styled(
730                    format!(" {}", d.subject),
731                    Style::default()
732                        .fg(Color::Cyan)
733                        .add_modifier(Modifier::BOLD),
734                )));
735            }
736            let (mark_style, symbol) = match d.mark {
737                '!' => (Style::default().fg(Color::Yellow), "!"),
738                '+' => (Style::default().fg(Color::Green), "✓"),
739                _ => (dim(), " "),
740            };
741            ListItem::new(Line::from(vec![
742                Span::styled(format!("  {symbol} "), mark_style),
743                Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
744                Span::styled(d.state.clone(), dim()),
745            ]))
746        })
747        .collect();
748
749    // A `List` rather than a `Paragraph` only for the scrolling: no highlight symbol and
750    // no highlight style, because nothing on this screen is selectable.
751    frame.render_stateful_widget(
752        List::new(items).block(
753            Block::default()
754                .title(" Declaration ")
755                .borders(Borders::ALL)
756                .border_style(dim()),
757        ),
758        chunks[1],
759        &mut state.decl_list,
760    );
761
762    frame.render_widget(
763        Paragraph::new(Line::from(Span::styled(
764            format!("  {}", state.session.standing),
765            Style::default().fg(Color::Green),
766        )))
767        .block(Block::default().borders(Borders::ALL).border_style(dim())),
768        chunks[2],
769    );
770
771    frame.render_widget(
772        footer(&[
773            ("↑↓", "read"),
774            ("y", "keep all defaults and go"),
775            ("Enter", "configure"),
776            ("q", "cancel"),
777        ]),
778        chunks[3],
779    );
780}
781
782fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
783    let chunks = Layout::vertical([
784        Constraint::Length(3),
785        Constraint::Min(5),
786        Constraint::Length(4),
787        Constraint::Length(2),
788    ])
789    .split(frame.area());
790
791    let changed = state.session.rows.iter().filter(|r| r.changed()).count();
792    let new = state.session.rows.iter().filter(|r| r.is_new).count();
793    let subtitle = match (changed, new) {
794        (0, 0) => "Nothing changed yet.".to_string(),
795        (c, 0) => format!("{c} changed."),
796        (0, n) => format!("{n} new in this version."),
797        (c, n) => format!("{c} changed, {n} new in this version."),
798    };
799    frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
800
801    let selected = state.list.selected();
802    let items: Vec<ListItem> = state
803        .session
804        .rows
805        .iter()
806        .enumerate()
807        .map(|(i, row)| {
808            let control = match row.control {
809                Control::Toggle if row.value == "true" => Span::styled(
810                    "[x] ",
811                    Style::default()
812                        .fg(Color::Green)
813                        .add_modifier(Modifier::BOLD),
814                ),
815                Control::Toggle => Span::styled("[ ] ", dim()),
816                Control::Number => Span::styled("123 ", dim()),
817                Control::Adapters | Control::AdapterDays => Span::styled("••• ", dim()),
818            };
819
820            let shown = if state.editing.is_some() && selected == Some(i) {
821                format!("{}_", state.editing.clone().unwrap_or_default())
822            } else {
823                row.value.clone()
824            };
825
826            let mut spans = vec![
827                control,
828                Span::styled(
829                    crate::output::pad_display(row.key, 28),
830                    if selected == Some(i) {
831                        Style::default().fg(Color::White)
832                    } else {
833                        Style::default()
834                    },
835                ),
836                Span::styled(
837                    crate::output::pad_display(&shown, 20),
838                    if row.changed() {
839                        Style::default().fg(Color::Yellow)
840                    } else {
841                        Style::default().fg(Color::Cyan)
842                    },
843                ),
844            ];
845            if row.is_new {
846                spans.push(Span::styled(
847                    "NEW ",
848                    Style::default()
849                        .fg(Color::Magenta)
850                        .add_modifier(Modifier::BOLD),
851                ));
852            }
853            if row.changed() {
854                spans.push(Span::styled(format!("was {}", row.original), dim()));
855            }
856            ListItem::new(Line::from(spans))
857        })
858        .collect();
859
860    let list = List::new(items)
861        .block(
862            Block::default()
863                .title(" Settings ")
864                .borders(Borders::ALL)
865                .border_style(dim()),
866        )
867        .highlight_style(
868            Style::default()
869                .bg(Color::Rgb(30, 40, 60))
870                .add_modifier(Modifier::BOLD),
871        )
872        .highlight_symbol("▶ ");
873    frame.render_stateful_widget(list, chunks[1], &mut state.list);
874
875    // The help for the highlighted row, and any refusal, in the same place: a message
876    // about a field belongs next to the field.
877    let index = selected.unwrap_or(0);
878    let row = &state.session.rows[index];
879    let mut detail = vec![Line::from(Span::styled(format!("  {}", row.help), dim()))];
880    if row.is_new {
881        detail.push(Line::from(Span::styled(
882            "  New in this version — it has been applying its default since the upgrade.",
883            Style::default().fg(Color::Magenta),
884        )));
885    }
886    if let Some(why) = &state.error {
887        detail.push(Line::from(Span::styled(
888            format!("  {why}"),
889            Style::default().fg(Color::Red),
890        )));
891    }
892    frame.render_widget(
893        Paragraph::new(detail)
894            .wrap(Wrap { trim: true })
895            .block(Block::default().borders(Borders::ALL).border_style(dim())),
896        chunks[2],
897    );
898
899    let keys: &[(&str, &str)] = if state.editing.is_some() {
900        &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
901    } else {
902        &[
903            ("↑↓", "move"),
904            ("Space", "change"),
905            ("r", "reset"),
906            ("y", "done"),
907            ("q", "cancel"),
908        ]
909    };
910    frame.render_widget(footer(keys), chunks[3]);
911}
912
913fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
914    let chunks = Layout::vertical([
915        Constraint::Length(3),
916        Constraint::Min(5),
917        Constraint::Length(4),
918        Constraint::Length(2),
919    ])
920    .split(frame.area());
921
922    let off = state.picker_active.iter().filter(|a| !**a).count();
923    frame.render_widget(
924        header(
925            "Adapters",
926            &format!(
927                "Unchecked adapters are left alone entirely — not scanned, not counted, \
928                 not pruned. {off} off.",
929            ),
930        ),
931        chunks[0],
932    );
933
934    let selected = state.picker_list.selected();
935    let items: Vec<ListItem> = state
936        .picker_entries
937        .iter()
938        .enumerate()
939        .map(|(line, entry)| match entry {
940            PickerEntry::Group { label, members } => {
941                let on = members.iter().filter(|&&i| state.picker_active[i]).count();
942                let mark = if on == members.len() {
943                    "[x]"
944                } else if on == 0 {
945                    "[ ]"
946                } else {
947                    // A language half on is neither, and drawing it as either is how
948                    // one Space press silently turns three adapters back on.
949                    "[-]"
950                };
951                let shared = members.first().and_then(|&i| state.picker_days[i]);
952                // A heading is an editing target like any adapter row, so it has to show
953                // the buffer being typed into it — otherwise the keys land silently.
954                let window = if state.picker_editing.is_some() && selected == Some(line) {
955                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
956                } else if members.iter().all(|&i| state.picker_days[i] == shared) {
957                    shared.map(|d| format!("{d}d")).unwrap_or_default()
958                } else {
959                    "mixed".to_string()
960                };
961                ListItem::new(Line::from(vec![
962                    Span::styled(
963                        format!("{mark} {}", crate::output::pad_display(label, 22)),
964                        Style::default()
965                            .fg(Color::Cyan)
966                            .add_modifier(Modifier::BOLD),
967                    ),
968                    Span::styled(
969                        crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
970                        dim(),
971                    ),
972                    Span::styled(window, dim()),
973                ]))
974            }
975            PickerEntry::Adapter(i) => {
976                let name = state.session.adapters[*i];
977                let shown = if state.picker_editing.is_some() && selected == Some(line) {
978                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
979                } else {
980                    state.picker_days[*i]
981                        .map(|d| format!("{d}d"))
982                        .unwrap_or_else(|| "default".to_string())
983                };
984                let mut spans = vec![
985                    if state.picker_active[*i] {
986                        Span::styled(
987                            "  [x] ",
988                            Style::default()
989                                .fg(Color::Green)
990                                .add_modifier(Modifier::BOLD),
991                        )
992                    } else {
993                        Span::styled("  [ ] ", dim())
994                    },
995                    Span::styled(crate::output::pad_display(name, 18), Style::default()),
996                    Span::styled(
997                        crate::output::pad_display(&shown, 10),
998                        if state.picker_days[*i].is_some() {
999                            Style::default().fg(Color::Yellow)
1000                        } else {
1001                            dim()
1002                        },
1003                    ),
1004                ];
1005                if state.session.opt_in_adapters.contains(&name) {
1006                    // Naming the cost is the whole argument for the switch: these come
1007                    // back by recompiling, and nobody should turn one on without being
1008                    // told that is what "restore" means here.
1009                    spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1010                }
1011                ListItem::new(Line::from(spans))
1012            }
1013        })
1014        .collect();
1015
1016    let list = List::new(items)
1017        .block(
1018            Block::default()
1019                .title(" Checked adapters stay active ")
1020                .borders(Borders::ALL)
1021                .border_style(dim()),
1022        )
1023        .highlight_style(
1024            Style::default()
1025                .bg(Color::Rgb(30, 40, 60))
1026                .add_modifier(Modifier::BOLD),
1027        )
1028        .highlight_symbol("▶ ");
1029    frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1030
1031    let mut detail = vec![Line::from(Span::styled(
1032        "  Space toggles one adapter, or a whole language from its heading. d sets how \
1033         many days that adapter — or that language — must be idle first; an empty value \
1034         puts it back on the global window.",
1035        dim(),
1036    ))];
1037    if let Some(why) = &state.error {
1038        detail.push(Line::from(Span::styled(
1039            format!("  {why}"),
1040            Style::default().fg(Color::Red),
1041        )));
1042    }
1043    frame.render_widget(
1044        Paragraph::new(detail)
1045            .wrap(Wrap { trim: true })
1046            .block(Block::default().borders(Borders::ALL).border_style(dim())),
1047        chunks[2],
1048    );
1049
1050    let keys: &[(&str, &str)] = if state.picker_editing.is_some() {
1051        &[
1052            ("digits", "days"),
1053            ("Enter", "accept"),
1054            ("empty", "use the global window"),
1055            ("Esc", "abandon"),
1056        ]
1057    } else {
1058        &[
1059            ("↑↓", "move"),
1060            ("Space", "toggle"),
1061            ("d", "idle days"),
1062            ("a", "all on"),
1063            ("n", "all off"),
1064            ("Enter", "accept"),
1065            ("Esc", "back"),
1066        ]
1067    };
1068    frame.render_widget(footer(keys), chunks[3]);
1069}
1070
1071fn render_summary(frame: &mut Frame, state: &State<'_>) {
1072    let chunks = Layout::vertical([
1073        Constraint::Length(3),
1074        Constraint::Min(5),
1075        Constraint::Length(2),
1076    ])
1077    .split(frame.area());
1078
1079    let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1080    frame.render_widget(
1081        header(
1082            "Summary",
1083            if changed.is_empty() {
1084                "Nothing changed. The defaults stay in place."
1085            } else {
1086                "These are the only values that will be written."
1087            },
1088        ),
1089        chunks[0],
1090    );
1091
1092    let mut lines: Vec<Line> = changed
1093        .iter()
1094        .map(|row| {
1095            Line::from(vec![
1096                Span::styled(
1097                    format!("  {}", crate::output::pad_display(row.key, 28)),
1098                    Style::default(),
1099                ),
1100                Span::styled(row.original.clone(), dim()),
1101                Span::styled(" → ", dim()),
1102                Span::styled(
1103                    row.value.clone(),
1104                    Style::default()
1105                        .fg(Color::Yellow)
1106                        .add_modifier(Modifier::BOLD),
1107                ),
1108            ])
1109        })
1110        .collect();
1111    if lines.is_empty() {
1112        lines.push(Line::from(Span::styled(
1113            "  Every setting is still at the value it had when this opened.",
1114            dim(),
1115        )));
1116    }
1117    lines.push(Line::from(""));
1118    lines.push(Line::from(Span::styled(
1119        format!("  {}", state.session.standing),
1120        Style::default().fg(Color::Green),
1121    )));
1122
1123    frame.render_widget(
1124        Paragraph::new(lines).wrap(Wrap { trim: true }).block(
1125            Block::default()
1126                .title(" About to be saved ")
1127                .borders(Borders::ALL)
1128                .border_style(dim()),
1129        ),
1130        chunks[1],
1131    );
1132
1133    frame.render_widget(
1134        footer(&[
1135            ("Enter", "save"),
1136            ("Esc", "back"),
1137            ("q", "discard everything"),
1138        ]),
1139        chunks[2],
1140    );
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
1148        ConfigRow {
1149            key,
1150            help: "help",
1151            control,
1152            value: value.to_string(),
1153            original: value.to_string(),
1154            is_new: false,
1155        }
1156    }
1157
1158    fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
1159        ConfigSession {
1160            declaration: Vec::new(),
1161            standing: String::new(),
1162            rows,
1163            adapters,
1164            opt_in_adapters: &[],
1165            groups: &[("Test", &["npm", "cargo", "go"])],
1166            validate: &|key, v| {
1167                // Stands in for the real setters: the same shapes accepted, so a test
1168                // that types a value the checklist stores is a test the wizard passes.
1169                let number = if key == "adapter_idle_days" {
1170                    v.split_once('=').map(|(_, d)| d).unwrap_or("")
1171                } else {
1172                    v
1173                };
1174                number
1175                    .parse::<u64>()
1176                    .map(|_| ())
1177                    .map_err(|_| "not a number".to_string())
1178            },
1179            title: "test",
1180        }
1181    }
1182
1183    fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
1184        let mut list = ListState::default();
1185        list.select(Some(0));
1186        let mut picker_list = ListState::default();
1187        picker_list.select(Some(0));
1188        State {
1189            picker_active: vec![true; s.adapters.len()],
1190            picker_days: vec![None; s.adapters.len()],
1191            picker_entries: build_entries(s.adapters, s.groups),
1192            picker_editing: None,
1193            session: s,
1194            screen: Screen::Settings,
1195            list,
1196            editing: None,
1197            error: None,
1198            picker_list,
1199            decl_list: ListState::default(),
1200        }
1201    }
1202
1203    /// Draw one screen into an off-screen buffer and return it as text.
1204    ///
1205    /// The layouts are the one part of this file a keypress test cannot reach, and a
1206    /// constraint that does not fit its area panics rather than clipping.
1207    fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
1208        st.screen = screen;
1209        let mut terminal =
1210            Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
1211        terminal.draw(|frame| render(frame, st)).expect("draw");
1212        terminal
1213            .backend()
1214            .buffer()
1215            .content()
1216            .iter()
1217            .map(|cell| cell.symbol())
1218            .collect()
1219    }
1220
1221    #[test]
1222    fn every_screen_draws() {
1223        let adapters: &[&'static str] = &["npm", "cargo"];
1224        let mut st = state(session(
1225            vec![
1226                row("idle_days", Control::Number, "14"),
1227                row("disabled_adapters", Control::Adapters, "(none)"),
1228            ],
1229            adapters,
1230        ));
1231        st.session.declaration.push(DeclarationLine {
1232            mark: '+',
1233            subject: "Lockfile verification".to_string(),
1234            state: "Required before every delete".to_string(),
1235        });
1236        st.session.standing = "Nothing has been deleted.".to_string();
1237
1238        let decl = screenshot(&mut st, Screen::Declaration);
1239        assert!(decl.contains("Lockfile verification"));
1240        assert!(decl.contains("Nothing has been deleted."));
1241
1242        let settings = screenshot(&mut st, Screen::Settings);
1243        assert!(settings.contains("idle_days"));
1244
1245        st.picker_entries = build_entries(st.session.adapters, st.session.groups);
1246        st.picker_days[1] = Some(45);
1247        let picker = screenshot(&mut st, Screen::Adapters);
1248        assert!(picker.contains("cargo"));
1249        assert!(picker.contains("Test"), "the language heading is missing");
1250        assert!(picker.contains("45d"), "the idle window is missing");
1251
1252        // The summary must say so when there is nothing to say, rather than draw an
1253        // empty box that reads as a rendering failure.
1254        let summary = screenshot(&mut st, Screen::Summary);
1255        assert!(summary.contains("still at the value"));
1256    }
1257
1258    #[test]
1259    fn y_on_the_declaration_still_means_yes_to_everything() {
1260        // The prompt this replaced was `Keep all of these? [Y/n]`. Anyone who has typed
1261        // `y` at it once will type `y` at this, and must get the same result.
1262        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1263        st.screen = Screen::Declaration;
1264        assert!(matches!(
1265            handle_key(&mut st, KeyCode::Char('y')),
1266            Some(Outcome::KeepAll)
1267        ));
1268    }
1269
1270    #[test]
1271    fn a_refused_value_is_not_stored() {
1272        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1273        handle_key(&mut st, KeyCode::Enter); // open the editor
1274        handle_key(&mut st, KeyCode::Backspace);
1275        handle_key(&mut st, KeyCode::Backspace); // buffer now empty, which will not parse
1276        handle_key(&mut st, KeyCode::Enter);
1277        assert_eq!(st.session.rows[0].value, "14");
1278        assert!(st.error.is_some(), "the reason was not shown");
1279        assert!(st.editing.is_some(), "the editor closed on a refusal");
1280    }
1281
1282    #[test]
1283    fn an_accepted_value_replaces_the_old_one() {
1284        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1285        handle_key(&mut st, KeyCode::Enter);
1286        handle_key(&mut st, KeyCode::Backspace);
1287        handle_key(&mut st, KeyCode::Backspace);
1288        handle_key(&mut st, KeyCode::Char('3'));
1289        handle_key(&mut st, KeyCode::Char('0'));
1290        handle_key(&mut st, KeyCode::Enter);
1291        assert_eq!(st.session.rows[0].value, "30");
1292        assert!(st.session.rows[0].changed());
1293    }
1294
1295    #[test]
1296    fn unchecking_an_adapter_writes_it_to_the_deny_list() {
1297        let adapters: &[&'static str] = &["npm", "cargo", "go"];
1298        let mut st = state(session(
1299            vec![row("disabled_adapters", Control::Adapters, "(none)")],
1300            adapters,
1301        ));
1302        handle_key(&mut st, KeyCode::Enter); // open the checklist
1303        assert_eq!(st.screen, Screen::Adapters);
1304        handle_key(&mut st, KeyCode::Down); // past the heading, onto npm
1305        handle_key(&mut st, KeyCode::Down); // cargo
1306        handle_key(&mut st, KeyCode::Char(' '));
1307        handle_key(&mut st, KeyCode::Enter);
1308        assert_eq!(st.session.rows[0].value, "cargo");
1309        assert_eq!(st.screen, Screen::Settings);
1310    }
1311
1312    #[test]
1313    fn every_adapter_appears_under_exactly_one_heading() {
1314        // An adapter no group claims still has to be listed: a checklist that silently
1315        // omits an adapter is a checklist that cannot turn it off.
1316        let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
1317        let groups: &[(&'static str, &'static [&'static str])] =
1318            &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1319        let entries = build_entries(adapters, groups);
1320        let headings: Vec<&str> = entries
1321            .iter()
1322            .filter_map(|e| match e {
1323                PickerEntry::Group { label, .. } => Some(*label),
1324                PickerEntry::Adapter(_) => None,
1325            })
1326            .collect();
1327        assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
1328
1329        let mut listed: Vec<usize> = entries
1330            .iter()
1331            .filter_map(|e| match e {
1332                PickerEntry::Adapter(i) => Some(*i),
1333                PickerEntry::Group { .. } => None,
1334            })
1335            .collect();
1336        listed.sort_unstable();
1337        assert_eq!(
1338            listed,
1339            vec![0, 1, 2],
1340            "an adapter was dropped from the list"
1341        );
1342    }
1343
1344    #[test]
1345    fn a_heading_turns_its_whole_language_off_in_one_press() {
1346        let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1347        let mut st = state(session(
1348            vec![row("disabled_adapters", Control::Adapters, "(none)")],
1349            adapters,
1350        ));
1351        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1352        handle_key(&mut st, KeyCode::Enter);
1353        handle_key(&mut st, KeyCode::Char(' ')); // on the JavaScript heading
1354        assert_eq!(st.picker_active, vec![false, false, true]);
1355        // And back on again: a heading that only ever turned things off would leave the
1356        // user unable to undo their own keypress.
1357        handle_key(&mut st, KeyCode::Char(' '));
1358        assert_eq!(st.picker_active, vec![true, true, true]);
1359    }
1360
1361    #[test]
1362    fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
1363        let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1364        let mut st = state(session(
1365            vec![
1366                row("disabled_adapters", Control::Adapters, "(none)"),
1367                row("adapter_idle_days", Control::AdapterDays, "(none)"),
1368            ],
1369            adapters,
1370        ));
1371        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1372        handle_key(&mut st, KeyCode::Enter);
1373        handle_key(&mut st, KeyCode::Char('d')); // on the JavaScript heading
1374        handle_key(&mut st, KeyCode::Char('3'));
1375        handle_key(&mut st, KeyCode::Char('0'));
1376        handle_key(&mut st, KeyCode::Enter);
1377        assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
1378
1379        handle_key(&mut st, KeyCode::Enter); // accept the checklist
1380        assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
1381
1382        // Clearing is how a window goes back to following the global one, and there is
1383        // no other way to spell it.
1384        handle_key(&mut st, KeyCode::Enter);
1385        handle_key(&mut st, KeyCode::Char('d'));
1386        handle_key(&mut st, KeyCode::Backspace);
1387        handle_key(&mut st, KeyCode::Backspace);
1388        handle_key(&mut st, KeyCode::Enter);
1389        handle_key(&mut st, KeyCode::Enter);
1390        assert_eq!(st.session.rows[1].value, "(none)");
1391    }
1392
1393    #[test]
1394    fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
1395        // Two ways to spell the same "off" would leave the settings screen showing a
1396        // contradiction: ticking cargo here has to read back there as enable_cargo.
1397        let adapters: &[&'static str] = &["npm", "cargo"];
1398        let opt_in: &[&'static str] = &["cargo"];
1399        let mut st = state(session(
1400            vec![
1401                row("disabled_adapters", Control::Adapters, "(none)"),
1402                row("enable_cargo", Control::Toggle, "false"),
1403            ],
1404            adapters,
1405        ));
1406        st.session.opt_in_adapters = opt_in;
1407        st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1408
1409        handle_key(&mut st, KeyCode::Enter);
1410        // Off by default and absent from the deny-list: showing it ticked would promise
1411        // a prune that never happens.
1412        assert_eq!(st.picker_active, vec![true, false]);
1413        handle_key(&mut st, KeyCode::Down); // JavaScript heading -> npm
1414        handle_key(&mut st, KeyCode::Down); // Rust heading
1415        handle_key(&mut st, KeyCode::Down); // cargo
1416        handle_key(&mut st, KeyCode::Char(' '));
1417        handle_key(&mut st, KeyCode::Enter);
1418        assert_eq!(st.session.rows[0].value, "(none)");
1419        assert_eq!(st.session.rows[1].value, "true");
1420    }
1421
1422    #[test]
1423    fn the_checklist_opens_showing_what_is_already_disabled() {
1424        // Opening with everything ticked would silently re-enable an adapter the user
1425        // turned off, the first time they visited the screen for any other reason.
1426        let adapters: &[&'static str] = &["npm", "cargo", "go"];
1427        let mut st = state(session(
1428            vec![row("disabled_adapters", Control::Adapters, "go")],
1429            adapters,
1430        ));
1431        handle_key(&mut st, KeyCode::Enter);
1432        assert_eq!(st.picker_active, vec![true, true, false]);
1433        handle_key(&mut st, KeyCode::Enter);
1434        assert_eq!(st.session.rows[0].value, "go");
1435    }
1436
1437    #[test]
1438    fn cancelling_reports_cancelled_rather_than_an_empty_save() {
1439        // The difference matters: `KeepAll` marks the settings reviewed and `Cancelled`
1440        // does not, so an escape must not be mistaken for an answer.
1441        let mut st = state(session(
1442            vec![row("auto_update", Control::Toggle, "false")],
1443            &[],
1444        ));
1445        assert!(matches!(
1446            handle_key(&mut st, KeyCode::Char('q')),
1447            Some(Outcome::Cancelled)
1448        ));
1449    }
1450
1451    #[test]
1452    fn only_changed_rows_are_saved() {
1453        let mut st = state(session(
1454            vec![
1455                row("auto_update", Control::Toggle, "false"),
1456                row("auto_config", Control::Toggle, "false"),
1457            ],
1458            &[],
1459        ));
1460        handle_key(&mut st, KeyCode::Char(' ')); // flip the first
1461        handle_key(&mut st, KeyCode::Char('y')); // to the summary
1462        let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
1463            panic!("expected a save");
1464        };
1465        assert_eq!(changed.len(), 1);
1466        assert_eq!(changed[0].key, "auto_update");
1467        assert_eq!(changed[0].value, "true");
1468    }
1469
1470    #[test]
1471    fn reset_puts_a_row_back_without_touching_the_others() {
1472        let mut st = state(session(
1473            vec![
1474                row("auto_update", Control::Toggle, "false"),
1475                row("auto_config", Control::Toggle, "true"),
1476            ],
1477            &[],
1478        ));
1479        handle_key(&mut st, KeyCode::Char(' '));
1480        assert!(st.session.rows[0].changed());
1481        handle_key(&mut st, KeyCode::Char('r'));
1482        assert!(!st.session.rows[0].changed());
1483        assert_eq!(st.session.rows[1].value, "true");
1484    }
1485
1486    #[test]
1487    fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
1488        let mut rows = [
1489            row("idle_days", Control::Number, "14"),
1490            row("auto_update", Control::Toggle, "false"),
1491            row("auto_config", Control::Toggle, "false"),
1492        ];
1493        assert_eq!(
1494            opening_index(&rows),
1495            0,
1496            "with nothing new, start at the top"
1497        );
1498        rows[2].is_new = true;
1499        assert_eq!(opening_index(&rows), 2);
1500    }
1501}