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,
101 pub is_new: bool,
103}
104
105impl ConfigRow {
106 pub fn changed(&self) -> bool {
107 self.value != self.original
108 }
109
110 pub fn takes_advice(&self) -> Option<bool> {
115 self.recommended.map(|r| self.value == r)
116 }
117}
118
119#[derive(Debug, Clone)]
121pub struct DeclarationLine {
122 pub mark: char,
125 pub subject: String,
126 pub state: String,
127}
128
129#[derive(Debug, Clone)]
136pub struct Suggestion {
137 pub key: &'static str,
138 pub label: &'static str,
140 pub help: &'static str,
142 pub plain: &'static str,
144 pub why: &'static str,
146 pub value: &'static str,
148 pub cautious: bool,
157}
158
159pub enum Outcome {
161 Save(Vec<ConfigRow>),
163 KeepAll,
165 Cancelled,
167}
168
169pub struct ConfigSession<'a> {
171 pub declaration: Vec<DeclarationLine>,
173 pub standing: String,
175 pub rows: Vec<ConfigRow>,
176 pub suggestions: Vec<Suggestion>,
179 pub adapters: &'a [&'static str],
181 pub opt_in_adapters: &'a [&'static str],
183 pub capped_adapters: &'a [&'static str],
193 pub groups: &'a [(&'static str, &'static [&'static str])],
196 pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
199 pub title: &'a str,
201 pub uninvited: Option<&'a str>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
217enum SettingEntry {
218 Heading(&'static str),
219 Row(usize),
220 Finish,
224}
225
226fn settings_entries(rows: &[ConfigRow]) -> Vec<SettingEntry> {
232 let mut entries = Vec::with_capacity(rows.len() + 8);
233 let mut current: Option<&str> = None;
234 for (i, row) in rows.iter().enumerate() {
235 if current != Some(row.category) {
236 entries.push(SettingEntry::Heading(row.category));
237 current = Some(row.category);
238 }
239 entries.push(SettingEntry::Row(i));
240 }
241 entries.push(SettingEntry::Finish);
242 entries
243}
244
245fn selected_row(state: &State<'_>) -> usize {
247 let at = state.list.selected().unwrap_or(0);
248 match state.setting_entries.get(at) {
249 Some(SettingEntry::Row(i)) => *i,
250 _ => first_row(&state.setting_entries).map_or(0, |at| match state.setting_entries[at] {
253 SettingEntry::Row(i) => i,
254 SettingEntry::Heading(_) | SettingEntry::Finish => 0,
255 }),
256 }
257}
258
259fn step(entries: &[SettingEntry], from: usize, forward: bool) -> usize {
261 let len = entries.len();
262 let mut at = from;
263 for _ in 0..len {
264 at = if forward {
265 if at + 1 >= len { 0 } else { at + 1 }
266 } else if at == 0 {
267 len - 1
268 } else {
269 at - 1
270 };
271 if matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish) {
272 return at;
273 }
274 }
275 from
276}
277
278fn first_row(entries: &[SettingEntry]) -> Option<usize> {
279 entries
280 .iter()
281 .position(|e| matches!(e, SettingEntry::Row(_)))
282}
283
284#[cfg(test)]
289fn last_stop(entries: &[SettingEntry]) -> Option<usize> {
290 entries
291 .iter()
292 .rposition(|e| matches!(e, SettingEntry::Row(_) | SettingEntry::Finish))
293}
294
295fn opening_index(entries: &[SettingEntry]) -> usize {
311 first_row(entries).unwrap_or(0)
312}
313
314#[derive(Debug, PartialEq, Eq, Clone, Copy)]
315enum Screen {
316 Declaration,
317 Suggestions,
318 Settings,
319 Adapters,
320 Summary,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
325enum PickerEntry {
326 Group {
329 label: &'static str,
330 members: Vec<usize>,
331 },
332 Adapter(usize),
334}
335
336fn build_entries(
343 adapters: &[&'static str],
344 groups: &[(&'static str, &'static [&'static str])],
345) -> Vec<PickerEntry> {
346 let mut entries = Vec::new();
347 let mut placed = vec![false; adapters.len()];
348
349 for (label, names) in groups {
350 let members: Vec<usize> = names
351 .iter()
352 .filter_map(|name| adapters.iter().position(|a| a == name))
353 .collect();
354 if members.is_empty() {
355 continue;
356 }
357 for &i in &members {
358 placed[i] = true;
359 }
360 entries.push(PickerEntry::Group {
361 label,
362 members: members.clone(),
363 });
364 entries.extend(members.into_iter().map(PickerEntry::Adapter));
365 }
366
367 let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
368 if !rest.is_empty() {
369 entries.push(PickerEntry::Group {
370 label: "Other",
371 members: rest.clone(),
372 });
373 entries.extend(rest.into_iter().map(PickerEntry::Adapter));
374 }
375 entries
376}
377
378fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
380 rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
381}
382
383fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
385 if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
386 row.value = value;
387 }
388}
389
390struct State<'a> {
391 session: ConfigSession<'a>,
392 screen: Screen,
393 list: ListState,
394 editing: Option<String>,
396 error: Option<String>,
398 picker_active: Vec<bool>,
400 picker_days: Vec<Option<u64>>,
402 picker_caps: Vec<Option<u64>>,
405 picker_entries: Vec<PickerEntry>,
409 setting_entries: Vec<SettingEntry>,
412 picker_editing: Option<String>,
414 picker_field: PickerField,
416 picker_list: ListState,
417 decl_list: ListState,
419 sugg_list: ListState,
421}
422
423pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
425 if session.rows.is_empty() {
426 return Ok(Outcome::KeepAll);
427 }
428
429 let setting_entries = settings_entries(&session.rows);
430 let mut list = ListState::default();
431 list.select(Some(opening_index(&setting_entries)));
432
433 let mut picker_list = ListState::default();
434 picker_list.select(Some(0));
435
436 let mut decl_list = ListState::default();
437 decl_list.select(Some(0));
438
439 let mut sugg_list = ListState::default();
440 sugg_list.select(Some(0));
441
442 let mut state = State {
443 picker_active: vec![true; session.adapters.len()],
444 picker_days: vec![None; session.adapters.len()],
445 picker_caps: vec![None; session.adapters.len()],
446 picker_entries: Vec::new(),
447 setting_entries,
448 picker_editing: None,
449 picker_field: PickerField::Days,
450 session,
451 screen: Screen::Declaration,
452 list,
453 editing: None,
454 error: None,
455 picker_list,
456 decl_list,
457 sugg_list,
458 };
459
460 preaccept_recommended(&mut state);
461
462 let mut tui = Tui::new()?;
465 tui.drain_stale_input(Duration::from_millis(300));
466 event_loop(&mut tui.terminal, &mut state)
467}
468
469fn event_loop(
470 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
471 state: &mut State<'_>,
472) -> Result<Outcome> {
473 loop {
474 terminal.draw(|frame| render(frame, state))?;
475
476 if !event::poll(Duration::from_millis(100))? {
477 continue;
478 }
479 let Event::Key(key) = event::read()? else {
480 continue;
481 };
482 if key.kind == KeyEventKind::Release {
485 continue;
486 }
487 if key.modifiers.contains(KeyModifiers::CONTROL)
490 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
491 {
492 return Ok(Outcome::Cancelled);
493 }
494
495 if key.code == KeyCode::Enter
503 && key.modifiers.contains(KeyModifiers::SHIFT)
504 && skip_ahead(state)
505 {
506 continue;
507 }
508
509 if let Some(outcome) = handle_key(state, key.code) {
510 return Ok(outcome);
511 }
512 }
513}
514
515fn skip_ahead(state: &mut State<'_>) -> bool {
527 if matches!(state.screen, Screen::Adapters | Screen::Summary) {
528 return false;
529 }
530 for row in &mut state.session.rows {
531 if let Some(rec) = row.recommended {
532 row.value = rec.to_string();
533 }
534 }
535 state.error = None;
536 state.editing = None;
537 state.screen = Screen::Summary;
538 true
539}
540
541fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
543 match state.screen {
544 Screen::Declaration => declaration_key(state, code),
545 Screen::Suggestions => suggestions_key(state, code),
546 Screen::Settings => settings_key(state, code),
547 Screen::Adapters => adapters_key(state, code),
548 Screen::Summary => summary_key(state, code),
549 }
550}
551
552fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
553 let len = state.session.declaration.len().max(1);
554 let current = state.decl_list.selected().unwrap_or(0);
555 match code {
556 KeyCode::Up | KeyCode::Char('k') => {
558 state.decl_list.select(Some(current.saturating_sub(1)));
559 None
560 }
561 KeyCode::Down | KeyCode::Char('j') => {
562 state.decl_list.select(Some((current + 1).min(len - 1)));
563 None
564 }
565 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
570 state.screen = if state.session.suggestions.is_empty() {
571 Screen::Settings
572 } else {
573 Screen::Suggestions
574 };
575 None
576 }
577 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
578 _ => None,
579 }
580}
581
582fn preaccept_recommended(state: &mut State<'_>) {
599 for i in 0..state.session.suggestions.len() {
600 if !state.session.suggestions[i].cautious {
601 apply_suggestion(state, i, true);
602 }
603 }
604}
605
606fn accepted(state: &State<'_>, index: usize) -> bool {
613 let s = &state.session.suggestions[index];
614 row_value(&state.session.rows, s.key).as_deref() == Some(s.value)
615}
616
617fn apply_suggestion(state: &mut State<'_>, index: usize, accept: bool) {
621 let (key, value) = {
622 let s = &state.session.suggestions[index];
623 (s.key, s.value)
624 };
625 let restore = state
626 .session
627 .rows
628 .iter()
629 .find(|r| r.key == key)
630 .map(|r| r.original.clone());
631 let Some(restore) = restore else { return };
632 let next = if accept { value.to_string() } else { restore };
633 set_row(&mut state.session.rows, key, next);
634}
635
636fn suggestions_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
637 let len = state.session.suggestions.len();
638 let current = state.sugg_list.selected().unwrap_or(0);
639 match code {
640 KeyCode::Up | KeyCode::Char('k') => {
641 state
642 .sugg_list
643 .select(Some(if current == 0 { len - 1 } else { current - 1 }))
644 }
645 KeyCode::Down | KeyCode::Char('j') => {
646 state
647 .sugg_list
648 .select(Some(if current + 1 >= len { 0 } else { current + 1 }))
649 }
650 KeyCode::Char(' ') => {
651 let now = accepted(state, current);
652 apply_suggestion(state, current, !now);
653 }
654 KeyCode::Char('a') | KeyCode::Char('A') => {
659 for i in 0..len {
660 apply_suggestion(state, i, true);
661 }
662 }
663 KeyCode::Char('r') | KeyCode::Char('R') => {
664 for i in 0..len {
665 apply_suggestion(state, i, false);
666 }
667 }
668 KeyCode::Char('c') | KeyCode::Char('C') => {
669 state.screen = Screen::Settings;
670 }
671 KeyCode::Enter => {
676 state.screen = Screen::Settings;
677 }
678 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
679 _ => {}
680 }
681 None
682}
683
684fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
685 if state.editing.is_some() {
687 return number_edit_key(state, code);
688 }
689
690 let at = state.list.selected().unwrap_or(0);
691 let current = selected_row(state);
692 let on_finish = matches!(state.setting_entries.get(at), Some(SettingEntry::Finish));
696 match code {
697 KeyCode::Up | KeyCode::Char('k') => {
698 state.error = None;
699 let to = step(&state.setting_entries, at, false);
700 state.list.select(Some(to));
701 }
702 KeyCode::Down | KeyCode::Char('j') => {
703 state.error = None;
704 let to = step(&state.setting_entries, at, true);
705 state.list.select(Some(to));
706 }
707 KeyCode::Home | KeyCode::Char('g') => state.list.select(first_row(&state.setting_entries)),
708 KeyCode::Enter if on_finish => {
709 state.error = None;
710 state.screen = Screen::Summary;
711 }
712 KeyCode::Enter if !on_finish => {
719 state.error = None;
720 let row = &mut state.session.rows[current];
721 if let Some(rec) = row.recommended
722 && row.value != rec
723 {
724 row.value = rec.to_string();
725 }
726 let to = step(&state.setting_entries, at, true);
727 state.list.select(Some(to));
728 }
729 KeyCode::Char('a') | KeyCode::Char('A') => {
732 skip_ahead(state);
733 }
734 KeyCode::Char(' ') if !on_finish => activate(state, current),
735 KeyCode::Char('r') | KeyCode::Char('R') if !on_finish => {
736 state.error = None;
737 let row = &mut state.session.rows[current];
738 row.value = row.original.clone();
739 }
740 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
741 _ => {}
742 }
743 None
744}
745
746fn activate(state: &mut State<'_>, index: usize) {
748 state.error = None;
749 match state.session.rows[index].control {
750 Control::Toggle => {
751 let row = &mut state.session.rows[index];
752 row.value = if row.value == "true" {
753 "false".to_string()
754 } else {
755 "true".to_string()
756 };
757 }
758 Control::Choice(options) => {
759 let row = &mut state.session.rows[index];
760 let next = options
764 .iter()
765 .position(|(value, _)| *value == row.value)
766 .map_or(0, |at| (at + 1) % options.len());
767 row.value = options[next].0.to_string();
768 }
769 Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
770 Control::Adapters | Control::AdapterDays | Control::CacheCaps => open_picker(state),
771 }
772}
773
774fn open_picker(state: &mut State<'_>) {
780 let rows = &state.session.rows;
781 let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
782 let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
783 let caps = parse_days(&row_value(rows, "cache_max_gb").unwrap_or_default());
784
785 state.picker_active = state
786 .session
787 .adapters
788 .iter()
789 .map(|name| {
790 if disabled.iter().any(|d| d == name) {
791 return false;
792 }
793 if state.session.opt_in_adapters.contains(name) {
797 return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
798 }
799 true
800 })
801 .collect();
802 state.picker_days = state
803 .session
804 .adapters
805 .iter()
806 .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
807 .collect();
808
809 state.picker_caps = state
810 .session
811 .adapters
812 .iter()
813 .map(|name| {
814 if !state.session.capped_adapters.contains(name) {
815 return None;
816 }
817 caps.iter().find(|(n, _)| n == name).map(|(_, g)| *g)
818 })
819 .collect();
820
821 state.picker_entries = build_entries(state.session.adapters, state.session.groups);
822 state.picker_editing = None;
823 state.picker_field = PickerField::Days;
824 state.picker_list.select(Some(0));
825 state.screen = Screen::Adapters;
826}
827
828fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
829 let index = selected_row(state);
830 match code {
831 KeyCode::Char(c) if c.is_ascii_digit() => {
832 if let Some(buf) = state.editing.as_mut() {
833 buf.push(c);
834 }
835 }
836 KeyCode::Backspace => {
837 if let Some(buf) = state.editing.as_mut() {
838 buf.pop();
839 }
840 }
841 KeyCode::Enter => {
842 let typed = state.editing.clone().unwrap_or_default();
843 let key = state.session.rows[index].key;
844 match (state.session.validate)(key, typed.trim()) {
845 Ok(()) => {
846 state.session.rows[index].value = typed.trim().to_string();
847 state.editing = None;
848 state.error = None;
849 }
850 Err(why) => state.error = Some(why),
853 }
854 }
855 KeyCode::Esc => {
856 state.editing = None;
857 state.error = None;
858 }
859 _ => {}
860 }
861 None
862}
863
864fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
865 if state.picker_editing.is_some() {
866 return picker_number_key(state, code);
867 }
868
869 let len = state.picker_entries.len().max(1);
870 let current = state.picker_list.selected().unwrap_or(0);
871 match code {
872 KeyCode::Up | KeyCode::Char('k') => {
873 state.error = None;
874 state
875 .picker_list
876 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
877 }
878 KeyCode::Down | KeyCode::Char('j') => {
879 state.error = None;
880 state
881 .picker_list
882 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
883 }
884 KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
887 Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
888 Some(PickerEntry::Group { members, .. }) => {
889 let target = !members.iter().any(|&i| state.picker_active[i]);
890 for i in members {
891 state.picker_active[i] = target;
892 }
893 }
894 None => {}
895 },
896 KeyCode::Char('d') | KeyCode::Char('D') => {
897 state.error = None;
898 let seed = match state.picker_entries.get(current) {
899 Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
900 Some(PickerEntry::Group { members, .. }) => {
903 let first = members.first().and_then(|&i| state.picker_days[i]);
904 if members.iter().all(|&i| state.picker_days[i] == first) {
905 first
906 } else {
907 None
908 }
909 }
910 None => None,
911 };
912 state.picker_field = PickerField::Days;
913 state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
914 }
915 KeyCode::Char('c') | KeyCode::Char('C') => {
916 state.error = None;
917 let targets = capped_targets(state, current);
921 if targets.is_empty() {
922 state.error = Some(
923 "No cache of that name for dev-prune to size. `devp caches` lists the ones \
924 there are; `devp config set cache_max_gb` caps them."
925 .to_string(),
926 );
927 return None;
928 }
929 let first = targets.first().and_then(|&i| state.picker_caps[i]);
930 let seed = if targets.iter().all(|&i| state.picker_caps[i] == first) {
931 first
932 } else {
933 None
934 };
935 state.picker_field = PickerField::Cap;
936 state.picker_editing = Some(seed.map(|g| g.to_string()).unwrap_or_default());
937 }
938 KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
939 KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
940 KeyCode::Enter => commit_picker(state),
941 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
942 _ => {}
943 }
944 None
945}
946
947fn capped_targets(state: &State<'_>, line: usize) -> Vec<usize> {
954 let members: Vec<usize> = match state.picker_entries.get(line) {
955 Some(PickerEntry::Adapter(i)) => vec![*i],
956 Some(PickerEntry::Group { members, .. }) => members.clone(),
957 None => Vec::new(),
958 };
959 members
960 .into_iter()
961 .filter(|&i| {
962 state
963 .session
964 .capped_adapters
965 .contains(&state.session.adapters[i])
966 })
967 .collect()
968}
969
970fn picker_number_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
974 let current = state.picker_list.selected().unwrap_or(0);
975 match code {
976 KeyCode::Char(c) if c.is_ascii_digit() => {
977 if let Some(buf) = state.picker_editing.as_mut() {
978 buf.push(c);
979 }
980 }
981 KeyCode::Backspace => {
982 if let Some(buf) = state.picker_editing.as_mut() {
983 buf.pop();
984 }
985 }
986 KeyCode::Enter => {
987 let typed = state.picker_editing.clone().unwrap_or_default();
988 let typed = typed.trim().to_string();
989 let key = match state.picker_field {
990 PickerField::Days => "adapter_idle_days",
991 PickerField::Cap => "cache_max_gb",
992 };
993 let targets: Vec<usize> = match state.picker_field {
994 PickerField::Days => match state.picker_entries.get(current) {
995 Some(PickerEntry::Adapter(i)) => vec![*i],
996 Some(PickerEntry::Group { members, .. }) => members.clone(),
997 None => Vec::new(),
998 },
999 PickerField::Cap => capped_targets(state, current),
1000 };
1001 let value = if typed.is_empty() {
1002 None
1003 } else {
1004 let Some(&first) = targets.first() else {
1005 state.picker_editing = None;
1006 return None;
1007 };
1008 let probe = format!("{}={typed}", state.session.adapters[first]);
1009 if let Err(why) = (state.session.validate)(key, &probe) {
1012 state.error = Some(why);
1013 return None;
1014 }
1015 typed.parse::<u64>().ok()
1016 };
1017 for i in targets {
1018 match state.picker_field {
1019 PickerField::Days => state.picker_days[i] = value,
1020 PickerField::Cap => state.picker_caps[i] = value,
1021 }
1022 }
1023 state.picker_editing = None;
1024 state.error = None;
1025 }
1026 KeyCode::Esc => {
1027 state.picker_editing = None;
1028 state.error = None;
1029 }
1030 _ => {}
1031 }
1032 None
1033}
1034
1035fn commit_picker(state: &mut State<'_>) {
1041 let adapters = state.session.adapters;
1042 let opt_in = state.session.opt_in_adapters;
1043
1044 let disabled: Vec<&str> = adapters
1045 .iter()
1046 .enumerate()
1047 .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
1048 .map(|(_, name)| *name)
1049 .collect();
1050 let previous =
1056 row_value(&state.session.rows, "disabled_adapters").unwrap_or_else(|| "(none)".into());
1057 let mut previous_names: Vec<&str> = previous
1058 .split(',')
1059 .map(str::trim)
1060 .filter(|s| !s.is_empty() && *s != "(none)")
1061 .collect();
1062 let mut current_names = disabled.clone();
1063 previous_names.sort_unstable();
1064 current_names.sort_unstable();
1065 let disabled = if previous_names == current_names {
1068 previous.clone()
1069 } else if disabled.is_empty() {
1070 "(none)".to_string()
1071 } else {
1072 disabled.join(",")
1073 };
1074
1075 let mut days: Vec<String> = adapters
1076 .iter()
1077 .enumerate()
1078 .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
1079 .collect();
1080 days.sort_unstable();
1084 let days = if days.is_empty() {
1085 "(none)".to_string()
1086 } else {
1087 days.join(",")
1088 };
1089
1090 let existing = parse_days(&row_value(&state.session.rows, "cache_max_gb").unwrap_or_default());
1095 let mut caps: Vec<String> = existing
1096 .iter()
1097 .filter(|(name, _)| !adapters.iter().any(|a| a == name))
1098 .map(|(name, gb)| format!("{name}={gb}"))
1099 .collect();
1100 caps.extend(
1101 adapters
1102 .iter()
1103 .enumerate()
1104 .filter_map(|(i, name)| state.picker_caps[i].map(|g| format!("{name}={g}"))),
1105 );
1106 caps.sort_unstable();
1107 let caps = if caps.is_empty() {
1108 "(none)".to_string()
1109 } else {
1110 caps.join(",")
1111 };
1112
1113 let switches: Vec<(String, String)> = adapters
1114 .iter()
1115 .enumerate()
1116 .filter(|(_, name)| opt_in.contains(name))
1117 .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
1118 .collect();
1119
1120 let rows = &mut state.session.rows;
1121 set_row(rows, "disabled_adapters", disabled);
1122 set_row(rows, "adapter_idle_days", days);
1123 set_row(rows, "cache_max_gb", caps);
1124 for (key, value) in switches {
1125 set_row(rows, &key, value);
1126 }
1127 state.screen = Screen::Settings;
1128}
1129
1130fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
1131 match code {
1132 KeyCode::Enter => {
1133 let changed: Vec<ConfigRow> = state
1134 .session
1135 .rows
1136 .iter()
1137 .filter(|r| r.changed())
1138 .cloned()
1139 .collect();
1140 if changed.is_empty() {
1141 Some(Outcome::KeepAll)
1142 } else {
1143 Some(Outcome::Save(changed))
1144 }
1145 }
1146 KeyCode::Esc | KeyCode::Backspace => {
1147 state.screen = Screen::Settings;
1148 None
1149 }
1150 KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
1151 _ => None,
1152 }
1153}
1154
1155fn parse_list(value: &str) -> Vec<String> {
1157 if value.trim().eq_ignore_ascii_case("(none)") {
1158 return Vec::new();
1159 }
1160 value
1161 .split(',')
1162 .map(|s| s.trim().to_lowercase())
1163 .filter(|s| !s.is_empty())
1164 .collect()
1165}
1166
1167fn parse_days(value: &str) -> Vec<(String, u64)> {
1173 if value.trim().eq_ignore_ascii_case("(none)") {
1174 return Vec::new();
1175 }
1176 value
1177 .split(',')
1178 .filter_map(|entry| {
1179 let (name, days) = entry.trim().split_once('=')?;
1180 Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
1181 })
1182 .collect()
1183}
1184
1185fn render(frame: &mut Frame, state: &mut State<'_>) {
1190 match state.screen {
1191 Screen::Declaration => render_declaration(frame, state),
1192 Screen::Suggestions => render_suggestions(frame, state),
1193 Screen::Settings => render_settings(frame, state),
1194 Screen::Adapters => render_adapters(frame, state),
1195 Screen::Summary => render_summary(frame, state),
1196 }
1197}
1198
1199fn dim() -> Style {
1200 Style::default().fg(Color::DarkGray)
1201}
1202
1203fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
1204 Paragraph::new(vec![
1205 Line::from(Span::styled(
1206 title.to_string(),
1207 Style::default()
1208 .fg(Color::Cyan)
1209 .add_modifier(Modifier::BOLD),
1210 )),
1211 Line::from(Span::styled(subtitle.to_string(), dim())),
1212 ])
1213}
1214
1215fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
1216 let mut spans = Vec::new();
1217 for (i, (key, what)) in keys.iter().enumerate() {
1218 if i > 0 {
1219 spans.push(Span::styled(" ", dim()));
1220 }
1221 spans.push(Span::styled(
1222 key.to_string(),
1223 Style::default().add_modifier(Modifier::BOLD),
1224 ));
1225 spans.push(Span::styled(format!(" {what}"), dim()));
1226 }
1227 Paragraph::new(Line::from(spans))
1228 .block(Block::default().borders(Borders::TOP).border_style(dim()))
1229}
1230
1231fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
1232 let notice = state.session.uninvited;
1236 let mut constraints = vec![Constraint::Length(3)];
1237 if notice.is_some() {
1238 constraints.push(Constraint::Length(6));
1239 }
1240 constraints.extend([
1241 Constraint::Min(5),
1242 Constraint::Length(5),
1246 Constraint::Length(2),
1247 ]);
1248 let chunks = Layout::vertical(constraints).split(frame.area());
1249 let mut at = 0;
1250
1251 frame.render_widget(
1252 header(
1253 state.session.title,
1254 "What this tool is allowed to do on this machine, before it does any of it.",
1255 ),
1256 chunks[at],
1257 );
1258 at += 1;
1259
1260 if let Some(why) = notice {
1261 frame.render_widget(
1262 Paragraph::new(why)
1263 .wrap(Wrap { trim: true })
1264 .style(Style::default().fg(Color::Yellow))
1265 .block(
1266 Block::default()
1267 .title(" Why this opened on its own ")
1268 .borders(Borders::ALL)
1269 .border_style(Style::default().fg(Color::Yellow)),
1270 ),
1271 chunks[at],
1272 );
1273 at += 1;
1274 }
1275
1276 let items: Vec<ListItem> = state
1277 .session
1278 .declaration
1279 .iter()
1280 .map(|d| {
1281 if d.mark == '#' {
1282 return ListItem::new(Line::from(Span::styled(
1283 format!(" {}", d.subject),
1284 Style::default()
1285 .fg(Color::Cyan)
1286 .add_modifier(Modifier::BOLD),
1287 )));
1288 }
1289 let (mark_style, symbol) = match d.mark {
1290 '!' => (Style::default().fg(Color::Yellow), "!"),
1291 '+' => (Style::default().fg(Color::Green), "✓"),
1292 _ => (dim(), " "),
1293 };
1294 ListItem::new(Line::from(vec![
1295 Span::styled(format!(" {symbol} "), mark_style),
1296 Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
1297 Span::styled(d.state.clone(), dim()),
1298 ]))
1299 })
1300 .collect();
1301
1302 frame.render_stateful_widget(
1305 List::new(items).block(
1306 Block::default()
1307 .title(" Declaration ")
1308 .borders(Borders::ALL)
1309 .border_style(dim()),
1310 ),
1311 chunks[at],
1312 &mut state.decl_list,
1313 );
1314 at += 1;
1315
1316 frame.render_widget(
1317 Paragraph::new(vec![
1318 Line::from(Span::styled(
1319 format!(" {}", state.session.standing),
1320 Style::default().fg(Color::Green),
1321 )),
1322 Line::from(Span::styled(
1327 " In a hurry? Hold Enter — it walks the whole setup, taking the \
1328 recommended answer on every screen.",
1329 Style::default().fg(Color::Cyan),
1330 )),
1331 Line::from(Span::styled(
1335 format!(" {}", crate::constants::LICENCE_NOTICE),
1336 dim(),
1337 )),
1338 ])
1339 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1340 chunks[at],
1341 );
1342 at += 1;
1343
1344 frame.render_widget(
1345 footer(&[
1346 ("↑↓", "read"),
1347 ("Enter", "configure"),
1348 ("hold Enter", "do it all"),
1349 ("q", "cancel"),
1350 ]),
1351 chunks[at],
1352 );
1353}
1354
1355fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
1362 if state.session.suggestions.is_empty() {
1365 state.screen = Screen::Settings;
1366 return render_settings(frame, state);
1367 }
1368 let chunks = Layout::vertical([
1369 Constraint::Length(3),
1370 Constraint::Min(5),
1371 Constraint::Length(7),
1372 Constraint::Length(2),
1373 ])
1374 .split(frame.area());
1375
1376 let on = (0..state.session.suggestions.len())
1377 .filter(|&i| accepted(state, i))
1378 .count();
1379 frame.render_widget(
1380 header(
1381 "Suggested settings",
1382 &format!(
1387 "{} of {} accepted \u{2014} press \u{2191}\u{2193} to read what each one does. \
1388 The safe ones start accepted; `r` turns every one of them back off.",
1389 on,
1390 state.session.suggestions.len()
1391 ),
1392 ),
1393 chunks[0],
1394 );
1395
1396 let selected = state
1397 .sugg_list
1398 .selected()
1399 .unwrap_or(0)
1400 .min(state.session.suggestions.len() - 1);
1401 let mut items: Vec<ListItem> = Vec::new();
1402 let mut tier_shown = false;
1403 for (i, s) in state.session.suggestions.iter().enumerate() {
1404 let mut lines = Vec::new();
1408 if s.cautious && !tier_shown {
1409 tier_shown = true;
1410 lines.push(Line::from(Span::styled(
1411 " Worth turning on once you know what it does",
1412 Style::default()
1413 .fg(Color::Yellow)
1414 .add_modifier(Modifier::BOLD),
1415 )));
1416 }
1417 let mark = if accepted(state, i) {
1418 Span::styled(
1419 "[x] ",
1420 Style::default()
1421 .fg(Color::Green)
1422 .add_modifier(Modifier::BOLD),
1423 )
1424 } else {
1425 Span::styled("[ ] ", dim())
1426 };
1427 lines.push(Line::from(vec![
1428 mark,
1429 Span::styled(
1430 crate::output::pad_display(s.label, 28),
1431 if selected == i {
1432 Style::default().fg(Color::White)
1433 } else {
1434 Style::default()
1435 },
1436 ),
1437 Span::styled(s.key.to_string(), dim()),
1438 ]));
1439 items.push(ListItem::new(lines));
1440 }
1441
1442 frame.render_stateful_widget(
1443 List::new(items)
1444 .block(
1445 Block::default()
1446 .title(" Suggested ")
1447 .borders(Borders::ALL)
1448 .border_style(dim()),
1449 )
1450 .highlight_style(
1451 Style::default()
1452 .bg(Color::Rgb(30, 40, 60))
1453 .add_modifier(Modifier::BOLD),
1454 )
1455 .highlight_symbol("\u{25b6} "),
1456 chunks[1],
1457 &mut state.sugg_list,
1458 );
1459
1460 let s = &state.session.suggestions[selected];
1461 frame.render_widget(
1462 Paragraph::new(vec![
1463 Line::from(Span::styled(format!(" {}", s.help), Style::default())),
1464 Line::from(""),
1465 Line::from(vec![
1466 Span::styled(" In plain words ", dim()),
1467 Span::styled(s.plain.to_string(), Style::default().fg(Color::Cyan)),
1468 ]),
1469 Line::from(""),
1470 Line::from(vec![
1471 Span::styled(" Why we suggest it ", dim()),
1472 Span::styled(s.why.to_string(), Style::default().fg(Color::Green)),
1473 ]),
1474 ])
1475 .wrap(Wrap { trim: true })
1476 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1477 chunks[2],
1478 );
1479
1480 frame.render_widget(
1481 footer(&[
1482 ("\u{2191}\u{2193}", "read"),
1483 ("Space", "accept one"),
1484 ("a", "accept all suggested"),
1485 ("r", "undo"),
1486 ("Enter", "on to the settings"),
1487 ]),
1488 chunks[3],
1489 );
1490}
1491
1492fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
1493 let chunks = Layout::vertical([
1494 Constraint::Length(3),
1495 Constraint::Min(5),
1496 Constraint::Length(7),
1499 Constraint::Length(2),
1500 ])
1501 .split(frame.area());
1502
1503 let changed = state.session.rows.iter().filter(|r| r.changed()).count();
1504 let new = state.session.rows.iter().filter(|r| r.is_new).count();
1505 let subtitle = match (changed, new) {
1506 (0, 0) => "Nothing changed yet.".to_string(),
1507 (c, 0) => format!("{c} changed."),
1508 (0, n) => format!("{n} new in this version."),
1509 (c, n) => format!("{c} changed, {n} new in this version."),
1510 };
1511 frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
1512
1513 let selected = state.list.selected();
1514 let items: Vec<ListItem> = state
1515 .setting_entries
1516 .iter()
1517 .enumerate()
1518 .map(|(at, entry)| {
1519 let row = match entry {
1522 SettingEntry::Heading(title) => {
1523 return ListItem::new(Line::from(Span::styled(
1524 format!(" {title}"),
1525 Style::default()
1526 .fg(Color::Cyan)
1527 .add_modifier(Modifier::BOLD),
1528 )));
1529 }
1530 SettingEntry::Row(i) => &state.session.rows[*i],
1531 SettingEntry::Finish => {
1532 return ListItem::new(Line::from(vec![
1533 Span::styled(
1534 " Finish — review the changes ",
1535 Style::default()
1536 .fg(Color::Cyan)
1537 .add_modifier(Modifier::BOLD),
1538 ),
1539 Span::styled("Press Enter to see what will be saved", dim()),
1540 ]));
1541 }
1542 };
1543 let control = match row.control {
1544 Control::Toggle if row.value == "true" => Span::styled(
1545 "[x] ",
1546 Style::default()
1547 .fg(Color::Green)
1548 .add_modifier(Modifier::BOLD),
1549 ),
1550 Control::Toggle => Span::styled("[ ] ", dim()),
1551 Control::Choice(_) => Span::styled("(o) ", dim()),
1552 Control::Number => Span::styled("123 ", dim()),
1553 Control::Adapters | Control::AdapterDays | Control::CacheCaps => {
1554 Span::styled("••• ", dim())
1555 }
1556 };
1557
1558 let shown = if state.editing.is_some() && selected == Some(at) {
1559 format!("{}_", state.editing.clone().unwrap_or_default())
1560 } else if let Control::Choice(options) = row.control {
1561 options
1564 .iter()
1565 .find(|(value, _)| *value == row.value)
1566 .map_or_else(
1567 || row.value.clone(),
1568 |(value, label)| format!("{value} {label}"),
1569 )
1570 } else {
1571 row.value.clone()
1572 };
1573
1574 let mut spans = vec![
1575 control,
1576 Span::styled(
1577 crate::output::pad_display(row.key, 28),
1578 if selected == Some(at) {
1579 Style::default().fg(Color::White)
1580 } else {
1581 Style::default()
1582 },
1583 ),
1584 Span::styled(
1585 crate::output::pad_display(&shown, 20),
1586 if row.changed() {
1587 Style::default().fg(Color::Yellow)
1588 } else {
1589 Style::default().fg(Color::Cyan)
1590 },
1591 ),
1592 ];
1593 if row.is_new {
1594 spans.push(Span::styled(
1595 "NEW ",
1596 Style::default()
1597 .fg(Color::Magenta)
1598 .add_modifier(Modifier::BOLD),
1599 ));
1600 }
1601 match row.takes_advice() {
1606 Some(true) => spans.push(Span::styled("REC ", Style::default().fg(Color::Green))),
1607 Some(false) => spans.push(Span::styled("REC ", Style::default().fg(Color::Yellow))),
1608 None => {}
1609 }
1610 if row.changed() {
1611 spans.push(Span::styled(format!("was {}", row.original), dim()));
1612 }
1613 ListItem::new(Line::from(spans))
1614 })
1615 .collect();
1616
1617 let list = List::new(items)
1618 .block(
1619 Block::default()
1620 .title(" Settings ")
1621 .borders(Borders::ALL)
1622 .border_style(dim()),
1623 )
1624 .highlight_style(
1625 Style::default()
1626 .bg(Color::Rgb(30, 40, 60))
1627 .add_modifier(Modifier::BOLD),
1628 )
1629 .highlight_symbol("▶ ");
1630 frame.render_stateful_widget(list, chunks[1], &mut state.list);
1631
1632 if matches!(
1635 state
1636 .setting_entries
1637 .get(state.list.selected().unwrap_or(0)),
1638 Some(SettingEntry::Finish)
1639 ) {
1640 let detail = vec![
1641 Line::from(Span::styled(
1642 " Enter opens a summary of every change. \
1643 Nothing has been written yet.",
1644 Style::default(),
1645 )),
1646 Line::from(vec![
1647 Span::styled(" Changed so far ", dim()),
1648 Span::styled(
1649 match changed {
1650 0 => "nothing".to_string(),
1651 1 => "1 setting".to_string(),
1652 n => format!("{n} settings"),
1653 },
1654 Style::default().fg(Color::Cyan),
1655 ),
1656 ]),
1657 ];
1658 frame.render_widget(
1659 Paragraph::new(detail)
1660 .wrap(Wrap { trim: true })
1661 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1662 chunks[2],
1663 );
1664 frame.render_widget(
1665 footer(&[
1666 ("↑↓", "move"),
1667 ("Enter", "review and finish"),
1668 ("a", "accept the rest & finish"),
1669 ("q", "cancel"),
1670 ]),
1671 chunks[3],
1672 );
1673 return;
1674 }
1675
1676 let row = &state.session.rows[selected_row(state)];
1679 let mut detail = vec![
1680 Line::from(Span::styled(format!(" {}", row.help), Style::default())),
1681 Line::from(vec![
1682 Span::styled(" In plain words ", dim()),
1683 Span::styled(row.plain.to_string(), Style::default().fg(Color::Cyan)),
1684 ]),
1685 ];
1686 let mut facts = vec![
1690 Span::styled(" Default ", dim()),
1691 Span::styled(
1692 crate::output::pad_display(&row.default, 12),
1693 Style::default(),
1694 ),
1695 ];
1696 if let Some(rec) = row.recommended {
1697 facts.push(Span::styled("Recommended ", dim()));
1698 facts.push(Span::styled(
1699 crate::output::pad_display(rec, 12),
1700 Style::default().fg(Color::Green),
1701 ));
1702 facts.push(Span::styled(
1703 if row.takes_advice() == Some(true) {
1704 "— already set"
1705 } else {
1706 "— suggested, not required; everything works without it"
1707 },
1708 dim(),
1709 ));
1710 }
1711 detail.push(Line::from(facts));
1712 if row.is_new {
1713 detail.push(Line::from(Span::styled(
1714 " New in this version — it has been applying its default since the upgrade.",
1715 Style::default().fg(Color::Magenta),
1716 )));
1717 }
1718 if let Some(why) = &state.error {
1719 detail.push(Line::from(Span::styled(
1720 format!(" {why}"),
1721 Style::default().fg(Color::Red),
1722 )));
1723 }
1724 frame.render_widget(
1725 Paragraph::new(detail)
1726 .wrap(Wrap { trim: true })
1727 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1728 chunks[2],
1729 );
1730
1731 let keys: &[(&str, &str)] = if state.editing.is_some() {
1732 &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
1733 } else {
1734 &[
1735 ("↑↓", "move"),
1736 ("Enter", "accept advice & next"),
1737 ("a", "accept the rest & finish"),
1738 ("Space", "change"),
1739 ("r", "reset"),
1740 ("q/Esc", "cancel"),
1741 ]
1742 };
1743 frame.render_widget(footer(keys), chunks[3]);
1744}
1745
1746fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
1747 let chunks = Layout::vertical([
1748 Constraint::Length(3),
1749 Constraint::Min(5),
1750 Constraint::Length(4),
1751 Constraint::Length(2),
1752 ])
1753 .split(frame.area());
1754
1755 let off = state.picker_active.iter().filter(|a| !**a).count();
1756 frame.render_widget(
1757 header(
1758 "Adapters",
1759 &format!(
1760 "Unchecked adapters are left alone entirely — not scanned, not counted, \
1761 not pruned. {off} off.",
1762 ),
1763 ),
1764 chunks[0],
1765 );
1766
1767 let selected = state.picker_list.selected();
1768 let items: Vec<ListItem> = state
1769 .picker_entries
1770 .iter()
1771 .enumerate()
1772 .map(|(line, entry)| match entry {
1773 PickerEntry::Group { label, members } => {
1774 let on = members.iter().filter(|&&i| state.picker_active[i]).count();
1775 let mark = if on == members.len() {
1776 "[x]"
1777 } else if on == 0 {
1778 "[ ]"
1779 } else {
1780 "[-]"
1783 };
1784 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1785 let shared = members.first().and_then(|&i| state.picker_days[i]);
1786 let window = if editing_here && state.picker_field == PickerField::Days {
1789 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1790 } else if members.iter().all(|&i| state.picker_days[i] == shared) {
1791 shared.map(|d| format!("{d}d")).unwrap_or_default()
1792 } else {
1793 "mixed".to_string()
1794 };
1795 let capped: Vec<usize> = members
1796 .iter()
1797 .copied()
1798 .filter(|&i| {
1799 state
1800 .session
1801 .capped_adapters
1802 .contains(&state.session.adapters[i])
1803 })
1804 .collect();
1805 let shared_cap = capped.first().and_then(|&i| state.picker_caps[i]);
1806 let cap = if editing_here && state.picker_field == PickerField::Cap {
1807 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1808 } else if capped.is_empty() {
1809 String::new()
1810 } else if capped.iter().all(|&i| state.picker_caps[i] == shared_cap) {
1811 shared_cap.map(|g| format!("{g}G")).unwrap_or_default()
1812 } else {
1813 "mixed".to_string()
1814 };
1815 ListItem::new(Line::from(vec![
1816 Span::styled(
1817 format!("{mark} {}", crate::output::pad_display(label, 22)),
1818 Style::default()
1819 .fg(Color::Cyan)
1820 .add_modifier(Modifier::BOLD),
1821 ),
1822 Span::styled(
1823 crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
1824 dim(),
1825 ),
1826 Span::styled(crate::output::pad_display(&window, 10), dim()),
1827 Span::styled(cap, dim()),
1828 ]))
1829 }
1830 PickerEntry::Adapter(i) => {
1831 let name = state.session.adapters[*i];
1832 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1833 let shown = if editing_here && state.picker_field == PickerField::Days {
1834 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1835 } else {
1836 state.picker_days[*i]
1837 .map(|d| format!("{d}d"))
1838 .unwrap_or_else(|| "default".to_string())
1839 };
1840 let cap = if editing_here && state.picker_field == PickerField::Cap {
1844 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1845 } else if !state.session.capped_adapters.contains(&name) {
1846 String::new()
1847 } else {
1848 state.picker_caps[*i]
1849 .map(|g| format!("{g}G"))
1850 .unwrap_or_else(|| "no cap".to_string())
1851 };
1852 let mut spans = vec![
1853 if state.picker_active[*i] {
1854 Span::styled(
1855 " [x] ",
1856 Style::default()
1857 .fg(Color::Green)
1858 .add_modifier(Modifier::BOLD),
1859 )
1860 } else {
1861 Span::styled(" [ ] ", dim())
1862 },
1863 Span::styled(crate::output::pad_display(name, 18), Style::default()),
1864 Span::styled(
1865 crate::output::pad_display(&shown, 10),
1866 if state.picker_days[*i].is_some() {
1867 Style::default().fg(Color::Yellow)
1868 } else {
1869 dim()
1870 },
1871 ),
1872 Span::styled(
1873 crate::output::pad_display(&cap, 10),
1874 if state.picker_caps[*i].is_some() {
1875 Style::default().fg(Color::Yellow)
1876 } else {
1877 dim()
1878 },
1879 ),
1880 ];
1881 if state.session.opt_in_adapters.contains(&name) {
1882 spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1886 }
1887 ListItem::new(Line::from(spans))
1888 }
1889 })
1890 .collect();
1891
1892 let list = List::new(items)
1893 .block(
1894 Block::default()
1895 .title(" Checked adapters stay active idle cache cap ")
1896 .borders(Borders::ALL)
1897 .border_style(dim()),
1898 )
1899 .highlight_style(
1900 Style::default()
1901 .bg(Color::Rgb(30, 40, 60))
1902 .add_modifier(Modifier::BOLD),
1903 )
1904 .highlight_symbol("▶ ");
1905 frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1906
1907 let mut detail = vec![Line::from(Span::styled(
1908 " Space toggles one adapter, or a whole language from its heading. d sets how \
1909 many days that adapter — or that language — must be idle first; an empty value \
1910 puts it back on the global window. c caps that ecosystem's download cache in \
1911 GiB — reported by `devp caches`, and emptied only when you run \
1912 `devp caches clear --over-cap`, never on a schedule.",
1913 dim(),
1914 ))];
1915 if let Some(why) = &state.error {
1916 detail.push(Line::from(Span::styled(
1917 format!(" {why}"),
1918 Style::default().fg(Color::Red),
1919 )));
1920 }
1921 frame.render_widget(
1922 Paragraph::new(detail)
1923 .wrap(Wrap { trim: true })
1924 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1925 chunks[2],
1926 );
1927
1928 let keys: &[(&str, &str)] = match (state.picker_editing.is_some(), state.picker_field) {
1929 (true, PickerField::Days) => &[
1930 ("digits", "days"),
1931 ("Enter", "accept"),
1932 ("empty", "use the global window"),
1933 ("Esc", "abandon"),
1934 ],
1935 (true, PickerField::Cap) => &[
1936 ("digits", "GiB"),
1937 ("Enter", "accept"),
1938 ("empty", "no cap"),
1939 ("Esc", "abandon"),
1940 ],
1941 (false, _) => &[
1942 ("↑↓", "move"),
1943 ("Space", "toggle"),
1944 ("d", "idle days"),
1945 ("c", "cache cap"),
1946 ("a", "all on"),
1947 ("n", "all off"),
1948 ("Enter", "accept"),
1949 ("Esc", "back"),
1950 ],
1951 };
1952 frame.render_widget(footer(keys), chunks[3]);
1953}
1954
1955fn render_summary(frame: &mut Frame, state: &State<'_>) {
1956 let chunks = Layout::vertical([
1957 Constraint::Length(3),
1958 Constraint::Min(5),
1959 Constraint::Length(2),
1960 ])
1961 .split(frame.area());
1962
1963 let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1964 frame.render_widget(
1965 header(
1966 "Summary",
1967 if changed.is_empty() {
1968 "Nothing changed. The defaults stay in place."
1969 } else {
1970 "These are the only values that will be written."
1971 },
1972 ),
1973 chunks[0],
1974 );
1975
1976 let mut lines: Vec<Line> = changed
1977 .iter()
1978 .map(|row| {
1979 Line::from(vec![
1980 Span::styled(
1981 format!(" {}", crate::output::pad_display(row.key, 28)),
1982 Style::default(),
1983 ),
1984 Span::styled(row.original.clone(), dim()),
1985 Span::styled(" → ", dim()),
1986 Span::styled(
1987 row.value.clone(),
1988 Style::default()
1989 .fg(Color::Yellow)
1990 .add_modifier(Modifier::BOLD),
1991 ),
1992 ])
1993 })
1994 .collect();
1995 if lines.is_empty() {
1996 lines.push(Line::from(Span::styled(
1997 " Every setting is still at the value it had when this opened.",
1998 dim(),
1999 )));
2000 }
2001 lines.push(Line::from(""));
2002 lines.push(Line::from(Span::styled(
2003 format!(" {}", state.session.standing),
2004 Style::default().fg(Color::Green),
2005 )));
2006
2007 frame.render_widget(
2008 Paragraph::new(lines).wrap(Wrap { trim: true }).block(
2009 Block::default()
2010 .title(" About to be saved ")
2011 .borders(Borders::ALL)
2012 .border_style(dim()),
2013 ),
2014 chunks[1],
2015 );
2016
2017 frame.render_widget(
2018 footer(&[
2019 ("Enter", "save"),
2020 ("Esc", "back"),
2021 ("q", "discard everything"),
2022 ]),
2023 chunks[2],
2024 );
2025}
2026
2027#[cfg(test)]
2028mod tests {
2029 use super::*;
2030
2031 fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
2032 categorised_row(key, "Settings", control, value)
2033 }
2034
2035 fn categorised_row(
2037 key: &'static str,
2038 category: &'static str,
2039 control: Control,
2040 value: &str,
2041 ) -> ConfigRow {
2042 ConfigRow {
2043 key,
2044 category,
2045 help: "help",
2046 plain: "plain",
2047 control,
2048 value: value.to_string(),
2049 original: value.to_string(),
2050 default: value.to_string(),
2051 recommended: None,
2052 cautious: false,
2053 is_new: false,
2054 }
2055 }
2056
2057 fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
2058 ConfigSession {
2059 declaration: Vec::new(),
2060 standing: String::new(),
2061 suggestions: Vec::new(),
2062 rows,
2063 adapters,
2064 opt_in_adapters: &[],
2065 capped_adapters: &["npm", "pnpm", "cargo", "go"],
2066 groups: &[("Test", &["npm", "cargo", "go"])],
2067 validate: &|key, v| {
2068 let number = if key == "adapter_idle_days" || key == "cache_max_gb" {
2071 v.split_once('=').map(|(_, d)| d).unwrap_or("")
2072 } else {
2073 v
2074 };
2075 number
2076 .parse::<u64>()
2077 .map(|_| ())
2078 .map_err(|_| "not a number".to_string())
2079 },
2080 title: "test",
2081 uninvited: None,
2082 }
2083 }
2084
2085 fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
2086 let setting_entries = settings_entries(&s.rows);
2087 let mut list = ListState::default();
2088 list.select(first_row(&setting_entries));
2091 let mut picker_list = ListState::default();
2092 picker_list.select(Some(0));
2093 State {
2094 picker_active: vec![true; s.adapters.len()],
2095 picker_days: vec![None; s.adapters.len()],
2096 picker_caps: vec![None; s.adapters.len()],
2097 picker_entries: build_entries(s.adapters, s.groups),
2098 setting_entries,
2099 picker_editing: None,
2100 picker_field: PickerField::Days,
2101 session: s,
2102 screen: Screen::Settings,
2103 list,
2104 editing: None,
2105 error: None,
2106 picker_list,
2107 decl_list: ListState::default(),
2108 sugg_list: ListState::default(),
2109 }
2110 }
2111
2112 fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
2117 st.screen = screen;
2118 let mut terminal =
2119 Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
2120 terminal.draw(|frame| render(frame, st)).expect("draw");
2121 terminal
2122 .backend()
2123 .buffer()
2124 .content()
2125 .iter()
2126 .map(|cell| cell.symbol())
2127 .collect()
2128 }
2129
2130 #[test]
2131 fn every_screen_draws() {
2132 let adapters: &[&'static str] = &["npm", "cargo"];
2133 let mut st = state(session(
2134 vec![
2135 row("idle_days", Control::Number, "14"),
2136 row("disabled_adapters", Control::Adapters, "(none)"),
2137 ],
2138 adapters,
2139 ));
2140 st.session.declaration.push(DeclarationLine {
2141 mark: '+',
2142 subject: "Lockfile verification".to_string(),
2143 state: "Required before every delete".to_string(),
2144 });
2145 st.session.standing = "Nothing has been deleted.".to_string();
2146
2147 let decl = screenshot(&mut st, Screen::Declaration);
2148 assert!(decl.contains("Lockfile verification"));
2149 assert!(decl.contains("Nothing has been deleted."));
2150
2151 let settings = screenshot(&mut st, Screen::Settings);
2152 assert!(settings.contains("idle_days"));
2153
2154 st.picker_entries = build_entries(st.session.adapters, st.session.groups);
2155 st.picker_days[1] = Some(45);
2156 let picker = screenshot(&mut st, Screen::Adapters);
2157 assert!(picker.contains("cargo"));
2158 assert!(picker.contains("Test"), "the language heading is missing");
2159 assert!(picker.contains("45d"), "the idle window is missing");
2160
2161 let summary = screenshot(&mut st, Screen::Summary);
2164 assert!(summary.contains("still at the value"));
2165 }
2166
2167 #[test]
2168 fn the_declaration_no_longer_leaves_on_y() {
2169 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2173 st.screen = Screen::Declaration;
2174 assert!(handle_key(&mut st, KeyCode::Char('y')).is_none());
2175 assert_eq!(st.screen, Screen::Declaration);
2176 }
2177
2178 #[test]
2179 fn enter_takes_the_recommendation_and_moves_to_the_next_row() {
2180 let mut rows = vec![
2183 row("enable_cargo", Control::Toggle, "false"),
2184 row("auto_update", Control::Toggle, "false"),
2185 ];
2186 rows[0].recommended = Some("true");
2187 let mut st = state(session(rows, &[]));
2188
2189 let before = st.list.selected();
2190 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2191 assert_eq!(st.session.rows[0].value, "true", "the advice was taken");
2192 assert_eq!(st.screen, Screen::Settings, "one press must not leave");
2193 assert_ne!(st.list.selected(), before, "the cursor moved on");
2194
2195 handle_key(&mut st, KeyCode::Enter);
2196 assert_eq!(
2197 st.session.rows[1].value, "false",
2198 "a row with no recommendation is only stepped past, never flipped"
2199 );
2200 }
2201
2202 #[test]
2203 fn enter_takes_a_cautious_recommendation_too() {
2204 let mut rows = vec![row("allow_manifest_rewrite", Control::Toggle, "false")];
2211 rows[0].recommended = Some("true");
2212 rows[0].cautious = true;
2213 let mut st = state(session(rows, &[]));
2214
2215 handle_key(&mut st, KeyCode::Enter);
2216 assert_eq!(
2217 st.session.rows[0].value, "true",
2218 "the walk takes the cautious recommendation like any other"
2219 );
2220 }
2221
2222 #[test]
2223 fn a_takes_every_remaining_recommendation_and_lands_on_the_summary() {
2224 let mut rows = vec![
2228 row("enable_cargo", Control::Toggle, "false"),
2229 row("enable_node", Control::Toggle, "false"),
2230 row("allow_manifest_rewrite", Control::Toggle, "false"),
2231 row("auto_update", Control::Toggle, "false"),
2232 ];
2233 rows[0].recommended = Some("true");
2234 rows[1].recommended = Some("true");
2235 rows[2].recommended = Some("true");
2236 rows[2].cautious = true;
2237 let mut st = state(session(rows, &[]));
2238
2239 assert!(handle_key(&mut st, KeyCode::Char('a')).is_none());
2241 assert_eq!(st.screen, Screen::Summary);
2242 assert_eq!(st.session.rows[0].value, "true");
2243 assert_eq!(
2244 st.session.rows[1].value, "true",
2245 "a row the cursor never reached is still taken"
2246 );
2247 assert_eq!(
2248 st.session.rows[2].value, "true",
2249 "the cautious tier is taken here exactly as the walk takes it"
2250 );
2251 assert_eq!(
2252 st.session.rows[3].value, "false",
2253 "a row with no recommendation is left alone"
2254 );
2255
2256 let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2258 panic!("Enter on the summary saves");
2259 };
2260 assert_eq!(changed.len(), 3);
2261 }
2262
2263 #[test]
2264 fn shift_enter_is_the_same_instruction_as_a() {
2265 let build = || {
2269 let mut rows = vec![
2270 row("enable_cargo", Control::Toggle, "false"),
2271 row("allow_manifest_rewrite", Control::Toggle, "false"),
2272 ];
2273 rows[0].recommended = Some("true");
2274 rows[1].recommended = Some("true");
2275 rows[1].cautious = true;
2276 state(session(rows, &[]))
2277 };
2278
2279 let mut by_letter = build();
2280 handle_key(&mut by_letter, KeyCode::Char('a'));
2281
2282 let mut by_shift = build();
2283 assert!(skip_ahead(&mut by_shift), "settings screen accepts it");
2284
2285 assert_eq!(by_shift.screen, by_letter.screen);
2286 assert_eq!(by_shift.screen, Screen::Summary);
2287 for (a, b) in by_shift.session.rows.iter().zip(&by_letter.session.rows) {
2288 assert_eq!(a.value, b.value, "{} disagreed", a.key);
2289 }
2290
2291 by_shift.screen = Screen::Adapters;
2294 assert!(!skip_ahead(&mut by_shift));
2295 assert_eq!(by_shift.screen, Screen::Adapters);
2296 }
2297
2298 #[test]
2299 fn holding_enter_walks_the_list_and_ends_at_the_summary() {
2300 let mut rows = vec![
2304 row("enable_cargo", Control::Toggle, "false"),
2305 row("auto_update", Control::Toggle, "false"),
2306 ];
2307 rows[0].recommended = Some("true");
2308 let mut st = state(session(rows, &[]));
2309
2310 for _ in 0..3 {
2312 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2313 }
2314 assert_eq!(st.screen, Screen::Summary);
2315 let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2316 panic!("Enter on the summary saves");
2317 };
2318 assert_eq!(changed.len(), 1);
2319 assert_eq!(changed[0].key, "enable_cargo");
2320 assert_eq!(changed[0].value, "true");
2321 }
2322
2323 #[test]
2324 fn the_whole_first_run_is_one_held_key() {
2325 let adapters: &[&str] = &["npm", "cargo"];
2331 let mut rows = vec![
2332 row("idle_days", Control::Number, "14"),
2333 row("enable_cargo", Control::Toggle, "false"),
2334 row("disabled_adapters", Control::Adapters, "(none)"),
2335 row("allow_manifest_rewrite", Control::Toggle, "false"),
2336 ];
2337 rows[1].recommended = Some("true");
2338 rows[3].recommended = Some("true");
2339 rows[3].cautious = true;
2340 let mut s = session(rows, adapters);
2341 s.suggestions = vec![
2342 suggestion("enable_cargo", false),
2343 suggestion("allow_manifest_rewrite", true),
2344 ];
2345 let mut st = state(s);
2346 st.screen = Screen::Declaration;
2347 preaccept_recommended(&mut st);
2348
2349 let mut outcome = None;
2353 for _ in 0..32 {
2354 if let Some(o) = handle_key(&mut st, KeyCode::Enter) {
2355 outcome = Some(o);
2356 break;
2357 }
2358 }
2359 let Some(Outcome::Save(changed)) = outcome else {
2360 panic!("held Enter never reached a save");
2361 };
2362 assert_eq!(changed.len(), 2, "{changed:?}");
2363 assert_eq!(changed[0].key, "enable_cargo");
2364 assert_eq!(changed[0].value, "true");
2365 assert_eq!(changed[1].key, "allow_manifest_rewrite");
2366 assert_eq!(
2367 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2368 Some("true"),
2369 "the held key takes the cautious recommendation with the rest"
2370 );
2371 }
2372
2373 #[test]
2374 fn enter_leaves_the_suggestions_for_the_settings() {
2375 let adapters: &[&str] = &["npm"];
2379 let mut s = session(
2380 vec![row("enable_cargo", Control::Toggle, "false")],
2381 adapters,
2382 );
2383 s.suggestions = vec![suggestion("enable_cargo", false)];
2384 let mut st = state(s);
2385 st.screen = Screen::Suggestions;
2386 st.sugg_list.select(Some(0));
2387 assert!(suggestions_key(&mut st, KeyCode::Enter).is_none());
2388 assert_eq!(st.screen, Screen::Settings);
2389 }
2390
2391 #[test]
2392 fn the_finish_line_is_not_a_setting() {
2393 let mut st = state(session(
2397 vec![row("auto_update", Control::Toggle, "false")],
2398 &[],
2399 ));
2400 handle_key(&mut st, KeyCode::Up); handle_key(&mut st, KeyCode::Char(' '));
2402 handle_key(&mut st, KeyCode::Char('r'));
2403 assert_eq!(st.session.rows[0].value, "false");
2404 assert!(!st.session.rows[0].changed());
2405 }
2406
2407 #[test]
2408 fn a_refused_value_is_not_stored() {
2409 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2410 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Backspace);
2412 handle_key(&mut st, KeyCode::Backspace); handle_key(&mut st, KeyCode::Enter);
2414 assert_eq!(st.session.rows[0].value, "14");
2415 assert!(st.error.is_some(), "the reason was not shown");
2416 assert!(st.editing.is_some(), "the editor closed on a refusal");
2417 }
2418
2419 #[test]
2420 fn an_accepted_value_replaces_the_old_one() {
2421 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2422 handle_key(&mut st, KeyCode::Char(' '));
2423 handle_key(&mut st, KeyCode::Backspace);
2424 handle_key(&mut st, KeyCode::Backspace);
2425 handle_key(&mut st, KeyCode::Char('3'));
2426 handle_key(&mut st, KeyCode::Char('0'));
2427 handle_key(&mut st, KeyCode::Enter);
2428 assert_eq!(st.session.rows[0].value, "30");
2429 assert!(st.session.rows[0].changed());
2430 }
2431
2432 #[test]
2433 fn unchecking_an_adapter_writes_it_to_the_deny_list() {
2434 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2435 let mut st = state(session(
2436 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2437 adapters,
2438 ));
2439 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.screen, Screen::Adapters);
2441 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2444 handle_key(&mut st, KeyCode::Enter);
2445 assert_eq!(st.session.rows[0].value, "cargo");
2446 assert_eq!(st.screen, Screen::Settings);
2447 }
2448
2449 #[test]
2450 fn every_adapter_appears_under_exactly_one_heading() {
2451 let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
2454 let groups: &[(&'static str, &'static [&'static str])] =
2455 &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2456 let entries = build_entries(adapters, groups);
2457 let headings: Vec<&str> = entries
2458 .iter()
2459 .filter_map(|e| match e {
2460 PickerEntry::Group { label, .. } => Some(*label),
2461 PickerEntry::Adapter(_) => None,
2462 })
2463 .collect();
2464 assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
2465
2466 let mut listed: Vec<usize> = entries
2467 .iter()
2468 .filter_map(|e| match e {
2469 PickerEntry::Adapter(i) => Some(*i),
2470 PickerEntry::Group { .. } => None,
2471 })
2472 .collect();
2473 listed.sort_unstable();
2474 assert_eq!(
2475 listed,
2476 vec![0, 1, 2],
2477 "an adapter was dropped from the list"
2478 );
2479 }
2480
2481 #[test]
2482 fn a_heading_turns_its_whole_language_off_in_one_press() {
2483 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2484 let mut st = state(session(
2485 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2486 adapters,
2487 ));
2488 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2489 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![false, false, true]);
2492 handle_key(&mut st, KeyCode::Char(' '));
2495 assert_eq!(st.picker_active, vec![true, true, true]);
2496 }
2497
2498 #[test]
2499 fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
2500 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2501 let mut st = state(session(
2502 vec![
2503 row("disabled_adapters", Control::Adapters, "(none)"),
2504 row("adapter_idle_days", Control::AdapterDays, "(none)"),
2505 ],
2506 adapters,
2507 ));
2508 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2509 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('d')); handle_key(&mut st, KeyCode::Char('3'));
2512 handle_key(&mut st, KeyCode::Char('0'));
2513 handle_key(&mut st, KeyCode::Enter);
2514 assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
2515
2516 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
2518
2519 handle_key(&mut st, KeyCode::Char(' '));
2523 handle_key(&mut st, KeyCode::Char('d'));
2524 handle_key(&mut st, KeyCode::Backspace);
2525 handle_key(&mut st, KeyCode::Backspace);
2526 handle_key(&mut st, KeyCode::Enter);
2527 handle_key(&mut st, KeyCode::Enter);
2528 assert_eq!(st.session.rows[1].value, "(none)");
2529 }
2530
2531 #[test]
2532 fn a_cache_cap_typed_on_a_heading_reaches_only_the_adapters_that_have_a_cache() {
2533 let adapters: &[&'static str] = &["npm", "pnpm", "venv"];
2537 let mut st = state(session(
2538 vec![
2539 row("disabled_adapters", Control::Adapters, "(none)"),
2540 row("cache_max_gb", Control::CacheCaps, "(none)"),
2541 ],
2542 adapters,
2543 ));
2544 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Python", &["venv"])];
2545 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('c')); handle_key(&mut st, KeyCode::Char('1'));
2548 handle_key(&mut st, KeyCode::Char('0'));
2549 handle_key(&mut st, KeyCode::Enter);
2550 assert_eq!(st.picker_caps, vec![Some(10), Some(10), None]);
2551
2552 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pnpm=10");
2554 }
2555
2556 #[test]
2557 fn a_cache_cap_is_cleared_by_emptying_it() {
2558 let adapters: &[&'static str] = &["npm"];
2559 let mut st = state(session(
2560 vec![
2561 row("disabled_adapters", Control::Adapters, "(none)"),
2562 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2563 ],
2564 adapters,
2565 ));
2566 st.session.groups = &[("JavaScript", &["npm"])];
2567 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_caps, vec![Some(10)]);
2571 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2573 handle_key(&mut st, KeyCode::Backspace);
2574 handle_key(&mut st, KeyCode::Backspace);
2575 handle_key(&mut st, KeyCode::Enter);
2576 handle_key(&mut st, KeyCode::Enter);
2577 assert_eq!(st.session.rows[1].value, "(none)");
2578 }
2579
2580 #[test]
2581 fn a_cap_on_a_cache_with_no_adapter_survives_the_checklist() {
2582 let adapters: &[&'static str] = &["npm"];
2587 let mut st = state(session(
2588 vec![
2589 row("disabled_adapters", Control::Adapters, "(none)"),
2590 row("cache_max_gb", Control::CacheCaps, "npm=10,pip=20"),
2591 ],
2592 adapters,
2593 ));
2594 st.session.groups = &[("JavaScript", &["npm"])];
2595 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pip=20");
2598 }
2599
2600 #[test]
2601 fn typing_a_cap_where_there_is_no_cache_says_so_instead_of_dropping_it() {
2602 let adapters: &[&'static str] = &["venv"];
2603 let mut st = state(session(
2604 vec![
2605 row("disabled_adapters", Control::Adapters, "(none)"),
2606 row("cache_max_gb", Control::CacheCaps, "(none)"),
2607 ],
2608 adapters,
2609 ));
2610 st.session.groups = &[("Python", &["venv"])];
2611 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2614 assert!(st.picker_editing.is_none(), "no editor opened");
2615 let err = st.error.clone().expect("the refusal is explained");
2616 assert!(err.contains("devp caches"), "{err}");
2617 }
2618
2619 #[test]
2620 fn the_checklist_draws_the_cache_cap_beside_the_idle_window() {
2621 let adapters: &[&'static str] = &["npm", "venv"];
2624 let mut st = state(session(
2625 vec![
2626 row("disabled_adapters", Control::Adapters, "(none)"),
2627 row("adapter_idle_days", Control::AdapterDays, "npm=30"),
2628 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2629 ],
2630 adapters,
2631 ));
2632 st.session.groups = &[("JavaScript", &["npm"]), ("Python", &["venv"])];
2633 handle_key(&mut st, KeyCode::Char(' ')); let picker = screenshot(&mut st, Screen::Adapters);
2635 assert!(picker.contains("30d"), "the idle window is drawn");
2636 assert!(picker.contains("10G"), "the cap is drawn");
2637 assert!(picker.contains("no cap") || picker.contains("10G"));
2640 assert!(picker.contains("cache cap"), "the column is labelled");
2641 }
2642
2643 #[test]
2644 fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
2645 let adapters: &[&'static str] = &["npm", "cargo"];
2648 let opt_in: &[&'static str] = &["cargo"];
2649 let mut st = state(session(
2650 vec![
2651 row("disabled_adapters", Control::Adapters, "(none)"),
2652 row("enable_cargo", Control::Toggle, "false"),
2653 ],
2654 adapters,
2655 ));
2656 st.session.opt_in_adapters = opt_in;
2657 st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2658
2659 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![true, false]);
2663 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2667 handle_key(&mut st, KeyCode::Enter);
2668 assert_eq!(st.session.rows[0].value, "(none)");
2669 assert_eq!(st.session.rows[1].value, "true");
2670 }
2671
2672 #[test]
2673 fn the_checklist_opens_showing_what_is_already_disabled() {
2674 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2677 let mut st = state(session(
2678 vec![row("disabled_adapters", Control::Adapters, "go")],
2679 adapters,
2680 ));
2681 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![true, true, false]);
2683 handle_key(&mut st, KeyCode::Enter);
2684 assert_eq!(st.session.rows[0].value, "go");
2685 }
2686
2687 #[test]
2688 fn cancelling_reports_cancelled_rather_than_an_empty_save() {
2689 let mut st = state(session(
2692 vec![row("auto_update", Control::Toggle, "false")],
2693 &[],
2694 ));
2695 assert!(matches!(
2696 handle_key(&mut st, KeyCode::Char('q')),
2697 Some(Outcome::Cancelled)
2698 ));
2699 }
2700
2701 #[test]
2702 fn only_changed_rows_are_saved() {
2703 let mut st = state(session(
2704 vec![
2705 row("auto_update", Control::Toggle, "false"),
2706 row("auto_config", Control::Toggle, "false"),
2707 ],
2708 &[],
2709 ));
2710 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 {
2714 panic!("expected a save");
2715 };
2716 assert_eq!(changed.len(), 1);
2717 assert_eq!(changed[0].key, "auto_update");
2718 assert_eq!(changed[0].value, "true");
2719 }
2720
2721 #[test]
2722 fn reset_puts_a_row_back_without_touching_the_others() {
2723 let mut st = state(session(
2724 vec![
2725 row("auto_update", Control::Toggle, "false"),
2726 row("auto_config", Control::Toggle, "true"),
2727 ],
2728 &[],
2729 ));
2730 handle_key(&mut st, KeyCode::Char(' '));
2731 assert!(st.session.rows[0].changed());
2732 handle_key(&mut st, KeyCode::Char('r'));
2733 assert!(!st.session.rows[0].changed());
2734 assert_eq!(st.session.rows[1].value, "true");
2735 }
2736
2737 #[test]
2738 fn the_view_opens_at_the_top_even_when_a_later_setting_is_new() {
2739 let mut rows = [
2740 categorised_row("idle_days", "Scope", Control::Number, "14"),
2741 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2742 categorised_row("auto_config", "Updates", Control::Toggle, "false"),
2743 ];
2744 let entries = settings_entries(&rows);
2745 assert_eq!(entries.len(), 6);
2747 assert_eq!(
2748 opening_index(&entries),
2749 1,
2750 "start at the first row — never on a heading"
2751 );
2752
2753 rows[2].is_new = true;
2757 assert_eq!(
2758 opening_index(&entries),
2759 1,
2760 "a new setting further down does not move the start; the walk reaches it"
2761 );
2762
2763 let mut at = opening_index(&entries);
2765 let mut seen = vec![at];
2766 while entries[at] != SettingEntry::Finish {
2767 at = step(&entries, at, true);
2768 seen.push(at);
2769 }
2770 assert!(
2771 seen.contains(&4),
2772 "the walk from the top must visit the new setting, saw {seen:?}"
2773 );
2774 }
2775
2776 #[test]
2777 fn the_cursor_never_lands_on_a_heading() {
2778 let rows = vec![
2779 categorised_row("idle_days", "Scope", Control::Number, "14"),
2780 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2781 ];
2782 let entries = settings_entries(&rows);
2783 assert_eq!(entries.len(), 5, "two rows, two headings, one finish line");
2784
2785 for forward in [true, false] {
2787 let mut at = first_row(&entries).expect("a row");
2788 for _ in 0..entries.len() * 2 {
2789 at = step(&entries, at, forward);
2790 assert!(
2791 matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish),
2792 "stopped on entry {at}, which is a heading"
2793 );
2794 }
2795 }
2796
2797 let last = last_stop(&entries).expect("a stop");
2799 assert_eq!(step(&entries, last, true), first_row(&entries).unwrap());
2800 assert_eq!(step(&entries, first_row(&entries).unwrap(), false), last);
2801 }
2802
2803 #[test]
2804 fn a_run_of_one_category_gets_one_heading() {
2805 let rows = vec![
2806 categorised_row("a", "Scope", Control::Toggle, "false"),
2807 categorised_row("b", "Scope", Control::Toggle, "false"),
2808 categorised_row("c", "Scope", Control::Toggle, "false"),
2809 ];
2810 assert_eq!(settings_entries(&rows).len(), 5);
2812 }
2813
2814 fn suggestion(key: &'static str, cautious: bool) -> Suggestion {
2815 Suggestion {
2816 key,
2817 label: "label",
2818 help: "help",
2819 plain: "plain",
2820 why: "why",
2821 value: "true",
2822 cautious,
2823 }
2824 }
2825
2826 #[test]
2827 fn the_safe_tier_arrives_accepted_and_the_cautious_one_does_not() {
2828 let adapters: &[&str] = &["npm"];
2832 let mut s = session(
2833 vec![
2834 row("enable_cargo", Control::Toggle, "false"),
2835 row("allow_manifest_rewrite", Control::Toggle, "false"),
2836 ],
2837 adapters,
2838 );
2839 s.suggestions = vec![
2840 suggestion("enable_cargo", false),
2841 suggestion("allow_manifest_rewrite", true),
2842 ];
2843 let mut st = state(s);
2844 preaccept_recommended(&mut st);
2845
2846 assert_eq!(
2847 row_value(&st.session.rows, "enable_cargo").as_deref(),
2848 Some("true"),
2849 "the safe tier should already be on"
2850 );
2851 assert_eq!(
2852 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2853 Some("false"),
2854 "the cautious tier must still be a deliberate choice"
2855 );
2856
2857 st.screen = Screen::Suggestions;
2860 st.sugg_list.select(Some(0));
2861 assert!(suggestions_key(&mut st, KeyCode::Char('r')).is_none());
2862 assert_eq!(
2863 row_value(&st.session.rows, "enable_cargo").as_deref(),
2864 Some("false")
2865 );
2866 }
2867
2868 #[test]
2869 fn a_recommended_row_says_so_and_names_the_fresh_default() {
2870 let adapters: &[&str] = &["npm"];
2871 let mut rows = vec![row("enable_cargo", Control::Toggle, "false")];
2872 rows[0].recommended = Some("true");
2873 let mut st = state(session(rows, adapters));
2874
2875 let shot = screenshot(&mut st, Screen::Settings);
2876 assert!(
2877 shot.contains("REC"),
2878 "the badge is the only thing on the row \
2879 that says a recommendation exists"
2880 );
2881 assert!(
2882 shot.contains("Default"),
2883 "a value nobody chose is unreadable without the one they would have got"
2884 );
2885 assert!(shot.contains("not required"));
2889 }
2890
2891 #[test]
2892 fn accept_all_takes_the_cautious_tier_too() {
2893 let adapters: &[&str] = &["npm"];
2898 let mut s = session(
2899 vec![
2900 row("enable_cargo", Control::Toggle, "false"),
2901 row("allow_manifest_rewrite", Control::Toggle, "false"),
2902 ],
2903 adapters,
2904 );
2905 s.suggestions = vec![
2906 suggestion("enable_cargo", false),
2907 suggestion("allow_manifest_rewrite", true),
2908 ];
2909 let mut st = state(s);
2910 st.screen = Screen::Suggestions;
2911 st.sugg_list.select(Some(0));
2912
2913 assert!(suggestions_key(&mut st, KeyCode::Char('a')).is_none());
2914 assert_eq!(
2915 row_value(&st.session.rows, "enable_cargo").as_deref(),
2916 Some("true")
2917 );
2918 assert_eq!(
2919 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2920 Some("true")
2921 );
2922
2923 st.sugg_list.select(Some(1));
2925 suggestions_key(&mut st, KeyCode::Char(' '));
2926 assert_eq!(
2927 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2928 Some("false")
2929 );
2930 }
2931
2932 #[test]
2933 fn undoing_a_suggestion_puts_back_what_the_setting_had() {
2934 let adapters: &[&str] = &["npm"];
2935 let mut s = session(
2936 vec![row("enable_cargo", Control::Toggle, "false")],
2937 adapters,
2938 );
2939 s.suggestions = vec![suggestion("enable_cargo", false)];
2940 let mut st = state(s);
2941 st.screen = Screen::Suggestions;
2942 st.sugg_list.select(Some(0));
2943
2944 suggestions_key(&mut st, KeyCode::Char(' '));
2945 assert!(accepted(&st, 0));
2946 suggestions_key(&mut st, KeyCode::Char(' '));
2947 assert!(!accepted(&st, 0));
2948 assert_eq!(
2949 row_value(&st.session.rows, "enable_cargo").as_deref(),
2950 Some("false")
2951 );
2952 assert!(!st.session.rows[0].changed());
2954 }
2955
2956 #[test]
2957 fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
2958 let adapters: &[&str] = &["npm"];
2961 let s = session(vec![row("idle_days", Control::Number, "30")], adapters);
2962 let mut st = state(s);
2963 st.screen = Screen::Declaration;
2964 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2965 assert_eq!(st.screen, Screen::Settings);
2966 }
2967
2968 #[test]
2969 fn the_first_run_reaches_the_suggestions_first() {
2970 let adapters: &[&str] = &["npm"];
2971 let mut s = session(
2972 vec![row("enable_cargo", Control::Toggle, "false")],
2973 adapters,
2974 );
2975 s.suggestions = vec![suggestion("enable_cargo", false)];
2976 let mut st = state(s);
2977 st.screen = Screen::Declaration;
2978 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2979 assert_eq!(st.screen, Screen::Suggestions);
2980 }
2981}