Skip to main content

dev_prune/tui/
config_view.rs

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