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