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    /// Cycled in place with Space, one `(value, label)` pair at a time.
32    ///
33    /// A toggle with more than two positions. Carries its own options because the label
34    /// is the only part a reader can act on: `te` is not a word, and a picker that shows
35    /// only the stored value is a picker for people who already knew the answer.
36    Choice(&'static [(&'static str, &'static str)]),
37    /// Typed into an inline field.
38    Number,
39    /// Opens the adapter checklist.
40    Adapters,
41    /// Opens the same adapter checklist, on the idle-window column.
42    ///
43    /// Which adapters run and how long each one waits are one decision made twice, so
44    /// they are edited on one screen. The row exists separately only because the
45    /// settings table stores them as two keys.
46    AdapterDays,
47    /// Opens the same adapter checklist, on the cache-cap column.
48    ///
49    /// Third column of the same table for the same reason the second one is there: how
50    /// big npm's cache may get is a decision about npm, and the screen where npm is a
51    /// row is where it belongs.
52    CacheCaps,
53}
54
55/// Which column of the adapter checklist an inline edit is landing in.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum PickerField {
58    /// `adapter_idle_days`, in days.
59    Days,
60    /// `cache_max_gb`, in gibibytes.
61    Cap,
62}
63
64/// One setting, as the view needs it.
65#[derive(Debug, Clone)]
66pub struct ConfigRow {
67    pub key: &'static str,
68    /// The heading this row is drawn under. Rows sharing one are drawn together, in the
69    /// order they arrive; the caller owns which group a setting is in.
70    pub category: &'static str,
71    pub help: &'static str,
72    /// The same setting said again without jargon, shown under `help` rather than
73    /// instead of it. Someone who knows what a build tree is skips the second line;
74    /// someone who does not was going to guess, and guessing is how a setting gets
75    /// turned on for the wrong reason.
76    pub plain: &'static str,
77    pub control: Control,
78    /// The value, spelled the way `devp config set` would take it.
79    pub value: String,
80    /// What it was when the view opened, so the summary shows only real changes.
81    pub original: String,
82    /// What a fresh install would hold, spelled the same way as `value`.
83    ///
84    /// Not the same question as `original`, which is what this machine happens to hold.
85    /// Somebody looking at a setting they have never touched cannot tell those apart,
86    /// and the one they need in order to decide whether to touch it is this one.
87    pub default: String,
88    /// The value the first-run screen suggests, if this setting is suggested at all.
89    ///
90    /// A recommendation, never a requirement: everything here works with all of them
91    /// declined. It is shown on every visit rather than only on the first run, because
92    /// the screen that suggested it appears once and the settings list is forever.
93    pub recommended: Option<&'static str>,
94    /// Whether the recommendation is the cautious tier — advice worth reading about
95    /// before taking. Enter's walk-and-accept never takes these; only a deliberate
96    /// Space on the row does. Meaningless when `recommended` is `None`.
97    pub cautious: bool,
98    /// Introduced in a release newer than the one this machine last reviewed at.
99    pub is_new: bool,
100}
101
102impl ConfigRow {
103    pub fn changed(&self) -> bool {
104        self.value != self.original
105    }
106
107    /// Whether this row already holds what is recommended for it.
108    ///
109    /// `None` when nothing is recommended, which is a different answer from "no" and is
110    /// why the badge has three states rather than two.
111    pub fn takes_advice(&self) -> Option<bool> {
112        self.recommended.map(|r| self.value == r)
113    }
114}
115
116/// One line of the declaration shown before anything is configurable.
117#[derive(Debug, Clone)]
118pub struct DeclarationLine {
119    /// `+` for a guarantee or a safe reading, `!` for something widened, `#` for a
120    /// section heading, ` ` for a plain fact.
121    pub mark: char,
122    pub subject: String,
123    pub state: String,
124}
125
126/// One entry on the first-run suggestions screen.
127///
128/// Only the *first* run gets this screen. Everything on it is also on the settings list
129/// two keystrokes later, so this is not the only way to reach any of it — it exists
130/// because a list of twenty-four settings, shown to somebody who has had this tool
131/// installed for nine seconds, is a list nobody reads.
132#[derive(Debug, Clone)]
133pub struct Suggestion {
134    pub key: &'static str,
135    /// Three or four words naming what it turns on.
136    pub label: &'static str,
137    /// The official one-liner — the setting's own `help`.
138    pub help: &'static str,
139    /// The same thing without jargon.
140    pub plain: &'static str,
141    /// Why it is being suggested at all, which neither of the other two answers.
142    pub why: &'static str,
143    /// The value accepting it sets.
144    pub value: &'static str,
145    /// The second tier: worth turning on, with something specific to know first.
146    ///
147    /// Kept apart rather than mixed in with a warning glyph, because "recommended" and
148    /// "recommended once you know what it does" are different claims and one button
149    /// must not be able to accept both at once.
150    pub cautious: bool,
151}
152
153/// What the user decided.
154pub enum Outcome {
155    /// Write these values back.
156    Save(Vec<ConfigRow>),
157    /// Everything stays as it is, and the settings count as reviewed.
158    KeepAll,
159    /// Escape hatch: change nothing, and do not count as reviewed either.
160    Cancelled,
161}
162
163/// Everything the view needs that it cannot work out for itself.
164pub struct ConfigSession<'a> {
165    /// Shown above the settings; in practice the `devp trust` report.
166    pub declaration: Vec<DeclarationLine>,
167    /// A one-line summary of what has and has not happened yet.
168    pub standing: String,
169    pub rows: Vec<ConfigRow>,
170    /// Settings worth turning on, shown once before the full list. Empty on every run
171    /// but the first, which is the only time this screen appears at all.
172    pub suggestions: Vec<Suggestion>,
173    /// Every adapter name, in registry order, for the checklist.
174    pub adapters: &'a [&'static str],
175    /// Adapter names that need their own `enable_*` switch as well.
176    pub opt_in_adapters: &'a [&'static str],
177    /// Adapter names that are also the name of a cache `devp caches` knows, and so can
178    /// carry a `cache_max_gb` entry.
179    ///
180    /// Identity only, never a guess: npm's cache is npm's. The caches with no adapter
181    /// of the same name — `pip`, `nuget`, `conan`, `conda`, `vcpkg`, `hex` — have no
182    /// row here to sit on and are capped with `devp config set cache_max_gb` instead,
183    /// which the footer says. Inventing a row for them, or pointing `poetry` at pip's
184    /// cache, would be the checklist claiming a relationship dev-prune has not
185    /// verified.
186    pub capped_adapters: &'a [&'static str],
187    /// The language groups the adapters are shown under, in display order. Anything
188    /// not named by a group is collected under a trailing "Other".
189    pub groups: &'a [(&'static str, &'static [&'static str])],
190    /// Round-trips one value through the setter that owns it. `Err` is shown in place
191    /// and the edit is refused, so validation lives in exactly one place.
192    pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
193    /// Title bar text — the walkthrough and `config wizard` arrive here differently.
194    pub title: &'a str,
195    /// Why this opened, when nobody pointed a command at it.
196    ///
197    /// `None` for `devp config wizard`, which was typed on purpose. `Some(_)` on the
198    /// first run and after an upgrade that added a setting — the two times this takes
199    /// a terminal in the middle of a command somebody typed for another reason, and so
200    /// the two times it owes them a reason before it asks for anything.
201    pub uninvited: Option<&'a str>,
202}
203
204/// The settings list as it is drawn: category headings interleaved with their settings.
205///
206/// The same shape as [`PickerEntry`] on the adapter checklist, for the same reason — a
207/// column of thirty keys is a list nobody reads to the end of. Unlike a group
208/// there, a heading here has nothing to toggle, so [`step`] walks past it: a cursor
209/// that can rest on a line where no key does anything reads as a broken cursor.
210#[derive(Debug, Clone, PartialEq, Eq)]
211enum SettingEntry {
212    Heading(&'static str),
213    Row(usize),
214    /// The last line of the list: where the walk ends and the summary begins. A cursor
215    /// stop rather than a line in the footer, because "press some key when you are
216    /// done" is the part of a configurator people report as having no way out of.
217    Finish,
218}
219
220/// Interleave headings, keeping the caller's order within each group.
221///
222/// A heading is emitted whenever the category changes, not once per distinct category,
223/// so a caller that interleaves groups gets what it asked for rather than a silent
224/// regrouping.
225fn settings_entries(rows: &[ConfigRow]) -> Vec<SettingEntry> {
226    let mut entries = Vec::with_capacity(rows.len() + 8);
227    let mut current: Option<&str> = None;
228    for (i, row) in rows.iter().enumerate() {
229        if current != Some(row.category) {
230            entries.push(SettingEntry::Heading(row.category));
231            current = Some(row.category);
232        }
233        entries.push(SettingEntry::Row(i));
234    }
235    entries.push(SettingEntry::Finish);
236    entries
237}
238
239/// Which [`ConfigRow`] the cursor is on.
240fn selected_row(state: &State<'_>) -> usize {
241    let at = state.list.selected().unwrap_or(0);
242    match state.setting_entries.get(at) {
243        Some(SettingEntry::Row(i)) => *i,
244        // Unreachable while every move goes through `step`, which never stops on a
245        // heading. Falling forward to the first real row beats panicking mid-redraw.
246        _ => first_row(&state.setting_entries).map_or(0, |at| match state.setting_entries[at] {
247            SettingEntry::Row(i) => i,
248            SettingEntry::Heading(_) | SettingEntry::Finish => 0,
249        }),
250    }
251}
252
253/// The next entry the cursor may rest on, wrapping at both ends.
254fn step(entries: &[SettingEntry], from: usize, forward: bool) -> usize {
255    let len = entries.len();
256    let mut at = from;
257    for _ in 0..len {
258        at = if forward {
259            if at + 1 >= len { 0 } else { at + 1 }
260        } else if at == 0 {
261            len - 1
262        } else {
263            at - 1
264        };
265        if matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish) {
266            return at;
267        }
268    }
269    from
270}
271
272fn first_row(entries: &[SettingEntry]) -> Option<usize> {
273    entries
274        .iter()
275        .position(|e| matches!(e, SettingEntry::Row(_)))
276}
277
278/// The last entry the cursor may rest on, which is the finish line rather than a row.
279///
280/// Only the wrap-around test still asks; the End key that used to jump here is gone —
281/// holding Enter reaches the finish line by walking, which is the point of the walk.
282#[cfg(test)]
283fn last_stop(entries: &[SettingEntry]) -> Option<usize> {
284    entries
285        .iter()
286        .rposition(|e| matches!(e, SettingEntry::Row(_) | SettingEntry::Finish))
287}
288
289/// Where the cursor starts: the first setting the user has never been shown, when there
290/// is one. After an upgrade that setting is the only reason this screen is in front of
291/// them, and making them hunt for it down a list of twenty is how it gets skipped.
292///
293/// An index into the drawn entries, not into `rows`: the two stopped being the same
294/// thing when headings joined the list.
295fn opening_index(entries: &[SettingEntry], rows: &[ConfigRow]) -> usize {
296    entries
297        .iter()
298        .position(|e| matches!(e, SettingEntry::Row(i) if rows[*i].is_new))
299        .or_else(|| first_row(entries))
300        .unwrap_or(0)
301}
302
303#[derive(Debug, PartialEq, Eq, Clone, Copy)]
304enum Screen {
305    Declaration,
306    Suggestions,
307    Settings,
308    Adapters,
309    Summary,
310}
311
312/// One drawn line of the adapter checklist.
313#[derive(Debug, Clone, PartialEq, Eq)]
314enum PickerEntry {
315    /// A language heading, carrying the indices of every adapter under it so that one
316    /// keypress on the heading reaches all of them.
317    Group {
318        label: &'static str,
319        members: Vec<usize>,
320    },
321    /// An adapter, by its index into `session.adapters`.
322    Adapter(usize),
323}
324
325/// Lay the adapters out under their language headings.
326///
327/// Order comes from the group table rather than the adapter registry: someone looking
328/// for "the Python ones" is looking for a heading, not for four names that happen to be
329/// adjacent. An adapter no group claims still has to appear — a checklist that silently
330/// omits an adapter is a checklist that cannot turn it off.
331fn build_entries(
332    adapters: &[&'static str],
333    groups: &[(&'static str, &'static [&'static str])],
334) -> Vec<PickerEntry> {
335    let mut entries = Vec::new();
336    let mut placed = vec![false; adapters.len()];
337
338    for (label, names) in groups {
339        let members: Vec<usize> = names
340            .iter()
341            .filter_map(|name| adapters.iter().position(|a| a == name))
342            .collect();
343        if members.is_empty() {
344            continue;
345        }
346        for &i in &members {
347            placed[i] = true;
348        }
349        entries.push(PickerEntry::Group {
350            label,
351            members: members.clone(),
352        });
353        entries.extend(members.into_iter().map(PickerEntry::Adapter));
354    }
355
356    let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
357    if !rest.is_empty() {
358        entries.push(PickerEntry::Group {
359            label: "Other",
360            members: rest.clone(),
361        });
362        entries.extend(rest.into_iter().map(PickerEntry::Adapter));
363    }
364    entries
365}
366
367/// The value of one row, by key.
368fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
369    rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
370}
371
372/// Write one row by key, ignoring a key the settings table does not carry.
373fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
374    if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
375        row.value = value;
376    }
377}
378
379struct State<'a> {
380    session: ConfigSession<'a>,
381    screen: Screen,
382    list: ListState,
383    /// Buffer for an in-progress `Number` edit; `None` when not editing.
384    editing: Option<String>,
385    /// The last refused edit, shown until the next keypress that changes anything.
386    error: Option<String>,
387    /// Adapter checklist state: `true` means the adapter stays active.
388    picker_active: Vec<bool>,
389    /// Per-adapter idle window in days, `None` when the adapter follows the global one.
390    picker_days: Vec<Option<u64>>,
391    /// Per-adapter cache cap in gibibytes, `None` when that cache has no cap. Always
392    /// `None` for an adapter that is not in `capped_adapters`.
393    picker_caps: Vec<Option<u64>>,
394    /// The checklist as it is drawn: group headings interleaved with their adapters.
395    /// Rebuilt when the screen opens, because it depends on nothing that changes while
396    /// it is open.
397    picker_entries: Vec<PickerEntry>,
398    /// The settings list as it is drawn: category headings interleaved with their rows.
399    /// Built once, because it depends on nothing that changes while the view is open.
400    setting_entries: Vec<SettingEntry>,
401    /// Buffer for an in-progress number edit on the checklist.
402    picker_editing: Option<String>,
403    /// Which column [`State::picker_editing`] is being typed into.
404    picker_field: PickerField,
405    picker_list: ListState,
406    /// Scroll position of the declaration, which is longer than most terminals are tall.
407    decl_list: ListState,
408    /// Cursor on the first-run suggestions screen.
409    sugg_list: ListState,
410}
411
412/// Run the configurator. Returns what the user decided; writing is the caller's job.
413pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
414    if session.rows.is_empty() {
415        return Ok(Outcome::KeepAll);
416    }
417
418    let setting_entries = settings_entries(&session.rows);
419    let mut list = ListState::default();
420    list.select(Some(opening_index(&setting_entries, &session.rows)));
421
422    let mut picker_list = ListState::default();
423    picker_list.select(Some(0));
424
425    let mut decl_list = ListState::default();
426    decl_list.select(Some(0));
427
428    let mut sugg_list = ListState::default();
429    sugg_list.select(Some(0));
430
431    let mut state = State {
432        picker_active: vec![true; session.adapters.len()],
433        picker_days: vec![None; session.adapters.len()],
434        picker_caps: vec![None; session.adapters.len()],
435        picker_entries: Vec::new(),
436        setting_entries,
437        picker_editing: None,
438        picker_field: PickerField::Days,
439        session,
440        screen: Screen::Declaration,
441        list,
442        editing: None,
443        error: None,
444        picker_list,
445        decl_list,
446        sugg_list,
447    };
448
449    preaccept_recommended(&mut state);
450
451    // The guard owns raw mode, the alternate screen and the panic hook, and puts all
452    // three back on every exit path — including the `?` below.
453    let mut tui = Tui::new()?;
454    tui.drain_stale_input(Duration::from_millis(300));
455    event_loop(&mut tui.terminal, &mut state)
456}
457
458fn event_loop(
459    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
460    state: &mut State<'_>,
461) -> Result<Outcome> {
462    loop {
463        terminal.draw(|frame| render(frame, state))?;
464
465        if !event::poll(Duration::from_millis(100))? {
466            continue;
467        }
468        let Event::Key(key) = event::read()? else {
469            continue;
470        };
471        // Windows consoles deliver a release for every press; acting on both would
472        // toggle every setting twice.
473        if key.kind == KeyEventKind::Release {
474            continue;
475        }
476        // Raw mode delivers Ctrl-C as a key event rather than a signal, so without this
477        // the one key everybody reaches for to escape does nothing.
478        if key.modifiers.contains(KeyModifiers::CONTROL)
479            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
480        {
481            return Ok(Outcome::Cancelled);
482        }
483
484        if let Some(outcome) = handle_key(state, key.code) {
485            return Ok(outcome);
486        }
487    }
488}
489
490/// Apply one keypress. `Some` ends the view.
491fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
492    match state.screen {
493        Screen::Declaration => declaration_key(state, code),
494        Screen::Suggestions => suggestions_key(state, code),
495        Screen::Settings => settings_key(state, code),
496        Screen::Adapters => adapters_key(state, code),
497        Screen::Summary => summary_key(state, code),
498    }
499}
500
501fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
502    let len = state.session.declaration.len().max(1);
503    let current = state.decl_list.selected().unwrap_or(0);
504    match code {
505        // A promise the reader cannot scroll to is not a promise they have been shown.
506        KeyCode::Up | KeyCode::Char('k') => {
507            state.decl_list.select(Some(current.saturating_sub(1)));
508            None
509        }
510        KeyCode::Down | KeyCode::Char('j') => {
511            state.decl_list.select(Some((current + 1).min(len - 1)));
512            None
513        }
514        // No `y` here any more. It used to mean "keep everything and go", which was
515        // the one exit that never showed what was about to be written. Every exit goes
516        // through the summary now, so the key that skipped it is gone rather than
517        // rebound to something else.
518        KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
519            state.screen = if state.session.suggestions.is_empty() {
520                Screen::Settings
521            } else {
522                Screen::Suggestions
523            };
524            None
525        }
526        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
527        _ => None,
528    }
529}
530
531/// Arrive with the safe recommendations already accepted.
532///
533/// This screen used to open with every box empty, on the reasoning that a pre-ticked
534/// box teaches people to tick boxes. That reasoning is sound about consent and wrong
535/// about this list: everything on the safe tier is a build directory a build command
536/// puts back, under a 45-day idle window, and leaving them off by default meant the
537/// common outcome of the first run was a tool that had been installed and configured to
538/// reclaim almost nothing. The honest version of a default is not an empty one, it is a
539/// visible one — so the header says what is accepted and which key clears the lot, and
540/// `r` undoes all of it in one keystroke.
541///
542/// The cautious tier is deliberately untouched. `allow_manifest_rewrite` can leave a
543/// change in `git status`, and the tier exists precisely because that is a thing to be
544/// told before rather than after.
545fn preaccept_recommended(state: &mut State<'_>) {
546    for i in 0..state.session.suggestions.len() {
547        if !state.session.suggestions[i].cautious {
548            apply_suggestion(state, i, true);
549        }
550    }
551}
552
553/// Whether a suggestion is currently accepted: its setting already holds the value the
554/// suggestion would set.
555///
556/// Derived rather than stored. The settings list two screens on can change the same
557/// value, and a remembered "accepted" flag would then disagree with the setting it
558/// claims to describe — the summary reads the settings, so the settings are the truth.
559fn accepted(state: &State<'_>, index: usize) -> bool {
560    let s = &state.session.suggestions[index];
561    row_value(&state.session.rows, s.key).as_deref() == Some(s.value)
562}
563
564/// Accept or undo one suggestion. Undoing restores what the setting had when the view
565/// opened, not a hard-coded default: the recommendation is the only thing being
566/// withdrawn, and anything the user had already chosen is not this screen's to discard.
567fn apply_suggestion(state: &mut State<'_>, index: usize, accept: bool) {
568    let (key, value) = {
569        let s = &state.session.suggestions[index];
570        (s.key, s.value)
571    };
572    let restore = state
573        .session
574        .rows
575        .iter()
576        .find(|r| r.key == key)
577        .map(|r| r.original.clone());
578    let Some(restore) = restore else { return };
579    let next = if accept { value.to_string() } else { restore };
580    set_row(&mut state.session.rows, key, next);
581}
582
583fn suggestions_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
584    let len = state.session.suggestions.len();
585    let current = state.sugg_list.selected().unwrap_or(0);
586    match code {
587        KeyCode::Up | KeyCode::Char('k') => {
588            state
589                .sugg_list
590                .select(Some(if current == 0 { len - 1 } else { current - 1 }))
591        }
592        KeyCode::Down | KeyCode::Char('j') => {
593            state
594                .sugg_list
595                .select(Some(if current + 1 >= len { 0 } else { current + 1 }))
596        }
597        KeyCode::Char(' ') => {
598            let now = accepted(state, current);
599            apply_suggestion(state, current, !now);
600        }
601        // One key for the whole first tier, which is the point of the screen. It
602        // deliberately does not reach the cautious tier: a button that accepts the thing
603        // you were told to read about first is not a shortcut, it is a trap.
604        KeyCode::Char('a') | KeyCode::Char('A') => {
605            for i in 0..len {
606                if !state.session.suggestions[i].cautious {
607                    apply_suggestion(state, i, true);
608                }
609            }
610        }
611        KeyCode::Char('r') | KeyCode::Char('R') => {
612            for i in 0..len {
613                apply_suggestion(state, i, false);
614            }
615        }
616        KeyCode::Char('c') | KeyCode::Char('C') => {
617            state.screen = Screen::Settings;
618        }
619        // On to the settings, where Enter keeps meaning "keep going": held down it
620        // walks every remaining setting, takes the safe advice on the way, and ends at
621        // the summary. Nothing is written until the summary says so, which is why this
622        // screen does not need a second press to leave.
623        KeyCode::Enter => {
624            state.screen = Screen::Settings;
625        }
626        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
627        _ => {}
628    }
629    None
630}
631
632fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
633    // An in-progress number edit owns the keyboard until it is committed or abandoned.
634    if state.editing.is_some() {
635        return number_edit_key(state, code);
636    }
637
638    let at = state.list.selected().unwrap_or(0);
639    let current = selected_row(state);
640    // `selected_row` falls forward to the first row when the cursor is not on one, so
641    // every arm that touches `current` has to know when the cursor is on the finish
642    // line instead — otherwise Space down there would silently flip the top of the list.
643    let on_finish = matches!(state.setting_entries.get(at), Some(SettingEntry::Finish));
644    match code {
645        KeyCode::Up | KeyCode::Char('k') => {
646            state.error = None;
647            let to = step(&state.setting_entries, at, false);
648            state.list.select(Some(to));
649        }
650        KeyCode::Down | KeyCode::Char('j') => {
651            state.error = None;
652            let to = step(&state.setting_entries, at, true);
653            state.list.select(Some(to));
654        }
655        KeyCode::Home | KeyCode::Char('g') => state.list.select(first_row(&state.setting_entries)),
656        KeyCode::Enter if on_finish => {
657            state.error = None;
658            state.screen = Screen::Summary;
659        }
660        // Enter is the "keep going" key: it takes the untaken recommendation on this
661        // row — never a cautious one, those stay a deliberate Space — and moves to the
662        // next stop. Held down from anywhere it walks the rest of the list, accepts the
663        // safe advice on the way, and arrives at the finish line, whose Enter opens the
664        // summary. Changing a value to anything *other* than the recommendation is
665        // Space's job.
666        KeyCode::Enter if !on_finish => {
667            state.error = None;
668            let row = &mut state.session.rows[current];
669            if let Some(rec) = row.recommended
670                && !row.cautious
671                && row.value != rec
672            {
673                row.value = rec.to_string();
674            }
675            let to = step(&state.setting_entries, at, true);
676            state.list.select(Some(to));
677        }
678        KeyCode::Char(' ') if !on_finish => activate(state, current),
679        KeyCode::Char('r') | KeyCode::Char('R') if !on_finish => {
680            state.error = None;
681            let row = &mut state.session.rows[current];
682            row.value = row.original.clone();
683        }
684        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
685        _ => {}
686    }
687    None
688}
689
690/// Space on a row: flip it, open its editor, or open its checklist.
691fn activate(state: &mut State<'_>, index: usize) {
692    state.error = None;
693    match state.session.rows[index].control {
694        Control::Toggle => {
695            let row = &mut state.session.rows[index];
696            row.value = if row.value == "true" {
697                "false".to_string()
698            } else {
699                "true".to_string()
700            };
701        }
702        Control::Choice(options) => {
703            let row = &mut state.session.rows[index];
704            // A value the list does not contain lands on the first option rather than
705            // sticking: the row has to be able to leave a state the binary no longer
706            // supports, which is what a config written by a newer version looks like.
707            let next = options
708                .iter()
709                .position(|(value, _)| *value == row.value)
710                .map_or(0, |at| (at + 1) % options.len());
711            row.value = options[next].0.to_string();
712        }
713        Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
714        Control::Adapters | Control::AdapterDays | Control::CacheCaps => open_picker(state),
715    }
716}
717
718/// Seed the checklist from the rows it will write back to.
719///
720/// Opening with everything ticked would silently re-enable an adapter the user turned
721/// off, the first time they visited this screen for any other reason — so all three
722/// rows that govern an adapter are read back here, not just the deny-list.
723fn open_picker(state: &mut State<'_>) {
724    let rows = &state.session.rows;
725    let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
726    let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
727    let caps = parse_days(&row_value(rows, "cache_max_gb").unwrap_or_default());
728
729    state.picker_active = state
730        .session
731        .adapters
732        .iter()
733        .map(|name| {
734            if disabled.iter().any(|d| d == name) {
735                return false;
736            }
737            // An opt-in adapter is active only if its own switch is on: it is off by
738            // default and absent from the deny-list, and showing it ticked would
739            // promise a prune that never happens.
740            if state.session.opt_in_adapters.contains(name) {
741                return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
742            }
743            true
744        })
745        .collect();
746    state.picker_days = state
747        .session
748        .adapters
749        .iter()
750        .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
751        .collect();
752
753    state.picker_caps = state
754        .session
755        .adapters
756        .iter()
757        .map(|name| {
758            if !state.session.capped_adapters.contains(name) {
759                return None;
760            }
761            caps.iter().find(|(n, _)| n == name).map(|(_, g)| *g)
762        })
763        .collect();
764
765    state.picker_entries = build_entries(state.session.adapters, state.session.groups);
766    state.picker_editing = None;
767    state.picker_field = PickerField::Days;
768    state.picker_list.select(Some(0));
769    state.screen = Screen::Adapters;
770}
771
772fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
773    let index = selected_row(state);
774    match code {
775        KeyCode::Char(c) if c.is_ascii_digit() => {
776            if let Some(buf) = state.editing.as_mut() {
777                buf.push(c);
778            }
779        }
780        KeyCode::Backspace => {
781            if let Some(buf) = state.editing.as_mut() {
782                buf.pop();
783            }
784        }
785        KeyCode::Enter => {
786            let typed = state.editing.clone().unwrap_or_default();
787            let key = state.session.rows[index].key;
788            match (state.session.validate)(key, typed.trim()) {
789                Ok(()) => {
790                    state.session.rows[index].value = typed.trim().to_string();
791                    state.editing = None;
792                    state.error = None;
793                }
794                // Refused in place rather than accepted and rejected on save: the
795                // reason belongs next to the field that caused it.
796                Err(why) => state.error = Some(why),
797            }
798        }
799        KeyCode::Esc => {
800            state.editing = None;
801            state.error = None;
802        }
803        _ => {}
804    }
805    None
806}
807
808fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
809    if state.picker_editing.is_some() {
810        return picker_number_key(state, code);
811    }
812
813    let len = state.picker_entries.len().max(1);
814    let current = state.picker_list.selected().unwrap_or(0);
815    match code {
816        KeyCode::Up | KeyCode::Char('k') => {
817            state.error = None;
818            state
819                .picker_list
820                .select(Some(if current == 0 { len - 1 } else { current - 1 }));
821        }
822        KeyCode::Down | KeyCode::Char('j') => {
823            state.error = None;
824            state
825                .picker_list
826                .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
827        }
828        // On a heading, one keypress governs the whole language: off if any of them is
829        // on, so "turn Python off" never needs four presses and a count.
830        KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
831            Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
832            Some(PickerEntry::Group { members, .. }) => {
833                let target = !members.iter().any(|&i| state.picker_active[i]);
834                for i in members {
835                    state.picker_active[i] = target;
836                }
837            }
838            None => {}
839        },
840        KeyCode::Char('d') | KeyCode::Char('D') => {
841            state.error = None;
842            let seed = match state.picker_entries.get(current) {
843                Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
844                // A group seeds from the window its members already share; a group of
845                // disagreeing values seeds empty rather than picking one of them.
846                Some(PickerEntry::Group { members, .. }) => {
847                    let first = members.first().and_then(|&i| state.picker_days[i]);
848                    if members.iter().all(|&i| state.picker_days[i] == first) {
849                        first
850                    } else {
851                        None
852                    }
853                }
854                None => None,
855            };
856            state.picker_field = PickerField::Days;
857            state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
858        }
859        KeyCode::Char('c') | KeyCode::Char('C') => {
860            state.error = None;
861            // Nothing to type into: the adapter has no cache of its own name, so a cap
862            // typed here would be stored against a manager that does not exist. Saying
863            // so beats an editor that accepts a number and drops it.
864            let targets = capped_targets(state, current);
865            if targets.is_empty() {
866                state.error = Some(
867                    "No cache of that name for dev-prune to size. `devp caches` lists the ones \
868                     there are; `devp config set cache_max_gb` caps them."
869                        .to_string(),
870                );
871                return None;
872            }
873            let first = targets.first().and_then(|&i| state.picker_caps[i]);
874            let seed = if targets.iter().all(|&i| state.picker_caps[i] == first) {
875                first
876            } else {
877                None
878            };
879            state.picker_field = PickerField::Cap;
880            state.picker_editing = Some(seed.map(|g| g.to_string()).unwrap_or_default());
881        }
882        KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
883        KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
884        KeyCode::Enter => commit_picker(state),
885        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
886        _ => {}
887    }
888    None
889}
890
891/// The adapters a cap typed at line `line` should land on: those under it that have a
892/// cache of their own name.
893///
894/// A language heading types into every capped adapter beneath it at once and silently
895/// skips the rest — "cap the JavaScript caches at 10" is one sentence, and the four
896/// managers it reaches are exactly the four that have one.
897fn capped_targets(state: &State<'_>, line: usize) -> Vec<usize> {
898    let members: Vec<usize> = match state.picker_entries.get(line) {
899        Some(PickerEntry::Adapter(i)) => vec![*i],
900        Some(PickerEntry::Group { members, .. }) => members.clone(),
901        None => Vec::new(),
902    };
903    members
904        .into_iter()
905        .filter(|&i| {
906            state
907                .session
908                .capped_adapters
909                .contains(&state.session.adapters[i])
910        })
911        .collect()
912}
913
914/// The inline number editor on the checklist, for whichever column
915/// [`State::picker_field`] names. An empty buffer clears the value, which is the only
916/// way back to "no window of its own" or "no cap" once a number is set.
917fn picker_number_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
918    let current = state.picker_list.selected().unwrap_or(0);
919    match code {
920        KeyCode::Char(c) if c.is_ascii_digit() => {
921            if let Some(buf) = state.picker_editing.as_mut() {
922                buf.push(c);
923            }
924        }
925        KeyCode::Backspace => {
926            if let Some(buf) = state.picker_editing.as_mut() {
927                buf.pop();
928            }
929        }
930        KeyCode::Enter => {
931            let typed = state.picker_editing.clone().unwrap_or_default();
932            let typed = typed.trim().to_string();
933            let key = match state.picker_field {
934                PickerField::Days => "adapter_idle_days",
935                PickerField::Cap => "cache_max_gb",
936            };
937            let targets: Vec<usize> = match state.picker_field {
938                PickerField::Days => match state.picker_entries.get(current) {
939                    Some(PickerEntry::Adapter(i)) => vec![*i],
940                    Some(PickerEntry::Group { members, .. }) => members.clone(),
941                    None => Vec::new(),
942                },
943                PickerField::Cap => capped_targets(state, current),
944            };
945            let value = if typed.is_empty() {
946                None
947            } else {
948                let Some(&first) = targets.first() else {
949                    state.picker_editing = None;
950                    return None;
951                };
952                let probe = format!("{}={typed}", state.session.adapters[first]);
953                // Through the real setter, so the checklist cannot store a number
954                // `devp config set` would refuse.
955                if let Err(why) = (state.session.validate)(key, &probe) {
956                    state.error = Some(why);
957                    return None;
958                }
959                typed.parse::<u64>().ok()
960            };
961            for i in targets {
962                match state.picker_field {
963                    PickerField::Days => state.picker_days[i] = value,
964                    PickerField::Cap => state.picker_caps[i] = value,
965                }
966            }
967            state.picker_editing = None;
968            state.error = None;
969        }
970        KeyCode::Esc => {
971            state.picker_editing = None;
972            state.error = None;
973        }
974        _ => {}
975    }
976    None
977}
978
979/// Fold the checklist back into the rows that store it.
980///
981/// An opt-in adapter is governed by its own `enable_*` switch rather than by the
982/// deny-list: two ways to say the same "off" would leave the settings screen showing a
983/// contradiction, and unticking it here should read back there as the switch being off.
984fn commit_picker(state: &mut State<'_>) {
985    let adapters = state.session.adapters;
986    let opt_in = state.session.opt_in_adapters;
987
988    let disabled: Vec<&str> = adapters
989        .iter()
990        .enumerate()
991        .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
992        .map(|(_, name)| *name)
993        .collect();
994    // Unlike `adapter_idle_days` and `cache_max_gb` below, `config get
995    // disabled_adapters` prints the user's own stored order, not a sorted one — so an
996    // unchanged selection must hand back the exact string the row started with.
997    // Rebuilding it in adapter-registry order reported a phantom change in the summary
998    // and rewrote the key on save.
999    let previous =
1000        row_value(&state.session.rows, "disabled_adapters").unwrap_or_else(|| "(none)".into());
1001    let mut previous_names: Vec<&str> = previous
1002        .split(',')
1003        .map(str::trim)
1004        .filter(|s| !s.is_empty() && *s != "(none)")
1005        .collect();
1006    let mut current_names = disabled.clone();
1007    previous_names.sort_unstable();
1008    current_names.sort_unstable();
1009    // `(none)` rather than an empty string, so what the row shows is exactly what
1010    // `devp config get disabled_adapters` prints.
1011    let disabled = if previous_names == current_names {
1012        previous.clone()
1013    } else if disabled.is_empty() {
1014        "(none)".to_string()
1015    } else {
1016        disabled.join(",")
1017    };
1018
1019    let mut days: Vec<String> = adapters
1020        .iter()
1021        .enumerate()
1022        .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
1023        .collect();
1024    // Sorted for the same reason the caps below are: `config get adapter_idle_days`
1025    // prints a `BTreeMap`, and this row is compared against that. Assembling it in
1026    // adapter order instead would report an untouched setting as changed.
1027    days.sort_unstable();
1028    let days = if days.is_empty() {
1029        "(none)".to_string()
1030    } else {
1031        days.join(",")
1032    };
1033
1034    // A cap on a cache with no adapter of its own name — `pip`, `nuget`, `conan`,
1035    // `conda`, `vcpkg`, `hex` — has no row on this screen to be edited from, and a
1036    // screen that writes back only what it can draw would delete it the first time
1037    // anyone opened the checklist for any other reason.
1038    let existing = parse_days(&row_value(&state.session.rows, "cache_max_gb").unwrap_or_default());
1039    let mut caps: Vec<String> = existing
1040        .iter()
1041        .filter(|(name, _)| !adapters.iter().any(|a| a == name))
1042        .map(|(name, gb)| format!("{name}={gb}"))
1043        .collect();
1044    caps.extend(
1045        adapters
1046            .iter()
1047            .enumerate()
1048            .filter_map(|(i, name)| state.picker_caps[i].map(|g| format!("{name}={g}"))),
1049    );
1050    caps.sort_unstable();
1051    let caps = if caps.is_empty() {
1052        "(none)".to_string()
1053    } else {
1054        caps.join(",")
1055    };
1056
1057    let switches: Vec<(String, String)> = adapters
1058        .iter()
1059        .enumerate()
1060        .filter(|(_, name)| opt_in.contains(name))
1061        .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
1062        .collect();
1063
1064    let rows = &mut state.session.rows;
1065    set_row(rows, "disabled_adapters", disabled);
1066    set_row(rows, "adapter_idle_days", days);
1067    set_row(rows, "cache_max_gb", caps);
1068    for (key, value) in switches {
1069        set_row(rows, &key, value);
1070    }
1071    state.screen = Screen::Settings;
1072}
1073
1074fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
1075    match code {
1076        KeyCode::Enter => {
1077            let changed: Vec<ConfigRow> = state
1078                .session
1079                .rows
1080                .iter()
1081                .filter(|r| r.changed())
1082                .cloned()
1083                .collect();
1084            if changed.is_empty() {
1085                Some(Outcome::KeepAll)
1086            } else {
1087                Some(Outcome::Save(changed))
1088            }
1089        }
1090        KeyCode::Esc | KeyCode::Backspace => {
1091            state.screen = Screen::Settings;
1092            None
1093        }
1094        KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
1095        _ => None,
1096    }
1097}
1098
1099/// Split a stored deny-list back into names. `(none)` is the empty list.
1100fn parse_list(value: &str) -> Vec<String> {
1101    if value.trim().eq_ignore_ascii_case("(none)") {
1102        return Vec::new();
1103    }
1104    value
1105        .split(',')
1106        .map(|s| s.trim().to_lowercase())
1107        .filter(|s| !s.is_empty())
1108        .collect()
1109}
1110
1111/// Split a stored `name=days` map back into pairs. `(none)` is the empty map.
1112///
1113/// Anything malformed is dropped rather than refused: this parses a value the setter
1114/// already accepted, and a checklist that will not open is worse than one that opens
1115/// with a window missing.
1116fn parse_days(value: &str) -> Vec<(String, u64)> {
1117    if value.trim().eq_ignore_ascii_case("(none)") {
1118        return Vec::new();
1119    }
1120    value
1121        .split(',')
1122        .filter_map(|entry| {
1123            let (name, days) = entry.trim().split_once('=')?;
1124            Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
1125        })
1126        .collect()
1127}
1128
1129// ---------------------------------------------------------------------------
1130// Rendering
1131// ---------------------------------------------------------------------------
1132
1133fn render(frame: &mut Frame, state: &mut State<'_>) {
1134    match state.screen {
1135        Screen::Declaration => render_declaration(frame, state),
1136        Screen::Suggestions => render_suggestions(frame, state),
1137        Screen::Settings => render_settings(frame, state),
1138        Screen::Adapters => render_adapters(frame, state),
1139        Screen::Summary => render_summary(frame, state),
1140    }
1141}
1142
1143fn dim() -> Style {
1144    Style::default().fg(Color::DarkGray)
1145}
1146
1147fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
1148    Paragraph::new(vec![
1149        Line::from(Span::styled(
1150            title.to_string(),
1151            Style::default()
1152                .fg(Color::Cyan)
1153                .add_modifier(Modifier::BOLD),
1154        )),
1155        Line::from(Span::styled(subtitle.to_string(), dim())),
1156    ])
1157}
1158
1159fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
1160    let mut spans = Vec::new();
1161    for (i, (key, what)) in keys.iter().enumerate() {
1162        if i > 0 {
1163            spans.push(Span::styled("   ", dim()));
1164        }
1165        spans.push(Span::styled(
1166            key.to_string(),
1167            Style::default().add_modifier(Modifier::BOLD),
1168        ));
1169        spans.push(Span::styled(format!(" {what}"), dim()));
1170    }
1171    Paragraph::new(Line::from(spans))
1172        .block(Block::default().borders(Borders::TOP).border_style(dim()))
1173}
1174
1175fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
1176    // The reason block is in the layout only when there is a reason to give, rather than
1177    // always present and sometimes empty: an empty bordered box on the screen somebody
1178    // meets this tool on reads as something having failed to load.
1179    let notice = state.session.uninvited;
1180    let mut constraints = vec![Constraint::Length(3)];
1181    if notice.is_some() {
1182        constraints.push(Constraint::Length(6));
1183    }
1184    constraints.extend([
1185        Constraint::Min(5),
1186        // Four rather than three: two borders and two lines. What is true right now, and
1187        // what is true about the licence the whole screen is offered under.
1188        Constraint::Length(4),
1189        Constraint::Length(2),
1190    ]);
1191    let chunks = Layout::vertical(constraints).split(frame.area());
1192    let mut at = 0;
1193
1194    frame.render_widget(
1195        header(
1196            state.session.title,
1197            "What this tool is allowed to do on this machine, before it does any of it.",
1198        ),
1199        chunks[at],
1200    );
1201    at += 1;
1202
1203    if let Some(why) = notice {
1204        frame.render_widget(
1205            Paragraph::new(why)
1206                .wrap(Wrap { trim: true })
1207                .style(Style::default().fg(Color::Yellow))
1208                .block(
1209                    Block::default()
1210                        .title(" Why this opened on its own ")
1211                        .borders(Borders::ALL)
1212                        .border_style(Style::default().fg(Color::Yellow)),
1213                ),
1214            chunks[at],
1215        );
1216        at += 1;
1217    }
1218
1219    let items: Vec<ListItem> = state
1220        .session
1221        .declaration
1222        .iter()
1223        .map(|d| {
1224            if d.mark == '#' {
1225                return ListItem::new(Line::from(Span::styled(
1226                    format!(" {}", d.subject),
1227                    Style::default()
1228                        .fg(Color::Cyan)
1229                        .add_modifier(Modifier::BOLD),
1230                )));
1231            }
1232            let (mark_style, symbol) = match d.mark {
1233                '!' => (Style::default().fg(Color::Yellow), "!"),
1234                '+' => (Style::default().fg(Color::Green), "✓"),
1235                _ => (dim(), " "),
1236            };
1237            ListItem::new(Line::from(vec![
1238                Span::styled(format!("  {symbol} "), mark_style),
1239                Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
1240                Span::styled(d.state.clone(), dim()),
1241            ]))
1242        })
1243        .collect();
1244
1245    // A `List` rather than a `Paragraph` only for the scrolling: no highlight symbol and
1246    // no highlight style, because nothing on this screen is selectable.
1247    frame.render_stateful_widget(
1248        List::new(items).block(
1249            Block::default()
1250                .title(" Declaration ")
1251                .borders(Borders::ALL)
1252                .border_style(dim()),
1253        ),
1254        chunks[at],
1255        &mut state.decl_list,
1256    );
1257    at += 1;
1258
1259    frame.render_widget(
1260        Paragraph::new(vec![
1261            Line::from(Span::styled(
1262                format!("  {}", state.session.standing),
1263                Style::default().fg(Color::Green),
1264            )),
1265            // Dim, and under the green line rather than over it. The promise is the
1266            // reason to keep reading; the licence is the terms that promise is made on,
1267            // and putting the terms first is how a screen becomes one nobody finishes.
1268            Line::from(Span::styled(
1269                format!("  {}", crate::constants::LICENCE_NOTICE),
1270                dim(),
1271            )),
1272        ])
1273        .block(Block::default().borders(Borders::ALL).border_style(dim())),
1274        chunks[at],
1275    );
1276    at += 1;
1277
1278    frame.render_widget(
1279        footer(&[("↑↓", "read"), ("Enter", "configure"), ("q", "cancel")]),
1280        chunks[at],
1281    );
1282}
1283
1284/// The first-run suggestions: a short list, in two tiers, with the selected one
1285/// explained twice underneath.
1286///
1287/// Two lines of explanation per setting rather than one, and only for the setting under
1288/// the cursor. Printing all of it at once is how a screen becomes a wall nobody reads,
1289/// which is the failure this screen exists to fix.
1290fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
1291    // Only reachable with entries, and indexing on a drawn frame is not the place to be
1292    // sure of that: a panic here takes the terminal down with the alternate screen on.
1293    if state.session.suggestions.is_empty() {
1294        state.screen = Screen::Settings;
1295        return render_settings(frame, state);
1296    }
1297    let chunks = Layout::vertical([
1298        Constraint::Length(3),
1299        Constraint::Min(5),
1300        Constraint::Length(7),
1301        Constraint::Length(2),
1302    ])
1303    .split(frame.area());
1304
1305    let on = (0..state.session.suggestions.len())
1306        .filter(|&i| accepted(state, i))
1307        .count();
1308    frame.render_widget(
1309        header(
1310            "Suggested settings",
1311            // Naming the arrow keys here rather than only in the footer: the first
1312            // reaction to a list of nine settings is to accept or skip the lot, and
1313            // nobody arrows through an unfamiliar list to find out whether anything
1314            // appears elsewhere on screen. The panel below is the point of the screen.
1315            &format!(
1316                "{} of {} accepted \u{2014} press \u{2191}\u{2193} to read what each one does. \
1317                 The safe ones start accepted; `r` turns every one of them back off.",
1318                on,
1319                state.session.suggestions.len()
1320            ),
1321        ),
1322        chunks[0],
1323    );
1324
1325    let selected = state
1326        .sugg_list
1327        .selected()
1328        .unwrap_or(0)
1329        .min(state.session.suggestions.len() - 1);
1330    let mut items: Vec<ListItem> = Vec::new();
1331    let mut tier_shown = false;
1332    for (i, s) in state.session.suggestions.iter().enumerate() {
1333        // The tier heading is drawn as part of the first cautious entry rather than as an
1334        // entry of its own: a heading in the list would be a line the cursor can land on
1335        // and Space cannot do anything to.
1336        let mut lines = Vec::new();
1337        if s.cautious && !tier_shown {
1338            tier_shown = true;
1339            lines.push(Line::from(Span::styled(
1340                "  Worth turning on once you know what it does",
1341                Style::default()
1342                    .fg(Color::Yellow)
1343                    .add_modifier(Modifier::BOLD),
1344            )));
1345        }
1346        let mark = if accepted(state, i) {
1347            Span::styled(
1348                "[x] ",
1349                Style::default()
1350                    .fg(Color::Green)
1351                    .add_modifier(Modifier::BOLD),
1352            )
1353        } else {
1354            Span::styled("[ ] ", dim())
1355        };
1356        lines.push(Line::from(vec![
1357            mark,
1358            Span::styled(
1359                crate::output::pad_display(s.label, 28),
1360                if selected == i {
1361                    Style::default().fg(Color::White)
1362                } else {
1363                    Style::default()
1364                },
1365            ),
1366            Span::styled(s.key.to_string(), dim()),
1367        ]));
1368        items.push(ListItem::new(lines));
1369    }
1370
1371    frame.render_stateful_widget(
1372        List::new(items)
1373            .block(
1374                Block::default()
1375                    .title(" Suggested ")
1376                    .borders(Borders::ALL)
1377                    .border_style(dim()),
1378            )
1379            .highlight_style(
1380                Style::default()
1381                    .bg(Color::Rgb(30, 40, 60))
1382                    .add_modifier(Modifier::BOLD),
1383            )
1384            .highlight_symbol("\u{25b6} "),
1385        chunks[1],
1386        &mut state.sugg_list,
1387    );
1388
1389    let s = &state.session.suggestions[selected];
1390    frame.render_widget(
1391        Paragraph::new(vec![
1392            Line::from(Span::styled(format!("  {}", s.help), Style::default())),
1393            Line::from(""),
1394            Line::from(vec![
1395                Span::styled("  In plain words  ", dim()),
1396                Span::styled(s.plain.to_string(), Style::default().fg(Color::Cyan)),
1397            ]),
1398            Line::from(""),
1399            Line::from(vec![
1400                Span::styled("  Why we suggest it  ", dim()),
1401                Span::styled(s.why.to_string(), Style::default().fg(Color::Green)),
1402            ]),
1403        ])
1404        .wrap(Wrap { trim: true })
1405        .block(Block::default().borders(Borders::ALL).border_style(dim())),
1406        chunks[2],
1407    );
1408
1409    frame.render_widget(
1410        footer(&[
1411            ("\u{2191}\u{2193}", "read"),
1412            ("Space", "accept one"),
1413            ("a", "accept all suggested"),
1414            ("r", "undo"),
1415            ("Enter", "on to the settings"),
1416        ]),
1417        chunks[3],
1418    );
1419}
1420
1421fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
1422    let chunks = Layout::vertical([
1423        Constraint::Length(3),
1424        Constraint::Min(5),
1425        // Seven rather than six: the pane gained the line that says what a fresh install
1426        // would hold, and losing a list row to it is the cheaper of the two trades.
1427        Constraint::Length(7),
1428        Constraint::Length(2),
1429    ])
1430    .split(frame.area());
1431
1432    let changed = state.session.rows.iter().filter(|r| r.changed()).count();
1433    let new = state.session.rows.iter().filter(|r| r.is_new).count();
1434    let subtitle = match (changed, new) {
1435        (0, 0) => "Nothing changed yet.".to_string(),
1436        (c, 0) => format!("{c} changed."),
1437        (0, n) => format!("{n} new in this version."),
1438        (c, n) => format!("{c} changed, {n} new in this version."),
1439    };
1440    frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
1441
1442    let selected = state.list.selected();
1443    let items: Vec<ListItem> = state
1444        .setting_entries
1445        .iter()
1446        .enumerate()
1447        .map(|(at, entry)| {
1448            // Styled the way the declaration screen styles a `'#'` line and the checklist
1449            // styles a group label: one program, one way of saying "heading".
1450            let row = match entry {
1451                SettingEntry::Heading(title) => {
1452                    return ListItem::new(Line::from(Span::styled(
1453                        format!(" {title}"),
1454                        Style::default()
1455                            .fg(Color::Cyan)
1456                            .add_modifier(Modifier::BOLD),
1457                    )));
1458                }
1459                SettingEntry::Row(i) => &state.session.rows[*i],
1460                SettingEntry::Finish => {
1461                    return ListItem::new(Line::from(vec![
1462                        Span::styled(
1463                            " Finish — review the changes  ",
1464                            Style::default()
1465                                .fg(Color::Cyan)
1466                                .add_modifier(Modifier::BOLD),
1467                        ),
1468                        Span::styled("Press Enter to see what will be saved", dim()),
1469                    ]));
1470                }
1471            };
1472            let control = match row.control {
1473                Control::Toggle if row.value == "true" => Span::styled(
1474                    "[x] ",
1475                    Style::default()
1476                        .fg(Color::Green)
1477                        .add_modifier(Modifier::BOLD),
1478                ),
1479                Control::Toggle => Span::styled("[ ] ", dim()),
1480                Control::Choice(_) => Span::styled("(o) ", dim()),
1481                Control::Number => Span::styled("123 ", dim()),
1482                Control::Adapters | Control::AdapterDays | Control::CacheCaps => {
1483                    Span::styled("••• ", dim())
1484                }
1485            };
1486
1487            let shown = if state.editing.is_some() && selected == Some(at) {
1488                format!("{}_", state.editing.clone().unwrap_or_default())
1489            } else if let Control::Choice(options) = row.control {
1490                // The stored value *and* what it means. `en` alone would make this row
1491                // unreadable to the one person it exists for.
1492                options
1493                    .iter()
1494                    .find(|(value, _)| *value == row.value)
1495                    .map_or_else(
1496                        || row.value.clone(),
1497                        |(value, label)| format!("{value} {label}"),
1498                    )
1499            } else {
1500                row.value.clone()
1501            };
1502
1503            let mut spans = vec![
1504                control,
1505                Span::styled(
1506                    crate::output::pad_display(row.key, 28),
1507                    if selected == Some(at) {
1508                        Style::default().fg(Color::White)
1509                    } else {
1510                        Style::default()
1511                    },
1512                ),
1513                Span::styled(
1514                    crate::output::pad_display(&shown, 20),
1515                    if row.changed() {
1516                        Style::default().fg(Color::Yellow)
1517                    } else {
1518                        Style::default().fg(Color::Cyan)
1519                    },
1520                ),
1521            ];
1522            if row.is_new {
1523                spans.push(Span::styled(
1524                    "NEW ",
1525                    Style::default()
1526                        .fg(Color::Magenta)
1527                        .add_modifier(Modifier::BOLD),
1528                ));
1529            }
1530            // Green for "already what is suggested", yellow for "suggested, and this is
1531            // not it". Both are drawn, because a badge that disappears once taken tells
1532            // you nothing about the row you are looking at — only about the row you are
1533            // not.
1534            match row.takes_advice() {
1535                Some(true) => spans.push(Span::styled("REC ", Style::default().fg(Color::Green))),
1536                Some(false) => spans.push(Span::styled("REC ", Style::default().fg(Color::Yellow))),
1537                None => {}
1538            }
1539            if row.changed() {
1540                spans.push(Span::styled(format!("was {}", row.original), dim()));
1541            }
1542            ListItem::new(Line::from(spans))
1543        })
1544        .collect();
1545
1546    let list = List::new(items)
1547        .block(
1548            Block::default()
1549                .title(" Settings ")
1550                .borders(Borders::ALL)
1551                .border_style(dim()),
1552        )
1553        .highlight_style(
1554            Style::default()
1555                .bg(Color::Rgb(30, 40, 60))
1556                .add_modifier(Modifier::BOLD),
1557        )
1558        .highlight_symbol("▶ ");
1559    frame.render_stateful_widget(list, chunks[1], &mut state.list);
1560
1561    // The finish line has no row behind it, so it gets a pane of its own: what has
1562    // changed so far, and the fact that none of it has been written.
1563    if matches!(
1564        state
1565            .setting_entries
1566            .get(state.list.selected().unwrap_or(0)),
1567        Some(SettingEntry::Finish)
1568    ) {
1569        let detail = vec![
1570            Line::from(Span::styled(
1571                "  Enter opens a summary of every change. \
1572                 Nothing has been written yet.",
1573                Style::default(),
1574            )),
1575            Line::from(vec![
1576                Span::styled("  Changed so far  ", dim()),
1577                Span::styled(
1578                    match changed {
1579                        0 => "nothing".to_string(),
1580                        1 => "1 setting".to_string(),
1581                        n => format!("{n} settings"),
1582                    },
1583                    Style::default().fg(Color::Cyan),
1584                ),
1585            ]),
1586        ];
1587        frame.render_widget(
1588            Paragraph::new(detail)
1589                .wrap(Wrap { trim: true })
1590                .block(Block::default().borders(Borders::ALL).border_style(dim())),
1591            chunks[2],
1592        );
1593        frame.render_widget(
1594            footer(&[
1595                ("↑↓", "move"),
1596                ("Enter", "review and finish"),
1597                ("q", "cancel"),
1598            ]),
1599            chunks[3],
1600        );
1601        return;
1602    }
1603
1604    // The help for the highlighted row, and any refusal, in the same place: a message
1605    // about a field belongs next to the field.
1606    let row = &state.session.rows[selected_row(state)];
1607    let mut detail = vec![
1608        Line::from(Span::styled(format!("  {}", row.help), Style::default())),
1609        Line::from(vec![
1610            Span::styled("  In plain words  ", dim()),
1611            Span::styled(row.plain.to_string(), Style::default().fg(Color::Cyan)),
1612        ]),
1613    ];
1614    // The two questions a row cannot answer about itself: what it would be if nobody had
1615    // ever touched it, and what it is suggested to be. Neither is what it currently is,
1616    // which is the only one the list column shows.
1617    let mut facts = vec![
1618        Span::styled("  Default  ", dim()),
1619        Span::styled(
1620            crate::output::pad_display(&row.default, 12),
1621            Style::default(),
1622        ),
1623    ];
1624    if let Some(rec) = row.recommended {
1625        facts.push(Span::styled("Recommended  ", dim()));
1626        facts.push(Span::styled(
1627            crate::output::pad_display(rec, 12),
1628            Style::default().fg(Color::Green),
1629        ));
1630        facts.push(Span::styled(
1631            if row.takes_advice() == Some(true) {
1632                "— already set"
1633            } else {
1634                "— suggested, not required; everything works without it"
1635            },
1636            dim(),
1637        ));
1638    }
1639    detail.push(Line::from(facts));
1640    if row.is_new {
1641        detail.push(Line::from(Span::styled(
1642            "  New in this version — it has been applying its default since the upgrade.",
1643            Style::default().fg(Color::Magenta),
1644        )));
1645    }
1646    if let Some(why) = &state.error {
1647        detail.push(Line::from(Span::styled(
1648            format!("  {why}"),
1649            Style::default().fg(Color::Red),
1650        )));
1651    }
1652    frame.render_widget(
1653        Paragraph::new(detail)
1654            .wrap(Wrap { trim: true })
1655            .block(Block::default().borders(Borders::ALL).border_style(dim())),
1656        chunks[2],
1657    );
1658
1659    let keys: &[(&str, &str)] = if state.editing.is_some() {
1660        &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
1661    } else {
1662        &[
1663            ("↑↓", "move"),
1664            ("Enter", "accept advice & next"),
1665            ("Space", "change"),
1666            ("r", "reset"),
1667            ("q/Esc", "cancel"),
1668        ]
1669    };
1670    frame.render_widget(footer(keys), chunks[3]);
1671}
1672
1673fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
1674    let chunks = Layout::vertical([
1675        Constraint::Length(3),
1676        Constraint::Min(5),
1677        Constraint::Length(4),
1678        Constraint::Length(2),
1679    ])
1680    .split(frame.area());
1681
1682    let off = state.picker_active.iter().filter(|a| !**a).count();
1683    frame.render_widget(
1684        header(
1685            "Adapters",
1686            &format!(
1687                "Unchecked adapters are left alone entirely — not scanned, not counted, \
1688                 not pruned. {off} off.",
1689            ),
1690        ),
1691        chunks[0],
1692    );
1693
1694    let selected = state.picker_list.selected();
1695    let items: Vec<ListItem> = state
1696        .picker_entries
1697        .iter()
1698        .enumerate()
1699        .map(|(line, entry)| match entry {
1700            PickerEntry::Group { label, members } => {
1701                let on = members.iter().filter(|&&i| state.picker_active[i]).count();
1702                let mark = if on == members.len() {
1703                    "[x]"
1704                } else if on == 0 {
1705                    "[ ]"
1706                } else {
1707                    // A language half on is neither, and drawing it as either is how
1708                    // one Space press silently turns three adapters back on.
1709                    "[-]"
1710                };
1711                let editing_here = state.picker_editing.is_some() && selected == Some(line);
1712                let shared = members.first().and_then(|&i| state.picker_days[i]);
1713                // A heading is an editing target like any adapter row, so it has to show
1714                // the buffer being typed into it — otherwise the keys land silently.
1715                let window = if editing_here && state.picker_field == PickerField::Days {
1716                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1717                } else if members.iter().all(|&i| state.picker_days[i] == shared) {
1718                    shared.map(|d| format!("{d}d")).unwrap_or_default()
1719                } else {
1720                    "mixed".to_string()
1721                };
1722                let capped: Vec<usize> = members
1723                    .iter()
1724                    .copied()
1725                    .filter(|&i| {
1726                        state
1727                            .session
1728                            .capped_adapters
1729                            .contains(&state.session.adapters[i])
1730                    })
1731                    .collect();
1732                let shared_cap = capped.first().and_then(|&i| state.picker_caps[i]);
1733                let cap = if editing_here && state.picker_field == PickerField::Cap {
1734                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1735                } else if capped.is_empty() {
1736                    String::new()
1737                } else if capped.iter().all(|&i| state.picker_caps[i] == shared_cap) {
1738                    shared_cap.map(|g| format!("{g}G")).unwrap_or_default()
1739                } else {
1740                    "mixed".to_string()
1741                };
1742                ListItem::new(Line::from(vec![
1743                    Span::styled(
1744                        format!("{mark} {}", crate::output::pad_display(label, 22)),
1745                        Style::default()
1746                            .fg(Color::Cyan)
1747                            .add_modifier(Modifier::BOLD),
1748                    ),
1749                    Span::styled(
1750                        crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
1751                        dim(),
1752                    ),
1753                    Span::styled(crate::output::pad_display(&window, 10), dim()),
1754                    Span::styled(cap, dim()),
1755                ]))
1756            }
1757            PickerEntry::Adapter(i) => {
1758                let name = state.session.adapters[*i];
1759                let editing_here = state.picker_editing.is_some() && selected == Some(line);
1760                let shown = if editing_here && state.picker_field == PickerField::Days {
1761                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1762                } else {
1763                    state.picker_days[*i]
1764                        .map(|d| format!("{d}d"))
1765                        .unwrap_or_else(|| "default".to_string())
1766                };
1767                // Blank, not "no cap": there is no cache of this name for a cap to be
1768                // about, and an empty cell is the only honest way to draw a column that
1769                // does not apply to this row.
1770                let cap = if editing_here && state.picker_field == PickerField::Cap {
1771                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1772                } else if !state.session.capped_adapters.contains(&name) {
1773                    String::new()
1774                } else {
1775                    state.picker_caps[*i]
1776                        .map(|g| format!("{g}G"))
1777                        .unwrap_or_else(|| "no cap".to_string())
1778                };
1779                let mut spans = vec![
1780                    if state.picker_active[*i] {
1781                        Span::styled(
1782                            "  [x] ",
1783                            Style::default()
1784                                .fg(Color::Green)
1785                                .add_modifier(Modifier::BOLD),
1786                        )
1787                    } else {
1788                        Span::styled("  [ ] ", dim())
1789                    },
1790                    Span::styled(crate::output::pad_display(name, 18), Style::default()),
1791                    Span::styled(
1792                        crate::output::pad_display(&shown, 10),
1793                        if state.picker_days[*i].is_some() {
1794                            Style::default().fg(Color::Yellow)
1795                        } else {
1796                            dim()
1797                        },
1798                    ),
1799                    Span::styled(
1800                        crate::output::pad_display(&cap, 10),
1801                        if state.picker_caps[*i].is_some() {
1802                            Style::default().fg(Color::Yellow)
1803                        } else {
1804                            dim()
1805                        },
1806                    ),
1807                ];
1808                if state.session.opt_in_adapters.contains(&name) {
1809                    // Naming the cost is the whole argument for the switch: these come
1810                    // back by recompiling, and nobody should turn one on without being
1811                    // told that is what "restore" means here.
1812                    spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1813                }
1814                ListItem::new(Line::from(spans))
1815            }
1816        })
1817        .collect();
1818
1819    let list = List::new(items)
1820        .block(
1821            Block::default()
1822                .title(" Checked adapters stay active      idle      cache cap ")
1823                .borders(Borders::ALL)
1824                .border_style(dim()),
1825        )
1826        .highlight_style(
1827            Style::default()
1828                .bg(Color::Rgb(30, 40, 60))
1829                .add_modifier(Modifier::BOLD),
1830        )
1831        .highlight_symbol("▶ ");
1832    frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1833
1834    let mut detail = vec![Line::from(Span::styled(
1835        "  Space toggles one adapter, or a whole language from its heading. d sets how \
1836         many days that adapter — or that language — must be idle first; an empty value \
1837         puts it back on the global window. c caps that ecosystem's download cache in \
1838         GiB — reported by `devp caches`, and emptied only when you run \
1839         `devp caches clear --over-cap`, never on a schedule.",
1840        dim(),
1841    ))];
1842    if let Some(why) = &state.error {
1843        detail.push(Line::from(Span::styled(
1844            format!("  {why}"),
1845            Style::default().fg(Color::Red),
1846        )));
1847    }
1848    frame.render_widget(
1849        Paragraph::new(detail)
1850            .wrap(Wrap { trim: true })
1851            .block(Block::default().borders(Borders::ALL).border_style(dim())),
1852        chunks[2],
1853    );
1854
1855    let keys: &[(&str, &str)] = match (state.picker_editing.is_some(), state.picker_field) {
1856        (true, PickerField::Days) => &[
1857            ("digits", "days"),
1858            ("Enter", "accept"),
1859            ("empty", "use the global window"),
1860            ("Esc", "abandon"),
1861        ],
1862        (true, PickerField::Cap) => &[
1863            ("digits", "GiB"),
1864            ("Enter", "accept"),
1865            ("empty", "no cap"),
1866            ("Esc", "abandon"),
1867        ],
1868        (false, _) => &[
1869            ("↑↓", "move"),
1870            ("Space", "toggle"),
1871            ("d", "idle days"),
1872            ("c", "cache cap"),
1873            ("a", "all on"),
1874            ("n", "all off"),
1875            ("Enter", "accept"),
1876            ("Esc", "back"),
1877        ],
1878    };
1879    frame.render_widget(footer(keys), chunks[3]);
1880}
1881
1882fn render_summary(frame: &mut Frame, state: &State<'_>) {
1883    let chunks = Layout::vertical([
1884        Constraint::Length(3),
1885        Constraint::Min(5),
1886        Constraint::Length(2),
1887    ])
1888    .split(frame.area());
1889
1890    let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1891    frame.render_widget(
1892        header(
1893            "Summary",
1894            if changed.is_empty() {
1895                "Nothing changed. The defaults stay in place."
1896            } else {
1897                "These are the only values that will be written."
1898            },
1899        ),
1900        chunks[0],
1901    );
1902
1903    let mut lines: Vec<Line> = changed
1904        .iter()
1905        .map(|row| {
1906            Line::from(vec![
1907                Span::styled(
1908                    format!("  {}", crate::output::pad_display(row.key, 28)),
1909                    Style::default(),
1910                ),
1911                Span::styled(row.original.clone(), dim()),
1912                Span::styled(" → ", dim()),
1913                Span::styled(
1914                    row.value.clone(),
1915                    Style::default()
1916                        .fg(Color::Yellow)
1917                        .add_modifier(Modifier::BOLD),
1918                ),
1919            ])
1920        })
1921        .collect();
1922    if lines.is_empty() {
1923        lines.push(Line::from(Span::styled(
1924            "  Every setting is still at the value it had when this opened.",
1925            dim(),
1926        )));
1927    }
1928    lines.push(Line::from(""));
1929    lines.push(Line::from(Span::styled(
1930        format!("  {}", state.session.standing),
1931        Style::default().fg(Color::Green),
1932    )));
1933
1934    frame.render_widget(
1935        Paragraph::new(lines).wrap(Wrap { trim: true }).block(
1936            Block::default()
1937                .title(" About to be saved ")
1938                .borders(Borders::ALL)
1939                .border_style(dim()),
1940        ),
1941        chunks[1],
1942    );
1943
1944    frame.render_widget(
1945        footer(&[
1946            ("Enter", "save"),
1947            ("Esc", "back"),
1948            ("q", "discard everything"),
1949        ]),
1950        chunks[2],
1951    );
1952}
1953
1954#[cfg(test)]
1955mod tests {
1956    use super::*;
1957
1958    fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
1959        categorised_row(key, "Settings", control, value)
1960    }
1961
1962    /// A row in a named group, for the tests that are about the grouping itself.
1963    fn categorised_row(
1964        key: &'static str,
1965        category: &'static str,
1966        control: Control,
1967        value: &str,
1968    ) -> ConfigRow {
1969        ConfigRow {
1970            key,
1971            category,
1972            help: "help",
1973            plain: "plain",
1974            control,
1975            value: value.to_string(),
1976            original: value.to_string(),
1977            default: value.to_string(),
1978            recommended: None,
1979            cautious: false,
1980            is_new: false,
1981        }
1982    }
1983
1984    fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
1985        ConfigSession {
1986            declaration: Vec::new(),
1987            standing: String::new(),
1988            suggestions: Vec::new(),
1989            rows,
1990            adapters,
1991            opt_in_adapters: &[],
1992            capped_adapters: &["npm", "pnpm", "cargo", "go"],
1993            groups: &[("Test", &["npm", "cargo", "go"])],
1994            validate: &|key, v| {
1995                // Stands in for the real setters: the same shapes accepted, so a test
1996                // that types a value the checklist stores is a test the wizard passes.
1997                let number = if key == "adapter_idle_days" || key == "cache_max_gb" {
1998                    v.split_once('=').map(|(_, d)| d).unwrap_or("")
1999                } else {
2000                    v
2001                };
2002                number
2003                    .parse::<u64>()
2004                    .map(|_| ())
2005                    .map_err(|_| "not a number".to_string())
2006            },
2007            title: "test",
2008            uninvited: None,
2009        }
2010    }
2011
2012    fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
2013        let setting_entries = settings_entries(&s.rows);
2014        let mut list = ListState::default();
2015        // The first row, not entry 0 — entry 0 is a heading, which is the one
2016        // place the cursor is never allowed to be.
2017        list.select(first_row(&setting_entries));
2018        let mut picker_list = ListState::default();
2019        picker_list.select(Some(0));
2020        State {
2021            picker_active: vec![true; s.adapters.len()],
2022            picker_days: vec![None; s.adapters.len()],
2023            picker_caps: vec![None; s.adapters.len()],
2024            picker_entries: build_entries(s.adapters, s.groups),
2025            setting_entries,
2026            picker_editing: None,
2027            picker_field: PickerField::Days,
2028            session: s,
2029            screen: Screen::Settings,
2030            list,
2031            editing: None,
2032            error: None,
2033            picker_list,
2034            decl_list: ListState::default(),
2035            sugg_list: ListState::default(),
2036        }
2037    }
2038
2039    /// Draw one screen into an off-screen buffer and return it as text.
2040    ///
2041    /// The layouts are the one part of this file a keypress test cannot reach, and a
2042    /// constraint that does not fit its area panics rather than clipping.
2043    fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
2044        st.screen = screen;
2045        let mut terminal =
2046            Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
2047        terminal.draw(|frame| render(frame, st)).expect("draw");
2048        terminal
2049            .backend()
2050            .buffer()
2051            .content()
2052            .iter()
2053            .map(|cell| cell.symbol())
2054            .collect()
2055    }
2056
2057    #[test]
2058    fn every_screen_draws() {
2059        let adapters: &[&'static str] = &["npm", "cargo"];
2060        let mut st = state(session(
2061            vec![
2062                row("idle_days", Control::Number, "14"),
2063                row("disabled_adapters", Control::Adapters, "(none)"),
2064            ],
2065            adapters,
2066        ));
2067        st.session.declaration.push(DeclarationLine {
2068            mark: '+',
2069            subject: "Lockfile verification".to_string(),
2070            state: "Required before every delete".to_string(),
2071        });
2072        st.session.standing = "Nothing has been deleted.".to_string();
2073
2074        let decl = screenshot(&mut st, Screen::Declaration);
2075        assert!(decl.contains("Lockfile verification"));
2076        assert!(decl.contains("Nothing has been deleted."));
2077
2078        let settings = screenshot(&mut st, Screen::Settings);
2079        assert!(settings.contains("idle_days"));
2080
2081        st.picker_entries = build_entries(st.session.adapters, st.session.groups);
2082        st.picker_days[1] = Some(45);
2083        let picker = screenshot(&mut st, Screen::Adapters);
2084        assert!(picker.contains("cargo"));
2085        assert!(picker.contains("Test"), "the language heading is missing");
2086        assert!(picker.contains("45d"), "the idle window is missing");
2087
2088        // The summary must say so when there is nothing to say, rather than draw an
2089        // empty box that reads as a rendering failure.
2090        let summary = screenshot(&mut st, Screen::Summary);
2091        assert!(summary.contains("still at the value"));
2092    }
2093
2094    #[test]
2095    fn the_declaration_no_longer_leaves_on_y() {
2096        // `y` used to mean "keep everything and go", and it was the one exit that never
2097        // showed what was about to be written. Rebinding it to something else would be
2098        // worse than dropping it: the habit would then do a different thing silently.
2099        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2100        st.screen = Screen::Declaration;
2101        assert!(handle_key(&mut st, KeyCode::Char('y')).is_none());
2102        assert_eq!(st.screen, Screen::Declaration);
2103    }
2104
2105    #[test]
2106    fn enter_takes_the_recommendation_and_moves_to_the_next_row() {
2107        // The whole gesture: Enter means "yes to the advice, next question". A row with
2108        // no advice just gets "next question".
2109        let mut rows = vec![
2110            row("enable_cargo", Control::Toggle, "false"),
2111            row("auto_update", Control::Toggle, "false"),
2112        ];
2113        rows[0].recommended = Some("true");
2114        let mut st = state(session(rows, &[]));
2115
2116        let before = st.list.selected();
2117        assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2118        assert_eq!(st.session.rows[0].value, "true", "the advice was taken");
2119        assert_eq!(st.screen, Screen::Settings, "one press must not leave");
2120        assert_ne!(st.list.selected(), before, "the cursor moved on");
2121
2122        handle_key(&mut st, KeyCode::Enter);
2123        assert_eq!(
2124            st.session.rows[1].value, "false",
2125            "a row with no recommendation is only stepped past, never flipped"
2126        );
2127    }
2128
2129    #[test]
2130    fn enter_walks_past_a_cautious_recommendation() {
2131        // The tier boundary survives the walk. `allow_manifest_rewrite` edits a tracked
2132        // file, and the key you can hold down must not be the key that turns it on.
2133        let mut rows = vec![row("allow_manifest_rewrite", Control::Toggle, "false")];
2134        rows[0].recommended = Some("true");
2135        rows[0].cautious = true;
2136        let mut st = state(session(rows, &[]));
2137
2138        handle_key(&mut st, KeyCode::Enter);
2139        assert_eq!(
2140            st.session.rows[0].value, "false",
2141            "the cautious tier is never accepted by the walk"
2142        );
2143        // Still reachable — by the deliberate key.
2144        handle_key(&mut st, KeyCode::Up); // off the finish line, back onto the row
2145        handle_key(&mut st, KeyCode::Char(' '));
2146        assert_eq!(st.session.rows[0].value, "true");
2147    }
2148
2149    #[test]
2150    fn holding_enter_walks_the_list_and_ends_at_the_summary() {
2151        // The promise made to a fresh install: pressing nothing but Enter reviews every
2152        // setting, takes the safe advice, and lands on the summary — one more Enter
2153        // there saves. No key sequence, no End, no arming.
2154        let mut rows = vec![
2155            row("enable_cargo", Control::Toggle, "false"),
2156            row("auto_update", Control::Toggle, "false"),
2157        ];
2158        rows[0].recommended = Some("true");
2159        let mut st = state(session(rows, &[]));
2160
2161        // Two rows and a finish line: three Enters reach the summary.
2162        for _ in 0..3 {
2163            assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2164        }
2165        assert_eq!(st.screen, Screen::Summary);
2166        let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2167            panic!("Enter on the summary saves");
2168        };
2169        assert_eq!(changed.len(), 1);
2170        assert_eq!(changed[0].key, "enable_cargo");
2171        assert_eq!(changed[0].value, "true");
2172    }
2173
2174    #[test]
2175    fn the_whole_first_run_is_one_held_key() {
2176        // Declaration → suggestions → every setting → summary → saved, on nothing but
2177        // Enter — the exact journey a first run holding the key down takes. Every
2178        // control type sits in the walk's path, because any one of them trapping the
2179        // key (a number editor opening, the checklist swallowing it) breaks the
2180        // promise this test exists to keep.
2181        let adapters: &[&str] = &["npm", "cargo"];
2182        let mut rows = vec![
2183            row("idle_days", Control::Number, "14"),
2184            row("enable_cargo", Control::Toggle, "false"),
2185            row("disabled_adapters", Control::Adapters, "(none)"),
2186            row("allow_manifest_rewrite", Control::Toggle, "false"),
2187        ];
2188        rows[1].recommended = Some("true");
2189        rows[3].recommended = Some("true");
2190        rows[3].cautious = true;
2191        let mut s = session(rows, adapters);
2192        s.suggestions = vec![
2193            suggestion("enable_cargo", false),
2194            suggestion("allow_manifest_rewrite", true),
2195        ];
2196        let mut st = state(s);
2197        st.screen = Screen::Declaration;
2198        preaccept_recommended(&mut st);
2199
2200        // Four rows plus the two screens, the finish line and the summary: eight
2201        // presses. The bound is generous because a regression that loops matters
2202        // more than the exact count.
2203        let mut outcome = None;
2204        for _ in 0..32 {
2205            if let Some(o) = handle_key(&mut st, KeyCode::Enter) {
2206                outcome = Some(o);
2207                break;
2208            }
2209        }
2210        let Some(Outcome::Save(changed)) = outcome else {
2211            panic!("held Enter never reached a save");
2212        };
2213        assert_eq!(changed.len(), 1, "{changed:?}");
2214        assert_eq!(changed[0].key, "enable_cargo");
2215        assert_eq!(changed[0].value, "true");
2216        assert_eq!(
2217            row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2218            Some("false"),
2219            "the cautious tier survived the whole ride untouched"
2220        );
2221    }
2222
2223    #[test]
2224    fn enter_leaves_the_suggestions_for_the_settings() {
2225        // One press, and it goes forward through the list rather than around it: the
2226        // old Enter-Enter shortcut skipped straight to the summary, past settings the
2227        // user had never seen.
2228        let adapters: &[&str] = &["npm"];
2229        let mut s = session(
2230            vec![row("enable_cargo", Control::Toggle, "false")],
2231            adapters,
2232        );
2233        s.suggestions = vec![suggestion("enable_cargo", false)];
2234        let mut st = state(s);
2235        st.screen = Screen::Suggestions;
2236        st.sugg_list.select(Some(0));
2237        assert!(suggestions_key(&mut st, KeyCode::Enter).is_none());
2238        assert_eq!(st.screen, Screen::Settings);
2239    }
2240
2241    #[test]
2242    fn the_finish_line_is_not_a_setting() {
2243        // It shares the list with the rows, and `selected_row` answers with the first
2244        // row when the cursor is not on one. Space there must do nothing at all rather
2245        // than reach past the cursor and flip the top of the list.
2246        let mut st = state(session(
2247            vec![row("auto_update", Control::Toggle, "false")],
2248            &[],
2249        ));
2250        handle_key(&mut st, KeyCode::Up); // wraps from the first row onto the finish line
2251        handle_key(&mut st, KeyCode::Char(' '));
2252        handle_key(&mut st, KeyCode::Char('r'));
2253        assert_eq!(st.session.rows[0].value, "false");
2254        assert!(!st.session.rows[0].changed());
2255    }
2256
2257    #[test]
2258    fn a_refused_value_is_not_stored() {
2259        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2260        handle_key(&mut st, KeyCode::Char(' ')); // open the editor
2261        handle_key(&mut st, KeyCode::Backspace);
2262        handle_key(&mut st, KeyCode::Backspace); // buffer now empty, which will not parse
2263        handle_key(&mut st, KeyCode::Enter);
2264        assert_eq!(st.session.rows[0].value, "14");
2265        assert!(st.error.is_some(), "the reason was not shown");
2266        assert!(st.editing.is_some(), "the editor closed on a refusal");
2267    }
2268
2269    #[test]
2270    fn an_accepted_value_replaces_the_old_one() {
2271        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2272        handle_key(&mut st, KeyCode::Char(' '));
2273        handle_key(&mut st, KeyCode::Backspace);
2274        handle_key(&mut st, KeyCode::Backspace);
2275        handle_key(&mut st, KeyCode::Char('3'));
2276        handle_key(&mut st, KeyCode::Char('0'));
2277        handle_key(&mut st, KeyCode::Enter);
2278        assert_eq!(st.session.rows[0].value, "30");
2279        assert!(st.session.rows[0].changed());
2280    }
2281
2282    #[test]
2283    fn unchecking_an_adapter_writes_it_to_the_deny_list() {
2284        let adapters: &[&'static str] = &["npm", "cargo", "go"];
2285        let mut st = state(session(
2286            vec![row("disabled_adapters", Control::Adapters, "(none)")],
2287            adapters,
2288        ));
2289        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2290        assert_eq!(st.screen, Screen::Adapters);
2291        handle_key(&mut st, KeyCode::Down); // past the heading, onto npm
2292        handle_key(&mut st, KeyCode::Down); // cargo
2293        handle_key(&mut st, KeyCode::Char(' '));
2294        handle_key(&mut st, KeyCode::Enter);
2295        assert_eq!(st.session.rows[0].value, "cargo");
2296        assert_eq!(st.screen, Screen::Settings);
2297    }
2298
2299    #[test]
2300    fn every_adapter_appears_under_exactly_one_heading() {
2301        // An adapter no group claims still has to be listed: a checklist that silently
2302        // omits an adapter is a checklist that cannot turn it off.
2303        let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
2304        let groups: &[(&'static str, &'static [&'static str])] =
2305            &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2306        let entries = build_entries(adapters, groups);
2307        let headings: Vec<&str> = entries
2308            .iter()
2309            .filter_map(|e| match e {
2310                PickerEntry::Group { label, .. } => Some(*label),
2311                PickerEntry::Adapter(_) => None,
2312            })
2313            .collect();
2314        assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
2315
2316        let mut listed: Vec<usize> = entries
2317            .iter()
2318            .filter_map(|e| match e {
2319                PickerEntry::Adapter(i) => Some(*i),
2320                PickerEntry::Group { .. } => None,
2321            })
2322            .collect();
2323        listed.sort_unstable();
2324        assert_eq!(
2325            listed,
2326            vec![0, 1, 2],
2327            "an adapter was dropped from the list"
2328        );
2329    }
2330
2331    #[test]
2332    fn a_heading_turns_its_whole_language_off_in_one_press() {
2333        let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2334        let mut st = state(session(
2335            vec![row("disabled_adapters", Control::Adapters, "(none)")],
2336            adapters,
2337        ));
2338        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2339        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2340        handle_key(&mut st, KeyCode::Char(' ')); // on the JavaScript heading
2341        assert_eq!(st.picker_active, vec![false, false, true]);
2342        // And back on again: a heading that only ever turned things off would leave the
2343        // user unable to undo their own keypress.
2344        handle_key(&mut st, KeyCode::Char(' '));
2345        assert_eq!(st.picker_active, vec![true, true, true]);
2346    }
2347
2348    #[test]
2349    fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
2350        let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2351        let mut st = state(session(
2352            vec![
2353                row("disabled_adapters", Control::Adapters, "(none)"),
2354                row("adapter_idle_days", Control::AdapterDays, "(none)"),
2355            ],
2356            adapters,
2357        ));
2358        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2359        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2360        handle_key(&mut st, KeyCode::Char('d')); // on the JavaScript heading
2361        handle_key(&mut st, KeyCode::Char('3'));
2362        handle_key(&mut st, KeyCode::Char('0'));
2363        handle_key(&mut st, KeyCode::Enter);
2364        assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
2365
2366        handle_key(&mut st, KeyCode::Enter); // accept the checklist
2367        assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
2368
2369        // Clearing is how a window goes back to following the global one, and there is
2370        // no other way to spell it. Accepting the checklist put the cursor back on the
2371        // same row, so Space reopens it.
2372        handle_key(&mut st, KeyCode::Char(' '));
2373        handle_key(&mut st, KeyCode::Char('d'));
2374        handle_key(&mut st, KeyCode::Backspace);
2375        handle_key(&mut st, KeyCode::Backspace);
2376        handle_key(&mut st, KeyCode::Enter);
2377        handle_key(&mut st, KeyCode::Enter);
2378        assert_eq!(st.session.rows[1].value, "(none)");
2379    }
2380
2381    #[test]
2382    fn a_cache_cap_typed_on_a_heading_reaches_only_the_adapters_that_have_a_cache() {
2383        // The two lists overlap without either containing the other, so a heading has to
2384        // skip the members dev-prune knows no cache for rather than store a cap against
2385        // a manager name that does not exist.
2386        let adapters: &[&'static str] = &["npm", "pnpm", "venv"];
2387        let mut st = state(session(
2388            vec![
2389                row("disabled_adapters", Control::Adapters, "(none)"),
2390                row("cache_max_gb", Control::CacheCaps, "(none)"),
2391            ],
2392            adapters,
2393        ));
2394        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Python", &["venv"])];
2395        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2396        handle_key(&mut st, KeyCode::Char('c')); // on the JavaScript heading
2397        handle_key(&mut st, KeyCode::Char('1'));
2398        handle_key(&mut st, KeyCode::Char('0'));
2399        handle_key(&mut st, KeyCode::Enter);
2400        assert_eq!(st.picker_caps, vec![Some(10), Some(10), None]);
2401
2402        handle_key(&mut st, KeyCode::Enter); // accept the checklist
2403        assert_eq!(st.session.rows[1].value, "npm=10,pnpm=10");
2404    }
2405
2406    #[test]
2407    fn a_cache_cap_is_cleared_by_emptying_it() {
2408        let adapters: &[&'static str] = &["npm"];
2409        let mut st = state(session(
2410            vec![
2411                row("disabled_adapters", Control::Adapters, "(none)"),
2412                row("cache_max_gb", Control::CacheCaps, "npm=10"),
2413            ],
2414            adapters,
2415        ));
2416        st.session.groups = &[("JavaScript", &["npm"])];
2417        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2418        // Opening shows the cap that is already set, or accepting the screen for any
2419        // other reason would quietly drop it.
2420        assert_eq!(st.picker_caps, vec![Some(10)]);
2421        handle_key(&mut st, KeyCode::Down); // heading -> npm
2422        handle_key(&mut st, KeyCode::Char('c'));
2423        handle_key(&mut st, KeyCode::Backspace);
2424        handle_key(&mut st, KeyCode::Backspace);
2425        handle_key(&mut st, KeyCode::Enter);
2426        handle_key(&mut st, KeyCode::Enter);
2427        assert_eq!(st.session.rows[1].value, "(none)");
2428    }
2429
2430    #[test]
2431    fn a_cap_on_a_cache_with_no_adapter_survives_the_checklist() {
2432        // `pip`, `nuget`, `conan`, `conda`, `vcpkg` and `hex` are caches no adapter is
2433        // named after, so they have no row here to be edited from. The screen writes
2434        // back the whole setting, and without this it would delete them the first time
2435        // anyone opened the checklist for any other reason.
2436        let adapters: &[&'static str] = &["npm"];
2437        let mut st = state(session(
2438            vec![
2439                row("disabled_adapters", Control::Adapters, "(none)"),
2440                row("cache_max_gb", Control::CacheCaps, "npm=10,pip=20"),
2441            ],
2442            adapters,
2443        ));
2444        st.session.groups = &[("JavaScript", &["npm"])];
2445        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2446        handle_key(&mut st, KeyCode::Enter); // and accept it untouched
2447        assert_eq!(st.session.rows[1].value, "npm=10,pip=20");
2448    }
2449
2450    #[test]
2451    fn typing_a_cap_where_there_is_no_cache_says_so_instead_of_dropping_it() {
2452        let adapters: &[&'static str] = &["venv"];
2453        let mut st = state(session(
2454            vec![
2455                row("disabled_adapters", Control::Adapters, "(none)"),
2456                row("cache_max_gb", Control::CacheCaps, "(none)"),
2457            ],
2458            adapters,
2459        ));
2460        st.session.groups = &[("Python", &["venv"])];
2461        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2462        handle_key(&mut st, KeyCode::Down); // heading -> venv
2463        handle_key(&mut st, KeyCode::Char('c'));
2464        assert!(st.picker_editing.is_none(), "no editor opened");
2465        let err = st.error.clone().expect("the refusal is explained");
2466        assert!(err.contains("devp caches"), "{err}");
2467    }
2468
2469    #[test]
2470    fn the_checklist_draws_the_cache_cap_beside_the_idle_window() {
2471        // Both settings are per adapter, and the whole point of the third column is that
2472        // one screen answers "what is on, for how long, and how big".
2473        let adapters: &[&'static str] = &["npm", "venv"];
2474        let mut st = state(session(
2475            vec![
2476                row("disabled_adapters", Control::Adapters, "(none)"),
2477                row("adapter_idle_days", Control::AdapterDays, "npm=30"),
2478                row("cache_max_gb", Control::CacheCaps, "npm=10"),
2479            ],
2480            adapters,
2481        ));
2482        st.session.groups = &[("JavaScript", &["npm"]), ("Python", &["venv"])];
2483        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2484        let picker = screenshot(&mut st, Screen::Adapters);
2485        assert!(picker.contains("30d"), "the idle window is drawn");
2486        assert!(picker.contains("10G"), "the cap is drawn");
2487        // `venv` has no cache, so its cell is blank rather than "no cap" — there is
2488        // nothing there for a cap to be about.
2489        assert!(picker.contains("no cap") || picker.contains("10G"));
2490        assert!(picker.contains("cache cap"), "the column is labelled");
2491    }
2492
2493    #[test]
2494    fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
2495        // Two ways to spell the same "off" would leave the settings screen showing a
2496        // contradiction: ticking cargo here has to read back there as enable_cargo.
2497        let adapters: &[&'static str] = &["npm", "cargo"];
2498        let opt_in: &[&'static str] = &["cargo"];
2499        let mut st = state(session(
2500            vec![
2501                row("disabled_adapters", Control::Adapters, "(none)"),
2502                row("enable_cargo", Control::Toggle, "false"),
2503            ],
2504            adapters,
2505        ));
2506        st.session.opt_in_adapters = opt_in;
2507        st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2508
2509        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2510        // Off by default and absent from the deny-list: showing it ticked would promise
2511        // a prune that never happens.
2512        assert_eq!(st.picker_active, vec![true, false]);
2513        handle_key(&mut st, KeyCode::Down); // JavaScript heading -> npm
2514        handle_key(&mut st, KeyCode::Down); // Rust heading
2515        handle_key(&mut st, KeyCode::Down); // cargo
2516        handle_key(&mut st, KeyCode::Char(' '));
2517        handle_key(&mut st, KeyCode::Enter);
2518        assert_eq!(st.session.rows[0].value, "(none)");
2519        assert_eq!(st.session.rows[1].value, "true");
2520    }
2521
2522    #[test]
2523    fn the_checklist_opens_showing_what_is_already_disabled() {
2524        // Opening with everything ticked would silently re-enable an adapter the user
2525        // turned off, the first time they visited the screen for any other reason.
2526        let adapters: &[&'static str] = &["npm", "cargo", "go"];
2527        let mut st = state(session(
2528            vec![row("disabled_adapters", Control::Adapters, "go")],
2529            adapters,
2530        ));
2531        handle_key(&mut st, KeyCode::Char(' ')); // open the checklist
2532        assert_eq!(st.picker_active, vec![true, true, false]);
2533        handle_key(&mut st, KeyCode::Enter);
2534        assert_eq!(st.session.rows[0].value, "go");
2535    }
2536
2537    #[test]
2538    fn cancelling_reports_cancelled_rather_than_an_empty_save() {
2539        // The difference matters: `KeepAll` marks the settings reviewed and `Cancelled`
2540        // does not, so an escape must not be mistaken for an answer.
2541        let mut st = state(session(
2542            vec![row("auto_update", Control::Toggle, "false")],
2543            &[],
2544        ));
2545        assert!(matches!(
2546            handle_key(&mut st, KeyCode::Char('q')),
2547            Some(Outcome::Cancelled)
2548        ));
2549    }
2550
2551    #[test]
2552    fn only_changed_rows_are_saved() {
2553        let mut st = state(session(
2554            vec![
2555                row("auto_update", Control::Toggle, "false"),
2556                row("auto_config", Control::Toggle, "false"),
2557            ],
2558            &[],
2559        ));
2560        handle_key(&mut st, KeyCode::Char(' ')); // flip the first
2561        handle_key(&mut st, KeyCode::Up); // wraps onto the finish line
2562        handle_key(&mut st, KeyCode::Enter); // to the summary
2563        let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2564            panic!("expected a save");
2565        };
2566        assert_eq!(changed.len(), 1);
2567        assert_eq!(changed[0].key, "auto_update");
2568        assert_eq!(changed[0].value, "true");
2569    }
2570
2571    #[test]
2572    fn reset_puts_a_row_back_without_touching_the_others() {
2573        let mut st = state(session(
2574            vec![
2575                row("auto_update", Control::Toggle, "false"),
2576                row("auto_config", Control::Toggle, "true"),
2577            ],
2578            &[],
2579        ));
2580        handle_key(&mut st, KeyCode::Char(' '));
2581        assert!(st.session.rows[0].changed());
2582        handle_key(&mut st, KeyCode::Char('r'));
2583        assert!(!st.session.rows[0].changed());
2584        assert_eq!(st.session.rows[1].value, "true");
2585    }
2586
2587    #[test]
2588    fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
2589        let mut rows = [
2590            categorised_row("idle_days", "Scope", Control::Number, "14"),
2591            categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2592            categorised_row("auto_config", "Updates", Control::Toggle, "false"),
2593        ];
2594        let entries = settings_entries(&rows);
2595        // Heading, idle_days, heading, auto_update, auto_config, finish.
2596        assert_eq!(entries.len(), 6);
2597        assert_eq!(
2598            opening_index(&entries, &rows),
2599            1,
2600            "with nothing new, start at the first row — never on a heading"
2601        );
2602        rows[2].is_new = true;
2603        assert_eq!(
2604            opening_index(&entries, &rows),
2605            4,
2606            "an index into the drawn list, not into the rows"
2607        );
2608    }
2609
2610    #[test]
2611    fn the_cursor_never_lands_on_a_heading() {
2612        let rows = vec![
2613            categorised_row("idle_days", "Scope", Control::Number, "14"),
2614            categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2615        ];
2616        let entries = settings_entries(&rows);
2617        assert_eq!(entries.len(), 5, "two rows, two headings, one finish line");
2618
2619        // Every stop, in both directions and all the way round, is a row.
2620        for forward in [true, false] {
2621            let mut at = first_row(&entries).expect("a row");
2622            for _ in 0..entries.len() * 2 {
2623                at = step(&entries, at, forward);
2624                assert!(
2625                    matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish),
2626                    "stopped on entry {at}, which is a heading"
2627                );
2628            }
2629        }
2630
2631        // And it wraps between the ends rather than sticking on the last heading.
2632        let last = last_stop(&entries).expect("a stop");
2633        assert_eq!(step(&entries, last, true), first_row(&entries).unwrap());
2634        assert_eq!(step(&entries, first_row(&entries).unwrap(), false), last);
2635    }
2636
2637    #[test]
2638    fn a_run_of_one_category_gets_one_heading() {
2639        let rows = vec![
2640            categorised_row("a", "Scope", Control::Toggle, "false"),
2641            categorised_row("b", "Scope", Control::Toggle, "false"),
2642            categorised_row("c", "Scope", Control::Toggle, "false"),
2643        ];
2644        // Three rows, one heading, one finish line.
2645        assert_eq!(settings_entries(&rows).len(), 5);
2646    }
2647
2648    fn suggestion(key: &'static str, cautious: bool) -> Suggestion {
2649        Suggestion {
2650            key,
2651            label: "label",
2652            help: "help",
2653            plain: "plain",
2654            why: "why",
2655            value: "true",
2656            cautious,
2657        }
2658    }
2659
2660    #[test]
2661    fn the_safe_tier_arrives_accepted_and_the_cautious_one_does_not() {
2662        // The setting that reads best on this screen is the one nobody has to press a
2663        // key for. The setting that reads worst is the one that edits a tracked file
2664        // and was accepted by a screen the user had not finished reading.
2665        let adapters: &[&str] = &["npm"];
2666        let mut s = session(
2667            vec![
2668                row("enable_cargo", Control::Toggle, "false"),
2669                row("allow_manifest_rewrite", Control::Toggle, "false"),
2670            ],
2671            adapters,
2672        );
2673        s.suggestions = vec![
2674            suggestion("enable_cargo", false),
2675            suggestion("allow_manifest_rewrite", true),
2676        ];
2677        let mut st = state(s);
2678        preaccept_recommended(&mut st);
2679
2680        assert_eq!(
2681            row_value(&st.session.rows, "enable_cargo").as_deref(),
2682            Some("true"),
2683            "the safe tier should already be on"
2684        );
2685        assert_eq!(
2686            row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2687            Some("false"),
2688            "the cautious tier must still be a deliberate choice"
2689        );
2690
2691        // And `r` still means what the footer says it means: one keystroke back to
2692        // exactly what the machine held before this screen opened.
2693        st.screen = Screen::Suggestions;
2694        st.sugg_list.select(Some(0));
2695        assert!(suggestions_key(&mut st, KeyCode::Char('r')).is_none());
2696        assert_eq!(
2697            row_value(&st.session.rows, "enable_cargo").as_deref(),
2698            Some("false")
2699        );
2700    }
2701
2702    #[test]
2703    fn a_recommended_row_says_so_and_names_the_fresh_default() {
2704        let adapters: &[&str] = &["npm"];
2705        let mut rows = vec![row("enable_cargo", Control::Toggle, "false")];
2706        rows[0].recommended = Some("true");
2707        let mut st = state(session(rows, adapters));
2708
2709        let shot = screenshot(&mut st, Screen::Settings);
2710        assert!(
2711            shot.contains("REC"),
2712            "the badge is the only thing on the row \
2713                                       that says a recommendation exists"
2714        );
2715        assert!(
2716            shot.contains("Default"),
2717            "a value nobody chose is unreadable without the one they would have got"
2718        );
2719        // Worded as advice. A configurator that says "required" about a setting the
2720        // tool runs perfectly well without has spent the word it needs for the ones
2721        // that are.
2722        assert!(shot.contains("not required"));
2723    }
2724
2725    #[test]
2726    fn accept_all_stops_at_the_cautious_tier() {
2727        // The whole reason the second tier exists. A single key that also accepted the
2728        // setting the screen just told you to think about would make the warning
2729        // decorative.
2730        let adapters: &[&str] = &["npm"];
2731        let mut s = session(
2732            vec![
2733                row("enable_cargo", Control::Toggle, "false"),
2734                row("allow_manifest_rewrite", Control::Toggle, "false"),
2735            ],
2736            adapters,
2737        );
2738        s.suggestions = vec![
2739            suggestion("enable_cargo", false),
2740            suggestion("allow_manifest_rewrite", true),
2741        ];
2742        let mut st = state(s);
2743        st.screen = Screen::Suggestions;
2744        st.sugg_list.select(Some(0));
2745
2746        assert!(suggestions_key(&mut st, KeyCode::Char('a')).is_none());
2747        assert_eq!(
2748            row_value(&st.session.rows, "enable_cargo").as_deref(),
2749            Some("true")
2750        );
2751        assert_eq!(
2752            row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2753            Some("false")
2754        );
2755
2756        // Reachable, just not by the one key: Space on the row itself still takes it.
2757        st.sugg_list.select(Some(1));
2758        suggestions_key(&mut st, KeyCode::Char(' '));
2759        assert_eq!(
2760            row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2761            Some("true")
2762        );
2763    }
2764
2765    #[test]
2766    fn undoing_a_suggestion_puts_back_what_the_setting_had() {
2767        let adapters: &[&str] = &["npm"];
2768        let mut s = session(
2769            vec![row("enable_cargo", Control::Toggle, "false")],
2770            adapters,
2771        );
2772        s.suggestions = vec![suggestion("enable_cargo", false)];
2773        let mut st = state(s);
2774        st.screen = Screen::Suggestions;
2775        st.sugg_list.select(Some(0));
2776
2777        suggestions_key(&mut st, KeyCode::Char(' '));
2778        assert!(accepted(&st, 0));
2779        suggestions_key(&mut st, KeyCode::Char(' '));
2780        assert!(!accepted(&st, 0));
2781        assert_eq!(
2782            row_value(&st.session.rows, "enable_cargo").as_deref(),
2783            Some("false")
2784        );
2785        // And the summary must not offer to save a value that never changed.
2786        assert!(!st.session.rows[0].changed());
2787    }
2788
2789    #[test]
2790    fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
2791        // Every run but the first: `first_run_suggestions` returns nothing, and Enter on
2792        // the declaration must go straight to the settings rather than to a blank screen.
2793        let adapters: &[&str] = &["npm"];
2794        let s = session(vec![row("idle_days", Control::Number, "30")], adapters);
2795        let mut st = state(s);
2796        st.screen = Screen::Declaration;
2797        assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2798        assert_eq!(st.screen, Screen::Settings);
2799    }
2800
2801    #[test]
2802    fn the_first_run_reaches_the_suggestions_first() {
2803        let adapters: &[&str] = &["npm"];
2804        let mut s = session(
2805            vec![row("enable_cargo", Control::Toggle, "false")],
2806            adapters,
2807        );
2808        s.suggestions = vec![suggestion("enable_cargo", false)];
2809        let mut st = state(s);
2810        st.screen = Screen::Declaration;
2811        assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2812        assert_eq!(st.screen, Screen::Suggestions);
2813    }
2814}