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