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