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    /// Typed into an inline field.
32    Number,
33    /// Opens the adapter checklist.
34    Adapters,
35    /// Opens the same adapter checklist, on the idle-window column.
36    ///
37    /// Which adapters run and how long each one waits are one decision made twice, so
38    /// they are edited on one screen. The row exists separately only because the
39    /// settings table stores them as two keys.
40    AdapterDays,
41    /// Opens the same adapter checklist, on the cache-cap column.
42    ///
43    /// Third column of the same table for the same reason the second one is there: how
44    /// big npm's cache may get is a decision about npm, and the screen where npm is a
45    /// row is where it belongs.
46    CacheCaps,
47}
48
49/// Which column of the adapter checklist an inline edit is landing in.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum PickerField {
52    /// `adapter_idle_days`, in days.
53    Days,
54    /// `cache_max_gb`, in gibibytes.
55    Cap,
56}
57
58/// One setting, as the view needs it.
59#[derive(Debug, Clone)]
60pub struct ConfigRow {
61    pub key: &'static str,
62    pub help: &'static str,
63    /// The same setting said again without jargon, shown under `help` rather than
64    /// instead of it. Someone who knows what a build tree is skips the second line;
65    /// someone who does not was going to guess, and guessing is how a setting gets
66    /// turned on for the wrong reason.
67    pub plain: &'static str,
68    pub control: Control,
69    /// The value, spelled the way `devp config set` would take it.
70    pub value: String,
71    /// What it was when the view opened, so the summary shows only real changes.
72    pub original: String,
73    /// Introduced in a release newer than the one this machine last reviewed at.
74    pub is_new: bool,
75}
76
77impl ConfigRow {
78    pub fn changed(&self) -> bool {
79        self.value != self.original
80    }
81}
82
83/// One line of the declaration shown before anything is configurable.
84#[derive(Debug, Clone)]
85pub struct DeclarationLine {
86    /// `+` for a guarantee or a safe reading, `!` for something widened, `#` for a
87    /// section heading, ` ` for a plain fact.
88    pub mark: char,
89    pub subject: String,
90    pub state: String,
91}
92
93/// One entry on the first-run suggestions screen.
94///
95/// Only the *first* run gets this screen. Everything on it is also on the settings list
96/// two keystrokes later, so this is not the only way to reach any of it — it exists
97/// because a list of twenty-four settings, shown to somebody who has had this tool
98/// installed for nine seconds, is a list nobody reads.
99#[derive(Debug, Clone)]
100pub struct Suggestion {
101    pub key: &'static str,
102    /// Three or four words naming what it turns on.
103    pub label: &'static str,
104    /// The official one-liner — the setting's own `help`.
105    pub help: &'static str,
106    /// The same thing without jargon.
107    pub plain: &'static str,
108    /// Why it is being suggested at all, which neither of the other two answers.
109    pub why: &'static str,
110    /// The value accepting it sets.
111    pub value: &'static str,
112    /// The second tier: worth turning on, with something specific to know first.
113    ///
114    /// Kept apart rather than mixed in with a warning glyph, because "recommended" and
115    /// "recommended once you know what it does" are different claims and one button
116    /// must not be able to accept both at once.
117    pub cautious: bool,
118}
119
120/// What the user decided.
121pub enum Outcome {
122    /// Write these values back.
123    Save(Vec<ConfigRow>),
124    /// Everything stays as it is, and the settings count as reviewed.
125    KeepAll,
126    /// Escape hatch: change nothing, and do not count as reviewed either.
127    Cancelled,
128}
129
130/// Everything the view needs that it cannot work out for itself.
131pub struct ConfigSession<'a> {
132    /// Shown above the settings; in practice the `devp trust` report.
133    pub declaration: Vec<DeclarationLine>,
134    /// A one-line summary of what has and has not happened yet.
135    pub standing: String,
136    pub rows: Vec<ConfigRow>,
137    /// Settings worth turning on, shown once before the full list. Empty on every run
138    /// but the first, which is the only time this screen appears at all.
139    pub suggestions: Vec<Suggestion>,
140    /// Every adapter name, in registry order, for the checklist.
141    pub adapters: &'a [&'static str],
142    /// Adapter names that need their own `enable_*` switch as well.
143    pub opt_in_adapters: &'a [&'static str],
144    /// Adapter names that are also the name of a cache `devp caches` knows, and so can
145    /// carry a `cache_max_gb` entry.
146    ///
147    /// Identity only, never a guess: npm's cache is npm's. The caches with no adapter
148    /// of the same name — `pip`, `nuget`, `conan`, `conda`, `vcpkg`, `hex` — have no
149    /// row here to sit on and are capped with `devp config set cache_max_gb` instead,
150    /// which the footer says. Inventing a row for them, or pointing `poetry` at pip's
151    /// cache, would be the checklist claiming a relationship dev-prune has not
152    /// verified.
153    pub capped_adapters: &'a [&'static str],
154    /// The language groups the adapters are shown under, in display order. Anything
155    /// not named by a group is collected under a trailing "Other".
156    pub groups: &'a [(&'static str, &'static [&'static str])],
157    /// Round-trips one value through the setter that owns it. `Err` is shown in place
158    /// and the edit is refused, so validation lives in exactly one place.
159    pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
160    /// Title bar text — the walkthrough and `config wizard` arrive here differently.
161    pub title: &'a str,
162}
163
164/// Where the cursor starts: the first setting the user has never been shown, when there
165/// is one. After an upgrade that setting is the only reason this screen is in front of
166/// them, and making them hunt for it down a list of twenty is how it gets skipped.
167fn opening_index(rows: &[ConfigRow]) -> usize {
168    rows.iter().position(|r| r.is_new).unwrap_or(0)
169}
170
171#[derive(Debug, PartialEq, Eq, Clone, Copy)]
172enum Screen {
173    Declaration,
174    Suggestions,
175    Settings,
176    Adapters,
177    Summary,
178}
179
180/// One drawn line of the adapter checklist.
181#[derive(Debug, Clone, PartialEq, Eq)]
182enum PickerEntry {
183    /// A language heading, carrying the indices of every adapter under it so that one
184    /// keypress on the heading reaches all of them.
185    Group {
186        label: &'static str,
187        members: Vec<usize>,
188    },
189    /// An adapter, by its index into `session.adapters`.
190    Adapter(usize),
191}
192
193/// Lay the adapters out under their language headings.
194///
195/// Order comes from the group table rather than the adapter registry: someone looking
196/// for "the Python ones" is looking for a heading, not for four names that happen to be
197/// adjacent. An adapter no group claims still has to appear — a checklist that silently
198/// omits an adapter is a checklist that cannot turn it off.
199fn build_entries(
200    adapters: &[&'static str],
201    groups: &[(&'static str, &'static [&'static str])],
202) -> Vec<PickerEntry> {
203    let mut entries = Vec::new();
204    let mut placed = vec![false; adapters.len()];
205
206    for (label, names) in groups {
207        let members: Vec<usize> = names
208            .iter()
209            .filter_map(|name| adapters.iter().position(|a| a == name))
210            .collect();
211        if members.is_empty() {
212            continue;
213        }
214        for &i in &members {
215            placed[i] = true;
216        }
217        entries.push(PickerEntry::Group {
218            label,
219            members: members.clone(),
220        });
221        entries.extend(members.into_iter().map(PickerEntry::Adapter));
222    }
223
224    let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
225    if !rest.is_empty() {
226        entries.push(PickerEntry::Group {
227            label: "Other",
228            members: rest.clone(),
229        });
230        entries.extend(rest.into_iter().map(PickerEntry::Adapter));
231    }
232    entries
233}
234
235/// The value of one row, by key.
236fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
237    rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
238}
239
240/// Write one row by key, ignoring a key the settings table does not carry.
241fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
242    if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
243        row.value = value;
244    }
245}
246
247struct State<'a> {
248    session: ConfigSession<'a>,
249    screen: Screen,
250    list: ListState,
251    /// Buffer for an in-progress `Number` edit; `None` when not editing.
252    editing: Option<String>,
253    /// The last refused edit, shown until the next keypress that changes anything.
254    error: Option<String>,
255    /// Adapter checklist state: `true` means the adapter stays active.
256    picker_active: Vec<bool>,
257    /// Per-adapter idle window in days, `None` when the adapter follows the global one.
258    picker_days: Vec<Option<u64>>,
259    /// Per-adapter cache cap in gibibytes, `None` when that cache has no cap. Always
260    /// `None` for an adapter that is not in `capped_adapters`.
261    picker_caps: Vec<Option<u64>>,
262    /// The checklist as it is drawn: group headings interleaved with their adapters.
263    /// Rebuilt when the screen opens, because it depends on nothing that changes while
264    /// it is open.
265    picker_entries: Vec<PickerEntry>,
266    /// Buffer for an in-progress number edit on the checklist.
267    picker_editing: Option<String>,
268    /// Which column [`State::picker_editing`] is being typed into.
269    picker_field: PickerField,
270    picker_list: ListState,
271    /// Scroll position of the declaration, which is longer than most terminals are tall.
272    decl_list: ListState,
273    /// Cursor on the first-run suggestions screen.
274    sugg_list: ListState,
275}
276
277/// Run the configurator. Returns what the user decided; writing is the caller's job.
278pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
279    if session.rows.is_empty() {
280        return Ok(Outcome::KeepAll);
281    }
282
283    let mut list = ListState::default();
284    list.select(Some(opening_index(&session.rows)));
285
286    let mut picker_list = ListState::default();
287    picker_list.select(Some(0));
288
289    let mut decl_list = ListState::default();
290    decl_list.select(Some(0));
291
292    let mut sugg_list = ListState::default();
293    sugg_list.select(Some(0));
294
295    let mut state = State {
296        picker_active: vec![true; session.adapters.len()],
297        picker_days: vec![None; session.adapters.len()],
298        picker_caps: vec![None; session.adapters.len()],
299        picker_entries: Vec::new(),
300        picker_editing: None,
301        picker_field: PickerField::Days,
302        session,
303        screen: Screen::Declaration,
304        list,
305        editing: None,
306        error: None,
307        picker_list,
308        decl_list,
309        sugg_list,
310    };
311
312    // The guard owns raw mode, the alternate screen and the panic hook, and puts all
313    // three back on every exit path — including the `?` below.
314    let mut tui = Tui::new()?;
315    tui.drain_stale_input(Duration::from_millis(300));
316    event_loop(&mut tui.terminal, &mut state)
317}
318
319fn event_loop(
320    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
321    state: &mut State<'_>,
322) -> Result<Outcome> {
323    loop {
324        terminal.draw(|frame| render(frame, state))?;
325
326        if !event::poll(Duration::from_millis(100))? {
327            continue;
328        }
329        let Event::Key(key) = event::read()? else {
330            continue;
331        };
332        // Windows consoles deliver a release for every press; acting on both would
333        // toggle every setting twice.
334        if key.kind == KeyEventKind::Release {
335            continue;
336        }
337        // Raw mode delivers Ctrl-C as a key event rather than a signal, so without this
338        // the one key everybody reaches for to escape does nothing.
339        if key.modifiers.contains(KeyModifiers::CONTROL)
340            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
341        {
342            return Ok(Outcome::Cancelled);
343        }
344
345        if let Some(outcome) = handle_key(state, key.code) {
346            return Ok(outcome);
347        }
348    }
349}
350
351/// Apply one keypress. `Some` ends the view.
352fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
353    match state.screen {
354        Screen::Declaration => declaration_key(state, code),
355        Screen::Suggestions => suggestions_key(state, code),
356        Screen::Settings => settings_key(state, code),
357        Screen::Adapters => adapters_key(state, code),
358        Screen::Summary => summary_key(state, code),
359    }
360}
361
362fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
363    let len = state.session.declaration.len().max(1);
364    let current = state.decl_list.selected().unwrap_or(0);
365    match code {
366        // A promise the reader cannot scroll to is not a promise they have been shown.
367        KeyCode::Up | KeyCode::Char('k') => {
368            state.decl_list.select(Some(current.saturating_sub(1)));
369            None
370        }
371        KeyCode::Down | KeyCode::Char('j') => {
372            state.decl_list.select(Some((current + 1).min(len - 1)));
373            None
374        }
375        // `y` has meant "yes, all of it, carry on" at this prompt since 1.0.0, and it
376        // still does — this screen must not turn a habit into a detour.
377        KeyCode::Char('y') | KeyCode::Char('Y') => Some(Outcome::KeepAll),
378        KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
379            state.screen = if state.session.suggestions.is_empty() {
380                Screen::Settings
381            } else {
382                Screen::Suggestions
383            };
384            None
385        }
386        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
387        _ => None,
388    }
389}
390
391/// Whether a suggestion is currently accepted: its setting already holds the value the
392/// suggestion would set.
393///
394/// Derived rather than stored. The settings list two screens on can change the same
395/// value, and a remembered "accepted" flag would then disagree with the setting it
396/// claims to describe — the summary reads the settings, so the settings are the truth.
397fn accepted(state: &State<'_>, index: usize) -> bool {
398    let s = &state.session.suggestions[index];
399    row_value(&state.session.rows, s.key).as_deref() == Some(s.value)
400}
401
402/// Accept or undo one suggestion. Undoing restores what the setting had when the view
403/// opened, not a hard-coded default: the recommendation is the only thing being
404/// withdrawn, and anything the user had already chosen is not this screen's to discard.
405fn apply_suggestion(state: &mut State<'_>, index: usize, accept: bool) {
406    let (key, value) = {
407        let s = &state.session.suggestions[index];
408        (s.key, s.value)
409    };
410    let restore = state
411        .session
412        .rows
413        .iter()
414        .find(|r| r.key == key)
415        .map(|r| r.original.clone());
416    let Some(restore) = restore else { return };
417    let next = if accept { value.to_string() } else { restore };
418    set_row(&mut state.session.rows, key, next);
419}
420
421fn suggestions_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
422    let len = state.session.suggestions.len();
423    let current = state.sugg_list.selected().unwrap_or(0);
424    match code {
425        KeyCode::Up | KeyCode::Char('k') => {
426            state
427                .sugg_list
428                .select(Some(if current == 0 { len - 1 } else { current - 1 }))
429        }
430        KeyCode::Down | KeyCode::Char('j') => {
431            state
432                .sugg_list
433                .select(Some(if current + 1 >= len { 0 } else { current + 1 }))
434        }
435        KeyCode::Char(' ') => {
436            let now = accepted(state, current);
437            apply_suggestion(state, current, !now);
438        }
439        // One key for the whole first tier, which is the point of the screen. It
440        // deliberately does not reach the cautious tier: a button that accepts the thing
441        // you were told to read about first is not a shortcut, it is a trap.
442        KeyCode::Char('a') | KeyCode::Char('A') => {
443            for i in 0..len {
444                if !state.session.suggestions[i].cautious {
445                    apply_suggestion(state, i, true);
446                }
447            }
448        }
449        KeyCode::Char('r') | KeyCode::Char('R') => {
450            for i in 0..len {
451                apply_suggestion(state, i, false);
452            }
453        }
454        KeyCode::Enter | KeyCode::Char('c') | KeyCode::Char('C') => {
455            state.screen = Screen::Settings;
456        }
457        // Straight to the summary: someone who accepted the suggestions and wants nothing
458        // else should not have to walk the full list to get out.
459        KeyCode::Char('y') | KeyCode::Char('Y') => state.screen = Screen::Summary,
460        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
461        _ => {}
462    }
463    None
464}
465
466fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
467    // An in-progress number edit owns the keyboard until it is committed or abandoned.
468    if state.editing.is_some() {
469        return number_edit_key(state, code);
470    }
471
472    let len = state.session.rows.len();
473    let current = state.list.selected().unwrap_or(0);
474    match code {
475        KeyCode::Up | KeyCode::Char('k') => {
476            state.error = None;
477            state
478                .list
479                .select(Some(if current == 0 { len - 1 } else { current - 1 }));
480        }
481        KeyCode::Down | KeyCode::Char('j') => {
482            state.error = None;
483            state
484                .list
485                .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
486        }
487        KeyCode::Home | KeyCode::Char('g') => state.list.select(Some(0)),
488        KeyCode::End | KeyCode::Char('G') => state.list.select(Some(len - 1)),
489        KeyCode::Char(' ') | KeyCode::Enter => activate(state, current),
490        KeyCode::Char('r') | KeyCode::Char('R') => {
491            state.error = None;
492            let row = &mut state.session.rows[current];
493            row.value = row.original.clone();
494        }
495        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') | KeyCode::Char('S') => {
496            state.screen = Screen::Summary;
497        }
498        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
499        _ => {}
500    }
501    None
502}
503
504/// Space or Enter on a row: flip it, open its editor, or open its checklist.
505fn activate(state: &mut State<'_>, index: usize) {
506    state.error = None;
507    match state.session.rows[index].control {
508        Control::Toggle => {
509            let row = &mut state.session.rows[index];
510            row.value = if row.value == "true" {
511                "false".to_string()
512            } else {
513                "true".to_string()
514            };
515        }
516        Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
517        Control::Adapters | Control::AdapterDays | Control::CacheCaps => open_picker(state),
518    }
519}
520
521/// Seed the checklist from the rows it will write back to.
522///
523/// Opening with everything ticked would silently re-enable an adapter the user turned
524/// off, the first time they visited this screen for any other reason — so all three
525/// rows that govern an adapter are read back here, not just the deny-list.
526fn open_picker(state: &mut State<'_>) {
527    let rows = &state.session.rows;
528    let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
529    let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
530    let caps = parse_days(&row_value(rows, "cache_max_gb").unwrap_or_default());
531
532    state.picker_active = state
533        .session
534        .adapters
535        .iter()
536        .map(|name| {
537            if disabled.iter().any(|d| d == name) {
538                return false;
539            }
540            // An opt-in adapter is active only if its own switch is on: it is off by
541            // default and absent from the deny-list, and showing it ticked would
542            // promise a prune that never happens.
543            if state.session.opt_in_adapters.contains(name) {
544                return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
545            }
546            true
547        })
548        .collect();
549    state.picker_days = state
550        .session
551        .adapters
552        .iter()
553        .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
554        .collect();
555
556    state.picker_caps = state
557        .session
558        .adapters
559        .iter()
560        .map(|name| {
561            if !state.session.capped_adapters.contains(name) {
562                return None;
563            }
564            caps.iter().find(|(n, _)| n == name).map(|(_, g)| *g)
565        })
566        .collect();
567
568    state.picker_entries = build_entries(state.session.adapters, state.session.groups);
569    state.picker_editing = None;
570    state.picker_field = PickerField::Days;
571    state.picker_list.select(Some(0));
572    state.screen = Screen::Adapters;
573}
574
575fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
576    let index = state.list.selected().unwrap_or(0);
577    match code {
578        KeyCode::Char(c) if c.is_ascii_digit() => {
579            if let Some(buf) = state.editing.as_mut() {
580                buf.push(c);
581            }
582        }
583        KeyCode::Backspace => {
584            if let Some(buf) = state.editing.as_mut() {
585                buf.pop();
586            }
587        }
588        KeyCode::Enter => {
589            let typed = state.editing.clone().unwrap_or_default();
590            let key = state.session.rows[index].key;
591            match (state.session.validate)(key, typed.trim()) {
592                Ok(()) => {
593                    state.session.rows[index].value = typed.trim().to_string();
594                    state.editing = None;
595                    state.error = None;
596                }
597                // Refused in place rather than accepted and rejected on save: the
598                // reason belongs next to the field that caused it.
599                Err(why) => state.error = Some(why),
600            }
601        }
602        KeyCode::Esc => {
603            state.editing = None;
604            state.error = None;
605        }
606        _ => {}
607    }
608    None
609}
610
611fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
612    if state.picker_editing.is_some() {
613        return picker_number_key(state, code);
614    }
615
616    let len = state.picker_entries.len().max(1);
617    let current = state.picker_list.selected().unwrap_or(0);
618    match code {
619        KeyCode::Up | KeyCode::Char('k') => {
620            state.error = None;
621            state
622                .picker_list
623                .select(Some(if current == 0 { len - 1 } else { current - 1 }));
624        }
625        KeyCode::Down | KeyCode::Char('j') => {
626            state.error = None;
627            state
628                .picker_list
629                .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
630        }
631        // On a heading, one keypress governs the whole language: off if any of them is
632        // on, so "turn Python off" never needs four presses and a count.
633        KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
634            Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
635            Some(PickerEntry::Group { members, .. }) => {
636                let target = !members.iter().any(|&i| state.picker_active[i]);
637                for i in members {
638                    state.picker_active[i] = target;
639                }
640            }
641            None => {}
642        },
643        KeyCode::Char('d') | KeyCode::Char('D') => {
644            state.error = None;
645            let seed = match state.picker_entries.get(current) {
646                Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
647                // A group seeds from the window its members already share; a group of
648                // disagreeing values seeds empty rather than picking one of them.
649                Some(PickerEntry::Group { members, .. }) => {
650                    let first = members.first().and_then(|&i| state.picker_days[i]);
651                    if members.iter().all(|&i| state.picker_days[i] == first) {
652                        first
653                    } else {
654                        None
655                    }
656                }
657                None => None,
658            };
659            state.picker_field = PickerField::Days;
660            state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
661        }
662        KeyCode::Char('c') | KeyCode::Char('C') => {
663            state.error = None;
664            // Nothing to type into: the adapter has no cache of its own name, so a cap
665            // typed here would be stored against a manager that does not exist. Saying
666            // so beats an editor that accepts a number and drops it.
667            let targets = capped_targets(state, current);
668            if targets.is_empty() {
669                state.error = Some(
670                    "No cache of that name for dev-prune to size. `devp caches` lists the ones \
671                     there are; `devp config set cache_max_gb` caps them."
672                        .to_string(),
673                );
674                return None;
675            }
676            let first = targets.first().and_then(|&i| state.picker_caps[i]);
677            let seed = if targets.iter().all(|&i| state.picker_caps[i] == first) {
678                first
679            } else {
680                None
681            };
682            state.picker_field = PickerField::Cap;
683            state.picker_editing = Some(seed.map(|g| g.to_string()).unwrap_or_default());
684        }
685        KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
686        KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
687        KeyCode::Enter => commit_picker(state),
688        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
689        _ => {}
690    }
691    None
692}
693
694/// The adapters a cap typed at line `line` should land on: those under it that have a
695/// cache of their own name.
696///
697/// A language heading types into every capped adapter beneath it at once and silently
698/// skips the rest — "cap the JavaScript caches at 10" is one sentence, and the four
699/// managers it reaches are exactly the four that have one.
700fn capped_targets(state: &State<'_>, line: usize) -> Vec<usize> {
701    let members: Vec<usize> = match state.picker_entries.get(line) {
702        Some(PickerEntry::Adapter(i)) => vec![*i],
703        Some(PickerEntry::Group { members, .. }) => members.clone(),
704        None => Vec::new(),
705    };
706    members
707        .into_iter()
708        .filter(|&i| {
709            state
710                .session
711                .capped_adapters
712                .contains(&state.session.adapters[i])
713        })
714        .collect()
715}
716
717/// The inline number editor on the checklist, for whichever column
718/// [`State::picker_field`] names. An empty buffer clears the value, which is the only
719/// way back to "no window of its own" or "no cap" once a number is set.
720fn picker_number_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
721    let current = state.picker_list.selected().unwrap_or(0);
722    match code {
723        KeyCode::Char(c) if c.is_ascii_digit() => {
724            if let Some(buf) = state.picker_editing.as_mut() {
725                buf.push(c);
726            }
727        }
728        KeyCode::Backspace => {
729            if let Some(buf) = state.picker_editing.as_mut() {
730                buf.pop();
731            }
732        }
733        KeyCode::Enter => {
734            let typed = state.picker_editing.clone().unwrap_or_default();
735            let typed = typed.trim().to_string();
736            let key = match state.picker_field {
737                PickerField::Days => "adapter_idle_days",
738                PickerField::Cap => "cache_max_gb",
739            };
740            let targets: Vec<usize> = match state.picker_field {
741                PickerField::Days => match state.picker_entries.get(current) {
742                    Some(PickerEntry::Adapter(i)) => vec![*i],
743                    Some(PickerEntry::Group { members, .. }) => members.clone(),
744                    None => Vec::new(),
745                },
746                PickerField::Cap => capped_targets(state, current),
747            };
748            let value = if typed.is_empty() {
749                None
750            } else {
751                let Some(&first) = targets.first() else {
752                    state.picker_editing = None;
753                    return None;
754                };
755                let probe = format!("{}={typed}", state.session.adapters[first]);
756                // Through the real setter, so the checklist cannot store a number
757                // `devp config set` would refuse.
758                if let Err(why) = (state.session.validate)(key, &probe) {
759                    state.error = Some(why);
760                    return None;
761                }
762                typed.parse::<u64>().ok()
763            };
764            for i in targets {
765                match state.picker_field {
766                    PickerField::Days => state.picker_days[i] = value,
767                    PickerField::Cap => state.picker_caps[i] = value,
768                }
769            }
770            state.picker_editing = None;
771            state.error = None;
772        }
773        KeyCode::Esc => {
774            state.picker_editing = None;
775            state.error = None;
776        }
777        _ => {}
778    }
779    None
780}
781
782/// Fold the checklist back into the rows that store it.
783///
784/// An opt-in adapter is governed by its own `enable_*` switch rather than by the
785/// deny-list: two ways to say the same "off" would leave the settings screen showing a
786/// contradiction, and unticking it here should read back there as the switch being off.
787fn commit_picker(state: &mut State<'_>) {
788    let adapters = state.session.adapters;
789    let opt_in = state.session.opt_in_adapters;
790
791    let disabled: Vec<&str> = adapters
792        .iter()
793        .enumerate()
794        .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
795        .map(|(_, name)| *name)
796        .collect();
797    // `(none)` rather than an empty string, so what the row shows is exactly what
798    // `devp config get disabled_adapters` prints.
799    let disabled = if disabled.is_empty() {
800        "(none)".to_string()
801    } else {
802        disabled.join(",")
803    };
804
805    let mut days: Vec<String> = adapters
806        .iter()
807        .enumerate()
808        .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
809        .collect();
810    // Sorted for the same reason the caps below are: `config get adapter_idle_days`
811    // prints a `BTreeMap`, and this row is compared against that. Assembling it in
812    // adapter order instead would report an untouched setting as changed.
813    days.sort_unstable();
814    let days = if days.is_empty() {
815        "(none)".to_string()
816    } else {
817        days.join(",")
818    };
819
820    // A cap on a cache with no adapter of its own name — `pip`, `nuget`, `conan`,
821    // `conda`, `vcpkg`, `hex` — has no row on this screen to be edited from, and a
822    // screen that writes back only what it can draw would delete it the first time
823    // anyone opened the checklist for any other reason.
824    let existing = parse_days(&row_value(&state.session.rows, "cache_max_gb").unwrap_or_default());
825    let mut caps: Vec<String> = existing
826        .iter()
827        .filter(|(name, _)| !adapters.iter().any(|a| a == name))
828        .map(|(name, gb)| format!("{name}={gb}"))
829        .collect();
830    caps.extend(
831        adapters
832            .iter()
833            .enumerate()
834            .filter_map(|(i, name)| state.picker_caps[i].map(|g| format!("{name}={g}"))),
835    );
836    caps.sort_unstable();
837    let caps = if caps.is_empty() {
838        "(none)".to_string()
839    } else {
840        caps.join(",")
841    };
842
843    let switches: Vec<(String, String)> = adapters
844        .iter()
845        .enumerate()
846        .filter(|(_, name)| opt_in.contains(name))
847        .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
848        .collect();
849
850    let rows = &mut state.session.rows;
851    set_row(rows, "disabled_adapters", disabled);
852    set_row(rows, "adapter_idle_days", days);
853    set_row(rows, "cache_max_gb", caps);
854    for (key, value) in switches {
855        set_row(rows, &key, value);
856    }
857    state.screen = Screen::Settings;
858}
859
860fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
861    match code {
862        KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') => {
863            let changed: Vec<ConfigRow> = state
864                .session
865                .rows
866                .iter()
867                .filter(|r| r.changed())
868                .cloned()
869                .collect();
870            if changed.is_empty() {
871                Some(Outcome::KeepAll)
872            } else {
873                Some(Outcome::Save(changed))
874            }
875        }
876        KeyCode::Esc | KeyCode::Backspace => {
877            state.screen = Screen::Settings;
878            None
879        }
880        KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
881        _ => None,
882    }
883}
884
885/// Split a stored deny-list back into names. `(none)` is the empty list.
886fn parse_list(value: &str) -> Vec<String> {
887    if value.trim().eq_ignore_ascii_case("(none)") {
888        return Vec::new();
889    }
890    value
891        .split(',')
892        .map(|s| s.trim().to_lowercase())
893        .filter(|s| !s.is_empty())
894        .collect()
895}
896
897/// Split a stored `name=days` map back into pairs. `(none)` is the empty map.
898///
899/// Anything malformed is dropped rather than refused: this parses a value the setter
900/// already accepted, and a checklist that will not open is worse than one that opens
901/// with a window missing.
902fn parse_days(value: &str) -> Vec<(String, u64)> {
903    if value.trim().eq_ignore_ascii_case("(none)") {
904        return Vec::new();
905    }
906    value
907        .split(',')
908        .filter_map(|entry| {
909            let (name, days) = entry.trim().split_once('=')?;
910            Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
911        })
912        .collect()
913}
914
915// ---------------------------------------------------------------------------
916// Rendering
917// ---------------------------------------------------------------------------
918
919fn render(frame: &mut Frame, state: &mut State<'_>) {
920    match state.screen {
921        Screen::Declaration => render_declaration(frame, state),
922        Screen::Suggestions => render_suggestions(frame, state),
923        Screen::Settings => render_settings(frame, state),
924        Screen::Adapters => render_adapters(frame, state),
925        Screen::Summary => render_summary(frame, state),
926    }
927}
928
929fn dim() -> Style {
930    Style::default().fg(Color::DarkGray)
931}
932
933fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
934    Paragraph::new(vec![
935        Line::from(Span::styled(
936            title.to_string(),
937            Style::default()
938                .fg(Color::Cyan)
939                .add_modifier(Modifier::BOLD),
940        )),
941        Line::from(Span::styled(subtitle.to_string(), dim())),
942    ])
943}
944
945fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
946    let mut spans = Vec::new();
947    for (i, (key, what)) in keys.iter().enumerate() {
948        if i > 0 {
949            spans.push(Span::styled("   ", dim()));
950        }
951        spans.push(Span::styled(
952            key.to_string(),
953            Style::default().add_modifier(Modifier::BOLD),
954        ));
955        spans.push(Span::styled(format!(" {what}"), dim()));
956    }
957    Paragraph::new(Line::from(spans))
958        .block(Block::default().borders(Borders::TOP).border_style(dim()))
959}
960
961fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
962    let chunks = Layout::vertical([
963        Constraint::Length(3),
964        Constraint::Min(5),
965        Constraint::Length(3),
966        Constraint::Length(2),
967    ])
968    .split(frame.area());
969
970    frame.render_widget(
971        header(
972            state.session.title,
973            "What this tool is allowed to do on this machine, before it does any of it.",
974        ),
975        chunks[0],
976    );
977
978    let items: Vec<ListItem> = state
979        .session
980        .declaration
981        .iter()
982        .map(|d| {
983            if d.mark == '#' {
984                return ListItem::new(Line::from(Span::styled(
985                    format!(" {}", d.subject),
986                    Style::default()
987                        .fg(Color::Cyan)
988                        .add_modifier(Modifier::BOLD),
989                )));
990            }
991            let (mark_style, symbol) = match d.mark {
992                '!' => (Style::default().fg(Color::Yellow), "!"),
993                '+' => (Style::default().fg(Color::Green), "✓"),
994                _ => (dim(), " "),
995            };
996            ListItem::new(Line::from(vec![
997                Span::styled(format!("  {symbol} "), mark_style),
998                Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
999                Span::styled(d.state.clone(), dim()),
1000            ]))
1001        })
1002        .collect();
1003
1004    // A `List` rather than a `Paragraph` only for the scrolling: no highlight symbol and
1005    // no highlight style, because nothing on this screen is selectable.
1006    frame.render_stateful_widget(
1007        List::new(items).block(
1008            Block::default()
1009                .title(" Declaration ")
1010                .borders(Borders::ALL)
1011                .border_style(dim()),
1012        ),
1013        chunks[1],
1014        &mut state.decl_list,
1015    );
1016
1017    frame.render_widget(
1018        Paragraph::new(Line::from(Span::styled(
1019            format!("  {}", state.session.standing),
1020            Style::default().fg(Color::Green),
1021        )))
1022        .block(Block::default().borders(Borders::ALL).border_style(dim())),
1023        chunks[2],
1024    );
1025
1026    frame.render_widget(
1027        footer(&[
1028            ("↑↓", "read"),
1029            ("y", "keep all defaults and go"),
1030            ("Enter", "configure"),
1031            ("q", "cancel"),
1032        ]),
1033        chunks[3],
1034    );
1035}
1036
1037/// The first-run suggestions: a short list, in two tiers, with the selected one
1038/// explained twice underneath.
1039///
1040/// Two lines of explanation per setting rather than one, and only for the setting under
1041/// the cursor. Printing all of it at once is how a screen becomes a wall nobody reads,
1042/// which is the failure this screen exists to fix.
1043fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
1044    // Only reachable with entries, and indexing on a drawn frame is not the place to be
1045    // sure of that: a panic here takes the terminal down with the alternate screen on.
1046    if state.session.suggestions.is_empty() {
1047        state.screen = Screen::Settings;
1048        return render_settings(frame, state);
1049    }
1050    let chunks = Layout::vertical([
1051        Constraint::Length(3),
1052        Constraint::Min(5),
1053        Constraint::Length(7),
1054        Constraint::Length(2),
1055    ])
1056    .split(frame.area());
1057
1058    let on = (0..state.session.suggestions.len())
1059        .filter(|&i| accepted(state, i))
1060        .count();
1061    frame.render_widget(
1062        header(
1063            "Suggested settings",
1064            // Naming the arrow keys here rather than only in the footer: the first
1065            // reaction to a list of nine settings is to accept or skip the lot, and
1066            // nobody arrows through an unfamiliar list to find out whether anything
1067            // appears elsewhere on screen. The panel below is the point of the screen.
1068            &format!(
1069                "{} of {} accepted \u{2014} press \u{2191}\u{2193} to read what each one does \
1070                 before deciding. Every one is off until you accept it.",
1071                on,
1072                state.session.suggestions.len()
1073            ),
1074        ),
1075        chunks[0],
1076    );
1077
1078    let selected = state
1079        .sugg_list
1080        .selected()
1081        .unwrap_or(0)
1082        .min(state.session.suggestions.len() - 1);
1083    let mut items: Vec<ListItem> = Vec::new();
1084    let mut tier_shown = false;
1085    for (i, s) in state.session.suggestions.iter().enumerate() {
1086        // The tier heading is drawn as part of the first cautious entry rather than as an
1087        // entry of its own: a heading in the list would be a line the cursor can land on
1088        // and Space cannot do anything to.
1089        let mut lines = Vec::new();
1090        if s.cautious && !tier_shown {
1091            tier_shown = true;
1092            lines.push(Line::from(Span::styled(
1093                "  Worth turning on once you know what it does",
1094                Style::default()
1095                    .fg(Color::Yellow)
1096                    .add_modifier(Modifier::BOLD),
1097            )));
1098        }
1099        let mark = if accepted(state, i) {
1100            Span::styled(
1101                "[x] ",
1102                Style::default()
1103                    .fg(Color::Green)
1104                    .add_modifier(Modifier::BOLD),
1105            )
1106        } else {
1107            Span::styled("[ ] ", dim())
1108        };
1109        lines.push(Line::from(vec![
1110            mark,
1111            Span::styled(
1112                crate::output::pad_display(s.label, 28),
1113                if selected == i {
1114                    Style::default().fg(Color::White)
1115                } else {
1116                    Style::default()
1117                },
1118            ),
1119            Span::styled(s.key.to_string(), dim()),
1120        ]));
1121        items.push(ListItem::new(lines));
1122    }
1123
1124    frame.render_stateful_widget(
1125        List::new(items)
1126            .block(
1127                Block::default()
1128                    .title(" Suggested ")
1129                    .borders(Borders::ALL)
1130                    .border_style(dim()),
1131            )
1132            .highlight_style(
1133                Style::default()
1134                    .bg(Color::Rgb(30, 40, 60))
1135                    .add_modifier(Modifier::BOLD),
1136            )
1137            .highlight_symbol("\u{25b6} "),
1138        chunks[1],
1139        &mut state.sugg_list,
1140    );
1141
1142    let s = &state.session.suggestions[selected];
1143    frame.render_widget(
1144        Paragraph::new(vec![
1145            Line::from(Span::styled(format!("  {}", s.help), Style::default())),
1146            Line::from(""),
1147            Line::from(vec![
1148                Span::styled("  In plain words  ", dim()),
1149                Span::styled(s.plain.to_string(), Style::default().fg(Color::Cyan)),
1150            ]),
1151            Line::from(""),
1152            Line::from(vec![
1153                Span::styled("  Why we suggest it  ", dim()),
1154                Span::styled(s.why.to_string(), Style::default().fg(Color::Green)),
1155            ]),
1156        ])
1157        .wrap(Wrap { trim: true })
1158        .block(Block::default().borders(Borders::ALL).border_style(dim())),
1159        chunks[2],
1160    );
1161
1162    frame.render_widget(
1163        footer(&[
1164            ("\u{2191}\u{2193}", "read"),
1165            ("Space", "accept one"),
1166            ("a", "accept all suggested"),
1167            ("r", "undo"),
1168            ("Enter", "all settings"),
1169            ("y", "done"),
1170        ]),
1171        chunks[3],
1172    );
1173}
1174
1175fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
1176    let chunks = Layout::vertical([
1177        Constraint::Length(3),
1178        Constraint::Min(5),
1179        Constraint::Length(6),
1180        Constraint::Length(2),
1181    ])
1182    .split(frame.area());
1183
1184    let changed = state.session.rows.iter().filter(|r| r.changed()).count();
1185    let new = state.session.rows.iter().filter(|r| r.is_new).count();
1186    let subtitle = match (changed, new) {
1187        (0, 0) => "Nothing changed yet.".to_string(),
1188        (c, 0) => format!("{c} changed."),
1189        (0, n) => format!("{n} new in this version."),
1190        (c, n) => format!("{c} changed, {n} new in this version."),
1191    };
1192    frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
1193
1194    let selected = state.list.selected();
1195    let items: Vec<ListItem> = state
1196        .session
1197        .rows
1198        .iter()
1199        .enumerate()
1200        .map(|(i, row)| {
1201            let control = match row.control {
1202                Control::Toggle if row.value == "true" => Span::styled(
1203                    "[x] ",
1204                    Style::default()
1205                        .fg(Color::Green)
1206                        .add_modifier(Modifier::BOLD),
1207                ),
1208                Control::Toggle => Span::styled("[ ] ", dim()),
1209                Control::Number => Span::styled("123 ", dim()),
1210                Control::Adapters | Control::AdapterDays | Control::CacheCaps => {
1211                    Span::styled("••• ", dim())
1212                }
1213            };
1214
1215            let shown = if state.editing.is_some() && selected == Some(i) {
1216                format!("{}_", state.editing.clone().unwrap_or_default())
1217            } else {
1218                row.value.clone()
1219            };
1220
1221            let mut spans = vec![
1222                control,
1223                Span::styled(
1224                    crate::output::pad_display(row.key, 28),
1225                    if selected == Some(i) {
1226                        Style::default().fg(Color::White)
1227                    } else {
1228                        Style::default()
1229                    },
1230                ),
1231                Span::styled(
1232                    crate::output::pad_display(&shown, 20),
1233                    if row.changed() {
1234                        Style::default().fg(Color::Yellow)
1235                    } else {
1236                        Style::default().fg(Color::Cyan)
1237                    },
1238                ),
1239            ];
1240            if row.is_new {
1241                spans.push(Span::styled(
1242                    "NEW ",
1243                    Style::default()
1244                        .fg(Color::Magenta)
1245                        .add_modifier(Modifier::BOLD),
1246                ));
1247            }
1248            if row.changed() {
1249                spans.push(Span::styled(format!("was {}", row.original), dim()));
1250            }
1251            ListItem::new(Line::from(spans))
1252        })
1253        .collect();
1254
1255    let list = List::new(items)
1256        .block(
1257            Block::default()
1258                .title(" Settings ")
1259                .borders(Borders::ALL)
1260                .border_style(dim()),
1261        )
1262        .highlight_style(
1263            Style::default()
1264                .bg(Color::Rgb(30, 40, 60))
1265                .add_modifier(Modifier::BOLD),
1266        )
1267        .highlight_symbol("▶ ");
1268    frame.render_stateful_widget(list, chunks[1], &mut state.list);
1269
1270    // The help for the highlighted row, and any refusal, in the same place: a message
1271    // about a field belongs next to the field.
1272    let index = selected.unwrap_or(0);
1273    let row = &state.session.rows[index];
1274    let mut detail = vec![
1275        Line::from(Span::styled(format!("  {}", row.help), Style::default())),
1276        Line::from(vec![
1277            Span::styled("  In plain words  ", dim()),
1278            Span::styled(row.plain.to_string(), Style::default().fg(Color::Cyan)),
1279        ]),
1280    ];
1281    if row.is_new {
1282        detail.push(Line::from(Span::styled(
1283            "  New in this version — it has been applying its default since the upgrade.",
1284            Style::default().fg(Color::Magenta),
1285        )));
1286    }
1287    if let Some(why) = &state.error {
1288        detail.push(Line::from(Span::styled(
1289            format!("  {why}"),
1290            Style::default().fg(Color::Red),
1291        )));
1292    }
1293    frame.render_widget(
1294        Paragraph::new(detail)
1295            .wrap(Wrap { trim: true })
1296            .block(Block::default().borders(Borders::ALL).border_style(dim())),
1297        chunks[2],
1298    );
1299
1300    let keys: &[(&str, &str)] = if state.editing.is_some() {
1301        &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
1302    } else {
1303        &[
1304            ("↑↓", "move"),
1305            ("Space", "change"),
1306            ("r", "reset"),
1307            ("y", "done"),
1308            ("q", "cancel"),
1309        ]
1310    };
1311    frame.render_widget(footer(keys), chunks[3]);
1312}
1313
1314fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
1315    let chunks = Layout::vertical([
1316        Constraint::Length(3),
1317        Constraint::Min(5),
1318        Constraint::Length(4),
1319        Constraint::Length(2),
1320    ])
1321    .split(frame.area());
1322
1323    let off = state.picker_active.iter().filter(|a| !**a).count();
1324    frame.render_widget(
1325        header(
1326            "Adapters",
1327            &format!(
1328                "Unchecked adapters are left alone entirely — not scanned, not counted, \
1329                 not pruned. {off} off.",
1330            ),
1331        ),
1332        chunks[0],
1333    );
1334
1335    let selected = state.picker_list.selected();
1336    let items: Vec<ListItem> = state
1337        .picker_entries
1338        .iter()
1339        .enumerate()
1340        .map(|(line, entry)| match entry {
1341            PickerEntry::Group { label, members } => {
1342                let on = members.iter().filter(|&&i| state.picker_active[i]).count();
1343                let mark = if on == members.len() {
1344                    "[x]"
1345                } else if on == 0 {
1346                    "[ ]"
1347                } else {
1348                    // A language half on is neither, and drawing it as either is how
1349                    // one Space press silently turns three adapters back on.
1350                    "[-]"
1351                };
1352                let editing_here = state.picker_editing.is_some() && selected == Some(line);
1353                let shared = members.first().and_then(|&i| state.picker_days[i]);
1354                // A heading is an editing target like any adapter row, so it has to show
1355                // the buffer being typed into it — otherwise the keys land silently.
1356                let window = if editing_here && state.picker_field == PickerField::Days {
1357                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1358                } else if members.iter().all(|&i| state.picker_days[i] == shared) {
1359                    shared.map(|d| format!("{d}d")).unwrap_or_default()
1360                } else {
1361                    "mixed".to_string()
1362                };
1363                let capped: Vec<usize> = members
1364                    .iter()
1365                    .copied()
1366                    .filter(|&i| {
1367                        state
1368                            .session
1369                            .capped_adapters
1370                            .contains(&state.session.adapters[i])
1371                    })
1372                    .collect();
1373                let shared_cap = capped.first().and_then(|&i| state.picker_caps[i]);
1374                let cap = if editing_here && state.picker_field == PickerField::Cap {
1375                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1376                } else if capped.is_empty() {
1377                    String::new()
1378                } else if capped.iter().all(|&i| state.picker_caps[i] == shared_cap) {
1379                    shared_cap.map(|g| format!("{g}G")).unwrap_or_default()
1380                } else {
1381                    "mixed".to_string()
1382                };
1383                ListItem::new(Line::from(vec![
1384                    Span::styled(
1385                        format!("{mark} {}", crate::output::pad_display(label, 22)),
1386                        Style::default()
1387                            .fg(Color::Cyan)
1388                            .add_modifier(Modifier::BOLD),
1389                    ),
1390                    Span::styled(
1391                        crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
1392                        dim(),
1393                    ),
1394                    Span::styled(crate::output::pad_display(&window, 10), dim()),
1395                    Span::styled(cap, dim()),
1396                ]))
1397            }
1398            PickerEntry::Adapter(i) => {
1399                let name = state.session.adapters[*i];
1400                let editing_here = state.picker_editing.is_some() && selected == Some(line);
1401                let shown = if editing_here && state.picker_field == PickerField::Days {
1402                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1403                } else {
1404                    state.picker_days[*i]
1405                        .map(|d| format!("{d}d"))
1406                        .unwrap_or_else(|| "default".to_string())
1407                };
1408                // Blank, not "no cap": there is no cache of this name for a cap to be
1409                // about, and an empty cell is the only honest way to draw a column that
1410                // does not apply to this row.
1411                let cap = if editing_here && state.picker_field == PickerField::Cap {
1412                    format!("{}_", state.picker_editing.clone().unwrap_or_default())
1413                } else if !state.session.capped_adapters.contains(&name) {
1414                    String::new()
1415                } else {
1416                    state.picker_caps[*i]
1417                        .map(|g| format!("{g}G"))
1418                        .unwrap_or_else(|| "no cap".to_string())
1419                };
1420                let mut spans = vec![
1421                    if state.picker_active[*i] {
1422                        Span::styled(
1423                            "  [x] ",
1424                            Style::default()
1425                                .fg(Color::Green)
1426                                .add_modifier(Modifier::BOLD),
1427                        )
1428                    } else {
1429                        Span::styled("  [ ] ", dim())
1430                    },
1431                    Span::styled(crate::output::pad_display(name, 18), Style::default()),
1432                    Span::styled(
1433                        crate::output::pad_display(&shown, 10),
1434                        if state.picker_days[*i].is_some() {
1435                            Style::default().fg(Color::Yellow)
1436                        } else {
1437                            dim()
1438                        },
1439                    ),
1440                    Span::styled(
1441                        crate::output::pad_display(&cap, 10),
1442                        if state.picker_caps[*i].is_some() {
1443                            Style::default().fg(Color::Yellow)
1444                        } else {
1445                            dim()
1446                        },
1447                    ),
1448                ];
1449                if state.session.opt_in_adapters.contains(&name) {
1450                    // Naming the cost is the whole argument for the switch: these come
1451                    // back by recompiling, and nobody should turn one on without being
1452                    // told that is what "restore" means here.
1453                    spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1454                }
1455                ListItem::new(Line::from(spans))
1456            }
1457        })
1458        .collect();
1459
1460    let list = List::new(items)
1461        .block(
1462            Block::default()
1463                .title(" Checked adapters stay active      idle      cache cap ")
1464                .borders(Borders::ALL)
1465                .border_style(dim()),
1466        )
1467        .highlight_style(
1468            Style::default()
1469                .bg(Color::Rgb(30, 40, 60))
1470                .add_modifier(Modifier::BOLD),
1471        )
1472        .highlight_symbol("▶ ");
1473    frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1474
1475    let mut detail = vec![Line::from(Span::styled(
1476        "  Space toggles one adapter, or a whole language from its heading. d sets how \
1477         many days that adapter — or that language — must be idle first; an empty value \
1478         puts it back on the global window. c caps that ecosystem's download cache in \
1479         GiB — reported by `devp caches`, and emptied only when you run \
1480         `devp caches clear --over-cap`, never on a schedule.",
1481        dim(),
1482    ))];
1483    if let Some(why) = &state.error {
1484        detail.push(Line::from(Span::styled(
1485            format!("  {why}"),
1486            Style::default().fg(Color::Red),
1487        )));
1488    }
1489    frame.render_widget(
1490        Paragraph::new(detail)
1491            .wrap(Wrap { trim: true })
1492            .block(Block::default().borders(Borders::ALL).border_style(dim())),
1493        chunks[2],
1494    );
1495
1496    let keys: &[(&str, &str)] = match (state.picker_editing.is_some(), state.picker_field) {
1497        (true, PickerField::Days) => &[
1498            ("digits", "days"),
1499            ("Enter", "accept"),
1500            ("empty", "use the global window"),
1501            ("Esc", "abandon"),
1502        ],
1503        (true, PickerField::Cap) => &[
1504            ("digits", "GiB"),
1505            ("Enter", "accept"),
1506            ("empty", "no cap"),
1507            ("Esc", "abandon"),
1508        ],
1509        (false, _) => &[
1510            ("↑↓", "move"),
1511            ("Space", "toggle"),
1512            ("d", "idle days"),
1513            ("c", "cache cap"),
1514            ("a", "all on"),
1515            ("n", "all off"),
1516            ("Enter", "accept"),
1517            ("Esc", "back"),
1518        ],
1519    };
1520    frame.render_widget(footer(keys), chunks[3]);
1521}
1522
1523fn render_summary(frame: &mut Frame, state: &State<'_>) {
1524    let chunks = Layout::vertical([
1525        Constraint::Length(3),
1526        Constraint::Min(5),
1527        Constraint::Length(2),
1528    ])
1529    .split(frame.area());
1530
1531    let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1532    frame.render_widget(
1533        header(
1534            "Summary",
1535            if changed.is_empty() {
1536                "Nothing changed. The defaults stay in place."
1537            } else {
1538                "These are the only values that will be written."
1539            },
1540        ),
1541        chunks[0],
1542    );
1543
1544    let mut lines: Vec<Line> = changed
1545        .iter()
1546        .map(|row| {
1547            Line::from(vec![
1548                Span::styled(
1549                    format!("  {}", crate::output::pad_display(row.key, 28)),
1550                    Style::default(),
1551                ),
1552                Span::styled(row.original.clone(), dim()),
1553                Span::styled(" → ", dim()),
1554                Span::styled(
1555                    row.value.clone(),
1556                    Style::default()
1557                        .fg(Color::Yellow)
1558                        .add_modifier(Modifier::BOLD),
1559                ),
1560            ])
1561        })
1562        .collect();
1563    if lines.is_empty() {
1564        lines.push(Line::from(Span::styled(
1565            "  Every setting is still at the value it had when this opened.",
1566            dim(),
1567        )));
1568    }
1569    lines.push(Line::from(""));
1570    lines.push(Line::from(Span::styled(
1571        format!("  {}", state.session.standing),
1572        Style::default().fg(Color::Green),
1573    )));
1574
1575    frame.render_widget(
1576        Paragraph::new(lines).wrap(Wrap { trim: true }).block(
1577            Block::default()
1578                .title(" About to be saved ")
1579                .borders(Borders::ALL)
1580                .border_style(dim()),
1581        ),
1582        chunks[1],
1583    );
1584
1585    frame.render_widget(
1586        footer(&[
1587            ("Enter", "save"),
1588            ("Esc", "back"),
1589            ("q", "discard everything"),
1590        ]),
1591        chunks[2],
1592    );
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597    use super::*;
1598
1599    fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
1600        ConfigRow {
1601            key,
1602            help: "help",
1603            plain: "plain",
1604            control,
1605            value: value.to_string(),
1606            original: value.to_string(),
1607            is_new: false,
1608        }
1609    }
1610
1611    fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
1612        ConfigSession {
1613            declaration: Vec::new(),
1614            standing: String::new(),
1615            suggestions: Vec::new(),
1616            rows,
1617            adapters,
1618            opt_in_adapters: &[],
1619            capped_adapters: &["npm", "pnpm", "cargo", "go"],
1620            groups: &[("Test", &["npm", "cargo", "go"])],
1621            validate: &|key, v| {
1622                // Stands in for the real setters: the same shapes accepted, so a test
1623                // that types a value the checklist stores is a test the wizard passes.
1624                let number = if key == "adapter_idle_days" || key == "cache_max_gb" {
1625                    v.split_once('=').map(|(_, d)| d).unwrap_or("")
1626                } else {
1627                    v
1628                };
1629                number
1630                    .parse::<u64>()
1631                    .map(|_| ())
1632                    .map_err(|_| "not a number".to_string())
1633            },
1634            title: "test",
1635        }
1636    }
1637
1638    fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
1639        let mut list = ListState::default();
1640        list.select(Some(0));
1641        let mut picker_list = ListState::default();
1642        picker_list.select(Some(0));
1643        State {
1644            picker_active: vec![true; s.adapters.len()],
1645            picker_days: vec![None; s.adapters.len()],
1646            picker_caps: vec![None; s.adapters.len()],
1647            picker_entries: build_entries(s.adapters, s.groups),
1648            picker_editing: None,
1649            picker_field: PickerField::Days,
1650            session: s,
1651            screen: Screen::Settings,
1652            list,
1653            editing: None,
1654            error: None,
1655            picker_list,
1656            decl_list: ListState::default(),
1657            sugg_list: ListState::default(),
1658        }
1659    }
1660
1661    /// Draw one screen into an off-screen buffer and return it as text.
1662    ///
1663    /// The layouts are the one part of this file a keypress test cannot reach, and a
1664    /// constraint that does not fit its area panics rather than clipping.
1665    fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
1666        st.screen = screen;
1667        let mut terminal =
1668            Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
1669        terminal.draw(|frame| render(frame, st)).expect("draw");
1670        terminal
1671            .backend()
1672            .buffer()
1673            .content()
1674            .iter()
1675            .map(|cell| cell.symbol())
1676            .collect()
1677    }
1678
1679    #[test]
1680    fn every_screen_draws() {
1681        let adapters: &[&'static str] = &["npm", "cargo"];
1682        let mut st = state(session(
1683            vec![
1684                row("idle_days", Control::Number, "14"),
1685                row("disabled_adapters", Control::Adapters, "(none)"),
1686            ],
1687            adapters,
1688        ));
1689        st.session.declaration.push(DeclarationLine {
1690            mark: '+',
1691            subject: "Lockfile verification".to_string(),
1692            state: "Required before every delete".to_string(),
1693        });
1694        st.session.standing = "Nothing has been deleted.".to_string();
1695
1696        let decl = screenshot(&mut st, Screen::Declaration);
1697        assert!(decl.contains("Lockfile verification"));
1698        assert!(decl.contains("Nothing has been deleted."));
1699
1700        let settings = screenshot(&mut st, Screen::Settings);
1701        assert!(settings.contains("idle_days"));
1702
1703        st.picker_entries = build_entries(st.session.adapters, st.session.groups);
1704        st.picker_days[1] = Some(45);
1705        let picker = screenshot(&mut st, Screen::Adapters);
1706        assert!(picker.contains("cargo"));
1707        assert!(picker.contains("Test"), "the language heading is missing");
1708        assert!(picker.contains("45d"), "the idle window is missing");
1709
1710        // The summary must say so when there is nothing to say, rather than draw an
1711        // empty box that reads as a rendering failure.
1712        let summary = screenshot(&mut st, Screen::Summary);
1713        assert!(summary.contains("still at the value"));
1714    }
1715
1716    #[test]
1717    fn y_on_the_declaration_still_means_yes_to_everything() {
1718        // The prompt this replaced was `Keep all of these? [Y/n]`. Anyone who has typed
1719        // `y` at it once will type `y` at this, and must get the same result.
1720        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1721        st.screen = Screen::Declaration;
1722        assert!(matches!(
1723            handle_key(&mut st, KeyCode::Char('y')),
1724            Some(Outcome::KeepAll)
1725        ));
1726    }
1727
1728    #[test]
1729    fn a_refused_value_is_not_stored() {
1730        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1731        handle_key(&mut st, KeyCode::Enter); // open the editor
1732        handle_key(&mut st, KeyCode::Backspace);
1733        handle_key(&mut st, KeyCode::Backspace); // buffer now empty, which will not parse
1734        handle_key(&mut st, KeyCode::Enter);
1735        assert_eq!(st.session.rows[0].value, "14");
1736        assert!(st.error.is_some(), "the reason was not shown");
1737        assert!(st.editing.is_some(), "the editor closed on a refusal");
1738    }
1739
1740    #[test]
1741    fn an_accepted_value_replaces_the_old_one() {
1742        let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1743        handle_key(&mut st, KeyCode::Enter);
1744        handle_key(&mut st, KeyCode::Backspace);
1745        handle_key(&mut st, KeyCode::Backspace);
1746        handle_key(&mut st, KeyCode::Char('3'));
1747        handle_key(&mut st, KeyCode::Char('0'));
1748        handle_key(&mut st, KeyCode::Enter);
1749        assert_eq!(st.session.rows[0].value, "30");
1750        assert!(st.session.rows[0].changed());
1751    }
1752
1753    #[test]
1754    fn unchecking_an_adapter_writes_it_to_the_deny_list() {
1755        let adapters: &[&'static str] = &["npm", "cargo", "go"];
1756        let mut st = state(session(
1757            vec![row("disabled_adapters", Control::Adapters, "(none)")],
1758            adapters,
1759        ));
1760        handle_key(&mut st, KeyCode::Enter); // open the checklist
1761        assert_eq!(st.screen, Screen::Adapters);
1762        handle_key(&mut st, KeyCode::Down); // past the heading, onto npm
1763        handle_key(&mut st, KeyCode::Down); // cargo
1764        handle_key(&mut st, KeyCode::Char(' '));
1765        handle_key(&mut st, KeyCode::Enter);
1766        assert_eq!(st.session.rows[0].value, "cargo");
1767        assert_eq!(st.screen, Screen::Settings);
1768    }
1769
1770    #[test]
1771    fn every_adapter_appears_under_exactly_one_heading() {
1772        // An adapter no group claims still has to be listed: a checklist that silently
1773        // omits an adapter is a checklist that cannot turn it off.
1774        let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
1775        let groups: &[(&'static str, &'static [&'static str])] =
1776            &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1777        let entries = build_entries(adapters, groups);
1778        let headings: Vec<&str> = entries
1779            .iter()
1780            .filter_map(|e| match e {
1781                PickerEntry::Group { label, .. } => Some(*label),
1782                PickerEntry::Adapter(_) => None,
1783            })
1784            .collect();
1785        assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
1786
1787        let mut listed: Vec<usize> = entries
1788            .iter()
1789            .filter_map(|e| match e {
1790                PickerEntry::Adapter(i) => Some(*i),
1791                PickerEntry::Group { .. } => None,
1792            })
1793            .collect();
1794        listed.sort_unstable();
1795        assert_eq!(
1796            listed,
1797            vec![0, 1, 2],
1798            "an adapter was dropped from the list"
1799        );
1800    }
1801
1802    #[test]
1803    fn a_heading_turns_its_whole_language_off_in_one_press() {
1804        let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1805        let mut st = state(session(
1806            vec![row("disabled_adapters", Control::Adapters, "(none)")],
1807            adapters,
1808        ));
1809        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1810        handle_key(&mut st, KeyCode::Enter);
1811        handle_key(&mut st, KeyCode::Char(' ')); // on the JavaScript heading
1812        assert_eq!(st.picker_active, vec![false, false, true]);
1813        // And back on again: a heading that only ever turned things off would leave the
1814        // user unable to undo their own keypress.
1815        handle_key(&mut st, KeyCode::Char(' '));
1816        assert_eq!(st.picker_active, vec![true, true, true]);
1817    }
1818
1819    #[test]
1820    fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
1821        let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1822        let mut st = state(session(
1823            vec![
1824                row("disabled_adapters", Control::Adapters, "(none)"),
1825                row("adapter_idle_days", Control::AdapterDays, "(none)"),
1826            ],
1827            adapters,
1828        ));
1829        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1830        handle_key(&mut st, KeyCode::Enter);
1831        handle_key(&mut st, KeyCode::Char('d')); // on the JavaScript heading
1832        handle_key(&mut st, KeyCode::Char('3'));
1833        handle_key(&mut st, KeyCode::Char('0'));
1834        handle_key(&mut st, KeyCode::Enter);
1835        assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
1836
1837        handle_key(&mut st, KeyCode::Enter); // accept the checklist
1838        assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
1839
1840        // Clearing is how a window goes back to following the global one, and there is
1841        // no other way to spell it.
1842        handle_key(&mut st, KeyCode::Enter);
1843        handle_key(&mut st, KeyCode::Char('d'));
1844        handle_key(&mut st, KeyCode::Backspace);
1845        handle_key(&mut st, KeyCode::Backspace);
1846        handle_key(&mut st, KeyCode::Enter);
1847        handle_key(&mut st, KeyCode::Enter);
1848        assert_eq!(st.session.rows[1].value, "(none)");
1849    }
1850
1851    #[test]
1852    fn a_cache_cap_typed_on_a_heading_reaches_only_the_adapters_that_have_a_cache() {
1853        // The two lists overlap without either containing the other, so a heading has to
1854        // skip the members dev-prune knows no cache for rather than store a cap against
1855        // a manager name that does not exist.
1856        let adapters: &[&'static str] = &["npm", "pnpm", "venv"];
1857        let mut st = state(session(
1858            vec![
1859                row("disabled_adapters", Control::Adapters, "(none)"),
1860                row("cache_max_gb", Control::CacheCaps, "(none)"),
1861            ],
1862            adapters,
1863        ));
1864        st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Python", &["venv"])];
1865        handle_key(&mut st, KeyCode::Enter);
1866        handle_key(&mut st, KeyCode::Char('c')); // on the JavaScript heading
1867        handle_key(&mut st, KeyCode::Char('1'));
1868        handle_key(&mut st, KeyCode::Char('0'));
1869        handle_key(&mut st, KeyCode::Enter);
1870        assert_eq!(st.picker_caps, vec![Some(10), Some(10), None]);
1871
1872        handle_key(&mut st, KeyCode::Enter); // accept the checklist
1873        assert_eq!(st.session.rows[1].value, "npm=10,pnpm=10");
1874    }
1875
1876    #[test]
1877    fn a_cache_cap_is_cleared_by_emptying_it() {
1878        let adapters: &[&'static str] = &["npm"];
1879        let mut st = state(session(
1880            vec![
1881                row("disabled_adapters", Control::Adapters, "(none)"),
1882                row("cache_max_gb", Control::CacheCaps, "npm=10"),
1883            ],
1884            adapters,
1885        ));
1886        st.session.groups = &[("JavaScript", &["npm"])];
1887        handle_key(&mut st, KeyCode::Enter);
1888        // Opening shows the cap that is already set, or accepting the screen for any
1889        // other reason would quietly drop it.
1890        assert_eq!(st.picker_caps, vec![Some(10)]);
1891        handle_key(&mut st, KeyCode::Down); // heading -> npm
1892        handle_key(&mut st, KeyCode::Char('c'));
1893        handle_key(&mut st, KeyCode::Backspace);
1894        handle_key(&mut st, KeyCode::Backspace);
1895        handle_key(&mut st, KeyCode::Enter);
1896        handle_key(&mut st, KeyCode::Enter);
1897        assert_eq!(st.session.rows[1].value, "(none)");
1898    }
1899
1900    #[test]
1901    fn a_cap_on_a_cache_with_no_adapter_survives_the_checklist() {
1902        // `pip`, `nuget`, `conan`, `conda`, `vcpkg` and `hex` are caches no adapter is
1903        // named after, so they have no row here to be edited from. The screen writes
1904        // back the whole setting, and without this it would delete them the first time
1905        // anyone opened the checklist for any other reason.
1906        let adapters: &[&'static str] = &["npm"];
1907        let mut st = state(session(
1908            vec![
1909                row("disabled_adapters", Control::Adapters, "(none)"),
1910                row("cache_max_gb", Control::CacheCaps, "npm=10,pip=20"),
1911            ],
1912            adapters,
1913        ));
1914        st.session.groups = &[("JavaScript", &["npm"])];
1915        handle_key(&mut st, KeyCode::Enter);
1916        handle_key(&mut st, KeyCode::Enter);
1917        assert_eq!(st.session.rows[1].value, "npm=10,pip=20");
1918    }
1919
1920    #[test]
1921    fn typing_a_cap_where_there_is_no_cache_says_so_instead_of_dropping_it() {
1922        let adapters: &[&'static str] = &["venv"];
1923        let mut st = state(session(
1924            vec![
1925                row("disabled_adapters", Control::Adapters, "(none)"),
1926                row("cache_max_gb", Control::CacheCaps, "(none)"),
1927            ],
1928            adapters,
1929        ));
1930        st.session.groups = &[("Python", &["venv"])];
1931        handle_key(&mut st, KeyCode::Enter);
1932        handle_key(&mut st, KeyCode::Down); // heading -> venv
1933        handle_key(&mut st, KeyCode::Char('c'));
1934        assert!(st.picker_editing.is_none(), "no editor opened");
1935        let err = st.error.clone().expect("the refusal is explained");
1936        assert!(err.contains("devp caches"), "{err}");
1937    }
1938
1939    #[test]
1940    fn the_checklist_draws_the_cache_cap_beside_the_idle_window() {
1941        // Both settings are per adapter, and the whole point of the third column is that
1942        // one screen answers "what is on, for how long, and how big".
1943        let adapters: &[&'static str] = &["npm", "venv"];
1944        let mut st = state(session(
1945            vec![
1946                row("disabled_adapters", Control::Adapters, "(none)"),
1947                row("adapter_idle_days", Control::AdapterDays, "npm=30"),
1948                row("cache_max_gb", Control::CacheCaps, "npm=10"),
1949            ],
1950            adapters,
1951        ));
1952        st.session.groups = &[("JavaScript", &["npm"]), ("Python", &["venv"])];
1953        handle_key(&mut st, KeyCode::Enter);
1954        let picker = screenshot(&mut st, Screen::Adapters);
1955        assert!(picker.contains("30d"), "the idle window is drawn");
1956        assert!(picker.contains("10G"), "the cap is drawn");
1957        // `venv` has no cache, so its cell is blank rather than "no cap" — there is
1958        // nothing there for a cap to be about.
1959        assert!(picker.contains("no cap") || picker.contains("10G"));
1960        assert!(picker.contains("cache cap"), "the column is labelled");
1961    }
1962
1963    #[test]
1964    fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
1965        // Two ways to spell the same "off" would leave the settings screen showing a
1966        // contradiction: ticking cargo here has to read back there as enable_cargo.
1967        let adapters: &[&'static str] = &["npm", "cargo"];
1968        let opt_in: &[&'static str] = &["cargo"];
1969        let mut st = state(session(
1970            vec![
1971                row("disabled_adapters", Control::Adapters, "(none)"),
1972                row("enable_cargo", Control::Toggle, "false"),
1973            ],
1974            adapters,
1975        ));
1976        st.session.opt_in_adapters = opt_in;
1977        st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1978
1979        handle_key(&mut st, KeyCode::Enter);
1980        // Off by default and absent from the deny-list: showing it ticked would promise
1981        // a prune that never happens.
1982        assert_eq!(st.picker_active, vec![true, false]);
1983        handle_key(&mut st, KeyCode::Down); // JavaScript heading -> npm
1984        handle_key(&mut st, KeyCode::Down); // Rust heading
1985        handle_key(&mut st, KeyCode::Down); // cargo
1986        handle_key(&mut st, KeyCode::Char(' '));
1987        handle_key(&mut st, KeyCode::Enter);
1988        assert_eq!(st.session.rows[0].value, "(none)");
1989        assert_eq!(st.session.rows[1].value, "true");
1990    }
1991
1992    #[test]
1993    fn the_checklist_opens_showing_what_is_already_disabled() {
1994        // Opening with everything ticked would silently re-enable an adapter the user
1995        // turned off, the first time they visited the screen for any other reason.
1996        let adapters: &[&'static str] = &["npm", "cargo", "go"];
1997        let mut st = state(session(
1998            vec![row("disabled_adapters", Control::Adapters, "go")],
1999            adapters,
2000        ));
2001        handle_key(&mut st, KeyCode::Enter);
2002        assert_eq!(st.picker_active, vec![true, true, false]);
2003        handle_key(&mut st, KeyCode::Enter);
2004        assert_eq!(st.session.rows[0].value, "go");
2005    }
2006
2007    #[test]
2008    fn cancelling_reports_cancelled_rather_than_an_empty_save() {
2009        // The difference matters: `KeepAll` marks the settings reviewed and `Cancelled`
2010        // does not, so an escape must not be mistaken for an answer.
2011        let mut st = state(session(
2012            vec![row("auto_update", Control::Toggle, "false")],
2013            &[],
2014        ));
2015        assert!(matches!(
2016            handle_key(&mut st, KeyCode::Char('q')),
2017            Some(Outcome::Cancelled)
2018        ));
2019    }
2020
2021    #[test]
2022    fn only_changed_rows_are_saved() {
2023        let mut st = state(session(
2024            vec![
2025                row("auto_update", Control::Toggle, "false"),
2026                row("auto_config", Control::Toggle, "false"),
2027            ],
2028            &[],
2029        ));
2030        handle_key(&mut st, KeyCode::Char(' ')); // flip the first
2031        handle_key(&mut st, KeyCode::Char('y')); // to the summary
2032        let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2033            panic!("expected a save");
2034        };
2035        assert_eq!(changed.len(), 1);
2036        assert_eq!(changed[0].key, "auto_update");
2037        assert_eq!(changed[0].value, "true");
2038    }
2039
2040    #[test]
2041    fn reset_puts_a_row_back_without_touching_the_others() {
2042        let mut st = state(session(
2043            vec![
2044                row("auto_update", Control::Toggle, "false"),
2045                row("auto_config", Control::Toggle, "true"),
2046            ],
2047            &[],
2048        ));
2049        handle_key(&mut st, KeyCode::Char(' '));
2050        assert!(st.session.rows[0].changed());
2051        handle_key(&mut st, KeyCode::Char('r'));
2052        assert!(!st.session.rows[0].changed());
2053        assert_eq!(st.session.rows[1].value, "true");
2054    }
2055
2056    #[test]
2057    fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
2058        let mut rows = [
2059            row("idle_days", Control::Number, "14"),
2060            row("auto_update", Control::Toggle, "false"),
2061            row("auto_config", Control::Toggle, "false"),
2062        ];
2063        assert_eq!(
2064            opening_index(&rows),
2065            0,
2066            "with nothing new, start at the top"
2067        );
2068        rows[2].is_new = true;
2069        assert_eq!(opening_index(&rows), 2);
2070    }
2071
2072    fn suggestion(key: &'static str, cautious: bool) -> Suggestion {
2073        Suggestion {
2074            key,
2075            label: "label",
2076            help: "help",
2077            plain: "plain",
2078            why: "why",
2079            value: "true",
2080            cautious,
2081        }
2082    }
2083
2084    #[test]
2085    fn accept_all_stops_at_the_cautious_tier() {
2086        // The whole reason the second tier exists. A single key that also accepted the
2087        // setting the screen just told you to think about would make the warning
2088        // decorative.
2089        let adapters: &[&str] = &["npm"];
2090        let mut s = session(
2091            vec![
2092                row("enable_cargo", Control::Toggle, "false"),
2093                row("allow_manifest_rewrite", Control::Toggle, "false"),
2094            ],
2095            adapters,
2096        );
2097        s.suggestions = vec![
2098            suggestion("enable_cargo", false),
2099            suggestion("allow_manifest_rewrite", true),
2100        ];
2101        let mut st = state(s);
2102        st.screen = Screen::Suggestions;
2103        st.sugg_list.select(Some(0));
2104
2105        assert!(suggestions_key(&mut st, KeyCode::Char('a')).is_none());
2106        assert_eq!(
2107            row_value(&st.session.rows, "enable_cargo").as_deref(),
2108            Some("true")
2109        );
2110        assert_eq!(
2111            row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2112            Some("false")
2113        );
2114
2115        // Reachable, just not by the one key: Space on the row itself still takes it.
2116        st.sugg_list.select(Some(1));
2117        suggestions_key(&mut st, KeyCode::Char(' '));
2118        assert_eq!(
2119            row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2120            Some("true")
2121        );
2122    }
2123
2124    #[test]
2125    fn undoing_a_suggestion_puts_back_what_the_setting_had() {
2126        let adapters: &[&str] = &["npm"];
2127        let mut s = session(
2128            vec![row("enable_cargo", Control::Toggle, "false")],
2129            adapters,
2130        );
2131        s.suggestions = vec![suggestion("enable_cargo", false)];
2132        let mut st = state(s);
2133        st.screen = Screen::Suggestions;
2134        st.sugg_list.select(Some(0));
2135
2136        suggestions_key(&mut st, KeyCode::Char(' '));
2137        assert!(accepted(&st, 0));
2138        suggestions_key(&mut st, KeyCode::Char(' '));
2139        assert!(!accepted(&st, 0));
2140        assert_eq!(
2141            row_value(&st.session.rows, "enable_cargo").as_deref(),
2142            Some("false")
2143        );
2144        // And the summary must not offer to save a value that never changed.
2145        assert!(!st.session.rows[0].changed());
2146    }
2147
2148    #[test]
2149    fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
2150        // Every run but the first: `first_run_suggestions` returns nothing, and Enter on
2151        // the declaration must go straight to the settings rather than to a blank screen.
2152        let adapters: &[&str] = &["npm"];
2153        let s = session(vec![row("idle_days", Control::Number, "30")], adapters);
2154        let mut st = state(s);
2155        st.screen = Screen::Declaration;
2156        assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2157        assert_eq!(st.screen, Screen::Settings);
2158    }
2159
2160    #[test]
2161    fn the_first_run_reaches_the_suggestions_first() {
2162        let adapters: &[&str] = &["npm"];
2163        let mut s = session(
2164            vec![row("enable_cargo", Control::Toggle, "false")],
2165            adapters,
2166        );
2167        s.suggestions = vec![suggestion("enable_cargo", false)];
2168        let mut st = state(s);
2169        st.screen = Screen::Declaration;
2170        assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2171        assert_eq!(st.screen, Screen::Suggestions);
2172    }
2173}