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]) -> usize {
305 first_row(entries).unwrap_or(0)
306}
307
308#[derive(Debug, PartialEq, Eq, Clone, Copy)]
309enum Screen {
310 Declaration,
311 Suggestions,
312 Settings,
313 Adapters,
314 Summary,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
319enum PickerEntry {
320 Group {
323 label: &'static str,
324 members: Vec<usize>,
325 },
326 Adapter(usize),
328}
329
330fn build_entries(
337 adapters: &[&'static str],
338 groups: &[(&'static str, &'static [&'static str])],
339) -> Vec<PickerEntry> {
340 let mut entries = Vec::new();
341 let mut placed = vec![false; adapters.len()];
342
343 for (label, names) in groups {
344 let members: Vec<usize> = names
345 .iter()
346 .filter_map(|name| adapters.iter().position(|a| a == name))
347 .collect();
348 if members.is_empty() {
349 continue;
350 }
351 for &i in &members {
352 placed[i] = true;
353 }
354 entries.push(PickerEntry::Group {
355 label,
356 members: members.clone(),
357 });
358 entries.extend(members.into_iter().map(PickerEntry::Adapter));
359 }
360
361 let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
362 if !rest.is_empty() {
363 entries.push(PickerEntry::Group {
364 label: "Other",
365 members: rest.clone(),
366 });
367 entries.extend(rest.into_iter().map(PickerEntry::Adapter));
368 }
369 entries
370}
371
372fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
374 rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
375}
376
377fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
379 if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
380 row.value = value;
381 }
382}
383
384struct State<'a> {
385 session: ConfigSession<'a>,
386 screen: Screen,
387 list: ListState,
388 editing: Option<String>,
390 error: Option<String>,
392 picker_active: Vec<bool>,
394 picker_days: Vec<Option<u64>>,
396 picker_caps: Vec<Option<u64>>,
399 picker_entries: Vec<PickerEntry>,
403 setting_entries: Vec<SettingEntry>,
406 picker_editing: Option<String>,
408 picker_field: PickerField,
410 picker_list: ListState,
411 decl_list: ListState,
413 sugg_list: ListState,
415}
416
417pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
419 if session.rows.is_empty() {
420 return Ok(Outcome::KeepAll);
421 }
422
423 let setting_entries = settings_entries(&session.rows);
424 let mut list = ListState::default();
425 list.select(Some(opening_index(&setting_entries)));
426
427 let mut picker_list = ListState::default();
428 picker_list.select(Some(0));
429
430 let mut decl_list = ListState::default();
431 decl_list.select(Some(0));
432
433 let mut sugg_list = ListState::default();
434 sugg_list.select(Some(0));
435
436 let mut state = State {
437 picker_active: vec![true; session.adapters.len()],
438 picker_days: vec![None; session.adapters.len()],
439 picker_caps: vec![None; session.adapters.len()],
440 picker_entries: Vec::new(),
441 setting_entries,
442 picker_editing: None,
443 picker_field: PickerField::Days,
444 session,
445 screen: Screen::Declaration,
446 list,
447 editing: None,
448 error: None,
449 picker_list,
450 decl_list,
451 sugg_list,
452 };
453
454 preaccept_recommended(&mut state);
455
456 let mut tui = Tui::new()?;
459 tui.drain_stale_input(Duration::from_millis(300));
460 event_loop(&mut tui.terminal, &mut state)
461}
462
463fn event_loop(
464 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
465 state: &mut State<'_>,
466) -> Result<Outcome> {
467 loop {
468 terminal.draw(|frame| render(frame, state))?;
469
470 if !event::poll(Duration::from_millis(100))? {
471 continue;
472 }
473 let Event::Key(key) = event::read()? else {
474 continue;
475 };
476 if key.kind == KeyEventKind::Release {
479 continue;
480 }
481 if key.modifiers.contains(KeyModifiers::CONTROL)
484 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
485 {
486 return Ok(Outcome::Cancelled);
487 }
488
489 if key.code == KeyCode::Enter
497 && key.modifiers.contains(KeyModifiers::SHIFT)
498 && skip_ahead(state)
499 {
500 continue;
501 }
502
503 if let Some(outcome) = handle_key(state, key.code) {
504 return Ok(outcome);
505 }
506 }
507}
508
509fn skip_ahead(state: &mut State<'_>) -> bool {
521 if matches!(state.screen, Screen::Adapters | Screen::Summary) {
522 return false;
523 }
524 for row in &mut state.session.rows {
525 if let Some(rec) = row.recommended
526 && !row.cautious
527 {
528 row.value = rec.to_string();
529 }
530 }
531 state.error = None;
532 state.editing = None;
533 state.screen = Screen::Summary;
534 true
535}
536
537fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
539 match state.screen {
540 Screen::Declaration => declaration_key(state, code),
541 Screen::Suggestions => suggestions_key(state, code),
542 Screen::Settings => settings_key(state, code),
543 Screen::Adapters => adapters_key(state, code),
544 Screen::Summary => summary_key(state, code),
545 }
546}
547
548fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
549 let len = state.session.declaration.len().max(1);
550 let current = state.decl_list.selected().unwrap_or(0);
551 match code {
552 KeyCode::Up | KeyCode::Char('k') => {
554 state.decl_list.select(Some(current.saturating_sub(1)));
555 None
556 }
557 KeyCode::Down | KeyCode::Char('j') => {
558 state.decl_list.select(Some((current + 1).min(len - 1)));
559 None
560 }
561 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
566 state.screen = if state.session.suggestions.is_empty() {
567 Screen::Settings
568 } else {
569 Screen::Suggestions
570 };
571 None
572 }
573 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
574 _ => None,
575 }
576}
577
578fn preaccept_recommended(state: &mut State<'_>) {
593 for i in 0..state.session.suggestions.len() {
594 if !state.session.suggestions[i].cautious {
595 apply_suggestion(state, i, true);
596 }
597 }
598}
599
600fn accepted(state: &State<'_>, index: usize) -> bool {
607 let s = &state.session.suggestions[index];
608 row_value(&state.session.rows, s.key).as_deref() == Some(s.value)
609}
610
611fn apply_suggestion(state: &mut State<'_>, index: usize, accept: bool) {
615 let (key, value) = {
616 let s = &state.session.suggestions[index];
617 (s.key, s.value)
618 };
619 let restore = state
620 .session
621 .rows
622 .iter()
623 .find(|r| r.key == key)
624 .map(|r| r.original.clone());
625 let Some(restore) = restore else { return };
626 let next = if accept { value.to_string() } else { restore };
627 set_row(&mut state.session.rows, key, next);
628}
629
630fn suggestions_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
631 let len = state.session.suggestions.len();
632 let current = state.sugg_list.selected().unwrap_or(0);
633 match code {
634 KeyCode::Up | KeyCode::Char('k') => {
635 state
636 .sugg_list
637 .select(Some(if current == 0 { len - 1 } else { current - 1 }))
638 }
639 KeyCode::Down | KeyCode::Char('j') => {
640 state
641 .sugg_list
642 .select(Some(if current + 1 >= len { 0 } else { current + 1 }))
643 }
644 KeyCode::Char(' ') => {
645 let now = accepted(state, current);
646 apply_suggestion(state, current, !now);
647 }
648 KeyCode::Char('a') | KeyCode::Char('A') => {
652 for i in 0..len {
653 if !state.session.suggestions[i].cautious {
654 apply_suggestion(state, i, true);
655 }
656 }
657 }
658 KeyCode::Char('r') | KeyCode::Char('R') => {
659 for i in 0..len {
660 apply_suggestion(state, i, false);
661 }
662 }
663 KeyCode::Char('c') | KeyCode::Char('C') => {
664 state.screen = Screen::Settings;
665 }
666 KeyCode::Enter => {
671 state.screen = Screen::Settings;
672 }
673 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
674 _ => {}
675 }
676 None
677}
678
679fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
680 if state.editing.is_some() {
682 return number_edit_key(state, code);
683 }
684
685 let at = state.list.selected().unwrap_or(0);
686 let current = selected_row(state);
687 let on_finish = matches!(state.setting_entries.get(at), Some(SettingEntry::Finish));
691 match code {
692 KeyCode::Up | KeyCode::Char('k') => {
693 state.error = None;
694 let to = step(&state.setting_entries, at, false);
695 state.list.select(Some(to));
696 }
697 KeyCode::Down | KeyCode::Char('j') => {
698 state.error = None;
699 let to = step(&state.setting_entries, at, true);
700 state.list.select(Some(to));
701 }
702 KeyCode::Home | KeyCode::Char('g') => state.list.select(first_row(&state.setting_entries)),
703 KeyCode::Enter if on_finish => {
704 state.error = None;
705 state.screen = Screen::Summary;
706 }
707 KeyCode::Enter if !on_finish => {
714 state.error = None;
715 let row = &mut state.session.rows[current];
716 if let Some(rec) = row.recommended
717 && !row.cautious
718 && row.value != rec
719 {
720 row.value = rec.to_string();
721 }
722 let to = step(&state.setting_entries, at, true);
723 state.list.select(Some(to));
724 }
725 KeyCode::Char('a') | KeyCode::Char('A') => {
728 skip_ahead(state);
729 }
730 KeyCode::Char(' ') if !on_finish => activate(state, current),
731 KeyCode::Char('r') | KeyCode::Char('R') if !on_finish => {
732 state.error = None;
733 let row = &mut state.session.rows[current];
734 row.value = row.original.clone();
735 }
736 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
737 _ => {}
738 }
739 None
740}
741
742fn activate(state: &mut State<'_>, index: usize) {
744 state.error = None;
745 match state.session.rows[index].control {
746 Control::Toggle => {
747 let row = &mut state.session.rows[index];
748 row.value = if row.value == "true" {
749 "false".to_string()
750 } else {
751 "true".to_string()
752 };
753 }
754 Control::Choice(options) => {
755 let row = &mut state.session.rows[index];
756 let next = options
760 .iter()
761 .position(|(value, _)| *value == row.value)
762 .map_or(0, |at| (at + 1) % options.len());
763 row.value = options[next].0.to_string();
764 }
765 Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
766 Control::Adapters | Control::AdapterDays | Control::CacheCaps => open_picker(state),
767 }
768}
769
770fn open_picker(state: &mut State<'_>) {
776 let rows = &state.session.rows;
777 let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
778 let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
779 let caps = parse_days(&row_value(rows, "cache_max_gb").unwrap_or_default());
780
781 state.picker_active = state
782 .session
783 .adapters
784 .iter()
785 .map(|name| {
786 if disabled.iter().any(|d| d == name) {
787 return false;
788 }
789 if state.session.opt_in_adapters.contains(name) {
793 return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
794 }
795 true
796 })
797 .collect();
798 state.picker_days = state
799 .session
800 .adapters
801 .iter()
802 .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
803 .collect();
804
805 state.picker_caps = state
806 .session
807 .adapters
808 .iter()
809 .map(|name| {
810 if !state.session.capped_adapters.contains(name) {
811 return None;
812 }
813 caps.iter().find(|(n, _)| n == name).map(|(_, g)| *g)
814 })
815 .collect();
816
817 state.picker_entries = build_entries(state.session.adapters, state.session.groups);
818 state.picker_editing = None;
819 state.picker_field = PickerField::Days;
820 state.picker_list.select(Some(0));
821 state.screen = Screen::Adapters;
822}
823
824fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
825 let index = selected_row(state);
826 match code {
827 KeyCode::Char(c) if c.is_ascii_digit() => {
828 if let Some(buf) = state.editing.as_mut() {
829 buf.push(c);
830 }
831 }
832 KeyCode::Backspace => {
833 if let Some(buf) = state.editing.as_mut() {
834 buf.pop();
835 }
836 }
837 KeyCode::Enter => {
838 let typed = state.editing.clone().unwrap_or_default();
839 let key = state.session.rows[index].key;
840 match (state.session.validate)(key, typed.trim()) {
841 Ok(()) => {
842 state.session.rows[index].value = typed.trim().to_string();
843 state.editing = None;
844 state.error = None;
845 }
846 Err(why) => state.error = Some(why),
849 }
850 }
851 KeyCode::Esc => {
852 state.editing = None;
853 state.error = None;
854 }
855 _ => {}
856 }
857 None
858}
859
860fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
861 if state.picker_editing.is_some() {
862 return picker_number_key(state, code);
863 }
864
865 let len = state.picker_entries.len().max(1);
866 let current = state.picker_list.selected().unwrap_or(0);
867 match code {
868 KeyCode::Up | KeyCode::Char('k') => {
869 state.error = None;
870 state
871 .picker_list
872 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
873 }
874 KeyCode::Down | KeyCode::Char('j') => {
875 state.error = None;
876 state
877 .picker_list
878 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
879 }
880 KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
883 Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
884 Some(PickerEntry::Group { members, .. }) => {
885 let target = !members.iter().any(|&i| state.picker_active[i]);
886 for i in members {
887 state.picker_active[i] = target;
888 }
889 }
890 None => {}
891 },
892 KeyCode::Char('d') | KeyCode::Char('D') => {
893 state.error = None;
894 let seed = match state.picker_entries.get(current) {
895 Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
896 Some(PickerEntry::Group { members, .. }) => {
899 let first = members.first().and_then(|&i| state.picker_days[i]);
900 if members.iter().all(|&i| state.picker_days[i] == first) {
901 first
902 } else {
903 None
904 }
905 }
906 None => None,
907 };
908 state.picker_field = PickerField::Days;
909 state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
910 }
911 KeyCode::Char('c') | KeyCode::Char('C') => {
912 state.error = None;
913 let targets = capped_targets(state, current);
917 if targets.is_empty() {
918 state.error = Some(
919 "No cache of that name for dev-prune to size. `devp caches` lists the ones \
920 there are; `devp config set cache_max_gb` caps them."
921 .to_string(),
922 );
923 return None;
924 }
925 let first = targets.first().and_then(|&i| state.picker_caps[i]);
926 let seed = if targets.iter().all(|&i| state.picker_caps[i] == first) {
927 first
928 } else {
929 None
930 };
931 state.picker_field = PickerField::Cap;
932 state.picker_editing = Some(seed.map(|g| g.to_string()).unwrap_or_default());
933 }
934 KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
935 KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
936 KeyCode::Enter => commit_picker(state),
937 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
938 _ => {}
939 }
940 None
941}
942
943fn capped_targets(state: &State<'_>, line: usize) -> Vec<usize> {
950 let members: Vec<usize> = match state.picker_entries.get(line) {
951 Some(PickerEntry::Adapter(i)) => vec![*i],
952 Some(PickerEntry::Group { members, .. }) => members.clone(),
953 None => Vec::new(),
954 };
955 members
956 .into_iter()
957 .filter(|&i| {
958 state
959 .session
960 .capped_adapters
961 .contains(&state.session.adapters[i])
962 })
963 .collect()
964}
965
966fn picker_number_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
970 let current = state.picker_list.selected().unwrap_or(0);
971 match code {
972 KeyCode::Char(c) if c.is_ascii_digit() => {
973 if let Some(buf) = state.picker_editing.as_mut() {
974 buf.push(c);
975 }
976 }
977 KeyCode::Backspace => {
978 if let Some(buf) = state.picker_editing.as_mut() {
979 buf.pop();
980 }
981 }
982 KeyCode::Enter => {
983 let typed = state.picker_editing.clone().unwrap_or_default();
984 let typed = typed.trim().to_string();
985 let key = match state.picker_field {
986 PickerField::Days => "adapter_idle_days",
987 PickerField::Cap => "cache_max_gb",
988 };
989 let targets: Vec<usize> = match state.picker_field {
990 PickerField::Days => match state.picker_entries.get(current) {
991 Some(PickerEntry::Adapter(i)) => vec![*i],
992 Some(PickerEntry::Group { members, .. }) => members.clone(),
993 None => Vec::new(),
994 },
995 PickerField::Cap => capped_targets(state, current),
996 };
997 let value = if typed.is_empty() {
998 None
999 } else {
1000 let Some(&first) = targets.first() else {
1001 state.picker_editing = None;
1002 return None;
1003 };
1004 let probe = format!("{}={typed}", state.session.adapters[first]);
1005 if let Err(why) = (state.session.validate)(key, &probe) {
1008 state.error = Some(why);
1009 return None;
1010 }
1011 typed.parse::<u64>().ok()
1012 };
1013 for i in targets {
1014 match state.picker_field {
1015 PickerField::Days => state.picker_days[i] = value,
1016 PickerField::Cap => state.picker_caps[i] = value,
1017 }
1018 }
1019 state.picker_editing = None;
1020 state.error = None;
1021 }
1022 KeyCode::Esc => {
1023 state.picker_editing = None;
1024 state.error = None;
1025 }
1026 _ => {}
1027 }
1028 None
1029}
1030
1031fn commit_picker(state: &mut State<'_>) {
1037 let adapters = state.session.adapters;
1038 let opt_in = state.session.opt_in_adapters;
1039
1040 let disabled: Vec<&str> = adapters
1041 .iter()
1042 .enumerate()
1043 .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
1044 .map(|(_, name)| *name)
1045 .collect();
1046 let previous =
1052 row_value(&state.session.rows, "disabled_adapters").unwrap_or_else(|| "(none)".into());
1053 let mut previous_names: Vec<&str> = previous
1054 .split(',')
1055 .map(str::trim)
1056 .filter(|s| !s.is_empty() && *s != "(none)")
1057 .collect();
1058 let mut current_names = disabled.clone();
1059 previous_names.sort_unstable();
1060 current_names.sort_unstable();
1061 let disabled = if previous_names == current_names {
1064 previous.clone()
1065 } else if disabled.is_empty() {
1066 "(none)".to_string()
1067 } else {
1068 disabled.join(",")
1069 };
1070
1071 let mut days: Vec<String> = adapters
1072 .iter()
1073 .enumerate()
1074 .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
1075 .collect();
1076 days.sort_unstable();
1080 let days = if days.is_empty() {
1081 "(none)".to_string()
1082 } else {
1083 days.join(",")
1084 };
1085
1086 let existing = parse_days(&row_value(&state.session.rows, "cache_max_gb").unwrap_or_default());
1091 let mut caps: Vec<String> = existing
1092 .iter()
1093 .filter(|(name, _)| !adapters.iter().any(|a| a == name))
1094 .map(|(name, gb)| format!("{name}={gb}"))
1095 .collect();
1096 caps.extend(
1097 adapters
1098 .iter()
1099 .enumerate()
1100 .filter_map(|(i, name)| state.picker_caps[i].map(|g| format!("{name}={g}"))),
1101 );
1102 caps.sort_unstable();
1103 let caps = if caps.is_empty() {
1104 "(none)".to_string()
1105 } else {
1106 caps.join(",")
1107 };
1108
1109 let switches: Vec<(String, String)> = adapters
1110 .iter()
1111 .enumerate()
1112 .filter(|(_, name)| opt_in.contains(name))
1113 .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
1114 .collect();
1115
1116 let rows = &mut state.session.rows;
1117 set_row(rows, "disabled_adapters", disabled);
1118 set_row(rows, "adapter_idle_days", days);
1119 set_row(rows, "cache_max_gb", caps);
1120 for (key, value) in switches {
1121 set_row(rows, &key, value);
1122 }
1123 state.screen = Screen::Settings;
1124}
1125
1126fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
1127 match code {
1128 KeyCode::Enter => {
1129 let changed: Vec<ConfigRow> = state
1130 .session
1131 .rows
1132 .iter()
1133 .filter(|r| r.changed())
1134 .cloned()
1135 .collect();
1136 if changed.is_empty() {
1137 Some(Outcome::KeepAll)
1138 } else {
1139 Some(Outcome::Save(changed))
1140 }
1141 }
1142 KeyCode::Esc | KeyCode::Backspace => {
1143 state.screen = Screen::Settings;
1144 None
1145 }
1146 KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
1147 _ => None,
1148 }
1149}
1150
1151fn parse_list(value: &str) -> Vec<String> {
1153 if value.trim().eq_ignore_ascii_case("(none)") {
1154 return Vec::new();
1155 }
1156 value
1157 .split(',')
1158 .map(|s| s.trim().to_lowercase())
1159 .filter(|s| !s.is_empty())
1160 .collect()
1161}
1162
1163fn parse_days(value: &str) -> Vec<(String, u64)> {
1169 if value.trim().eq_ignore_ascii_case("(none)") {
1170 return Vec::new();
1171 }
1172 value
1173 .split(',')
1174 .filter_map(|entry| {
1175 let (name, days) = entry.trim().split_once('=')?;
1176 Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
1177 })
1178 .collect()
1179}
1180
1181fn render(frame: &mut Frame, state: &mut State<'_>) {
1186 match state.screen {
1187 Screen::Declaration => render_declaration(frame, state),
1188 Screen::Suggestions => render_suggestions(frame, state),
1189 Screen::Settings => render_settings(frame, state),
1190 Screen::Adapters => render_adapters(frame, state),
1191 Screen::Summary => render_summary(frame, state),
1192 }
1193}
1194
1195fn dim() -> Style {
1196 Style::default().fg(Color::DarkGray)
1197}
1198
1199fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
1200 Paragraph::new(vec![
1201 Line::from(Span::styled(
1202 title.to_string(),
1203 Style::default()
1204 .fg(Color::Cyan)
1205 .add_modifier(Modifier::BOLD),
1206 )),
1207 Line::from(Span::styled(subtitle.to_string(), dim())),
1208 ])
1209}
1210
1211fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
1212 let mut spans = Vec::new();
1213 for (i, (key, what)) in keys.iter().enumerate() {
1214 if i > 0 {
1215 spans.push(Span::styled(" ", dim()));
1216 }
1217 spans.push(Span::styled(
1218 key.to_string(),
1219 Style::default().add_modifier(Modifier::BOLD),
1220 ));
1221 spans.push(Span::styled(format!(" {what}"), dim()));
1222 }
1223 Paragraph::new(Line::from(spans))
1224 .block(Block::default().borders(Borders::TOP).border_style(dim()))
1225}
1226
1227fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
1228 let notice = state.session.uninvited;
1232 let mut constraints = vec![Constraint::Length(3)];
1233 if notice.is_some() {
1234 constraints.push(Constraint::Length(6));
1235 }
1236 constraints.extend([
1237 Constraint::Min(5),
1238 Constraint::Length(5),
1242 Constraint::Length(2),
1243 ]);
1244 let chunks = Layout::vertical(constraints).split(frame.area());
1245 let mut at = 0;
1246
1247 frame.render_widget(
1248 header(
1249 state.session.title,
1250 "What this tool is allowed to do on this machine, before it does any of it.",
1251 ),
1252 chunks[at],
1253 );
1254 at += 1;
1255
1256 if let Some(why) = notice {
1257 frame.render_widget(
1258 Paragraph::new(why)
1259 .wrap(Wrap { trim: true })
1260 .style(Style::default().fg(Color::Yellow))
1261 .block(
1262 Block::default()
1263 .title(" Why this opened on its own ")
1264 .borders(Borders::ALL)
1265 .border_style(Style::default().fg(Color::Yellow)),
1266 ),
1267 chunks[at],
1268 );
1269 at += 1;
1270 }
1271
1272 let items: Vec<ListItem> = state
1273 .session
1274 .declaration
1275 .iter()
1276 .map(|d| {
1277 if d.mark == '#' {
1278 return ListItem::new(Line::from(Span::styled(
1279 format!(" {}", d.subject),
1280 Style::default()
1281 .fg(Color::Cyan)
1282 .add_modifier(Modifier::BOLD),
1283 )));
1284 }
1285 let (mark_style, symbol) = match d.mark {
1286 '!' => (Style::default().fg(Color::Yellow), "!"),
1287 '+' => (Style::default().fg(Color::Green), "✓"),
1288 _ => (dim(), " "),
1289 };
1290 ListItem::new(Line::from(vec![
1291 Span::styled(format!(" {symbol} "), mark_style),
1292 Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
1293 Span::styled(d.state.clone(), dim()),
1294 ]))
1295 })
1296 .collect();
1297
1298 frame.render_stateful_widget(
1301 List::new(items).block(
1302 Block::default()
1303 .title(" Declaration ")
1304 .borders(Borders::ALL)
1305 .border_style(dim()),
1306 ),
1307 chunks[at],
1308 &mut state.decl_list,
1309 );
1310 at += 1;
1311
1312 frame.render_widget(
1313 Paragraph::new(vec![
1314 Line::from(Span::styled(
1315 format!(" {}", state.session.standing),
1316 Style::default().fg(Color::Green),
1317 )),
1318 Line::from(Span::styled(
1323 " In a hurry? Hold Enter — it walks the whole setup, taking the \
1324 recommended answer on every screen.",
1325 Style::default().fg(Color::Cyan),
1326 )),
1327 Line::from(Span::styled(
1331 format!(" {}", crate::constants::LICENCE_NOTICE),
1332 dim(),
1333 )),
1334 ])
1335 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1336 chunks[at],
1337 );
1338 at += 1;
1339
1340 frame.render_widget(
1341 footer(&[
1342 ("↑↓", "read"),
1343 ("Enter", "configure"),
1344 ("hold Enter", "do it all"),
1345 ("q", "cancel"),
1346 ]),
1347 chunks[at],
1348 );
1349}
1350
1351fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
1358 if state.session.suggestions.is_empty() {
1361 state.screen = Screen::Settings;
1362 return render_settings(frame, state);
1363 }
1364 let chunks = Layout::vertical([
1365 Constraint::Length(3),
1366 Constraint::Min(5),
1367 Constraint::Length(7),
1368 Constraint::Length(2),
1369 ])
1370 .split(frame.area());
1371
1372 let on = (0..state.session.suggestions.len())
1373 .filter(|&i| accepted(state, i))
1374 .count();
1375 frame.render_widget(
1376 header(
1377 "Suggested settings",
1378 &format!(
1383 "{} of {} accepted \u{2014} press \u{2191}\u{2193} to read what each one does. \
1384 The safe ones start accepted; `r` turns every one of them back off.",
1385 on,
1386 state.session.suggestions.len()
1387 ),
1388 ),
1389 chunks[0],
1390 );
1391
1392 let selected = state
1393 .sugg_list
1394 .selected()
1395 .unwrap_or(0)
1396 .min(state.session.suggestions.len() - 1);
1397 let mut items: Vec<ListItem> = Vec::new();
1398 let mut tier_shown = false;
1399 for (i, s) in state.session.suggestions.iter().enumerate() {
1400 let mut lines = Vec::new();
1404 if s.cautious && !tier_shown {
1405 tier_shown = true;
1406 lines.push(Line::from(Span::styled(
1407 " Worth turning on once you know what it does",
1408 Style::default()
1409 .fg(Color::Yellow)
1410 .add_modifier(Modifier::BOLD),
1411 )));
1412 }
1413 let mark = if accepted(state, i) {
1414 Span::styled(
1415 "[x] ",
1416 Style::default()
1417 .fg(Color::Green)
1418 .add_modifier(Modifier::BOLD),
1419 )
1420 } else {
1421 Span::styled("[ ] ", dim())
1422 };
1423 lines.push(Line::from(vec![
1424 mark,
1425 Span::styled(
1426 crate::output::pad_display(s.label, 28),
1427 if selected == i {
1428 Style::default().fg(Color::White)
1429 } else {
1430 Style::default()
1431 },
1432 ),
1433 Span::styled(s.key.to_string(), dim()),
1434 ]));
1435 items.push(ListItem::new(lines));
1436 }
1437
1438 frame.render_stateful_widget(
1439 List::new(items)
1440 .block(
1441 Block::default()
1442 .title(" Suggested ")
1443 .borders(Borders::ALL)
1444 .border_style(dim()),
1445 )
1446 .highlight_style(
1447 Style::default()
1448 .bg(Color::Rgb(30, 40, 60))
1449 .add_modifier(Modifier::BOLD),
1450 )
1451 .highlight_symbol("\u{25b6} "),
1452 chunks[1],
1453 &mut state.sugg_list,
1454 );
1455
1456 let s = &state.session.suggestions[selected];
1457 frame.render_widget(
1458 Paragraph::new(vec![
1459 Line::from(Span::styled(format!(" {}", s.help), Style::default())),
1460 Line::from(""),
1461 Line::from(vec![
1462 Span::styled(" In plain words ", dim()),
1463 Span::styled(s.plain.to_string(), Style::default().fg(Color::Cyan)),
1464 ]),
1465 Line::from(""),
1466 Line::from(vec![
1467 Span::styled(" Why we suggest it ", dim()),
1468 Span::styled(s.why.to_string(), Style::default().fg(Color::Green)),
1469 ]),
1470 ])
1471 .wrap(Wrap { trim: true })
1472 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1473 chunks[2],
1474 );
1475
1476 frame.render_widget(
1477 footer(&[
1478 ("\u{2191}\u{2193}", "read"),
1479 ("Space", "accept one"),
1480 ("a", "accept all suggested"),
1481 ("r", "undo"),
1482 ("Enter", "on to the settings"),
1483 ]),
1484 chunks[3],
1485 );
1486}
1487
1488fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
1489 let chunks = Layout::vertical([
1490 Constraint::Length(3),
1491 Constraint::Min(5),
1492 Constraint::Length(7),
1495 Constraint::Length(2),
1496 ])
1497 .split(frame.area());
1498
1499 let changed = state.session.rows.iter().filter(|r| r.changed()).count();
1500 let new = state.session.rows.iter().filter(|r| r.is_new).count();
1501 let subtitle = match (changed, new) {
1502 (0, 0) => "Nothing changed yet.".to_string(),
1503 (c, 0) => format!("{c} changed."),
1504 (0, n) => format!("{n} new in this version."),
1505 (c, n) => format!("{c} changed, {n} new in this version."),
1506 };
1507 frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
1508
1509 let selected = state.list.selected();
1510 let items: Vec<ListItem> = state
1511 .setting_entries
1512 .iter()
1513 .enumerate()
1514 .map(|(at, entry)| {
1515 let row = match entry {
1518 SettingEntry::Heading(title) => {
1519 return ListItem::new(Line::from(Span::styled(
1520 format!(" {title}"),
1521 Style::default()
1522 .fg(Color::Cyan)
1523 .add_modifier(Modifier::BOLD),
1524 )));
1525 }
1526 SettingEntry::Row(i) => &state.session.rows[*i],
1527 SettingEntry::Finish => {
1528 return ListItem::new(Line::from(vec![
1529 Span::styled(
1530 " Finish — review the changes ",
1531 Style::default()
1532 .fg(Color::Cyan)
1533 .add_modifier(Modifier::BOLD),
1534 ),
1535 Span::styled("Press Enter to see what will be saved", dim()),
1536 ]));
1537 }
1538 };
1539 let control = match row.control {
1540 Control::Toggle if row.value == "true" => Span::styled(
1541 "[x] ",
1542 Style::default()
1543 .fg(Color::Green)
1544 .add_modifier(Modifier::BOLD),
1545 ),
1546 Control::Toggle => Span::styled("[ ] ", dim()),
1547 Control::Choice(_) => Span::styled("(o) ", dim()),
1548 Control::Number => Span::styled("123 ", dim()),
1549 Control::Adapters | Control::AdapterDays | Control::CacheCaps => {
1550 Span::styled("••• ", dim())
1551 }
1552 };
1553
1554 let shown = if state.editing.is_some() && selected == Some(at) {
1555 format!("{}_", state.editing.clone().unwrap_or_default())
1556 } else if let Control::Choice(options) = row.control {
1557 options
1560 .iter()
1561 .find(|(value, _)| *value == row.value)
1562 .map_or_else(
1563 || row.value.clone(),
1564 |(value, label)| format!("{value} {label}"),
1565 )
1566 } else {
1567 row.value.clone()
1568 };
1569
1570 let mut spans = vec![
1571 control,
1572 Span::styled(
1573 crate::output::pad_display(row.key, 28),
1574 if selected == Some(at) {
1575 Style::default().fg(Color::White)
1576 } else {
1577 Style::default()
1578 },
1579 ),
1580 Span::styled(
1581 crate::output::pad_display(&shown, 20),
1582 if row.changed() {
1583 Style::default().fg(Color::Yellow)
1584 } else {
1585 Style::default().fg(Color::Cyan)
1586 },
1587 ),
1588 ];
1589 if row.is_new {
1590 spans.push(Span::styled(
1591 "NEW ",
1592 Style::default()
1593 .fg(Color::Magenta)
1594 .add_modifier(Modifier::BOLD),
1595 ));
1596 }
1597 match row.takes_advice() {
1602 Some(true) => spans.push(Span::styled("REC ", Style::default().fg(Color::Green))),
1603 Some(false) => spans.push(Span::styled("REC ", Style::default().fg(Color::Yellow))),
1604 None => {}
1605 }
1606 if row.changed() {
1607 spans.push(Span::styled(format!("was {}", row.original), dim()));
1608 }
1609 ListItem::new(Line::from(spans))
1610 })
1611 .collect();
1612
1613 let list = List::new(items)
1614 .block(
1615 Block::default()
1616 .title(" Settings ")
1617 .borders(Borders::ALL)
1618 .border_style(dim()),
1619 )
1620 .highlight_style(
1621 Style::default()
1622 .bg(Color::Rgb(30, 40, 60))
1623 .add_modifier(Modifier::BOLD),
1624 )
1625 .highlight_symbol("▶ ");
1626 frame.render_stateful_widget(list, chunks[1], &mut state.list);
1627
1628 if matches!(
1631 state
1632 .setting_entries
1633 .get(state.list.selected().unwrap_or(0)),
1634 Some(SettingEntry::Finish)
1635 ) {
1636 let detail = vec![
1637 Line::from(Span::styled(
1638 " Enter opens a summary of every change. \
1639 Nothing has been written yet.",
1640 Style::default(),
1641 )),
1642 Line::from(vec![
1643 Span::styled(" Changed so far ", dim()),
1644 Span::styled(
1645 match changed {
1646 0 => "nothing".to_string(),
1647 1 => "1 setting".to_string(),
1648 n => format!("{n} settings"),
1649 },
1650 Style::default().fg(Color::Cyan),
1651 ),
1652 ]),
1653 ];
1654 frame.render_widget(
1655 Paragraph::new(detail)
1656 .wrap(Wrap { trim: true })
1657 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1658 chunks[2],
1659 );
1660 frame.render_widget(
1661 footer(&[
1662 ("↑↓", "move"),
1663 ("Enter", "review and finish"),
1664 ("a", "accept the rest & finish"),
1665 ("q", "cancel"),
1666 ]),
1667 chunks[3],
1668 );
1669 return;
1670 }
1671
1672 let row = &state.session.rows[selected_row(state)];
1675 let mut detail = vec![
1676 Line::from(Span::styled(format!(" {}", row.help), Style::default())),
1677 Line::from(vec![
1678 Span::styled(" In plain words ", dim()),
1679 Span::styled(row.plain.to_string(), Style::default().fg(Color::Cyan)),
1680 ]),
1681 ];
1682 let mut facts = vec![
1686 Span::styled(" Default ", dim()),
1687 Span::styled(
1688 crate::output::pad_display(&row.default, 12),
1689 Style::default(),
1690 ),
1691 ];
1692 if let Some(rec) = row.recommended {
1693 facts.push(Span::styled("Recommended ", dim()));
1694 facts.push(Span::styled(
1695 crate::output::pad_display(rec, 12),
1696 Style::default().fg(Color::Green),
1697 ));
1698 facts.push(Span::styled(
1699 if row.takes_advice() == Some(true) {
1700 "— already set"
1701 } else {
1702 "— suggested, not required; everything works without it"
1703 },
1704 dim(),
1705 ));
1706 }
1707 detail.push(Line::from(facts));
1708 if row.is_new {
1709 detail.push(Line::from(Span::styled(
1710 " New in this version — it has been applying its default since the upgrade.",
1711 Style::default().fg(Color::Magenta),
1712 )));
1713 }
1714 if let Some(why) = &state.error {
1715 detail.push(Line::from(Span::styled(
1716 format!(" {why}"),
1717 Style::default().fg(Color::Red),
1718 )));
1719 }
1720 frame.render_widget(
1721 Paragraph::new(detail)
1722 .wrap(Wrap { trim: true })
1723 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1724 chunks[2],
1725 );
1726
1727 let keys: &[(&str, &str)] = if state.editing.is_some() {
1728 &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
1729 } else {
1730 &[
1731 ("↑↓", "move"),
1732 ("Enter", "accept advice & next"),
1733 ("a", "accept the rest & finish"),
1734 ("Space", "change"),
1735 ("r", "reset"),
1736 ("q/Esc", "cancel"),
1737 ]
1738 };
1739 frame.render_widget(footer(keys), chunks[3]);
1740}
1741
1742fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
1743 let chunks = Layout::vertical([
1744 Constraint::Length(3),
1745 Constraint::Min(5),
1746 Constraint::Length(4),
1747 Constraint::Length(2),
1748 ])
1749 .split(frame.area());
1750
1751 let off = state.picker_active.iter().filter(|a| !**a).count();
1752 frame.render_widget(
1753 header(
1754 "Adapters",
1755 &format!(
1756 "Unchecked adapters are left alone entirely — not scanned, not counted, \
1757 not pruned. {off} off.",
1758 ),
1759 ),
1760 chunks[0],
1761 );
1762
1763 let selected = state.picker_list.selected();
1764 let items: Vec<ListItem> = state
1765 .picker_entries
1766 .iter()
1767 .enumerate()
1768 .map(|(line, entry)| match entry {
1769 PickerEntry::Group { label, members } => {
1770 let on = members.iter().filter(|&&i| state.picker_active[i]).count();
1771 let mark = if on == members.len() {
1772 "[x]"
1773 } else if on == 0 {
1774 "[ ]"
1775 } else {
1776 "[-]"
1779 };
1780 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1781 let shared = members.first().and_then(|&i| state.picker_days[i]);
1782 let window = if editing_here && state.picker_field == PickerField::Days {
1785 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1786 } else if members.iter().all(|&i| state.picker_days[i] == shared) {
1787 shared.map(|d| format!("{d}d")).unwrap_or_default()
1788 } else {
1789 "mixed".to_string()
1790 };
1791 let capped: Vec<usize> = members
1792 .iter()
1793 .copied()
1794 .filter(|&i| {
1795 state
1796 .session
1797 .capped_adapters
1798 .contains(&state.session.adapters[i])
1799 })
1800 .collect();
1801 let shared_cap = capped.first().and_then(|&i| state.picker_caps[i]);
1802 let cap = if editing_here && state.picker_field == PickerField::Cap {
1803 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1804 } else if capped.is_empty() {
1805 String::new()
1806 } else if capped.iter().all(|&i| state.picker_caps[i] == shared_cap) {
1807 shared_cap.map(|g| format!("{g}G")).unwrap_or_default()
1808 } else {
1809 "mixed".to_string()
1810 };
1811 ListItem::new(Line::from(vec![
1812 Span::styled(
1813 format!("{mark} {}", crate::output::pad_display(label, 22)),
1814 Style::default()
1815 .fg(Color::Cyan)
1816 .add_modifier(Modifier::BOLD),
1817 ),
1818 Span::styled(
1819 crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
1820 dim(),
1821 ),
1822 Span::styled(crate::output::pad_display(&window, 10), dim()),
1823 Span::styled(cap, dim()),
1824 ]))
1825 }
1826 PickerEntry::Adapter(i) => {
1827 let name = state.session.adapters[*i];
1828 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1829 let shown = if editing_here && state.picker_field == PickerField::Days {
1830 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1831 } else {
1832 state.picker_days[*i]
1833 .map(|d| format!("{d}d"))
1834 .unwrap_or_else(|| "default".to_string())
1835 };
1836 let cap = if editing_here && state.picker_field == PickerField::Cap {
1840 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1841 } else if !state.session.capped_adapters.contains(&name) {
1842 String::new()
1843 } else {
1844 state.picker_caps[*i]
1845 .map(|g| format!("{g}G"))
1846 .unwrap_or_else(|| "no cap".to_string())
1847 };
1848 let mut spans = vec![
1849 if state.picker_active[*i] {
1850 Span::styled(
1851 " [x] ",
1852 Style::default()
1853 .fg(Color::Green)
1854 .add_modifier(Modifier::BOLD),
1855 )
1856 } else {
1857 Span::styled(" [ ] ", dim())
1858 },
1859 Span::styled(crate::output::pad_display(name, 18), Style::default()),
1860 Span::styled(
1861 crate::output::pad_display(&shown, 10),
1862 if state.picker_days[*i].is_some() {
1863 Style::default().fg(Color::Yellow)
1864 } else {
1865 dim()
1866 },
1867 ),
1868 Span::styled(
1869 crate::output::pad_display(&cap, 10),
1870 if state.picker_caps[*i].is_some() {
1871 Style::default().fg(Color::Yellow)
1872 } else {
1873 dim()
1874 },
1875 ),
1876 ];
1877 if state.session.opt_in_adapters.contains(&name) {
1878 spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1882 }
1883 ListItem::new(Line::from(spans))
1884 }
1885 })
1886 .collect();
1887
1888 let list = List::new(items)
1889 .block(
1890 Block::default()
1891 .title(" Checked adapters stay active idle cache cap ")
1892 .borders(Borders::ALL)
1893 .border_style(dim()),
1894 )
1895 .highlight_style(
1896 Style::default()
1897 .bg(Color::Rgb(30, 40, 60))
1898 .add_modifier(Modifier::BOLD),
1899 )
1900 .highlight_symbol("▶ ");
1901 frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1902
1903 let mut detail = vec![Line::from(Span::styled(
1904 " Space toggles one adapter, or a whole language from its heading. d sets how \
1905 many days that adapter — or that language — must be idle first; an empty value \
1906 puts it back on the global window. c caps that ecosystem's download cache in \
1907 GiB — reported by `devp caches`, and emptied only when you run \
1908 `devp caches clear --over-cap`, never on a schedule.",
1909 dim(),
1910 ))];
1911 if let Some(why) = &state.error {
1912 detail.push(Line::from(Span::styled(
1913 format!(" {why}"),
1914 Style::default().fg(Color::Red),
1915 )));
1916 }
1917 frame.render_widget(
1918 Paragraph::new(detail)
1919 .wrap(Wrap { trim: true })
1920 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1921 chunks[2],
1922 );
1923
1924 let keys: &[(&str, &str)] = match (state.picker_editing.is_some(), state.picker_field) {
1925 (true, PickerField::Days) => &[
1926 ("digits", "days"),
1927 ("Enter", "accept"),
1928 ("empty", "use the global window"),
1929 ("Esc", "abandon"),
1930 ],
1931 (true, PickerField::Cap) => &[
1932 ("digits", "GiB"),
1933 ("Enter", "accept"),
1934 ("empty", "no cap"),
1935 ("Esc", "abandon"),
1936 ],
1937 (false, _) => &[
1938 ("↑↓", "move"),
1939 ("Space", "toggle"),
1940 ("d", "idle days"),
1941 ("c", "cache cap"),
1942 ("a", "all on"),
1943 ("n", "all off"),
1944 ("Enter", "accept"),
1945 ("Esc", "back"),
1946 ],
1947 };
1948 frame.render_widget(footer(keys), chunks[3]);
1949}
1950
1951fn render_summary(frame: &mut Frame, state: &State<'_>) {
1952 let chunks = Layout::vertical([
1953 Constraint::Length(3),
1954 Constraint::Min(5),
1955 Constraint::Length(2),
1956 ])
1957 .split(frame.area());
1958
1959 let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1960 frame.render_widget(
1961 header(
1962 "Summary",
1963 if changed.is_empty() {
1964 "Nothing changed. The defaults stay in place."
1965 } else {
1966 "These are the only values that will be written."
1967 },
1968 ),
1969 chunks[0],
1970 );
1971
1972 let mut lines: Vec<Line> = changed
1973 .iter()
1974 .map(|row| {
1975 Line::from(vec![
1976 Span::styled(
1977 format!(" {}", crate::output::pad_display(row.key, 28)),
1978 Style::default(),
1979 ),
1980 Span::styled(row.original.clone(), dim()),
1981 Span::styled(" → ", dim()),
1982 Span::styled(
1983 row.value.clone(),
1984 Style::default()
1985 .fg(Color::Yellow)
1986 .add_modifier(Modifier::BOLD),
1987 ),
1988 ])
1989 })
1990 .collect();
1991 if lines.is_empty() {
1992 lines.push(Line::from(Span::styled(
1993 " Every setting is still at the value it had when this opened.",
1994 dim(),
1995 )));
1996 }
1997 lines.push(Line::from(""));
1998 lines.push(Line::from(Span::styled(
1999 format!(" {}", state.session.standing),
2000 Style::default().fg(Color::Green),
2001 )));
2002
2003 frame.render_widget(
2004 Paragraph::new(lines).wrap(Wrap { trim: true }).block(
2005 Block::default()
2006 .title(" About to be saved ")
2007 .borders(Borders::ALL)
2008 .border_style(dim()),
2009 ),
2010 chunks[1],
2011 );
2012
2013 frame.render_widget(
2014 footer(&[
2015 ("Enter", "save"),
2016 ("Esc", "back"),
2017 ("q", "discard everything"),
2018 ]),
2019 chunks[2],
2020 );
2021}
2022
2023#[cfg(test)]
2024mod tests {
2025 use super::*;
2026
2027 fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
2028 categorised_row(key, "Settings", control, value)
2029 }
2030
2031 fn categorised_row(
2033 key: &'static str,
2034 category: &'static str,
2035 control: Control,
2036 value: &str,
2037 ) -> ConfigRow {
2038 ConfigRow {
2039 key,
2040 category,
2041 help: "help",
2042 plain: "plain",
2043 control,
2044 value: value.to_string(),
2045 original: value.to_string(),
2046 default: value.to_string(),
2047 recommended: None,
2048 cautious: false,
2049 is_new: false,
2050 }
2051 }
2052
2053 fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
2054 ConfigSession {
2055 declaration: Vec::new(),
2056 standing: String::new(),
2057 suggestions: Vec::new(),
2058 rows,
2059 adapters,
2060 opt_in_adapters: &[],
2061 capped_adapters: &["npm", "pnpm", "cargo", "go"],
2062 groups: &[("Test", &["npm", "cargo", "go"])],
2063 validate: &|key, v| {
2064 let number = if key == "adapter_idle_days" || key == "cache_max_gb" {
2067 v.split_once('=').map(|(_, d)| d).unwrap_or("")
2068 } else {
2069 v
2070 };
2071 number
2072 .parse::<u64>()
2073 .map(|_| ())
2074 .map_err(|_| "not a number".to_string())
2075 },
2076 title: "test",
2077 uninvited: None,
2078 }
2079 }
2080
2081 fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
2082 let setting_entries = settings_entries(&s.rows);
2083 let mut list = ListState::default();
2084 list.select(first_row(&setting_entries));
2087 let mut picker_list = ListState::default();
2088 picker_list.select(Some(0));
2089 State {
2090 picker_active: vec![true; s.adapters.len()],
2091 picker_days: vec![None; s.adapters.len()],
2092 picker_caps: vec![None; s.adapters.len()],
2093 picker_entries: build_entries(s.adapters, s.groups),
2094 setting_entries,
2095 picker_editing: None,
2096 picker_field: PickerField::Days,
2097 session: s,
2098 screen: Screen::Settings,
2099 list,
2100 editing: None,
2101 error: None,
2102 picker_list,
2103 decl_list: ListState::default(),
2104 sugg_list: ListState::default(),
2105 }
2106 }
2107
2108 fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
2113 st.screen = screen;
2114 let mut terminal =
2115 Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
2116 terminal.draw(|frame| render(frame, st)).expect("draw");
2117 terminal
2118 .backend()
2119 .buffer()
2120 .content()
2121 .iter()
2122 .map(|cell| cell.symbol())
2123 .collect()
2124 }
2125
2126 #[test]
2127 fn every_screen_draws() {
2128 let adapters: &[&'static str] = &["npm", "cargo"];
2129 let mut st = state(session(
2130 vec![
2131 row("idle_days", Control::Number, "14"),
2132 row("disabled_adapters", Control::Adapters, "(none)"),
2133 ],
2134 adapters,
2135 ));
2136 st.session.declaration.push(DeclarationLine {
2137 mark: '+',
2138 subject: "Lockfile verification".to_string(),
2139 state: "Required before every delete".to_string(),
2140 });
2141 st.session.standing = "Nothing has been deleted.".to_string();
2142
2143 let decl = screenshot(&mut st, Screen::Declaration);
2144 assert!(decl.contains("Lockfile verification"));
2145 assert!(decl.contains("Nothing has been deleted."));
2146
2147 let settings = screenshot(&mut st, Screen::Settings);
2148 assert!(settings.contains("idle_days"));
2149
2150 st.picker_entries = build_entries(st.session.adapters, st.session.groups);
2151 st.picker_days[1] = Some(45);
2152 let picker = screenshot(&mut st, Screen::Adapters);
2153 assert!(picker.contains("cargo"));
2154 assert!(picker.contains("Test"), "the language heading is missing");
2155 assert!(picker.contains("45d"), "the idle window is missing");
2156
2157 let summary = screenshot(&mut st, Screen::Summary);
2160 assert!(summary.contains("still at the value"));
2161 }
2162
2163 #[test]
2164 fn the_declaration_no_longer_leaves_on_y() {
2165 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2169 st.screen = Screen::Declaration;
2170 assert!(handle_key(&mut st, KeyCode::Char('y')).is_none());
2171 assert_eq!(st.screen, Screen::Declaration);
2172 }
2173
2174 #[test]
2175 fn enter_takes_the_recommendation_and_moves_to_the_next_row() {
2176 let mut rows = vec![
2179 row("enable_cargo", Control::Toggle, "false"),
2180 row("auto_update", Control::Toggle, "false"),
2181 ];
2182 rows[0].recommended = Some("true");
2183 let mut st = state(session(rows, &[]));
2184
2185 let before = st.list.selected();
2186 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2187 assert_eq!(st.session.rows[0].value, "true", "the advice was taken");
2188 assert_eq!(st.screen, Screen::Settings, "one press must not leave");
2189 assert_ne!(st.list.selected(), before, "the cursor moved on");
2190
2191 handle_key(&mut st, KeyCode::Enter);
2192 assert_eq!(
2193 st.session.rows[1].value, "false",
2194 "a row with no recommendation is only stepped past, never flipped"
2195 );
2196 }
2197
2198 #[test]
2199 fn enter_walks_past_a_cautious_recommendation() {
2200 let mut rows = vec![row("allow_manifest_rewrite", Control::Toggle, "false")];
2203 rows[0].recommended = Some("true");
2204 rows[0].cautious = true;
2205 let mut st = state(session(rows, &[]));
2206
2207 handle_key(&mut st, KeyCode::Enter);
2208 assert_eq!(
2209 st.session.rows[0].value, "false",
2210 "the cautious tier is never accepted by the walk"
2211 );
2212 handle_key(&mut st, KeyCode::Up); handle_key(&mut st, KeyCode::Char(' '));
2215 assert_eq!(st.session.rows[0].value, "true");
2216 }
2217
2218 #[test]
2219 fn a_takes_every_remaining_recommendation_and_lands_on_the_summary() {
2220 let mut rows = vec![
2224 row("enable_cargo", Control::Toggle, "false"),
2225 row("enable_node", Control::Toggle, "false"),
2226 row("allow_manifest_rewrite", Control::Toggle, "false"),
2227 row("auto_update", Control::Toggle, "false"),
2228 ];
2229 rows[0].recommended = Some("true");
2230 rows[1].recommended = Some("true");
2231 rows[2].recommended = Some("true");
2232 rows[2].cautious = true;
2233 let mut st = state(session(rows, &[]));
2234
2235 assert!(handle_key(&mut st, KeyCode::Char('a')).is_none());
2237 assert_eq!(st.screen, Screen::Summary);
2238 assert_eq!(st.session.rows[0].value, "true");
2239 assert_eq!(
2240 st.session.rows[1].value, "true",
2241 "a row the cursor never reached is still taken"
2242 );
2243 assert_eq!(
2244 st.session.rows[2].value, "false",
2245 "the cautious tier is no more automatic here than in the walk"
2246 );
2247 assert_eq!(
2248 st.session.rows[3].value, "false",
2249 "a row with no recommendation is left alone"
2250 );
2251
2252 let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2254 panic!("Enter on the summary saves");
2255 };
2256 assert_eq!(changed.len(), 2);
2257 }
2258
2259 #[test]
2260 fn shift_enter_is_the_same_instruction_as_a() {
2261 let build = || {
2265 let mut rows = vec![
2266 row("enable_cargo", Control::Toggle, "false"),
2267 row("allow_manifest_rewrite", Control::Toggle, "false"),
2268 ];
2269 rows[0].recommended = Some("true");
2270 rows[1].recommended = Some("true");
2271 rows[1].cautious = true;
2272 state(session(rows, &[]))
2273 };
2274
2275 let mut by_letter = build();
2276 handle_key(&mut by_letter, KeyCode::Char('a'));
2277
2278 let mut by_shift = build();
2279 assert!(skip_ahead(&mut by_shift), "settings screen accepts it");
2280
2281 assert_eq!(by_shift.screen, by_letter.screen);
2282 assert_eq!(by_shift.screen, Screen::Summary);
2283 for (a, b) in by_shift.session.rows.iter().zip(&by_letter.session.rows) {
2284 assert_eq!(a.value, b.value, "{} disagreed", a.key);
2285 }
2286
2287 by_shift.screen = Screen::Adapters;
2290 assert!(!skip_ahead(&mut by_shift));
2291 assert_eq!(by_shift.screen, Screen::Adapters);
2292 }
2293
2294 #[test]
2295 fn holding_enter_walks_the_list_and_ends_at_the_summary() {
2296 let mut rows = vec![
2300 row("enable_cargo", Control::Toggle, "false"),
2301 row("auto_update", Control::Toggle, "false"),
2302 ];
2303 rows[0].recommended = Some("true");
2304 let mut st = state(session(rows, &[]));
2305
2306 for _ in 0..3 {
2308 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2309 }
2310 assert_eq!(st.screen, Screen::Summary);
2311 let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2312 panic!("Enter on the summary saves");
2313 };
2314 assert_eq!(changed.len(), 1);
2315 assert_eq!(changed[0].key, "enable_cargo");
2316 assert_eq!(changed[0].value, "true");
2317 }
2318
2319 #[test]
2320 fn the_whole_first_run_is_one_held_key() {
2321 let adapters: &[&str] = &["npm", "cargo"];
2327 let mut rows = vec![
2328 row("idle_days", Control::Number, "14"),
2329 row("enable_cargo", Control::Toggle, "false"),
2330 row("disabled_adapters", Control::Adapters, "(none)"),
2331 row("allow_manifest_rewrite", Control::Toggle, "false"),
2332 ];
2333 rows[1].recommended = Some("true");
2334 rows[3].recommended = Some("true");
2335 rows[3].cautious = true;
2336 let mut s = session(rows, adapters);
2337 s.suggestions = vec![
2338 suggestion("enable_cargo", false),
2339 suggestion("allow_manifest_rewrite", true),
2340 ];
2341 let mut st = state(s);
2342 st.screen = Screen::Declaration;
2343 preaccept_recommended(&mut st);
2344
2345 let mut outcome = None;
2349 for _ in 0..32 {
2350 if let Some(o) = handle_key(&mut st, KeyCode::Enter) {
2351 outcome = Some(o);
2352 break;
2353 }
2354 }
2355 let Some(Outcome::Save(changed)) = outcome else {
2356 panic!("held Enter never reached a save");
2357 };
2358 assert_eq!(changed.len(), 1, "{changed:?}");
2359 assert_eq!(changed[0].key, "enable_cargo");
2360 assert_eq!(changed[0].value, "true");
2361 assert_eq!(
2362 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2363 Some("false"),
2364 "the cautious tier survived the whole ride untouched"
2365 );
2366 }
2367
2368 #[test]
2369 fn enter_leaves_the_suggestions_for_the_settings() {
2370 let adapters: &[&str] = &["npm"];
2374 let mut s = session(
2375 vec![row("enable_cargo", Control::Toggle, "false")],
2376 adapters,
2377 );
2378 s.suggestions = vec![suggestion("enable_cargo", false)];
2379 let mut st = state(s);
2380 st.screen = Screen::Suggestions;
2381 st.sugg_list.select(Some(0));
2382 assert!(suggestions_key(&mut st, KeyCode::Enter).is_none());
2383 assert_eq!(st.screen, Screen::Settings);
2384 }
2385
2386 #[test]
2387 fn the_finish_line_is_not_a_setting() {
2388 let mut st = state(session(
2392 vec![row("auto_update", Control::Toggle, "false")],
2393 &[],
2394 ));
2395 handle_key(&mut st, KeyCode::Up); handle_key(&mut st, KeyCode::Char(' '));
2397 handle_key(&mut st, KeyCode::Char('r'));
2398 assert_eq!(st.session.rows[0].value, "false");
2399 assert!(!st.session.rows[0].changed());
2400 }
2401
2402 #[test]
2403 fn a_refused_value_is_not_stored() {
2404 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2405 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Backspace);
2407 handle_key(&mut st, KeyCode::Backspace); handle_key(&mut st, KeyCode::Enter);
2409 assert_eq!(st.session.rows[0].value, "14");
2410 assert!(st.error.is_some(), "the reason was not shown");
2411 assert!(st.editing.is_some(), "the editor closed on a refusal");
2412 }
2413
2414 #[test]
2415 fn an_accepted_value_replaces_the_old_one() {
2416 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2417 handle_key(&mut st, KeyCode::Char(' '));
2418 handle_key(&mut st, KeyCode::Backspace);
2419 handle_key(&mut st, KeyCode::Backspace);
2420 handle_key(&mut st, KeyCode::Char('3'));
2421 handle_key(&mut st, KeyCode::Char('0'));
2422 handle_key(&mut st, KeyCode::Enter);
2423 assert_eq!(st.session.rows[0].value, "30");
2424 assert!(st.session.rows[0].changed());
2425 }
2426
2427 #[test]
2428 fn unchecking_an_adapter_writes_it_to_the_deny_list() {
2429 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2430 let mut st = state(session(
2431 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2432 adapters,
2433 ));
2434 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.screen, Screen::Adapters);
2436 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2439 handle_key(&mut st, KeyCode::Enter);
2440 assert_eq!(st.session.rows[0].value, "cargo");
2441 assert_eq!(st.screen, Screen::Settings);
2442 }
2443
2444 #[test]
2445 fn every_adapter_appears_under_exactly_one_heading() {
2446 let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
2449 let groups: &[(&'static str, &'static [&'static str])] =
2450 &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2451 let entries = build_entries(adapters, groups);
2452 let headings: Vec<&str> = entries
2453 .iter()
2454 .filter_map(|e| match e {
2455 PickerEntry::Group { label, .. } => Some(*label),
2456 PickerEntry::Adapter(_) => None,
2457 })
2458 .collect();
2459 assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
2460
2461 let mut listed: Vec<usize> = entries
2462 .iter()
2463 .filter_map(|e| match e {
2464 PickerEntry::Adapter(i) => Some(*i),
2465 PickerEntry::Group { .. } => None,
2466 })
2467 .collect();
2468 listed.sort_unstable();
2469 assert_eq!(
2470 listed,
2471 vec![0, 1, 2],
2472 "an adapter was dropped from the list"
2473 );
2474 }
2475
2476 #[test]
2477 fn a_heading_turns_its_whole_language_off_in_one_press() {
2478 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2479 let mut st = state(session(
2480 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2481 adapters,
2482 ));
2483 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2484 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![false, false, true]);
2487 handle_key(&mut st, KeyCode::Char(' '));
2490 assert_eq!(st.picker_active, vec![true, true, true]);
2491 }
2492
2493 #[test]
2494 fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
2495 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2496 let mut st = state(session(
2497 vec![
2498 row("disabled_adapters", Control::Adapters, "(none)"),
2499 row("adapter_idle_days", Control::AdapterDays, "(none)"),
2500 ],
2501 adapters,
2502 ));
2503 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2504 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('d')); handle_key(&mut st, KeyCode::Char('3'));
2507 handle_key(&mut st, KeyCode::Char('0'));
2508 handle_key(&mut st, KeyCode::Enter);
2509 assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
2510
2511 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
2513
2514 handle_key(&mut st, KeyCode::Char(' '));
2518 handle_key(&mut st, KeyCode::Char('d'));
2519 handle_key(&mut st, KeyCode::Backspace);
2520 handle_key(&mut st, KeyCode::Backspace);
2521 handle_key(&mut st, KeyCode::Enter);
2522 handle_key(&mut st, KeyCode::Enter);
2523 assert_eq!(st.session.rows[1].value, "(none)");
2524 }
2525
2526 #[test]
2527 fn a_cache_cap_typed_on_a_heading_reaches_only_the_adapters_that_have_a_cache() {
2528 let adapters: &[&'static str] = &["npm", "pnpm", "venv"];
2532 let mut st = state(session(
2533 vec![
2534 row("disabled_adapters", Control::Adapters, "(none)"),
2535 row("cache_max_gb", Control::CacheCaps, "(none)"),
2536 ],
2537 adapters,
2538 ));
2539 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Python", &["venv"])];
2540 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('c')); handle_key(&mut st, KeyCode::Char('1'));
2543 handle_key(&mut st, KeyCode::Char('0'));
2544 handle_key(&mut st, KeyCode::Enter);
2545 assert_eq!(st.picker_caps, vec![Some(10), Some(10), None]);
2546
2547 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pnpm=10");
2549 }
2550
2551 #[test]
2552 fn a_cache_cap_is_cleared_by_emptying_it() {
2553 let adapters: &[&'static str] = &["npm"];
2554 let mut st = state(session(
2555 vec![
2556 row("disabled_adapters", Control::Adapters, "(none)"),
2557 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2558 ],
2559 adapters,
2560 ));
2561 st.session.groups = &[("JavaScript", &["npm"])];
2562 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_caps, vec![Some(10)]);
2566 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2568 handle_key(&mut st, KeyCode::Backspace);
2569 handle_key(&mut st, KeyCode::Backspace);
2570 handle_key(&mut st, KeyCode::Enter);
2571 handle_key(&mut st, KeyCode::Enter);
2572 assert_eq!(st.session.rows[1].value, "(none)");
2573 }
2574
2575 #[test]
2576 fn a_cap_on_a_cache_with_no_adapter_survives_the_checklist() {
2577 let adapters: &[&'static str] = &["npm"];
2582 let mut st = state(session(
2583 vec![
2584 row("disabled_adapters", Control::Adapters, "(none)"),
2585 row("cache_max_gb", Control::CacheCaps, "npm=10,pip=20"),
2586 ],
2587 adapters,
2588 ));
2589 st.session.groups = &[("JavaScript", &["npm"])];
2590 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pip=20");
2593 }
2594
2595 #[test]
2596 fn typing_a_cap_where_there_is_no_cache_says_so_instead_of_dropping_it() {
2597 let adapters: &[&'static str] = &["venv"];
2598 let mut st = state(session(
2599 vec![
2600 row("disabled_adapters", Control::Adapters, "(none)"),
2601 row("cache_max_gb", Control::CacheCaps, "(none)"),
2602 ],
2603 adapters,
2604 ));
2605 st.session.groups = &[("Python", &["venv"])];
2606 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2609 assert!(st.picker_editing.is_none(), "no editor opened");
2610 let err = st.error.clone().expect("the refusal is explained");
2611 assert!(err.contains("devp caches"), "{err}");
2612 }
2613
2614 #[test]
2615 fn the_checklist_draws_the_cache_cap_beside_the_idle_window() {
2616 let adapters: &[&'static str] = &["npm", "venv"];
2619 let mut st = state(session(
2620 vec![
2621 row("disabled_adapters", Control::Adapters, "(none)"),
2622 row("adapter_idle_days", Control::AdapterDays, "npm=30"),
2623 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2624 ],
2625 adapters,
2626 ));
2627 st.session.groups = &[("JavaScript", &["npm"]), ("Python", &["venv"])];
2628 handle_key(&mut st, KeyCode::Char(' ')); let picker = screenshot(&mut st, Screen::Adapters);
2630 assert!(picker.contains("30d"), "the idle window is drawn");
2631 assert!(picker.contains("10G"), "the cap is drawn");
2632 assert!(picker.contains("no cap") || picker.contains("10G"));
2635 assert!(picker.contains("cache cap"), "the column is labelled");
2636 }
2637
2638 #[test]
2639 fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
2640 let adapters: &[&'static str] = &["npm", "cargo"];
2643 let opt_in: &[&'static str] = &["cargo"];
2644 let mut st = state(session(
2645 vec![
2646 row("disabled_adapters", Control::Adapters, "(none)"),
2647 row("enable_cargo", Control::Toggle, "false"),
2648 ],
2649 adapters,
2650 ));
2651 st.session.opt_in_adapters = opt_in;
2652 st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2653
2654 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![true, false]);
2658 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2662 handle_key(&mut st, KeyCode::Enter);
2663 assert_eq!(st.session.rows[0].value, "(none)");
2664 assert_eq!(st.session.rows[1].value, "true");
2665 }
2666
2667 #[test]
2668 fn the_checklist_opens_showing_what_is_already_disabled() {
2669 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2672 let mut st = state(session(
2673 vec![row("disabled_adapters", Control::Adapters, "go")],
2674 adapters,
2675 ));
2676 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![true, true, false]);
2678 handle_key(&mut st, KeyCode::Enter);
2679 assert_eq!(st.session.rows[0].value, "go");
2680 }
2681
2682 #[test]
2683 fn cancelling_reports_cancelled_rather_than_an_empty_save() {
2684 let mut st = state(session(
2687 vec![row("auto_update", Control::Toggle, "false")],
2688 &[],
2689 ));
2690 assert!(matches!(
2691 handle_key(&mut st, KeyCode::Char('q')),
2692 Some(Outcome::Cancelled)
2693 ));
2694 }
2695
2696 #[test]
2697 fn only_changed_rows_are_saved() {
2698 let mut st = state(session(
2699 vec![
2700 row("auto_update", Control::Toggle, "false"),
2701 row("auto_config", Control::Toggle, "false"),
2702 ],
2703 &[],
2704 ));
2705 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 {
2709 panic!("expected a save");
2710 };
2711 assert_eq!(changed.len(), 1);
2712 assert_eq!(changed[0].key, "auto_update");
2713 assert_eq!(changed[0].value, "true");
2714 }
2715
2716 #[test]
2717 fn reset_puts_a_row_back_without_touching_the_others() {
2718 let mut st = state(session(
2719 vec![
2720 row("auto_update", Control::Toggle, "false"),
2721 row("auto_config", Control::Toggle, "true"),
2722 ],
2723 &[],
2724 ));
2725 handle_key(&mut st, KeyCode::Char(' '));
2726 assert!(st.session.rows[0].changed());
2727 handle_key(&mut st, KeyCode::Char('r'));
2728 assert!(!st.session.rows[0].changed());
2729 assert_eq!(st.session.rows[1].value, "true");
2730 }
2731
2732 #[test]
2733 fn the_view_opens_at_the_top_even_when_a_later_setting_is_new() {
2734 let mut rows = [
2735 categorised_row("idle_days", "Scope", Control::Number, "14"),
2736 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2737 categorised_row("auto_config", "Updates", Control::Toggle, "false"),
2738 ];
2739 let entries = settings_entries(&rows);
2740 assert_eq!(entries.len(), 6);
2742 assert_eq!(
2743 opening_index(&entries),
2744 1,
2745 "start at the first row — never on a heading"
2746 );
2747
2748 rows[2].is_new = true;
2752 assert_eq!(
2753 opening_index(&entries),
2754 1,
2755 "a new setting further down does not move the start; the walk reaches it"
2756 );
2757
2758 let mut at = opening_index(&entries);
2760 let mut seen = vec![at];
2761 while entries[at] != SettingEntry::Finish {
2762 at = step(&entries, at, true);
2763 seen.push(at);
2764 }
2765 assert!(
2766 seen.contains(&4),
2767 "the walk from the top must visit the new setting, saw {seen:?}"
2768 );
2769 }
2770
2771 #[test]
2772 fn the_cursor_never_lands_on_a_heading() {
2773 let rows = vec![
2774 categorised_row("idle_days", "Scope", Control::Number, "14"),
2775 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2776 ];
2777 let entries = settings_entries(&rows);
2778 assert_eq!(entries.len(), 5, "two rows, two headings, one finish line");
2779
2780 for forward in [true, false] {
2782 let mut at = first_row(&entries).expect("a row");
2783 for _ in 0..entries.len() * 2 {
2784 at = step(&entries, at, forward);
2785 assert!(
2786 matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish),
2787 "stopped on entry {at}, which is a heading"
2788 );
2789 }
2790 }
2791
2792 let last = last_stop(&entries).expect("a stop");
2794 assert_eq!(step(&entries, last, true), first_row(&entries).unwrap());
2795 assert_eq!(step(&entries, first_row(&entries).unwrap(), false), last);
2796 }
2797
2798 #[test]
2799 fn a_run_of_one_category_gets_one_heading() {
2800 let rows = vec![
2801 categorised_row("a", "Scope", Control::Toggle, "false"),
2802 categorised_row("b", "Scope", Control::Toggle, "false"),
2803 categorised_row("c", "Scope", Control::Toggle, "false"),
2804 ];
2805 assert_eq!(settings_entries(&rows).len(), 5);
2807 }
2808
2809 fn suggestion(key: &'static str, cautious: bool) -> Suggestion {
2810 Suggestion {
2811 key,
2812 label: "label",
2813 help: "help",
2814 plain: "plain",
2815 why: "why",
2816 value: "true",
2817 cautious,
2818 }
2819 }
2820
2821 #[test]
2822 fn the_safe_tier_arrives_accepted_and_the_cautious_one_does_not() {
2823 let adapters: &[&str] = &["npm"];
2827 let mut s = session(
2828 vec![
2829 row("enable_cargo", Control::Toggle, "false"),
2830 row("allow_manifest_rewrite", Control::Toggle, "false"),
2831 ],
2832 adapters,
2833 );
2834 s.suggestions = vec![
2835 suggestion("enable_cargo", false),
2836 suggestion("allow_manifest_rewrite", true),
2837 ];
2838 let mut st = state(s);
2839 preaccept_recommended(&mut st);
2840
2841 assert_eq!(
2842 row_value(&st.session.rows, "enable_cargo").as_deref(),
2843 Some("true"),
2844 "the safe tier should already be on"
2845 );
2846 assert_eq!(
2847 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2848 Some("false"),
2849 "the cautious tier must still be a deliberate choice"
2850 );
2851
2852 st.screen = Screen::Suggestions;
2855 st.sugg_list.select(Some(0));
2856 assert!(suggestions_key(&mut st, KeyCode::Char('r')).is_none());
2857 assert_eq!(
2858 row_value(&st.session.rows, "enable_cargo").as_deref(),
2859 Some("false")
2860 );
2861 }
2862
2863 #[test]
2864 fn a_recommended_row_says_so_and_names_the_fresh_default() {
2865 let adapters: &[&str] = &["npm"];
2866 let mut rows = vec![row("enable_cargo", Control::Toggle, "false")];
2867 rows[0].recommended = Some("true");
2868 let mut st = state(session(rows, adapters));
2869
2870 let shot = screenshot(&mut st, Screen::Settings);
2871 assert!(
2872 shot.contains("REC"),
2873 "the badge is the only thing on the row \
2874 that says a recommendation exists"
2875 );
2876 assert!(
2877 shot.contains("Default"),
2878 "a value nobody chose is unreadable without the one they would have got"
2879 );
2880 assert!(shot.contains("not required"));
2884 }
2885
2886 #[test]
2887 fn accept_all_stops_at_the_cautious_tier() {
2888 let adapters: &[&str] = &["npm"];
2892 let mut s = session(
2893 vec![
2894 row("enable_cargo", Control::Toggle, "false"),
2895 row("allow_manifest_rewrite", Control::Toggle, "false"),
2896 ],
2897 adapters,
2898 );
2899 s.suggestions = vec![
2900 suggestion("enable_cargo", false),
2901 suggestion("allow_manifest_rewrite", true),
2902 ];
2903 let mut st = state(s);
2904 st.screen = Screen::Suggestions;
2905 st.sugg_list.select(Some(0));
2906
2907 assert!(suggestions_key(&mut st, KeyCode::Char('a')).is_none());
2908 assert_eq!(
2909 row_value(&st.session.rows, "enable_cargo").as_deref(),
2910 Some("true")
2911 );
2912 assert_eq!(
2913 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2914 Some("false")
2915 );
2916
2917 st.sugg_list.select(Some(1));
2919 suggestions_key(&mut st, KeyCode::Char(' '));
2920 assert_eq!(
2921 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2922 Some("true")
2923 );
2924 }
2925
2926 #[test]
2927 fn undoing_a_suggestion_puts_back_what_the_setting_had() {
2928 let adapters: &[&str] = &["npm"];
2929 let mut s = session(
2930 vec![row("enable_cargo", Control::Toggle, "false")],
2931 adapters,
2932 );
2933 s.suggestions = vec![suggestion("enable_cargo", false)];
2934 let mut st = state(s);
2935 st.screen = Screen::Suggestions;
2936 st.sugg_list.select(Some(0));
2937
2938 suggestions_key(&mut st, KeyCode::Char(' '));
2939 assert!(accepted(&st, 0));
2940 suggestions_key(&mut st, KeyCode::Char(' '));
2941 assert!(!accepted(&st, 0));
2942 assert_eq!(
2943 row_value(&st.session.rows, "enable_cargo").as_deref(),
2944 Some("false")
2945 );
2946 assert!(!st.session.rows[0].changed());
2948 }
2949
2950 #[test]
2951 fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
2952 let adapters: &[&str] = &["npm"];
2955 let s = session(vec![row("idle_days", Control::Number, "30")], adapters);
2956 let mut st = state(s);
2957 st.screen = Screen::Declaration;
2958 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2959 assert_eq!(st.screen, Screen::Settings);
2960 }
2961
2962 #[test]
2963 fn the_first_run_reaches_the_suggestions_first() {
2964 let adapters: &[&str] = &["npm"];
2965 let mut s = session(
2966 vec![row("enable_cargo", Control::Toggle, "false")],
2967 adapters,
2968 );
2969 s.suggestions = vec![suggestion("enable_cargo", false)];
2970 let mut st = state(s);
2971 st.screen = Screen::Declaration;
2972 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2973 assert_eq!(st.screen, Screen::Suggestions);
2974 }
2975}