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 Choice(&'static [(&'static str, &'static str)]),
37 Number,
39 Adapters,
41 AdapterDays,
47 CacheCaps,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum PickerField {
58 Days,
60 Cap,
62}
63
64#[derive(Debug, Clone)]
66pub struct ConfigRow {
67 pub key: &'static str,
68 pub category: &'static str,
71 pub help: &'static str,
72 pub plain: &'static str,
77 pub control: Control,
78 pub value: String,
80 pub original: String,
82 pub default: String,
88 pub recommended: Option<&'static str>,
94 pub cautious: bool,
98 pub is_new: bool,
100}
101
102impl ConfigRow {
103 pub fn changed(&self) -> bool {
104 self.value != self.original
105 }
106
107 pub fn takes_advice(&self) -> Option<bool> {
112 self.recommended.map(|r| self.value == r)
113 }
114}
115
116#[derive(Debug, Clone)]
118pub struct DeclarationLine {
119 pub mark: char,
122 pub subject: String,
123 pub state: String,
124}
125
126#[derive(Debug, Clone)]
133pub struct Suggestion {
134 pub key: &'static str,
135 pub label: &'static str,
137 pub help: &'static str,
139 pub plain: &'static str,
141 pub why: &'static str,
143 pub value: &'static str,
145 pub cautious: bool,
151}
152
153pub enum Outcome {
155 Save(Vec<ConfigRow>),
157 KeepAll,
159 Cancelled,
161}
162
163pub struct ConfigSession<'a> {
165 pub declaration: Vec<DeclarationLine>,
167 pub standing: String,
169 pub rows: Vec<ConfigRow>,
170 pub suggestions: Vec<Suggestion>,
173 pub adapters: &'a [&'static str],
175 pub opt_in_adapters: &'a [&'static str],
177 pub capped_adapters: &'a [&'static str],
187 pub groups: &'a [(&'static str, &'static [&'static str])],
190 pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
193 pub title: &'a str,
195 pub uninvited: Option<&'a str>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
211enum SettingEntry {
212 Heading(&'static str),
213 Row(usize),
214 Finish,
218}
219
220fn settings_entries(rows: &[ConfigRow]) -> Vec<SettingEntry> {
226 let mut entries = Vec::with_capacity(rows.len() + 8);
227 let mut current: Option<&str> = None;
228 for (i, row) in rows.iter().enumerate() {
229 if current != Some(row.category) {
230 entries.push(SettingEntry::Heading(row.category));
231 current = Some(row.category);
232 }
233 entries.push(SettingEntry::Row(i));
234 }
235 entries.push(SettingEntry::Finish);
236 entries
237}
238
239fn selected_row(state: &State<'_>) -> usize {
241 let at = state.list.selected().unwrap_or(0);
242 match state.setting_entries.get(at) {
243 Some(SettingEntry::Row(i)) => *i,
244 _ => first_row(&state.setting_entries).map_or(0, |at| match state.setting_entries[at] {
247 SettingEntry::Row(i) => i,
248 SettingEntry::Heading(_) | SettingEntry::Finish => 0,
249 }),
250 }
251}
252
253fn step(entries: &[SettingEntry], from: usize, forward: bool) -> usize {
255 let len = entries.len();
256 let mut at = from;
257 for _ in 0..len {
258 at = if forward {
259 if at + 1 >= len { 0 } else { at + 1 }
260 } else if at == 0 {
261 len - 1
262 } else {
263 at - 1
264 };
265 if matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish) {
266 return at;
267 }
268 }
269 from
270}
271
272fn first_row(entries: &[SettingEntry]) -> Option<usize> {
273 entries
274 .iter()
275 .position(|e| matches!(e, SettingEntry::Row(_)))
276}
277
278#[cfg(test)]
283fn last_stop(entries: &[SettingEntry]) -> Option<usize> {
284 entries
285 .iter()
286 .rposition(|e| matches!(e, SettingEntry::Row(_) | SettingEntry::Finish))
287}
288
289fn opening_index(entries: &[SettingEntry], rows: &[ConfigRow]) -> usize {
296 entries
297 .iter()
298 .position(|e| matches!(e, SettingEntry::Row(i) if rows[*i].is_new))
299 .or_else(|| first_row(entries))
300 .unwrap_or(0)
301}
302
303#[derive(Debug, PartialEq, Eq, Clone, Copy)]
304enum Screen {
305 Declaration,
306 Suggestions,
307 Settings,
308 Adapters,
309 Summary,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
314enum PickerEntry {
315 Group {
318 label: &'static str,
319 members: Vec<usize>,
320 },
321 Adapter(usize),
323}
324
325fn build_entries(
332 adapters: &[&'static str],
333 groups: &[(&'static str, &'static [&'static str])],
334) -> Vec<PickerEntry> {
335 let mut entries = Vec::new();
336 let mut placed = vec![false; adapters.len()];
337
338 for (label, names) in groups {
339 let members: Vec<usize> = names
340 .iter()
341 .filter_map(|name| adapters.iter().position(|a| a == name))
342 .collect();
343 if members.is_empty() {
344 continue;
345 }
346 for &i in &members {
347 placed[i] = true;
348 }
349 entries.push(PickerEntry::Group {
350 label,
351 members: members.clone(),
352 });
353 entries.extend(members.into_iter().map(PickerEntry::Adapter));
354 }
355
356 let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
357 if !rest.is_empty() {
358 entries.push(PickerEntry::Group {
359 label: "Other",
360 members: rest.clone(),
361 });
362 entries.extend(rest.into_iter().map(PickerEntry::Adapter));
363 }
364 entries
365}
366
367fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
369 rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
370}
371
372fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
374 if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
375 row.value = value;
376 }
377}
378
379struct State<'a> {
380 session: ConfigSession<'a>,
381 screen: Screen,
382 list: ListState,
383 editing: Option<String>,
385 error: Option<String>,
387 picker_active: Vec<bool>,
389 picker_days: Vec<Option<u64>>,
391 picker_caps: Vec<Option<u64>>,
394 picker_entries: Vec<PickerEntry>,
398 setting_entries: Vec<SettingEntry>,
401 picker_editing: Option<String>,
403 picker_field: PickerField,
405 picker_list: ListState,
406 decl_list: ListState,
408 sugg_list: ListState,
410}
411
412pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
414 if session.rows.is_empty() {
415 return Ok(Outcome::KeepAll);
416 }
417
418 let setting_entries = settings_entries(&session.rows);
419 let mut list = ListState::default();
420 list.select(Some(opening_index(&setting_entries, &session.rows)));
421
422 let mut picker_list = ListState::default();
423 picker_list.select(Some(0));
424
425 let mut decl_list = ListState::default();
426 decl_list.select(Some(0));
427
428 let mut sugg_list = ListState::default();
429 sugg_list.select(Some(0));
430
431 let mut state = State {
432 picker_active: vec![true; session.adapters.len()],
433 picker_days: vec![None; session.adapters.len()],
434 picker_caps: vec![None; session.adapters.len()],
435 picker_entries: Vec::new(),
436 setting_entries,
437 picker_editing: None,
438 picker_field: PickerField::Days,
439 session,
440 screen: Screen::Declaration,
441 list,
442 editing: None,
443 error: None,
444 picker_list,
445 decl_list,
446 sugg_list,
447 };
448
449 preaccept_recommended(&mut state);
450
451 let mut tui = Tui::new()?;
454 tui.drain_stale_input(Duration::from_millis(300));
455 event_loop(&mut tui.terminal, &mut state)
456}
457
458fn event_loop(
459 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
460 state: &mut State<'_>,
461) -> Result<Outcome> {
462 loop {
463 terminal.draw(|frame| render(frame, state))?;
464
465 if !event::poll(Duration::from_millis(100))? {
466 continue;
467 }
468 let Event::Key(key) = event::read()? else {
469 continue;
470 };
471 if key.kind == KeyEventKind::Release {
474 continue;
475 }
476 if key.modifiers.contains(KeyModifiers::CONTROL)
479 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
480 {
481 return Ok(Outcome::Cancelled);
482 }
483
484 if let Some(outcome) = handle_key(state, key.code) {
485 return Ok(outcome);
486 }
487 }
488}
489
490fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
492 match state.screen {
493 Screen::Declaration => declaration_key(state, code),
494 Screen::Suggestions => suggestions_key(state, code),
495 Screen::Settings => settings_key(state, code),
496 Screen::Adapters => adapters_key(state, code),
497 Screen::Summary => summary_key(state, code),
498 }
499}
500
501fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
502 let len = state.session.declaration.len().max(1);
503 let current = state.decl_list.selected().unwrap_or(0);
504 match code {
505 KeyCode::Up | KeyCode::Char('k') => {
507 state.decl_list.select(Some(current.saturating_sub(1)));
508 None
509 }
510 KeyCode::Down | KeyCode::Char('j') => {
511 state.decl_list.select(Some((current + 1).min(len - 1)));
512 None
513 }
514 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
519 state.screen = if state.session.suggestions.is_empty() {
520 Screen::Settings
521 } else {
522 Screen::Suggestions
523 };
524 None
525 }
526 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
527 _ => None,
528 }
529}
530
531fn preaccept_recommended(state: &mut State<'_>) {
546 for i in 0..state.session.suggestions.len() {
547 if !state.session.suggestions[i].cautious {
548 apply_suggestion(state, i, true);
549 }
550 }
551}
552
553fn accepted(state: &State<'_>, index: usize) -> bool {
560 let s = &state.session.suggestions[index];
561 row_value(&state.session.rows, s.key).as_deref() == Some(s.value)
562}
563
564fn apply_suggestion(state: &mut State<'_>, index: usize, accept: bool) {
568 let (key, value) = {
569 let s = &state.session.suggestions[index];
570 (s.key, s.value)
571 };
572 let restore = state
573 .session
574 .rows
575 .iter()
576 .find(|r| r.key == key)
577 .map(|r| r.original.clone());
578 let Some(restore) = restore else { return };
579 let next = if accept { value.to_string() } else { restore };
580 set_row(&mut state.session.rows, key, next);
581}
582
583fn suggestions_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
584 let len = state.session.suggestions.len();
585 let current = state.sugg_list.selected().unwrap_or(0);
586 match code {
587 KeyCode::Up | KeyCode::Char('k') => {
588 state
589 .sugg_list
590 .select(Some(if current == 0 { len - 1 } else { current - 1 }))
591 }
592 KeyCode::Down | KeyCode::Char('j') => {
593 state
594 .sugg_list
595 .select(Some(if current + 1 >= len { 0 } else { current + 1 }))
596 }
597 KeyCode::Char(' ') => {
598 let now = accepted(state, current);
599 apply_suggestion(state, current, !now);
600 }
601 KeyCode::Char('a') | KeyCode::Char('A') => {
605 for i in 0..len {
606 if !state.session.suggestions[i].cautious {
607 apply_suggestion(state, i, true);
608 }
609 }
610 }
611 KeyCode::Char('r') | KeyCode::Char('R') => {
612 for i in 0..len {
613 apply_suggestion(state, i, false);
614 }
615 }
616 KeyCode::Char('c') | KeyCode::Char('C') => {
617 state.screen = Screen::Settings;
618 }
619 KeyCode::Enter => {
624 state.screen = Screen::Settings;
625 }
626 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
627 _ => {}
628 }
629 None
630}
631
632fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
633 if state.editing.is_some() {
635 return number_edit_key(state, code);
636 }
637
638 let at = state.list.selected().unwrap_or(0);
639 let current = selected_row(state);
640 let on_finish = matches!(state.setting_entries.get(at), Some(SettingEntry::Finish));
644 match code {
645 KeyCode::Up | KeyCode::Char('k') => {
646 state.error = None;
647 let to = step(&state.setting_entries, at, false);
648 state.list.select(Some(to));
649 }
650 KeyCode::Down | KeyCode::Char('j') => {
651 state.error = None;
652 let to = step(&state.setting_entries, at, true);
653 state.list.select(Some(to));
654 }
655 KeyCode::Home | KeyCode::Char('g') => state.list.select(first_row(&state.setting_entries)),
656 KeyCode::Enter if on_finish => {
657 state.error = None;
658 state.screen = Screen::Summary;
659 }
660 KeyCode::Enter if !on_finish => {
667 state.error = None;
668 let row = &mut state.session.rows[current];
669 if let Some(rec) = row.recommended
670 && !row.cautious
671 && row.value != rec
672 {
673 row.value = rec.to_string();
674 }
675 let to = step(&state.setting_entries, at, true);
676 state.list.select(Some(to));
677 }
678 KeyCode::Char(' ') if !on_finish => activate(state, current),
679 KeyCode::Char('r') | KeyCode::Char('R') if !on_finish => {
680 state.error = None;
681 let row = &mut state.session.rows[current];
682 row.value = row.original.clone();
683 }
684 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
685 _ => {}
686 }
687 None
688}
689
690fn activate(state: &mut State<'_>, index: usize) {
692 state.error = None;
693 match state.session.rows[index].control {
694 Control::Toggle => {
695 let row = &mut state.session.rows[index];
696 row.value = if row.value == "true" {
697 "false".to_string()
698 } else {
699 "true".to_string()
700 };
701 }
702 Control::Choice(options) => {
703 let row = &mut state.session.rows[index];
704 let next = options
708 .iter()
709 .position(|(value, _)| *value == row.value)
710 .map_or(0, |at| (at + 1) % options.len());
711 row.value = options[next].0.to_string();
712 }
713 Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
714 Control::Adapters | Control::AdapterDays | Control::CacheCaps => open_picker(state),
715 }
716}
717
718fn open_picker(state: &mut State<'_>) {
724 let rows = &state.session.rows;
725 let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
726 let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
727 let caps = parse_days(&row_value(rows, "cache_max_gb").unwrap_or_default());
728
729 state.picker_active = state
730 .session
731 .adapters
732 .iter()
733 .map(|name| {
734 if disabled.iter().any(|d| d == name) {
735 return false;
736 }
737 if state.session.opt_in_adapters.contains(name) {
741 return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
742 }
743 true
744 })
745 .collect();
746 state.picker_days = state
747 .session
748 .adapters
749 .iter()
750 .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
751 .collect();
752
753 state.picker_caps = state
754 .session
755 .adapters
756 .iter()
757 .map(|name| {
758 if !state.session.capped_adapters.contains(name) {
759 return None;
760 }
761 caps.iter().find(|(n, _)| n == name).map(|(_, g)| *g)
762 })
763 .collect();
764
765 state.picker_entries = build_entries(state.session.adapters, state.session.groups);
766 state.picker_editing = None;
767 state.picker_field = PickerField::Days;
768 state.picker_list.select(Some(0));
769 state.screen = Screen::Adapters;
770}
771
772fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
773 let index = selected_row(state);
774 match code {
775 KeyCode::Char(c) if c.is_ascii_digit() => {
776 if let Some(buf) = state.editing.as_mut() {
777 buf.push(c);
778 }
779 }
780 KeyCode::Backspace => {
781 if let Some(buf) = state.editing.as_mut() {
782 buf.pop();
783 }
784 }
785 KeyCode::Enter => {
786 let typed = state.editing.clone().unwrap_or_default();
787 let key = state.session.rows[index].key;
788 match (state.session.validate)(key, typed.trim()) {
789 Ok(()) => {
790 state.session.rows[index].value = typed.trim().to_string();
791 state.editing = None;
792 state.error = None;
793 }
794 Err(why) => state.error = Some(why),
797 }
798 }
799 KeyCode::Esc => {
800 state.editing = None;
801 state.error = None;
802 }
803 _ => {}
804 }
805 None
806}
807
808fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
809 if state.picker_editing.is_some() {
810 return picker_number_key(state, code);
811 }
812
813 let len = state.picker_entries.len().max(1);
814 let current = state.picker_list.selected().unwrap_or(0);
815 match code {
816 KeyCode::Up | KeyCode::Char('k') => {
817 state.error = None;
818 state
819 .picker_list
820 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
821 }
822 KeyCode::Down | KeyCode::Char('j') => {
823 state.error = None;
824 state
825 .picker_list
826 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
827 }
828 KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
831 Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
832 Some(PickerEntry::Group { members, .. }) => {
833 let target = !members.iter().any(|&i| state.picker_active[i]);
834 for i in members {
835 state.picker_active[i] = target;
836 }
837 }
838 None => {}
839 },
840 KeyCode::Char('d') | KeyCode::Char('D') => {
841 state.error = None;
842 let seed = match state.picker_entries.get(current) {
843 Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
844 Some(PickerEntry::Group { members, .. }) => {
847 let first = members.first().and_then(|&i| state.picker_days[i]);
848 if members.iter().all(|&i| state.picker_days[i] == first) {
849 first
850 } else {
851 None
852 }
853 }
854 None => None,
855 };
856 state.picker_field = PickerField::Days;
857 state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
858 }
859 KeyCode::Char('c') | KeyCode::Char('C') => {
860 state.error = None;
861 let targets = capped_targets(state, current);
865 if targets.is_empty() {
866 state.error = Some(
867 "No cache of that name for dev-prune to size. `devp caches` lists the ones \
868 there are; `devp config set cache_max_gb` caps them."
869 .to_string(),
870 );
871 return None;
872 }
873 let first = targets.first().and_then(|&i| state.picker_caps[i]);
874 let seed = if targets.iter().all(|&i| state.picker_caps[i] == first) {
875 first
876 } else {
877 None
878 };
879 state.picker_field = PickerField::Cap;
880 state.picker_editing = Some(seed.map(|g| g.to_string()).unwrap_or_default());
881 }
882 KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
883 KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
884 KeyCode::Enter => commit_picker(state),
885 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
886 _ => {}
887 }
888 None
889}
890
891fn capped_targets(state: &State<'_>, line: usize) -> Vec<usize> {
898 let members: Vec<usize> = match state.picker_entries.get(line) {
899 Some(PickerEntry::Adapter(i)) => vec![*i],
900 Some(PickerEntry::Group { members, .. }) => members.clone(),
901 None => Vec::new(),
902 };
903 members
904 .into_iter()
905 .filter(|&i| {
906 state
907 .session
908 .capped_adapters
909 .contains(&state.session.adapters[i])
910 })
911 .collect()
912}
913
914fn picker_number_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
918 let current = state.picker_list.selected().unwrap_or(0);
919 match code {
920 KeyCode::Char(c) if c.is_ascii_digit() => {
921 if let Some(buf) = state.picker_editing.as_mut() {
922 buf.push(c);
923 }
924 }
925 KeyCode::Backspace => {
926 if let Some(buf) = state.picker_editing.as_mut() {
927 buf.pop();
928 }
929 }
930 KeyCode::Enter => {
931 let typed = state.picker_editing.clone().unwrap_or_default();
932 let typed = typed.trim().to_string();
933 let key = match state.picker_field {
934 PickerField::Days => "adapter_idle_days",
935 PickerField::Cap => "cache_max_gb",
936 };
937 let targets: Vec<usize> = match state.picker_field {
938 PickerField::Days => match state.picker_entries.get(current) {
939 Some(PickerEntry::Adapter(i)) => vec![*i],
940 Some(PickerEntry::Group { members, .. }) => members.clone(),
941 None => Vec::new(),
942 },
943 PickerField::Cap => capped_targets(state, current),
944 };
945 let value = if typed.is_empty() {
946 None
947 } else {
948 let Some(&first) = targets.first() else {
949 state.picker_editing = None;
950 return None;
951 };
952 let probe = format!("{}={typed}", state.session.adapters[first]);
953 if let Err(why) = (state.session.validate)(key, &probe) {
956 state.error = Some(why);
957 return None;
958 }
959 typed.parse::<u64>().ok()
960 };
961 for i in targets {
962 match state.picker_field {
963 PickerField::Days => state.picker_days[i] = value,
964 PickerField::Cap => state.picker_caps[i] = value,
965 }
966 }
967 state.picker_editing = None;
968 state.error = None;
969 }
970 KeyCode::Esc => {
971 state.picker_editing = None;
972 state.error = None;
973 }
974 _ => {}
975 }
976 None
977}
978
979fn commit_picker(state: &mut State<'_>) {
985 let adapters = state.session.adapters;
986 let opt_in = state.session.opt_in_adapters;
987
988 let disabled: Vec<&str> = adapters
989 .iter()
990 .enumerate()
991 .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
992 .map(|(_, name)| *name)
993 .collect();
994 let previous =
1000 row_value(&state.session.rows, "disabled_adapters").unwrap_or_else(|| "(none)".into());
1001 let mut previous_names: Vec<&str> = previous
1002 .split(',')
1003 .map(str::trim)
1004 .filter(|s| !s.is_empty() && *s != "(none)")
1005 .collect();
1006 let mut current_names = disabled.clone();
1007 previous_names.sort_unstable();
1008 current_names.sort_unstable();
1009 let disabled = if previous_names == current_names {
1012 previous.clone()
1013 } else if disabled.is_empty() {
1014 "(none)".to_string()
1015 } else {
1016 disabled.join(",")
1017 };
1018
1019 let mut days: Vec<String> = adapters
1020 .iter()
1021 .enumerate()
1022 .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
1023 .collect();
1024 days.sort_unstable();
1028 let days = if days.is_empty() {
1029 "(none)".to_string()
1030 } else {
1031 days.join(",")
1032 };
1033
1034 let existing = parse_days(&row_value(&state.session.rows, "cache_max_gb").unwrap_or_default());
1039 let mut caps: Vec<String> = existing
1040 .iter()
1041 .filter(|(name, _)| !adapters.iter().any(|a| a == name))
1042 .map(|(name, gb)| format!("{name}={gb}"))
1043 .collect();
1044 caps.extend(
1045 adapters
1046 .iter()
1047 .enumerate()
1048 .filter_map(|(i, name)| state.picker_caps[i].map(|g| format!("{name}={g}"))),
1049 );
1050 caps.sort_unstable();
1051 let caps = if caps.is_empty() {
1052 "(none)".to_string()
1053 } else {
1054 caps.join(",")
1055 };
1056
1057 let switches: Vec<(String, String)> = adapters
1058 .iter()
1059 .enumerate()
1060 .filter(|(_, name)| opt_in.contains(name))
1061 .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
1062 .collect();
1063
1064 let rows = &mut state.session.rows;
1065 set_row(rows, "disabled_adapters", disabled);
1066 set_row(rows, "adapter_idle_days", days);
1067 set_row(rows, "cache_max_gb", caps);
1068 for (key, value) in switches {
1069 set_row(rows, &key, value);
1070 }
1071 state.screen = Screen::Settings;
1072}
1073
1074fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
1075 match code {
1076 KeyCode::Enter => {
1077 let changed: Vec<ConfigRow> = state
1078 .session
1079 .rows
1080 .iter()
1081 .filter(|r| r.changed())
1082 .cloned()
1083 .collect();
1084 if changed.is_empty() {
1085 Some(Outcome::KeepAll)
1086 } else {
1087 Some(Outcome::Save(changed))
1088 }
1089 }
1090 KeyCode::Esc | KeyCode::Backspace => {
1091 state.screen = Screen::Settings;
1092 None
1093 }
1094 KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
1095 _ => None,
1096 }
1097}
1098
1099fn parse_list(value: &str) -> Vec<String> {
1101 if value.trim().eq_ignore_ascii_case("(none)") {
1102 return Vec::new();
1103 }
1104 value
1105 .split(',')
1106 .map(|s| s.trim().to_lowercase())
1107 .filter(|s| !s.is_empty())
1108 .collect()
1109}
1110
1111fn parse_days(value: &str) -> Vec<(String, u64)> {
1117 if value.trim().eq_ignore_ascii_case("(none)") {
1118 return Vec::new();
1119 }
1120 value
1121 .split(',')
1122 .filter_map(|entry| {
1123 let (name, days) = entry.trim().split_once('=')?;
1124 Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
1125 })
1126 .collect()
1127}
1128
1129fn render(frame: &mut Frame, state: &mut State<'_>) {
1134 match state.screen {
1135 Screen::Declaration => render_declaration(frame, state),
1136 Screen::Suggestions => render_suggestions(frame, state),
1137 Screen::Settings => render_settings(frame, state),
1138 Screen::Adapters => render_adapters(frame, state),
1139 Screen::Summary => render_summary(frame, state),
1140 }
1141}
1142
1143fn dim() -> Style {
1144 Style::default().fg(Color::DarkGray)
1145}
1146
1147fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
1148 Paragraph::new(vec![
1149 Line::from(Span::styled(
1150 title.to_string(),
1151 Style::default()
1152 .fg(Color::Cyan)
1153 .add_modifier(Modifier::BOLD),
1154 )),
1155 Line::from(Span::styled(subtitle.to_string(), dim())),
1156 ])
1157}
1158
1159fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
1160 let mut spans = Vec::new();
1161 for (i, (key, what)) in keys.iter().enumerate() {
1162 if i > 0 {
1163 spans.push(Span::styled(" ", dim()));
1164 }
1165 spans.push(Span::styled(
1166 key.to_string(),
1167 Style::default().add_modifier(Modifier::BOLD),
1168 ));
1169 spans.push(Span::styled(format!(" {what}"), dim()));
1170 }
1171 Paragraph::new(Line::from(spans))
1172 .block(Block::default().borders(Borders::TOP).border_style(dim()))
1173}
1174
1175fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
1176 let notice = state.session.uninvited;
1180 let mut constraints = vec![Constraint::Length(3)];
1181 if notice.is_some() {
1182 constraints.push(Constraint::Length(6));
1183 }
1184 constraints.extend([
1185 Constraint::Min(5),
1186 Constraint::Length(4),
1189 Constraint::Length(2),
1190 ]);
1191 let chunks = Layout::vertical(constraints).split(frame.area());
1192 let mut at = 0;
1193
1194 frame.render_widget(
1195 header(
1196 state.session.title,
1197 "What this tool is allowed to do on this machine, before it does any of it.",
1198 ),
1199 chunks[at],
1200 );
1201 at += 1;
1202
1203 if let Some(why) = notice {
1204 frame.render_widget(
1205 Paragraph::new(why)
1206 .wrap(Wrap { trim: true })
1207 .style(Style::default().fg(Color::Yellow))
1208 .block(
1209 Block::default()
1210 .title(" Why this opened on its own ")
1211 .borders(Borders::ALL)
1212 .border_style(Style::default().fg(Color::Yellow)),
1213 ),
1214 chunks[at],
1215 );
1216 at += 1;
1217 }
1218
1219 let items: Vec<ListItem> = state
1220 .session
1221 .declaration
1222 .iter()
1223 .map(|d| {
1224 if d.mark == '#' {
1225 return ListItem::new(Line::from(Span::styled(
1226 format!(" {}", d.subject),
1227 Style::default()
1228 .fg(Color::Cyan)
1229 .add_modifier(Modifier::BOLD),
1230 )));
1231 }
1232 let (mark_style, symbol) = match d.mark {
1233 '!' => (Style::default().fg(Color::Yellow), "!"),
1234 '+' => (Style::default().fg(Color::Green), "✓"),
1235 _ => (dim(), " "),
1236 };
1237 ListItem::new(Line::from(vec![
1238 Span::styled(format!(" {symbol} "), mark_style),
1239 Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
1240 Span::styled(d.state.clone(), dim()),
1241 ]))
1242 })
1243 .collect();
1244
1245 frame.render_stateful_widget(
1248 List::new(items).block(
1249 Block::default()
1250 .title(" Declaration ")
1251 .borders(Borders::ALL)
1252 .border_style(dim()),
1253 ),
1254 chunks[at],
1255 &mut state.decl_list,
1256 );
1257 at += 1;
1258
1259 frame.render_widget(
1260 Paragraph::new(vec![
1261 Line::from(Span::styled(
1262 format!(" {}", state.session.standing),
1263 Style::default().fg(Color::Green),
1264 )),
1265 Line::from(Span::styled(
1269 format!(" {}", crate::constants::LICENCE_NOTICE),
1270 dim(),
1271 )),
1272 ])
1273 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1274 chunks[at],
1275 );
1276 at += 1;
1277
1278 frame.render_widget(
1279 footer(&[("↑↓", "read"), ("Enter", "configure"), ("q", "cancel")]),
1280 chunks[at],
1281 );
1282}
1283
1284fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
1291 if state.session.suggestions.is_empty() {
1294 state.screen = Screen::Settings;
1295 return render_settings(frame, state);
1296 }
1297 let chunks = Layout::vertical([
1298 Constraint::Length(3),
1299 Constraint::Min(5),
1300 Constraint::Length(7),
1301 Constraint::Length(2),
1302 ])
1303 .split(frame.area());
1304
1305 let on = (0..state.session.suggestions.len())
1306 .filter(|&i| accepted(state, i))
1307 .count();
1308 frame.render_widget(
1309 header(
1310 "Suggested settings",
1311 &format!(
1316 "{} of {} accepted \u{2014} press \u{2191}\u{2193} to read what each one does. \
1317 The safe ones start accepted; `r` turns every one of them back off.",
1318 on,
1319 state.session.suggestions.len()
1320 ),
1321 ),
1322 chunks[0],
1323 );
1324
1325 let selected = state
1326 .sugg_list
1327 .selected()
1328 .unwrap_or(0)
1329 .min(state.session.suggestions.len() - 1);
1330 let mut items: Vec<ListItem> = Vec::new();
1331 let mut tier_shown = false;
1332 for (i, s) in state.session.suggestions.iter().enumerate() {
1333 let mut lines = Vec::new();
1337 if s.cautious && !tier_shown {
1338 tier_shown = true;
1339 lines.push(Line::from(Span::styled(
1340 " Worth turning on once you know what it does",
1341 Style::default()
1342 .fg(Color::Yellow)
1343 .add_modifier(Modifier::BOLD),
1344 )));
1345 }
1346 let mark = if accepted(state, i) {
1347 Span::styled(
1348 "[x] ",
1349 Style::default()
1350 .fg(Color::Green)
1351 .add_modifier(Modifier::BOLD),
1352 )
1353 } else {
1354 Span::styled("[ ] ", dim())
1355 };
1356 lines.push(Line::from(vec![
1357 mark,
1358 Span::styled(
1359 crate::output::pad_display(s.label, 28),
1360 if selected == i {
1361 Style::default().fg(Color::White)
1362 } else {
1363 Style::default()
1364 },
1365 ),
1366 Span::styled(s.key.to_string(), dim()),
1367 ]));
1368 items.push(ListItem::new(lines));
1369 }
1370
1371 frame.render_stateful_widget(
1372 List::new(items)
1373 .block(
1374 Block::default()
1375 .title(" Suggested ")
1376 .borders(Borders::ALL)
1377 .border_style(dim()),
1378 )
1379 .highlight_style(
1380 Style::default()
1381 .bg(Color::Rgb(30, 40, 60))
1382 .add_modifier(Modifier::BOLD),
1383 )
1384 .highlight_symbol("\u{25b6} "),
1385 chunks[1],
1386 &mut state.sugg_list,
1387 );
1388
1389 let s = &state.session.suggestions[selected];
1390 frame.render_widget(
1391 Paragraph::new(vec![
1392 Line::from(Span::styled(format!(" {}", s.help), Style::default())),
1393 Line::from(""),
1394 Line::from(vec![
1395 Span::styled(" In plain words ", dim()),
1396 Span::styled(s.plain.to_string(), Style::default().fg(Color::Cyan)),
1397 ]),
1398 Line::from(""),
1399 Line::from(vec![
1400 Span::styled(" Why we suggest it ", dim()),
1401 Span::styled(s.why.to_string(), Style::default().fg(Color::Green)),
1402 ]),
1403 ])
1404 .wrap(Wrap { trim: true })
1405 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1406 chunks[2],
1407 );
1408
1409 frame.render_widget(
1410 footer(&[
1411 ("\u{2191}\u{2193}", "read"),
1412 ("Space", "accept one"),
1413 ("a", "accept all suggested"),
1414 ("r", "undo"),
1415 ("Enter", "on to the settings"),
1416 ]),
1417 chunks[3],
1418 );
1419}
1420
1421fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
1422 let chunks = Layout::vertical([
1423 Constraint::Length(3),
1424 Constraint::Min(5),
1425 Constraint::Length(7),
1428 Constraint::Length(2),
1429 ])
1430 .split(frame.area());
1431
1432 let changed = state.session.rows.iter().filter(|r| r.changed()).count();
1433 let new = state.session.rows.iter().filter(|r| r.is_new).count();
1434 let subtitle = match (changed, new) {
1435 (0, 0) => "Nothing changed yet.".to_string(),
1436 (c, 0) => format!("{c} changed."),
1437 (0, n) => format!("{n} new in this version."),
1438 (c, n) => format!("{c} changed, {n} new in this version."),
1439 };
1440 frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
1441
1442 let selected = state.list.selected();
1443 let items: Vec<ListItem> = state
1444 .setting_entries
1445 .iter()
1446 .enumerate()
1447 .map(|(at, entry)| {
1448 let row = match entry {
1451 SettingEntry::Heading(title) => {
1452 return ListItem::new(Line::from(Span::styled(
1453 format!(" {title}"),
1454 Style::default()
1455 .fg(Color::Cyan)
1456 .add_modifier(Modifier::BOLD),
1457 )));
1458 }
1459 SettingEntry::Row(i) => &state.session.rows[*i],
1460 SettingEntry::Finish => {
1461 return ListItem::new(Line::from(vec![
1462 Span::styled(
1463 " Finish — review the changes ",
1464 Style::default()
1465 .fg(Color::Cyan)
1466 .add_modifier(Modifier::BOLD),
1467 ),
1468 Span::styled("Press Enter to see what will be saved", dim()),
1469 ]));
1470 }
1471 };
1472 let control = match row.control {
1473 Control::Toggle if row.value == "true" => Span::styled(
1474 "[x] ",
1475 Style::default()
1476 .fg(Color::Green)
1477 .add_modifier(Modifier::BOLD),
1478 ),
1479 Control::Toggle => Span::styled("[ ] ", dim()),
1480 Control::Choice(_) => Span::styled("(o) ", dim()),
1481 Control::Number => Span::styled("123 ", dim()),
1482 Control::Adapters | Control::AdapterDays | Control::CacheCaps => {
1483 Span::styled("••• ", dim())
1484 }
1485 };
1486
1487 let shown = if state.editing.is_some() && selected == Some(at) {
1488 format!("{}_", state.editing.clone().unwrap_or_default())
1489 } else if let Control::Choice(options) = row.control {
1490 options
1493 .iter()
1494 .find(|(value, _)| *value == row.value)
1495 .map_or_else(
1496 || row.value.clone(),
1497 |(value, label)| format!("{value} {label}"),
1498 )
1499 } else {
1500 row.value.clone()
1501 };
1502
1503 let mut spans = vec![
1504 control,
1505 Span::styled(
1506 crate::output::pad_display(row.key, 28),
1507 if selected == Some(at) {
1508 Style::default().fg(Color::White)
1509 } else {
1510 Style::default()
1511 },
1512 ),
1513 Span::styled(
1514 crate::output::pad_display(&shown, 20),
1515 if row.changed() {
1516 Style::default().fg(Color::Yellow)
1517 } else {
1518 Style::default().fg(Color::Cyan)
1519 },
1520 ),
1521 ];
1522 if row.is_new {
1523 spans.push(Span::styled(
1524 "NEW ",
1525 Style::default()
1526 .fg(Color::Magenta)
1527 .add_modifier(Modifier::BOLD),
1528 ));
1529 }
1530 match row.takes_advice() {
1535 Some(true) => spans.push(Span::styled("REC ", Style::default().fg(Color::Green))),
1536 Some(false) => spans.push(Span::styled("REC ", Style::default().fg(Color::Yellow))),
1537 None => {}
1538 }
1539 if row.changed() {
1540 spans.push(Span::styled(format!("was {}", row.original), dim()));
1541 }
1542 ListItem::new(Line::from(spans))
1543 })
1544 .collect();
1545
1546 let list = List::new(items)
1547 .block(
1548 Block::default()
1549 .title(" Settings ")
1550 .borders(Borders::ALL)
1551 .border_style(dim()),
1552 )
1553 .highlight_style(
1554 Style::default()
1555 .bg(Color::Rgb(30, 40, 60))
1556 .add_modifier(Modifier::BOLD),
1557 )
1558 .highlight_symbol("▶ ");
1559 frame.render_stateful_widget(list, chunks[1], &mut state.list);
1560
1561 if matches!(
1564 state
1565 .setting_entries
1566 .get(state.list.selected().unwrap_or(0)),
1567 Some(SettingEntry::Finish)
1568 ) {
1569 let detail = vec![
1570 Line::from(Span::styled(
1571 " Enter opens a summary of every change. \
1572 Nothing has been written yet.",
1573 Style::default(),
1574 )),
1575 Line::from(vec![
1576 Span::styled(" Changed so far ", dim()),
1577 Span::styled(
1578 match changed {
1579 0 => "nothing".to_string(),
1580 1 => "1 setting".to_string(),
1581 n => format!("{n} settings"),
1582 },
1583 Style::default().fg(Color::Cyan),
1584 ),
1585 ]),
1586 ];
1587 frame.render_widget(
1588 Paragraph::new(detail)
1589 .wrap(Wrap { trim: true })
1590 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1591 chunks[2],
1592 );
1593 frame.render_widget(
1594 footer(&[
1595 ("↑↓", "move"),
1596 ("Enter", "review and finish"),
1597 ("q", "cancel"),
1598 ]),
1599 chunks[3],
1600 );
1601 return;
1602 }
1603
1604 let row = &state.session.rows[selected_row(state)];
1607 let mut detail = vec![
1608 Line::from(Span::styled(format!(" {}", row.help), Style::default())),
1609 Line::from(vec![
1610 Span::styled(" In plain words ", dim()),
1611 Span::styled(row.plain.to_string(), Style::default().fg(Color::Cyan)),
1612 ]),
1613 ];
1614 let mut facts = vec![
1618 Span::styled(" Default ", dim()),
1619 Span::styled(
1620 crate::output::pad_display(&row.default, 12),
1621 Style::default(),
1622 ),
1623 ];
1624 if let Some(rec) = row.recommended {
1625 facts.push(Span::styled("Recommended ", dim()));
1626 facts.push(Span::styled(
1627 crate::output::pad_display(rec, 12),
1628 Style::default().fg(Color::Green),
1629 ));
1630 facts.push(Span::styled(
1631 if row.takes_advice() == Some(true) {
1632 "— already set"
1633 } else {
1634 "— suggested, not required; everything works without it"
1635 },
1636 dim(),
1637 ));
1638 }
1639 detail.push(Line::from(facts));
1640 if row.is_new {
1641 detail.push(Line::from(Span::styled(
1642 " New in this version — it has been applying its default since the upgrade.",
1643 Style::default().fg(Color::Magenta),
1644 )));
1645 }
1646 if let Some(why) = &state.error {
1647 detail.push(Line::from(Span::styled(
1648 format!(" {why}"),
1649 Style::default().fg(Color::Red),
1650 )));
1651 }
1652 frame.render_widget(
1653 Paragraph::new(detail)
1654 .wrap(Wrap { trim: true })
1655 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1656 chunks[2],
1657 );
1658
1659 let keys: &[(&str, &str)] = if state.editing.is_some() {
1660 &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
1661 } else {
1662 &[
1663 ("↑↓", "move"),
1664 ("Enter", "accept advice & next"),
1665 ("Space", "change"),
1666 ("r", "reset"),
1667 ("q/Esc", "cancel"),
1668 ]
1669 };
1670 frame.render_widget(footer(keys), chunks[3]);
1671}
1672
1673fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
1674 let chunks = Layout::vertical([
1675 Constraint::Length(3),
1676 Constraint::Min(5),
1677 Constraint::Length(4),
1678 Constraint::Length(2),
1679 ])
1680 .split(frame.area());
1681
1682 let off = state.picker_active.iter().filter(|a| !**a).count();
1683 frame.render_widget(
1684 header(
1685 "Adapters",
1686 &format!(
1687 "Unchecked adapters are left alone entirely — not scanned, not counted, \
1688 not pruned. {off} off.",
1689 ),
1690 ),
1691 chunks[0],
1692 );
1693
1694 let selected = state.picker_list.selected();
1695 let items: Vec<ListItem> = state
1696 .picker_entries
1697 .iter()
1698 .enumerate()
1699 .map(|(line, entry)| match entry {
1700 PickerEntry::Group { label, members } => {
1701 let on = members.iter().filter(|&&i| state.picker_active[i]).count();
1702 let mark = if on == members.len() {
1703 "[x]"
1704 } else if on == 0 {
1705 "[ ]"
1706 } else {
1707 "[-]"
1710 };
1711 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1712 let shared = members.first().and_then(|&i| state.picker_days[i]);
1713 let window = if editing_here && state.picker_field == PickerField::Days {
1716 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1717 } else if members.iter().all(|&i| state.picker_days[i] == shared) {
1718 shared.map(|d| format!("{d}d")).unwrap_or_default()
1719 } else {
1720 "mixed".to_string()
1721 };
1722 let capped: Vec<usize> = members
1723 .iter()
1724 .copied()
1725 .filter(|&i| {
1726 state
1727 .session
1728 .capped_adapters
1729 .contains(&state.session.adapters[i])
1730 })
1731 .collect();
1732 let shared_cap = capped.first().and_then(|&i| state.picker_caps[i]);
1733 let cap = if editing_here && state.picker_field == PickerField::Cap {
1734 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1735 } else if capped.is_empty() {
1736 String::new()
1737 } else if capped.iter().all(|&i| state.picker_caps[i] == shared_cap) {
1738 shared_cap.map(|g| format!("{g}G")).unwrap_or_default()
1739 } else {
1740 "mixed".to_string()
1741 };
1742 ListItem::new(Line::from(vec![
1743 Span::styled(
1744 format!("{mark} {}", crate::output::pad_display(label, 22)),
1745 Style::default()
1746 .fg(Color::Cyan)
1747 .add_modifier(Modifier::BOLD),
1748 ),
1749 Span::styled(
1750 crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
1751 dim(),
1752 ),
1753 Span::styled(crate::output::pad_display(&window, 10), dim()),
1754 Span::styled(cap, dim()),
1755 ]))
1756 }
1757 PickerEntry::Adapter(i) => {
1758 let name = state.session.adapters[*i];
1759 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1760 let shown = if editing_here && state.picker_field == PickerField::Days {
1761 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1762 } else {
1763 state.picker_days[*i]
1764 .map(|d| format!("{d}d"))
1765 .unwrap_or_else(|| "default".to_string())
1766 };
1767 let cap = if editing_here && state.picker_field == PickerField::Cap {
1771 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1772 } else if !state.session.capped_adapters.contains(&name) {
1773 String::new()
1774 } else {
1775 state.picker_caps[*i]
1776 .map(|g| format!("{g}G"))
1777 .unwrap_or_else(|| "no cap".to_string())
1778 };
1779 let mut spans = vec![
1780 if state.picker_active[*i] {
1781 Span::styled(
1782 " [x] ",
1783 Style::default()
1784 .fg(Color::Green)
1785 .add_modifier(Modifier::BOLD),
1786 )
1787 } else {
1788 Span::styled(" [ ] ", dim())
1789 },
1790 Span::styled(crate::output::pad_display(name, 18), Style::default()),
1791 Span::styled(
1792 crate::output::pad_display(&shown, 10),
1793 if state.picker_days[*i].is_some() {
1794 Style::default().fg(Color::Yellow)
1795 } else {
1796 dim()
1797 },
1798 ),
1799 Span::styled(
1800 crate::output::pad_display(&cap, 10),
1801 if state.picker_caps[*i].is_some() {
1802 Style::default().fg(Color::Yellow)
1803 } else {
1804 dim()
1805 },
1806 ),
1807 ];
1808 if state.session.opt_in_adapters.contains(&name) {
1809 spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1813 }
1814 ListItem::new(Line::from(spans))
1815 }
1816 })
1817 .collect();
1818
1819 let list = List::new(items)
1820 .block(
1821 Block::default()
1822 .title(" Checked adapters stay active idle cache cap ")
1823 .borders(Borders::ALL)
1824 .border_style(dim()),
1825 )
1826 .highlight_style(
1827 Style::default()
1828 .bg(Color::Rgb(30, 40, 60))
1829 .add_modifier(Modifier::BOLD),
1830 )
1831 .highlight_symbol("▶ ");
1832 frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1833
1834 let mut detail = vec![Line::from(Span::styled(
1835 " Space toggles one adapter, or a whole language from its heading. d sets how \
1836 many days that adapter — or that language — must be idle first; an empty value \
1837 puts it back on the global window. c caps that ecosystem's download cache in \
1838 GiB — reported by `devp caches`, and emptied only when you run \
1839 `devp caches clear --over-cap`, never on a schedule.",
1840 dim(),
1841 ))];
1842 if let Some(why) = &state.error {
1843 detail.push(Line::from(Span::styled(
1844 format!(" {why}"),
1845 Style::default().fg(Color::Red),
1846 )));
1847 }
1848 frame.render_widget(
1849 Paragraph::new(detail)
1850 .wrap(Wrap { trim: true })
1851 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1852 chunks[2],
1853 );
1854
1855 let keys: &[(&str, &str)] = match (state.picker_editing.is_some(), state.picker_field) {
1856 (true, PickerField::Days) => &[
1857 ("digits", "days"),
1858 ("Enter", "accept"),
1859 ("empty", "use the global window"),
1860 ("Esc", "abandon"),
1861 ],
1862 (true, PickerField::Cap) => &[
1863 ("digits", "GiB"),
1864 ("Enter", "accept"),
1865 ("empty", "no cap"),
1866 ("Esc", "abandon"),
1867 ],
1868 (false, _) => &[
1869 ("↑↓", "move"),
1870 ("Space", "toggle"),
1871 ("d", "idle days"),
1872 ("c", "cache cap"),
1873 ("a", "all on"),
1874 ("n", "all off"),
1875 ("Enter", "accept"),
1876 ("Esc", "back"),
1877 ],
1878 };
1879 frame.render_widget(footer(keys), chunks[3]);
1880}
1881
1882fn render_summary(frame: &mut Frame, state: &State<'_>) {
1883 let chunks = Layout::vertical([
1884 Constraint::Length(3),
1885 Constraint::Min(5),
1886 Constraint::Length(2),
1887 ])
1888 .split(frame.area());
1889
1890 let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1891 frame.render_widget(
1892 header(
1893 "Summary",
1894 if changed.is_empty() {
1895 "Nothing changed. The defaults stay in place."
1896 } else {
1897 "These are the only values that will be written."
1898 },
1899 ),
1900 chunks[0],
1901 );
1902
1903 let mut lines: Vec<Line> = changed
1904 .iter()
1905 .map(|row| {
1906 Line::from(vec![
1907 Span::styled(
1908 format!(" {}", crate::output::pad_display(row.key, 28)),
1909 Style::default(),
1910 ),
1911 Span::styled(row.original.clone(), dim()),
1912 Span::styled(" → ", dim()),
1913 Span::styled(
1914 row.value.clone(),
1915 Style::default()
1916 .fg(Color::Yellow)
1917 .add_modifier(Modifier::BOLD),
1918 ),
1919 ])
1920 })
1921 .collect();
1922 if lines.is_empty() {
1923 lines.push(Line::from(Span::styled(
1924 " Every setting is still at the value it had when this opened.",
1925 dim(),
1926 )));
1927 }
1928 lines.push(Line::from(""));
1929 lines.push(Line::from(Span::styled(
1930 format!(" {}", state.session.standing),
1931 Style::default().fg(Color::Green),
1932 )));
1933
1934 frame.render_widget(
1935 Paragraph::new(lines).wrap(Wrap { trim: true }).block(
1936 Block::default()
1937 .title(" About to be saved ")
1938 .borders(Borders::ALL)
1939 .border_style(dim()),
1940 ),
1941 chunks[1],
1942 );
1943
1944 frame.render_widget(
1945 footer(&[
1946 ("Enter", "save"),
1947 ("Esc", "back"),
1948 ("q", "discard everything"),
1949 ]),
1950 chunks[2],
1951 );
1952}
1953
1954#[cfg(test)]
1955mod tests {
1956 use super::*;
1957
1958 fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
1959 categorised_row(key, "Settings", control, value)
1960 }
1961
1962 fn categorised_row(
1964 key: &'static str,
1965 category: &'static str,
1966 control: Control,
1967 value: &str,
1968 ) -> ConfigRow {
1969 ConfigRow {
1970 key,
1971 category,
1972 help: "help",
1973 plain: "plain",
1974 control,
1975 value: value.to_string(),
1976 original: value.to_string(),
1977 default: value.to_string(),
1978 recommended: None,
1979 cautious: false,
1980 is_new: false,
1981 }
1982 }
1983
1984 fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
1985 ConfigSession {
1986 declaration: Vec::new(),
1987 standing: String::new(),
1988 suggestions: Vec::new(),
1989 rows,
1990 adapters,
1991 opt_in_adapters: &[],
1992 capped_adapters: &["npm", "pnpm", "cargo", "go"],
1993 groups: &[("Test", &["npm", "cargo", "go"])],
1994 validate: &|key, v| {
1995 let number = if key == "adapter_idle_days" || key == "cache_max_gb" {
1998 v.split_once('=').map(|(_, d)| d).unwrap_or("")
1999 } else {
2000 v
2001 };
2002 number
2003 .parse::<u64>()
2004 .map(|_| ())
2005 .map_err(|_| "not a number".to_string())
2006 },
2007 title: "test",
2008 uninvited: None,
2009 }
2010 }
2011
2012 fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
2013 let setting_entries = settings_entries(&s.rows);
2014 let mut list = ListState::default();
2015 list.select(first_row(&setting_entries));
2018 let mut picker_list = ListState::default();
2019 picker_list.select(Some(0));
2020 State {
2021 picker_active: vec![true; s.adapters.len()],
2022 picker_days: vec![None; s.adapters.len()],
2023 picker_caps: vec![None; s.adapters.len()],
2024 picker_entries: build_entries(s.adapters, s.groups),
2025 setting_entries,
2026 picker_editing: None,
2027 picker_field: PickerField::Days,
2028 session: s,
2029 screen: Screen::Settings,
2030 list,
2031 editing: None,
2032 error: None,
2033 picker_list,
2034 decl_list: ListState::default(),
2035 sugg_list: ListState::default(),
2036 }
2037 }
2038
2039 fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
2044 st.screen = screen;
2045 let mut terminal =
2046 Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
2047 terminal.draw(|frame| render(frame, st)).expect("draw");
2048 terminal
2049 .backend()
2050 .buffer()
2051 .content()
2052 .iter()
2053 .map(|cell| cell.symbol())
2054 .collect()
2055 }
2056
2057 #[test]
2058 fn every_screen_draws() {
2059 let adapters: &[&'static str] = &["npm", "cargo"];
2060 let mut st = state(session(
2061 vec![
2062 row("idle_days", Control::Number, "14"),
2063 row("disabled_adapters", Control::Adapters, "(none)"),
2064 ],
2065 adapters,
2066 ));
2067 st.session.declaration.push(DeclarationLine {
2068 mark: '+',
2069 subject: "Lockfile verification".to_string(),
2070 state: "Required before every delete".to_string(),
2071 });
2072 st.session.standing = "Nothing has been deleted.".to_string();
2073
2074 let decl = screenshot(&mut st, Screen::Declaration);
2075 assert!(decl.contains("Lockfile verification"));
2076 assert!(decl.contains("Nothing has been deleted."));
2077
2078 let settings = screenshot(&mut st, Screen::Settings);
2079 assert!(settings.contains("idle_days"));
2080
2081 st.picker_entries = build_entries(st.session.adapters, st.session.groups);
2082 st.picker_days[1] = Some(45);
2083 let picker = screenshot(&mut st, Screen::Adapters);
2084 assert!(picker.contains("cargo"));
2085 assert!(picker.contains("Test"), "the language heading is missing");
2086 assert!(picker.contains("45d"), "the idle window is missing");
2087
2088 let summary = screenshot(&mut st, Screen::Summary);
2091 assert!(summary.contains("still at the value"));
2092 }
2093
2094 #[test]
2095 fn the_declaration_no_longer_leaves_on_y() {
2096 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2100 st.screen = Screen::Declaration;
2101 assert!(handle_key(&mut st, KeyCode::Char('y')).is_none());
2102 assert_eq!(st.screen, Screen::Declaration);
2103 }
2104
2105 #[test]
2106 fn enter_takes_the_recommendation_and_moves_to_the_next_row() {
2107 let mut rows = vec![
2110 row("enable_cargo", Control::Toggle, "false"),
2111 row("auto_update", Control::Toggle, "false"),
2112 ];
2113 rows[0].recommended = Some("true");
2114 let mut st = state(session(rows, &[]));
2115
2116 let before = st.list.selected();
2117 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2118 assert_eq!(st.session.rows[0].value, "true", "the advice was taken");
2119 assert_eq!(st.screen, Screen::Settings, "one press must not leave");
2120 assert_ne!(st.list.selected(), before, "the cursor moved on");
2121
2122 handle_key(&mut st, KeyCode::Enter);
2123 assert_eq!(
2124 st.session.rows[1].value, "false",
2125 "a row with no recommendation is only stepped past, never flipped"
2126 );
2127 }
2128
2129 #[test]
2130 fn enter_walks_past_a_cautious_recommendation() {
2131 let mut rows = vec![row("allow_manifest_rewrite", Control::Toggle, "false")];
2134 rows[0].recommended = Some("true");
2135 rows[0].cautious = true;
2136 let mut st = state(session(rows, &[]));
2137
2138 handle_key(&mut st, KeyCode::Enter);
2139 assert_eq!(
2140 st.session.rows[0].value, "false",
2141 "the cautious tier is never accepted by the walk"
2142 );
2143 handle_key(&mut st, KeyCode::Up); handle_key(&mut st, KeyCode::Char(' '));
2146 assert_eq!(st.session.rows[0].value, "true");
2147 }
2148
2149 #[test]
2150 fn holding_enter_walks_the_list_and_ends_at_the_summary() {
2151 let mut rows = vec![
2155 row("enable_cargo", Control::Toggle, "false"),
2156 row("auto_update", Control::Toggle, "false"),
2157 ];
2158 rows[0].recommended = Some("true");
2159 let mut st = state(session(rows, &[]));
2160
2161 for _ in 0..3 {
2163 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2164 }
2165 assert_eq!(st.screen, Screen::Summary);
2166 let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2167 panic!("Enter on the summary saves");
2168 };
2169 assert_eq!(changed.len(), 1);
2170 assert_eq!(changed[0].key, "enable_cargo");
2171 assert_eq!(changed[0].value, "true");
2172 }
2173
2174 #[test]
2175 fn the_whole_first_run_is_one_held_key() {
2176 let adapters: &[&str] = &["npm", "cargo"];
2182 let mut rows = vec![
2183 row("idle_days", Control::Number, "14"),
2184 row("enable_cargo", Control::Toggle, "false"),
2185 row("disabled_adapters", Control::Adapters, "(none)"),
2186 row("allow_manifest_rewrite", Control::Toggle, "false"),
2187 ];
2188 rows[1].recommended = Some("true");
2189 rows[3].recommended = Some("true");
2190 rows[3].cautious = true;
2191 let mut s = session(rows, adapters);
2192 s.suggestions = vec![
2193 suggestion("enable_cargo", false),
2194 suggestion("allow_manifest_rewrite", true),
2195 ];
2196 let mut st = state(s);
2197 st.screen = Screen::Declaration;
2198 preaccept_recommended(&mut st);
2199
2200 let mut outcome = None;
2204 for _ in 0..32 {
2205 if let Some(o) = handle_key(&mut st, KeyCode::Enter) {
2206 outcome = Some(o);
2207 break;
2208 }
2209 }
2210 let Some(Outcome::Save(changed)) = outcome else {
2211 panic!("held Enter never reached a save");
2212 };
2213 assert_eq!(changed.len(), 1, "{changed:?}");
2214 assert_eq!(changed[0].key, "enable_cargo");
2215 assert_eq!(changed[0].value, "true");
2216 assert_eq!(
2217 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2218 Some("false"),
2219 "the cautious tier survived the whole ride untouched"
2220 );
2221 }
2222
2223 #[test]
2224 fn enter_leaves_the_suggestions_for_the_settings() {
2225 let adapters: &[&str] = &["npm"];
2229 let mut s = session(
2230 vec![row("enable_cargo", Control::Toggle, "false")],
2231 adapters,
2232 );
2233 s.suggestions = vec![suggestion("enable_cargo", false)];
2234 let mut st = state(s);
2235 st.screen = Screen::Suggestions;
2236 st.sugg_list.select(Some(0));
2237 assert!(suggestions_key(&mut st, KeyCode::Enter).is_none());
2238 assert_eq!(st.screen, Screen::Settings);
2239 }
2240
2241 #[test]
2242 fn the_finish_line_is_not_a_setting() {
2243 let mut st = state(session(
2247 vec![row("auto_update", Control::Toggle, "false")],
2248 &[],
2249 ));
2250 handle_key(&mut st, KeyCode::Up); handle_key(&mut st, KeyCode::Char(' '));
2252 handle_key(&mut st, KeyCode::Char('r'));
2253 assert_eq!(st.session.rows[0].value, "false");
2254 assert!(!st.session.rows[0].changed());
2255 }
2256
2257 #[test]
2258 fn a_refused_value_is_not_stored() {
2259 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2260 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Backspace);
2262 handle_key(&mut st, KeyCode::Backspace); handle_key(&mut st, KeyCode::Enter);
2264 assert_eq!(st.session.rows[0].value, "14");
2265 assert!(st.error.is_some(), "the reason was not shown");
2266 assert!(st.editing.is_some(), "the editor closed on a refusal");
2267 }
2268
2269 #[test]
2270 fn an_accepted_value_replaces_the_old_one() {
2271 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2272 handle_key(&mut st, KeyCode::Char(' '));
2273 handle_key(&mut st, KeyCode::Backspace);
2274 handle_key(&mut st, KeyCode::Backspace);
2275 handle_key(&mut st, KeyCode::Char('3'));
2276 handle_key(&mut st, KeyCode::Char('0'));
2277 handle_key(&mut st, KeyCode::Enter);
2278 assert_eq!(st.session.rows[0].value, "30");
2279 assert!(st.session.rows[0].changed());
2280 }
2281
2282 #[test]
2283 fn unchecking_an_adapter_writes_it_to_the_deny_list() {
2284 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2285 let mut st = state(session(
2286 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2287 adapters,
2288 ));
2289 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.screen, Screen::Adapters);
2291 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2294 handle_key(&mut st, KeyCode::Enter);
2295 assert_eq!(st.session.rows[0].value, "cargo");
2296 assert_eq!(st.screen, Screen::Settings);
2297 }
2298
2299 #[test]
2300 fn every_adapter_appears_under_exactly_one_heading() {
2301 let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
2304 let groups: &[(&'static str, &'static [&'static str])] =
2305 &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2306 let entries = build_entries(adapters, groups);
2307 let headings: Vec<&str> = entries
2308 .iter()
2309 .filter_map(|e| match e {
2310 PickerEntry::Group { label, .. } => Some(*label),
2311 PickerEntry::Adapter(_) => None,
2312 })
2313 .collect();
2314 assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
2315
2316 let mut listed: Vec<usize> = entries
2317 .iter()
2318 .filter_map(|e| match e {
2319 PickerEntry::Adapter(i) => Some(*i),
2320 PickerEntry::Group { .. } => None,
2321 })
2322 .collect();
2323 listed.sort_unstable();
2324 assert_eq!(
2325 listed,
2326 vec![0, 1, 2],
2327 "an adapter was dropped from the list"
2328 );
2329 }
2330
2331 #[test]
2332 fn a_heading_turns_its_whole_language_off_in_one_press() {
2333 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2334 let mut st = state(session(
2335 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2336 adapters,
2337 ));
2338 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2339 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![false, false, true]);
2342 handle_key(&mut st, KeyCode::Char(' '));
2345 assert_eq!(st.picker_active, vec![true, true, true]);
2346 }
2347
2348 #[test]
2349 fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
2350 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2351 let mut st = state(session(
2352 vec![
2353 row("disabled_adapters", Control::Adapters, "(none)"),
2354 row("adapter_idle_days", Control::AdapterDays, "(none)"),
2355 ],
2356 adapters,
2357 ));
2358 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2359 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('d')); handle_key(&mut st, KeyCode::Char('3'));
2362 handle_key(&mut st, KeyCode::Char('0'));
2363 handle_key(&mut st, KeyCode::Enter);
2364 assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
2365
2366 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
2368
2369 handle_key(&mut st, KeyCode::Char(' '));
2373 handle_key(&mut st, KeyCode::Char('d'));
2374 handle_key(&mut st, KeyCode::Backspace);
2375 handle_key(&mut st, KeyCode::Backspace);
2376 handle_key(&mut st, KeyCode::Enter);
2377 handle_key(&mut st, KeyCode::Enter);
2378 assert_eq!(st.session.rows[1].value, "(none)");
2379 }
2380
2381 #[test]
2382 fn a_cache_cap_typed_on_a_heading_reaches_only_the_adapters_that_have_a_cache() {
2383 let adapters: &[&'static str] = &["npm", "pnpm", "venv"];
2387 let mut st = state(session(
2388 vec![
2389 row("disabled_adapters", Control::Adapters, "(none)"),
2390 row("cache_max_gb", Control::CacheCaps, "(none)"),
2391 ],
2392 adapters,
2393 ));
2394 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Python", &["venv"])];
2395 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('c')); handle_key(&mut st, KeyCode::Char('1'));
2398 handle_key(&mut st, KeyCode::Char('0'));
2399 handle_key(&mut st, KeyCode::Enter);
2400 assert_eq!(st.picker_caps, vec![Some(10), Some(10), None]);
2401
2402 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pnpm=10");
2404 }
2405
2406 #[test]
2407 fn a_cache_cap_is_cleared_by_emptying_it() {
2408 let adapters: &[&'static str] = &["npm"];
2409 let mut st = state(session(
2410 vec![
2411 row("disabled_adapters", Control::Adapters, "(none)"),
2412 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2413 ],
2414 adapters,
2415 ));
2416 st.session.groups = &[("JavaScript", &["npm"])];
2417 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_caps, vec![Some(10)]);
2421 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2423 handle_key(&mut st, KeyCode::Backspace);
2424 handle_key(&mut st, KeyCode::Backspace);
2425 handle_key(&mut st, KeyCode::Enter);
2426 handle_key(&mut st, KeyCode::Enter);
2427 assert_eq!(st.session.rows[1].value, "(none)");
2428 }
2429
2430 #[test]
2431 fn a_cap_on_a_cache_with_no_adapter_survives_the_checklist() {
2432 let adapters: &[&'static str] = &["npm"];
2437 let mut st = state(session(
2438 vec![
2439 row("disabled_adapters", Control::Adapters, "(none)"),
2440 row("cache_max_gb", Control::CacheCaps, "npm=10,pip=20"),
2441 ],
2442 adapters,
2443 ));
2444 st.session.groups = &[("JavaScript", &["npm"])];
2445 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pip=20");
2448 }
2449
2450 #[test]
2451 fn typing_a_cap_where_there_is_no_cache_says_so_instead_of_dropping_it() {
2452 let adapters: &[&'static str] = &["venv"];
2453 let mut st = state(session(
2454 vec![
2455 row("disabled_adapters", Control::Adapters, "(none)"),
2456 row("cache_max_gb", Control::CacheCaps, "(none)"),
2457 ],
2458 adapters,
2459 ));
2460 st.session.groups = &[("Python", &["venv"])];
2461 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2464 assert!(st.picker_editing.is_none(), "no editor opened");
2465 let err = st.error.clone().expect("the refusal is explained");
2466 assert!(err.contains("devp caches"), "{err}");
2467 }
2468
2469 #[test]
2470 fn the_checklist_draws_the_cache_cap_beside_the_idle_window() {
2471 let adapters: &[&'static str] = &["npm", "venv"];
2474 let mut st = state(session(
2475 vec![
2476 row("disabled_adapters", Control::Adapters, "(none)"),
2477 row("adapter_idle_days", Control::AdapterDays, "npm=30"),
2478 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2479 ],
2480 adapters,
2481 ));
2482 st.session.groups = &[("JavaScript", &["npm"]), ("Python", &["venv"])];
2483 handle_key(&mut st, KeyCode::Char(' ')); let picker = screenshot(&mut st, Screen::Adapters);
2485 assert!(picker.contains("30d"), "the idle window is drawn");
2486 assert!(picker.contains("10G"), "the cap is drawn");
2487 assert!(picker.contains("no cap") || picker.contains("10G"));
2490 assert!(picker.contains("cache cap"), "the column is labelled");
2491 }
2492
2493 #[test]
2494 fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
2495 let adapters: &[&'static str] = &["npm", "cargo"];
2498 let opt_in: &[&'static str] = &["cargo"];
2499 let mut st = state(session(
2500 vec![
2501 row("disabled_adapters", Control::Adapters, "(none)"),
2502 row("enable_cargo", Control::Toggle, "false"),
2503 ],
2504 adapters,
2505 ));
2506 st.session.opt_in_adapters = opt_in;
2507 st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2508
2509 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![true, false]);
2513 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2517 handle_key(&mut st, KeyCode::Enter);
2518 assert_eq!(st.session.rows[0].value, "(none)");
2519 assert_eq!(st.session.rows[1].value, "true");
2520 }
2521
2522 #[test]
2523 fn the_checklist_opens_showing_what_is_already_disabled() {
2524 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2527 let mut st = state(session(
2528 vec![row("disabled_adapters", Control::Adapters, "go")],
2529 adapters,
2530 ));
2531 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![true, true, false]);
2533 handle_key(&mut st, KeyCode::Enter);
2534 assert_eq!(st.session.rows[0].value, "go");
2535 }
2536
2537 #[test]
2538 fn cancelling_reports_cancelled_rather_than_an_empty_save() {
2539 let mut st = state(session(
2542 vec![row("auto_update", Control::Toggle, "false")],
2543 &[],
2544 ));
2545 assert!(matches!(
2546 handle_key(&mut st, KeyCode::Char('q')),
2547 Some(Outcome::Cancelled)
2548 ));
2549 }
2550
2551 #[test]
2552 fn only_changed_rows_are_saved() {
2553 let mut st = state(session(
2554 vec![
2555 row("auto_update", Control::Toggle, "false"),
2556 row("auto_config", Control::Toggle, "false"),
2557 ],
2558 &[],
2559 ));
2560 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Up); handle_key(&mut st, KeyCode::Enter); let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2564 panic!("expected a save");
2565 };
2566 assert_eq!(changed.len(), 1);
2567 assert_eq!(changed[0].key, "auto_update");
2568 assert_eq!(changed[0].value, "true");
2569 }
2570
2571 #[test]
2572 fn reset_puts_a_row_back_without_touching_the_others() {
2573 let mut st = state(session(
2574 vec![
2575 row("auto_update", Control::Toggle, "false"),
2576 row("auto_config", Control::Toggle, "true"),
2577 ],
2578 &[],
2579 ));
2580 handle_key(&mut st, KeyCode::Char(' '));
2581 assert!(st.session.rows[0].changed());
2582 handle_key(&mut st, KeyCode::Char('r'));
2583 assert!(!st.session.rows[0].changed());
2584 assert_eq!(st.session.rows[1].value, "true");
2585 }
2586
2587 #[test]
2588 fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
2589 let mut rows = [
2590 categorised_row("idle_days", "Scope", Control::Number, "14"),
2591 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2592 categorised_row("auto_config", "Updates", Control::Toggle, "false"),
2593 ];
2594 let entries = settings_entries(&rows);
2595 assert_eq!(entries.len(), 6);
2597 assert_eq!(
2598 opening_index(&entries, &rows),
2599 1,
2600 "with nothing new, start at the first row — never on a heading"
2601 );
2602 rows[2].is_new = true;
2603 assert_eq!(
2604 opening_index(&entries, &rows),
2605 4,
2606 "an index into the drawn list, not into the rows"
2607 );
2608 }
2609
2610 #[test]
2611 fn the_cursor_never_lands_on_a_heading() {
2612 let rows = vec![
2613 categorised_row("idle_days", "Scope", Control::Number, "14"),
2614 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2615 ];
2616 let entries = settings_entries(&rows);
2617 assert_eq!(entries.len(), 5, "two rows, two headings, one finish line");
2618
2619 for forward in [true, false] {
2621 let mut at = first_row(&entries).expect("a row");
2622 for _ in 0..entries.len() * 2 {
2623 at = step(&entries, at, forward);
2624 assert!(
2625 matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish),
2626 "stopped on entry {at}, which is a heading"
2627 );
2628 }
2629 }
2630
2631 let last = last_stop(&entries).expect("a stop");
2633 assert_eq!(step(&entries, last, true), first_row(&entries).unwrap());
2634 assert_eq!(step(&entries, first_row(&entries).unwrap(), false), last);
2635 }
2636
2637 #[test]
2638 fn a_run_of_one_category_gets_one_heading() {
2639 let rows = vec![
2640 categorised_row("a", "Scope", Control::Toggle, "false"),
2641 categorised_row("b", "Scope", Control::Toggle, "false"),
2642 categorised_row("c", "Scope", Control::Toggle, "false"),
2643 ];
2644 assert_eq!(settings_entries(&rows).len(), 5);
2646 }
2647
2648 fn suggestion(key: &'static str, cautious: bool) -> Suggestion {
2649 Suggestion {
2650 key,
2651 label: "label",
2652 help: "help",
2653 plain: "plain",
2654 why: "why",
2655 value: "true",
2656 cautious,
2657 }
2658 }
2659
2660 #[test]
2661 fn the_safe_tier_arrives_accepted_and_the_cautious_one_does_not() {
2662 let adapters: &[&str] = &["npm"];
2666 let mut s = session(
2667 vec![
2668 row("enable_cargo", Control::Toggle, "false"),
2669 row("allow_manifest_rewrite", Control::Toggle, "false"),
2670 ],
2671 adapters,
2672 );
2673 s.suggestions = vec![
2674 suggestion("enable_cargo", false),
2675 suggestion("allow_manifest_rewrite", true),
2676 ];
2677 let mut st = state(s);
2678 preaccept_recommended(&mut st);
2679
2680 assert_eq!(
2681 row_value(&st.session.rows, "enable_cargo").as_deref(),
2682 Some("true"),
2683 "the safe tier should already be on"
2684 );
2685 assert_eq!(
2686 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2687 Some("false"),
2688 "the cautious tier must still be a deliberate choice"
2689 );
2690
2691 st.screen = Screen::Suggestions;
2694 st.sugg_list.select(Some(0));
2695 assert!(suggestions_key(&mut st, KeyCode::Char('r')).is_none());
2696 assert_eq!(
2697 row_value(&st.session.rows, "enable_cargo").as_deref(),
2698 Some("false")
2699 );
2700 }
2701
2702 #[test]
2703 fn a_recommended_row_says_so_and_names_the_fresh_default() {
2704 let adapters: &[&str] = &["npm"];
2705 let mut rows = vec![row("enable_cargo", Control::Toggle, "false")];
2706 rows[0].recommended = Some("true");
2707 let mut st = state(session(rows, adapters));
2708
2709 let shot = screenshot(&mut st, Screen::Settings);
2710 assert!(
2711 shot.contains("REC"),
2712 "the badge is the only thing on the row \
2713 that says a recommendation exists"
2714 );
2715 assert!(
2716 shot.contains("Default"),
2717 "a value nobody chose is unreadable without the one they would have got"
2718 );
2719 assert!(shot.contains("not required"));
2723 }
2724
2725 #[test]
2726 fn accept_all_stops_at_the_cautious_tier() {
2727 let adapters: &[&str] = &["npm"];
2731 let mut s = session(
2732 vec![
2733 row("enable_cargo", Control::Toggle, "false"),
2734 row("allow_manifest_rewrite", Control::Toggle, "false"),
2735 ],
2736 adapters,
2737 );
2738 s.suggestions = vec![
2739 suggestion("enable_cargo", false),
2740 suggestion("allow_manifest_rewrite", true),
2741 ];
2742 let mut st = state(s);
2743 st.screen = Screen::Suggestions;
2744 st.sugg_list.select(Some(0));
2745
2746 assert!(suggestions_key(&mut st, KeyCode::Char('a')).is_none());
2747 assert_eq!(
2748 row_value(&st.session.rows, "enable_cargo").as_deref(),
2749 Some("true")
2750 );
2751 assert_eq!(
2752 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2753 Some("false")
2754 );
2755
2756 st.sugg_list.select(Some(1));
2758 suggestions_key(&mut st, KeyCode::Char(' '));
2759 assert_eq!(
2760 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2761 Some("true")
2762 );
2763 }
2764
2765 #[test]
2766 fn undoing_a_suggestion_puts_back_what_the_setting_had() {
2767 let adapters: &[&str] = &["npm"];
2768 let mut s = session(
2769 vec![row("enable_cargo", Control::Toggle, "false")],
2770 adapters,
2771 );
2772 s.suggestions = vec![suggestion("enable_cargo", false)];
2773 let mut st = state(s);
2774 st.screen = Screen::Suggestions;
2775 st.sugg_list.select(Some(0));
2776
2777 suggestions_key(&mut st, KeyCode::Char(' '));
2778 assert!(accepted(&st, 0));
2779 suggestions_key(&mut st, KeyCode::Char(' '));
2780 assert!(!accepted(&st, 0));
2781 assert_eq!(
2782 row_value(&st.session.rows, "enable_cargo").as_deref(),
2783 Some("false")
2784 );
2785 assert!(!st.session.rows[0].changed());
2787 }
2788
2789 #[test]
2790 fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
2791 let adapters: &[&str] = &["npm"];
2794 let s = session(vec![row("idle_days", Control::Number, "30")], adapters);
2795 let mut st = state(s);
2796 st.screen = Screen::Declaration;
2797 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2798 assert_eq!(st.screen, Screen::Settings);
2799 }
2800
2801 #[test]
2802 fn the_first_run_reaches_the_suggestions_first() {
2803 let adapters: &[&str] = &["npm"];
2804 let mut s = session(
2805 vec![row("enable_cargo", Control::Toggle, "false")],
2806 adapters,
2807 );
2808 s.suggestions = vec![suggestion("enable_cargo", false)];
2809 let mut st = state(s);
2810 st.screen = Screen::Declaration;
2811 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2812 assert_eq!(st.screen, Screen::Suggestions);
2813 }
2814}