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