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