1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Control {
29 Toggle,
31 Number,
33 Adapters,
35 AdapterDays,
41}
42
43#[derive(Debug, Clone)]
45pub struct ConfigRow {
46 pub key: &'static str,
47 pub help: &'static str,
48 pub plain: &'static str,
53 pub control: Control,
54 pub value: String,
56 pub original: String,
58 pub is_new: bool,
60}
61
62impl ConfigRow {
63 pub fn changed(&self) -> bool {
64 self.value != self.original
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct DeclarationLine {
71 pub mark: char,
74 pub subject: String,
75 pub state: String,
76}
77
78#[derive(Debug, Clone)]
85pub struct Suggestion {
86 pub key: &'static str,
87 pub label: &'static str,
89 pub help: &'static str,
91 pub plain: &'static str,
93 pub why: &'static str,
95 pub value: &'static str,
97 pub cautious: bool,
103}
104
105pub enum Outcome {
107 Save(Vec<ConfigRow>),
109 KeepAll,
111 Cancelled,
113}
114
115pub struct ConfigSession<'a> {
117 pub declaration: Vec<DeclarationLine>,
119 pub standing: String,
121 pub rows: Vec<ConfigRow>,
122 pub suggestions: Vec<Suggestion>,
125 pub adapters: &'a [&'static str],
127 pub opt_in_adapters: &'a [&'static str],
129 pub groups: &'a [(&'static str, &'static [&'static str])],
132 pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
135 pub title: &'a str,
137}
138
139fn 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#[derive(Debug, Clone, PartialEq, Eq)]
157enum PickerEntry {
158 Group {
161 label: &'static str,
162 members: Vec<usize>,
163 },
164 Adapter(usize),
166}
167
168fn 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
210fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
212 rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
213}
214
215fn 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 editing: Option<String>,
228 error: Option<String>,
230 picker_active: Vec<bool>,
232 picker_days: Vec<Option<u64>>,
234 picker_entries: Vec<PickerEntry>,
238 picker_editing: Option<String>,
240 picker_list: ListState,
241 decl_list: ListState,
243 sugg_list: ListState,
245}
246
247pub 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 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 if key.kind == KeyEventKind::Release {
303 continue;
304 }
305 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
319fn 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 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 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
359fn 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
370fn 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 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 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 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
472fn 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
489fn 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 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 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 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 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
624fn 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 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
678fn 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 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
753fn 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
765fn 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
783fn 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 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
905fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
912 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 &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 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 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 "[-]"
1217 };
1218 let shared = members.first().and_then(|&i| state.picker_days[i]);
1219 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 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 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 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 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 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); handle_key(&mut st, KeyCode::Backspace);
1545 handle_key(&mut st, KeyCode::Backspace); 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); assert_eq!(st.screen, Screen::Adapters);
1574 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); 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 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(' ')); assert_eq!(st.picker_active, vec![false, false, true]);
1625 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')); 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); assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
1651
1652 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 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 assert_eq!(st.picker_active, vec![true, false]);
1683 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); 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 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 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(' ')); handle_key(&mut st, KeyCode::Char('y')); 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 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 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 assert!(!st.session.rows[0].changed());
1846 }
1847
1848 #[test]
1849 fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
1850 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}