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 is_new: bool,
96}
97
98impl ConfigRow {
99 pub fn changed(&self) -> bool {
100 self.value != self.original
101 }
102
103 pub fn takes_advice(&self) -> Option<bool> {
108 self.recommended.map(|r| self.value == r)
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct DeclarationLine {
115 pub mark: char,
118 pub subject: String,
119 pub state: String,
120}
121
122#[derive(Debug, Clone)]
129pub struct Suggestion {
130 pub key: &'static str,
131 pub label: &'static str,
133 pub help: &'static str,
135 pub plain: &'static str,
137 pub why: &'static str,
139 pub value: &'static str,
141 pub cautious: bool,
147}
148
149pub enum Outcome {
151 Save(Vec<ConfigRow>),
153 KeepAll,
155 Cancelled,
157}
158
159pub struct ConfigSession<'a> {
161 pub declaration: Vec<DeclarationLine>,
163 pub standing: String,
165 pub rows: Vec<ConfigRow>,
166 pub suggestions: Vec<Suggestion>,
169 pub adapters: &'a [&'static str],
171 pub opt_in_adapters: &'a [&'static str],
173 pub capped_adapters: &'a [&'static str],
183 pub groups: &'a [(&'static str, &'static [&'static str])],
186 pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
189 pub title: &'a str,
191 pub uninvited: Option<&'a str>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
207enum SettingEntry {
208 Heading(&'static str),
209 Row(usize),
210 Finish,
214}
215
216fn settings_entries(rows: &[ConfigRow]) -> Vec<SettingEntry> {
222 let mut entries = Vec::with_capacity(rows.len() + 8);
223 let mut current: Option<&str> = None;
224 for (i, row) in rows.iter().enumerate() {
225 if current != Some(row.category) {
226 entries.push(SettingEntry::Heading(row.category));
227 current = Some(row.category);
228 }
229 entries.push(SettingEntry::Row(i));
230 }
231 entries.push(SettingEntry::Finish);
232 entries
233}
234
235fn selected_row(state: &State<'_>) -> usize {
237 let at = state.list.selected().unwrap_or(0);
238 match state.setting_entries.get(at) {
239 Some(SettingEntry::Row(i)) => *i,
240 _ => first_row(&state.setting_entries).map_or(0, |at| match state.setting_entries[at] {
243 SettingEntry::Row(i) => i,
244 SettingEntry::Heading(_) | SettingEntry::Finish => 0,
245 }),
246 }
247}
248
249fn step(entries: &[SettingEntry], from: usize, forward: bool) -> usize {
251 let len = entries.len();
252 let mut at = from;
253 for _ in 0..len {
254 at = if forward {
255 if at + 1 >= len { 0 } else { at + 1 }
256 } else if at == 0 {
257 len - 1
258 } else {
259 at - 1
260 };
261 if matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish) {
262 return at;
263 }
264 }
265 from
266}
267
268fn first_row(entries: &[SettingEntry]) -> Option<usize> {
269 entries
270 .iter()
271 .position(|e| matches!(e, SettingEntry::Row(_)))
272}
273
274fn last_stop(entries: &[SettingEntry]) -> Option<usize> {
276 entries
277 .iter()
278 .rposition(|e| matches!(e, SettingEntry::Row(_) | SettingEntry::Finish))
279}
280
281fn opening_index(entries: &[SettingEntry], rows: &[ConfigRow]) -> usize {
288 entries
289 .iter()
290 .position(|e| matches!(e, SettingEntry::Row(i) if rows[*i].is_new))
291 .or_else(|| first_row(entries))
292 .unwrap_or(0)
293}
294
295#[derive(Debug, PartialEq, Eq, Clone, Copy)]
296enum Screen {
297 Declaration,
298 Suggestions,
299 Settings,
300 Adapters,
301 Summary,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306enum PickerEntry {
307 Group {
310 label: &'static str,
311 members: Vec<usize>,
312 },
313 Adapter(usize),
315}
316
317fn build_entries(
324 adapters: &[&'static str],
325 groups: &[(&'static str, &'static [&'static str])],
326) -> Vec<PickerEntry> {
327 let mut entries = Vec::new();
328 let mut placed = vec![false; adapters.len()];
329
330 for (label, names) in groups {
331 let members: Vec<usize> = names
332 .iter()
333 .filter_map(|name| adapters.iter().position(|a| a == name))
334 .collect();
335 if members.is_empty() {
336 continue;
337 }
338 for &i in &members {
339 placed[i] = true;
340 }
341 entries.push(PickerEntry::Group {
342 label,
343 members: members.clone(),
344 });
345 entries.extend(members.into_iter().map(PickerEntry::Adapter));
346 }
347
348 let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
349 if !rest.is_empty() {
350 entries.push(PickerEntry::Group {
351 label: "Other",
352 members: rest.clone(),
353 });
354 entries.extend(rest.into_iter().map(PickerEntry::Adapter));
355 }
356 entries
357}
358
359fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
361 rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
362}
363
364fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
366 if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
367 row.value = value;
368 }
369}
370
371struct State<'a> {
372 session: ConfigSession<'a>,
373 screen: Screen,
374 list: ListState,
375 editing: Option<String>,
377 error: Option<String>,
379 picker_active: Vec<bool>,
381 picker_days: Vec<Option<u64>>,
383 picker_caps: Vec<Option<u64>>,
386 picker_entries: Vec<PickerEntry>,
390 setting_entries: Vec<SettingEntry>,
393 picker_editing: Option<String>,
395 picker_field: PickerField,
397 picker_list: ListState,
398 decl_list: ListState,
400 sugg_list: ListState,
402 enter_armed: bool,
405}
406
407pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
409 if session.rows.is_empty() {
410 return Ok(Outcome::KeepAll);
411 }
412
413 let setting_entries = settings_entries(&session.rows);
414 let mut list = ListState::default();
415 list.select(Some(opening_index(&setting_entries, &session.rows)));
416
417 let mut picker_list = ListState::default();
418 picker_list.select(Some(0));
419
420 let mut decl_list = ListState::default();
421 decl_list.select(Some(0));
422
423 let mut sugg_list = ListState::default();
424 sugg_list.select(Some(0));
425
426 let mut state = State {
427 picker_active: vec![true; session.adapters.len()],
428 picker_days: vec![None; session.adapters.len()],
429 picker_caps: vec![None; session.adapters.len()],
430 picker_entries: Vec::new(),
431 setting_entries,
432 picker_editing: None,
433 picker_field: PickerField::Days,
434 session,
435 screen: Screen::Declaration,
436 list,
437 editing: None,
438 error: None,
439 picker_list,
440 decl_list,
441 sugg_list,
442 enter_armed: false,
443 };
444
445 preaccept_recommended(&mut state);
446
447 let mut tui = Tui::new()?;
450 tui.drain_stale_input(Duration::from_millis(300));
451 event_loop(&mut tui.terminal, &mut state)
452}
453
454fn event_loop(
455 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
456 state: &mut State<'_>,
457) -> Result<Outcome> {
458 loop {
459 terminal.draw(|frame| render(frame, state))?;
460
461 if !event::poll(Duration::from_millis(100))? {
462 continue;
463 }
464 let Event::Key(key) = event::read()? else {
465 continue;
466 };
467 if key.kind == KeyEventKind::Release {
470 continue;
471 }
472 if key.modifiers.contains(KeyModifiers::CONTROL)
475 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
476 {
477 return Ok(Outcome::Cancelled);
478 }
479
480 if let Some(outcome) = handle_key(state, key.code) {
481 return Ok(outcome);
482 }
483 }
484}
485
486fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
488 if code != KeyCode::Enter {
491 state.enter_armed = false;
492 }
493 match state.screen {
494 Screen::Declaration => declaration_key(state, code),
495 Screen::Suggestions => suggestions_key(state, code),
496 Screen::Settings => settings_key(state, code),
497 Screen::Adapters => adapters_key(state, code),
498 Screen::Summary => summary_key(state, code),
499 }
500}
501
502fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
503 let len = state.session.declaration.len().max(1);
504 let current = state.decl_list.selected().unwrap_or(0);
505 match code {
506 KeyCode::Up | KeyCode::Char('k') => {
508 state.decl_list.select(Some(current.saturating_sub(1)));
509 None
510 }
511 KeyCode::Down | KeyCode::Char('j') => {
512 state.decl_list.select(Some((current + 1).min(len - 1)));
513 None
514 }
515 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
520 state.screen = if state.session.suggestions.is_empty() {
521 Screen::Settings
522 } else {
523 Screen::Suggestions
524 };
525 None
526 }
527 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
528 _ => None,
529 }
530}
531
532fn preaccept_recommended(state: &mut State<'_>) {
547 for i in 0..state.session.suggestions.len() {
548 if !state.session.suggestions[i].cautious {
549 apply_suggestion(state, i, true);
550 }
551 }
552}
553
554fn accepted(state: &State<'_>, index: usize) -> bool {
561 let s = &state.session.suggestions[index];
562 row_value(&state.session.rows, s.key).as_deref() == Some(s.value)
563}
564
565fn apply_suggestion(state: &mut State<'_>, index: usize, accept: bool) {
569 let (key, value) = {
570 let s = &state.session.suggestions[index];
571 (s.key, s.value)
572 };
573 let restore = state
574 .session
575 .rows
576 .iter()
577 .find(|r| r.key == key)
578 .map(|r| r.original.clone());
579 let Some(restore) = restore else { return };
580 let next = if accept { value.to_string() } else { restore };
581 set_row(&mut state.session.rows, key, next);
582}
583
584fn suggestions_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
585 let len = state.session.suggestions.len();
586 let current = state.sugg_list.selected().unwrap_or(0);
587 match code {
588 KeyCode::Up | KeyCode::Char('k') => {
589 state
590 .sugg_list
591 .select(Some(if current == 0 { len - 1 } else { current - 1 }))
592 }
593 KeyCode::Down | KeyCode::Char('j') => {
594 state
595 .sugg_list
596 .select(Some(if current + 1 >= len { 0 } else { current + 1 }))
597 }
598 KeyCode::Char(' ') => {
599 let now = accepted(state, current);
600 apply_suggestion(state, current, !now);
601 }
602 KeyCode::Char('a') | KeyCode::Char('A') => {
606 for i in 0..len {
607 if !state.session.suggestions[i].cautious {
608 apply_suggestion(state, i, true);
609 }
610 }
611 }
612 KeyCode::Char('r') | KeyCode::Char('R') => {
613 for i in 0..len {
614 apply_suggestion(state, i, false);
615 }
616 }
617 KeyCode::Char('c') | KeyCode::Char('C') => {
618 state.screen = Screen::Settings;
619 }
620 KeyCode::Enter => {
625 if state.enter_armed {
626 state.enter_armed = false;
627 state.screen = Screen::Summary;
628 } else {
629 state.enter_armed = true;
630 }
631 }
632 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
633 _ => {}
634 }
635 None
636}
637
638fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
639 if state.editing.is_some() {
641 return number_edit_key(state, code);
642 }
643
644 let at = state.list.selected().unwrap_or(0);
645 let current = selected_row(state);
646 let on_finish = matches!(state.setting_entries.get(at), Some(SettingEntry::Finish));
650 match code {
651 KeyCode::Up | KeyCode::Char('k') => {
652 state.error = None;
653 let to = step(&state.setting_entries, at, false);
654 state.list.select(Some(to));
655 }
656 KeyCode::Down | KeyCode::Char('j') => {
657 state.error = None;
658 let to = step(&state.setting_entries, at, true);
659 state.list.select(Some(to));
660 }
661 KeyCode::Home | KeyCode::Char('g') => state.list.select(first_row(&state.setting_entries)),
662 KeyCode::End | KeyCode::Char('G') => state.list.select(last_stop(&state.setting_entries)),
663 KeyCode::Enter if on_finish => {
664 state.error = None;
665 if state.enter_armed {
666 state.enter_armed = false;
667 state.screen = Screen::Summary;
668 } else {
669 state.enter_armed = true;
670 }
671 }
672 KeyCode::Char(' ') | KeyCode::Enter if !on_finish => activate(state, current),
673 KeyCode::Char('r') | KeyCode::Char('R') if !on_finish => {
674 state.error = None;
675 let row = &mut state.session.rows[current];
676 row.value = row.original.clone();
677 }
678 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
679 _ => {}
680 }
681 None
682}
683
684fn activate(state: &mut State<'_>, index: usize) {
686 state.error = None;
687 match state.session.rows[index].control {
688 Control::Toggle => {
689 let row = &mut state.session.rows[index];
690 row.value = if row.value == "true" {
691 "false".to_string()
692 } else {
693 "true".to_string()
694 };
695 }
696 Control::Choice(options) => {
697 let row = &mut state.session.rows[index];
698 let next = options
702 .iter()
703 .position(|(value, _)| *value == row.value)
704 .map_or(0, |at| (at + 1) % options.len());
705 row.value = options[next].0.to_string();
706 }
707 Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
708 Control::Adapters | Control::AdapterDays | Control::CacheCaps => open_picker(state),
709 }
710}
711
712fn open_picker(state: &mut State<'_>) {
718 let rows = &state.session.rows;
719 let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
720 let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
721 let caps = parse_days(&row_value(rows, "cache_max_gb").unwrap_or_default());
722
723 state.picker_active = state
724 .session
725 .adapters
726 .iter()
727 .map(|name| {
728 if disabled.iter().any(|d| d == name) {
729 return false;
730 }
731 if state.session.opt_in_adapters.contains(name) {
735 return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
736 }
737 true
738 })
739 .collect();
740 state.picker_days = state
741 .session
742 .adapters
743 .iter()
744 .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
745 .collect();
746
747 state.picker_caps = state
748 .session
749 .adapters
750 .iter()
751 .map(|name| {
752 if !state.session.capped_adapters.contains(name) {
753 return None;
754 }
755 caps.iter().find(|(n, _)| n == name).map(|(_, g)| *g)
756 })
757 .collect();
758
759 state.picker_entries = build_entries(state.session.adapters, state.session.groups);
760 state.picker_editing = None;
761 state.picker_field = PickerField::Days;
762 state.picker_list.select(Some(0));
763 state.screen = Screen::Adapters;
764}
765
766fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
767 let index = selected_row(state);
768 match code {
769 KeyCode::Char(c) if c.is_ascii_digit() => {
770 if let Some(buf) = state.editing.as_mut() {
771 buf.push(c);
772 }
773 }
774 KeyCode::Backspace => {
775 if let Some(buf) = state.editing.as_mut() {
776 buf.pop();
777 }
778 }
779 KeyCode::Enter => {
780 let typed = state.editing.clone().unwrap_or_default();
781 let key = state.session.rows[index].key;
782 match (state.session.validate)(key, typed.trim()) {
783 Ok(()) => {
784 state.session.rows[index].value = typed.trim().to_string();
785 state.editing = None;
786 state.error = None;
787 }
788 Err(why) => state.error = Some(why),
791 }
792 }
793 KeyCode::Esc => {
794 state.editing = None;
795 state.error = None;
796 }
797 _ => {}
798 }
799 None
800}
801
802fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
803 if state.picker_editing.is_some() {
804 return picker_number_key(state, code);
805 }
806
807 let len = state.picker_entries.len().max(1);
808 let current = state.picker_list.selected().unwrap_or(0);
809 match code {
810 KeyCode::Up | KeyCode::Char('k') => {
811 state.error = None;
812 state
813 .picker_list
814 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
815 }
816 KeyCode::Down | KeyCode::Char('j') => {
817 state.error = None;
818 state
819 .picker_list
820 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
821 }
822 KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
825 Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
826 Some(PickerEntry::Group { members, .. }) => {
827 let target = !members.iter().any(|&i| state.picker_active[i]);
828 for i in members {
829 state.picker_active[i] = target;
830 }
831 }
832 None => {}
833 },
834 KeyCode::Char('d') | KeyCode::Char('D') => {
835 state.error = None;
836 let seed = match state.picker_entries.get(current) {
837 Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
838 Some(PickerEntry::Group { members, .. }) => {
841 let first = members.first().and_then(|&i| state.picker_days[i]);
842 if members.iter().all(|&i| state.picker_days[i] == first) {
843 first
844 } else {
845 None
846 }
847 }
848 None => None,
849 };
850 state.picker_field = PickerField::Days;
851 state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
852 }
853 KeyCode::Char('c') | KeyCode::Char('C') => {
854 state.error = None;
855 let targets = capped_targets(state, current);
859 if targets.is_empty() {
860 state.error = Some(
861 "No cache of that name for dev-prune to size. `devp caches` lists the ones \
862 there are; `devp config set cache_max_gb` caps them."
863 .to_string(),
864 );
865 return None;
866 }
867 let first = targets.first().and_then(|&i| state.picker_caps[i]);
868 let seed = if targets.iter().all(|&i| state.picker_caps[i] == first) {
869 first
870 } else {
871 None
872 };
873 state.picker_field = PickerField::Cap;
874 state.picker_editing = Some(seed.map(|g| g.to_string()).unwrap_or_default());
875 }
876 KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
877 KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
878 KeyCode::Enter => commit_picker(state),
879 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
880 _ => {}
881 }
882 None
883}
884
885fn capped_targets(state: &State<'_>, line: usize) -> Vec<usize> {
892 let members: Vec<usize> = match state.picker_entries.get(line) {
893 Some(PickerEntry::Adapter(i)) => vec![*i],
894 Some(PickerEntry::Group { members, .. }) => members.clone(),
895 None => Vec::new(),
896 };
897 members
898 .into_iter()
899 .filter(|&i| {
900 state
901 .session
902 .capped_adapters
903 .contains(&state.session.adapters[i])
904 })
905 .collect()
906}
907
908fn picker_number_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
912 let current = state.picker_list.selected().unwrap_or(0);
913 match code {
914 KeyCode::Char(c) if c.is_ascii_digit() => {
915 if let Some(buf) = state.picker_editing.as_mut() {
916 buf.push(c);
917 }
918 }
919 KeyCode::Backspace => {
920 if let Some(buf) = state.picker_editing.as_mut() {
921 buf.pop();
922 }
923 }
924 KeyCode::Enter => {
925 let typed = state.picker_editing.clone().unwrap_or_default();
926 let typed = typed.trim().to_string();
927 let key = match state.picker_field {
928 PickerField::Days => "adapter_idle_days",
929 PickerField::Cap => "cache_max_gb",
930 };
931 let targets: Vec<usize> = match state.picker_field {
932 PickerField::Days => match state.picker_entries.get(current) {
933 Some(PickerEntry::Adapter(i)) => vec![*i],
934 Some(PickerEntry::Group { members, .. }) => members.clone(),
935 None => Vec::new(),
936 },
937 PickerField::Cap => capped_targets(state, current),
938 };
939 let value = if typed.is_empty() {
940 None
941 } else {
942 let Some(&first) = targets.first() else {
943 state.picker_editing = None;
944 return None;
945 };
946 let probe = format!("{}={typed}", state.session.adapters[first]);
947 if let Err(why) = (state.session.validate)(key, &probe) {
950 state.error = Some(why);
951 return None;
952 }
953 typed.parse::<u64>().ok()
954 };
955 for i in targets {
956 match state.picker_field {
957 PickerField::Days => state.picker_days[i] = value,
958 PickerField::Cap => state.picker_caps[i] = value,
959 }
960 }
961 state.picker_editing = None;
962 state.error = None;
963 }
964 KeyCode::Esc => {
965 state.picker_editing = None;
966 state.error = None;
967 }
968 _ => {}
969 }
970 None
971}
972
973fn commit_picker(state: &mut State<'_>) {
979 let adapters = state.session.adapters;
980 let opt_in = state.session.opt_in_adapters;
981
982 let disabled: Vec<&str> = adapters
983 .iter()
984 .enumerate()
985 .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
986 .map(|(_, name)| *name)
987 .collect();
988 let disabled = if disabled.is_empty() {
991 "(none)".to_string()
992 } else {
993 disabled.join(",")
994 };
995
996 let mut days: Vec<String> = adapters
997 .iter()
998 .enumerate()
999 .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
1000 .collect();
1001 days.sort_unstable();
1005 let days = if days.is_empty() {
1006 "(none)".to_string()
1007 } else {
1008 days.join(",")
1009 };
1010
1011 let existing = parse_days(&row_value(&state.session.rows, "cache_max_gb").unwrap_or_default());
1016 let mut caps: Vec<String> = existing
1017 .iter()
1018 .filter(|(name, _)| !adapters.iter().any(|a| a == name))
1019 .map(|(name, gb)| format!("{name}={gb}"))
1020 .collect();
1021 caps.extend(
1022 adapters
1023 .iter()
1024 .enumerate()
1025 .filter_map(|(i, name)| state.picker_caps[i].map(|g| format!("{name}={g}"))),
1026 );
1027 caps.sort_unstable();
1028 let caps = if caps.is_empty() {
1029 "(none)".to_string()
1030 } else {
1031 caps.join(",")
1032 };
1033
1034 let switches: Vec<(String, String)> = adapters
1035 .iter()
1036 .enumerate()
1037 .filter(|(_, name)| opt_in.contains(name))
1038 .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
1039 .collect();
1040
1041 let rows = &mut state.session.rows;
1042 set_row(rows, "disabled_adapters", disabled);
1043 set_row(rows, "adapter_idle_days", days);
1044 set_row(rows, "cache_max_gb", caps);
1045 for (key, value) in switches {
1046 set_row(rows, &key, value);
1047 }
1048 state.screen = Screen::Settings;
1049}
1050
1051fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
1052 match code {
1053 KeyCode::Enter => {
1054 let changed: Vec<ConfigRow> = state
1055 .session
1056 .rows
1057 .iter()
1058 .filter(|r| r.changed())
1059 .cloned()
1060 .collect();
1061 if changed.is_empty() {
1062 Some(Outcome::KeepAll)
1063 } else {
1064 Some(Outcome::Save(changed))
1065 }
1066 }
1067 KeyCode::Esc | KeyCode::Backspace => {
1068 state.screen = Screen::Settings;
1069 None
1070 }
1071 KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
1072 _ => None,
1073 }
1074}
1075
1076fn parse_list(value: &str) -> Vec<String> {
1078 if value.trim().eq_ignore_ascii_case("(none)") {
1079 return Vec::new();
1080 }
1081 value
1082 .split(',')
1083 .map(|s| s.trim().to_lowercase())
1084 .filter(|s| !s.is_empty())
1085 .collect()
1086}
1087
1088fn parse_days(value: &str) -> Vec<(String, u64)> {
1094 if value.trim().eq_ignore_ascii_case("(none)") {
1095 return Vec::new();
1096 }
1097 value
1098 .split(',')
1099 .filter_map(|entry| {
1100 let (name, days) = entry.trim().split_once('=')?;
1101 Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
1102 })
1103 .collect()
1104}
1105
1106fn render(frame: &mut Frame, state: &mut State<'_>) {
1111 match state.screen {
1112 Screen::Declaration => render_declaration(frame, state),
1113 Screen::Suggestions => render_suggestions(frame, state),
1114 Screen::Settings => render_settings(frame, state),
1115 Screen::Adapters => render_adapters(frame, state),
1116 Screen::Summary => render_summary(frame, state),
1117 }
1118}
1119
1120fn dim() -> Style {
1121 Style::default().fg(Color::DarkGray)
1122}
1123
1124fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
1125 Paragraph::new(vec![
1126 Line::from(Span::styled(
1127 title.to_string(),
1128 Style::default()
1129 .fg(Color::Cyan)
1130 .add_modifier(Modifier::BOLD),
1131 )),
1132 Line::from(Span::styled(subtitle.to_string(), dim())),
1133 ])
1134}
1135
1136fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
1137 let mut spans = Vec::new();
1138 for (i, (key, what)) in keys.iter().enumerate() {
1139 if i > 0 {
1140 spans.push(Span::styled(" ", dim()));
1141 }
1142 spans.push(Span::styled(
1143 key.to_string(),
1144 Style::default().add_modifier(Modifier::BOLD),
1145 ));
1146 spans.push(Span::styled(format!(" {what}"), dim()));
1147 }
1148 Paragraph::new(Line::from(spans))
1149 .block(Block::default().borders(Borders::TOP).border_style(dim()))
1150}
1151
1152fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
1153 let notice = state.session.uninvited;
1157 let mut constraints = vec![Constraint::Length(3)];
1158 if notice.is_some() {
1159 constraints.push(Constraint::Length(6));
1160 }
1161 constraints.extend([
1162 Constraint::Min(5),
1163 Constraint::Length(4),
1166 Constraint::Length(2),
1167 ]);
1168 let chunks = Layout::vertical(constraints).split(frame.area());
1169 let mut at = 0;
1170
1171 frame.render_widget(
1172 header(
1173 state.session.title,
1174 "What this tool is allowed to do on this machine, before it does any of it.",
1175 ),
1176 chunks[at],
1177 );
1178 at += 1;
1179
1180 if let Some(why) = notice {
1181 frame.render_widget(
1182 Paragraph::new(why)
1183 .wrap(Wrap { trim: true })
1184 .style(Style::default().fg(Color::Yellow))
1185 .block(
1186 Block::default()
1187 .title(" Why this opened on its own ")
1188 .borders(Borders::ALL)
1189 .border_style(Style::default().fg(Color::Yellow)),
1190 ),
1191 chunks[at],
1192 );
1193 at += 1;
1194 }
1195
1196 let items: Vec<ListItem> = state
1197 .session
1198 .declaration
1199 .iter()
1200 .map(|d| {
1201 if d.mark == '#' {
1202 return ListItem::new(Line::from(Span::styled(
1203 format!(" {}", d.subject),
1204 Style::default()
1205 .fg(Color::Cyan)
1206 .add_modifier(Modifier::BOLD),
1207 )));
1208 }
1209 let (mark_style, symbol) = match d.mark {
1210 '!' => (Style::default().fg(Color::Yellow), "!"),
1211 '+' => (Style::default().fg(Color::Green), "✓"),
1212 _ => (dim(), " "),
1213 };
1214 ListItem::new(Line::from(vec![
1215 Span::styled(format!(" {symbol} "), mark_style),
1216 Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
1217 Span::styled(d.state.clone(), dim()),
1218 ]))
1219 })
1220 .collect();
1221
1222 frame.render_stateful_widget(
1225 List::new(items).block(
1226 Block::default()
1227 .title(" Declaration ")
1228 .borders(Borders::ALL)
1229 .border_style(dim()),
1230 ),
1231 chunks[at],
1232 &mut state.decl_list,
1233 );
1234 at += 1;
1235
1236 frame.render_widget(
1237 Paragraph::new(vec![
1238 Line::from(Span::styled(
1239 format!(" {}", state.session.standing),
1240 Style::default().fg(Color::Green),
1241 )),
1242 Line::from(Span::styled(
1246 format!(" {}", crate::constants::LICENCE_NOTICE),
1247 dim(),
1248 )),
1249 ])
1250 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1251 chunks[at],
1252 );
1253 at += 1;
1254
1255 frame.render_widget(
1256 footer(&[("↑↓", "read"), ("Enter", "configure"), ("q", "cancel")]),
1257 chunks[at],
1258 );
1259}
1260
1261fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
1268 if state.session.suggestions.is_empty() {
1271 state.screen = Screen::Settings;
1272 return render_settings(frame, state);
1273 }
1274 let chunks = Layout::vertical([
1275 Constraint::Length(3),
1276 Constraint::Min(5),
1277 Constraint::Length(7),
1278 Constraint::Length(2),
1279 ])
1280 .split(frame.area());
1281
1282 let on = (0..state.session.suggestions.len())
1283 .filter(|&i| accepted(state, i))
1284 .count();
1285 frame.render_widget(
1286 header(
1287 "Suggested settings",
1288 &format!(
1293 "{} of {} accepted \u{2014} press \u{2191}\u{2193} to read what each one does. \
1294 The safe ones start accepted; `r` turns every one of them back off.",
1295 on,
1296 state.session.suggestions.len()
1297 ),
1298 ),
1299 chunks[0],
1300 );
1301
1302 let selected = state
1303 .sugg_list
1304 .selected()
1305 .unwrap_or(0)
1306 .min(state.session.suggestions.len() - 1);
1307 let mut items: Vec<ListItem> = Vec::new();
1308 let mut tier_shown = false;
1309 for (i, s) in state.session.suggestions.iter().enumerate() {
1310 let mut lines = Vec::new();
1314 if s.cautious && !tier_shown {
1315 tier_shown = true;
1316 lines.push(Line::from(Span::styled(
1317 " Worth turning on once you know what it does",
1318 Style::default()
1319 .fg(Color::Yellow)
1320 .add_modifier(Modifier::BOLD),
1321 )));
1322 }
1323 let mark = if accepted(state, i) {
1324 Span::styled(
1325 "[x] ",
1326 Style::default()
1327 .fg(Color::Green)
1328 .add_modifier(Modifier::BOLD),
1329 )
1330 } else {
1331 Span::styled("[ ] ", dim())
1332 };
1333 lines.push(Line::from(vec![
1334 mark,
1335 Span::styled(
1336 crate::output::pad_display(s.label, 28),
1337 if selected == i {
1338 Style::default().fg(Color::White)
1339 } else {
1340 Style::default()
1341 },
1342 ),
1343 Span::styled(s.key.to_string(), dim()),
1344 ]));
1345 items.push(ListItem::new(lines));
1346 }
1347
1348 frame.render_stateful_widget(
1349 List::new(items)
1350 .block(
1351 Block::default()
1352 .title(" Suggested ")
1353 .borders(Borders::ALL)
1354 .border_style(dim()),
1355 )
1356 .highlight_style(
1357 Style::default()
1358 .bg(Color::Rgb(30, 40, 60))
1359 .add_modifier(Modifier::BOLD),
1360 )
1361 .highlight_symbol("\u{25b6} "),
1362 chunks[1],
1363 &mut state.sugg_list,
1364 );
1365
1366 let s = &state.session.suggestions[selected];
1367 frame.render_widget(
1368 Paragraph::new(vec![
1369 Line::from(Span::styled(format!(" {}", s.help), Style::default())),
1370 Line::from(""),
1371 Line::from(vec![
1372 Span::styled(" In plain words ", dim()),
1373 Span::styled(s.plain.to_string(), Style::default().fg(Color::Cyan)),
1374 ]),
1375 Line::from(""),
1376 Line::from(vec![
1377 Span::styled(" Why we suggest it ", dim()),
1378 Span::styled(s.why.to_string(), Style::default().fg(Color::Green)),
1379 ]),
1380 ])
1381 .wrap(Wrap { trim: true })
1382 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1383 chunks[2],
1384 );
1385
1386 frame.render_widget(
1387 footer(&[
1388 ("\u{2191}\u{2193}", "read"),
1389 ("Space", "accept one"),
1390 ("a", "accept all suggested"),
1391 ("r", "undo"),
1392 ("c", "all settings"),
1393 ("Enter Enter", "review and finish"),
1394 ]),
1395 chunks[3],
1396 );
1397}
1398
1399fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
1400 let chunks = Layout::vertical([
1401 Constraint::Length(3),
1402 Constraint::Min(5),
1403 Constraint::Length(7),
1406 Constraint::Length(2),
1407 ])
1408 .split(frame.area());
1409
1410 let changed = state.session.rows.iter().filter(|r| r.changed()).count();
1411 let new = state.session.rows.iter().filter(|r| r.is_new).count();
1412 let subtitle = match (changed, new) {
1413 (0, 0) => "Nothing changed yet.".to_string(),
1414 (c, 0) => format!("{c} changed."),
1415 (0, n) => format!("{n} new in this version."),
1416 (c, n) => format!("{c} changed, {n} new in this version."),
1417 };
1418 frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
1419
1420 let selected = state.list.selected();
1421 let items: Vec<ListItem> = state
1422 .setting_entries
1423 .iter()
1424 .enumerate()
1425 .map(|(at, entry)| {
1426 let row = match entry {
1429 SettingEntry::Heading(title) => {
1430 return ListItem::new(Line::from(Span::styled(
1431 format!(" {title}"),
1432 Style::default()
1433 .fg(Color::Cyan)
1434 .add_modifier(Modifier::BOLD),
1435 )));
1436 }
1437 SettingEntry::Row(i) => &state.session.rows[*i],
1438 SettingEntry::Finish => {
1439 return ListItem::new(Line::from(vec![
1440 Span::styled(
1441 " Finish — review the changes ",
1442 Style::default()
1443 .fg(Color::Cyan)
1444 .add_modifier(Modifier::BOLD),
1445 ),
1446 if state.enter_armed {
1447 Span::styled(
1448 "Press Enter again for the summary",
1449 Style::default()
1450 .fg(Color::Green)
1451 .add_modifier(Modifier::BOLD),
1452 )
1453 } else {
1454 Span::styled("Press Enter twice when you are done", dim())
1455 },
1456 ]));
1457 }
1458 };
1459 let control = match row.control {
1460 Control::Toggle if row.value == "true" => Span::styled(
1461 "[x] ",
1462 Style::default()
1463 .fg(Color::Green)
1464 .add_modifier(Modifier::BOLD),
1465 ),
1466 Control::Toggle => Span::styled("[ ] ", dim()),
1467 Control::Choice(_) => Span::styled("(o) ", dim()),
1468 Control::Number => Span::styled("123 ", dim()),
1469 Control::Adapters | Control::AdapterDays | Control::CacheCaps => {
1470 Span::styled("••• ", dim())
1471 }
1472 };
1473
1474 let shown = if state.editing.is_some() && selected == Some(at) {
1475 format!("{}_", state.editing.clone().unwrap_or_default())
1476 } else if let Control::Choice(options) = row.control {
1477 options
1480 .iter()
1481 .find(|(value, _)| *value == row.value)
1482 .map_or_else(
1483 || row.value.clone(),
1484 |(value, label)| format!("{value} {label}"),
1485 )
1486 } else {
1487 row.value.clone()
1488 };
1489
1490 let mut spans = vec![
1491 control,
1492 Span::styled(
1493 crate::output::pad_display(row.key, 28),
1494 if selected == Some(at) {
1495 Style::default().fg(Color::White)
1496 } else {
1497 Style::default()
1498 },
1499 ),
1500 Span::styled(
1501 crate::output::pad_display(&shown, 20),
1502 if row.changed() {
1503 Style::default().fg(Color::Yellow)
1504 } else {
1505 Style::default().fg(Color::Cyan)
1506 },
1507 ),
1508 ];
1509 if row.is_new {
1510 spans.push(Span::styled(
1511 "NEW ",
1512 Style::default()
1513 .fg(Color::Magenta)
1514 .add_modifier(Modifier::BOLD),
1515 ));
1516 }
1517 match row.takes_advice() {
1522 Some(true) => spans.push(Span::styled("REC ", Style::default().fg(Color::Green))),
1523 Some(false) => spans.push(Span::styled("REC ", Style::default().fg(Color::Yellow))),
1524 None => {}
1525 }
1526 if row.changed() {
1527 spans.push(Span::styled(format!("was {}", row.original), dim()));
1528 }
1529 ListItem::new(Line::from(spans))
1530 })
1531 .collect();
1532
1533 let list = List::new(items)
1534 .block(
1535 Block::default()
1536 .title(" Settings ")
1537 .borders(Borders::ALL)
1538 .border_style(dim()),
1539 )
1540 .highlight_style(
1541 Style::default()
1542 .bg(Color::Rgb(30, 40, 60))
1543 .add_modifier(Modifier::BOLD),
1544 )
1545 .highlight_symbol("▶ ");
1546 frame.render_stateful_widget(list, chunks[1], &mut state.list);
1547
1548 if matches!(
1551 state
1552 .setting_entries
1553 .get(state.list.selected().unwrap_or(0)),
1554 Some(SettingEntry::Finish)
1555 ) {
1556 let mut detail = vec![
1557 Line::from(Span::styled(
1558 " Two presses of Enter open a summary of every change. \
1559 Nothing has been written yet.",
1560 Style::default(),
1561 )),
1562 Line::from(vec![
1563 Span::styled(" Changed so far ", dim()),
1564 Span::styled(
1565 match changed {
1566 0 => "nothing".to_string(),
1567 1 => "1 setting".to_string(),
1568 n => format!("{n} settings"),
1569 },
1570 Style::default().fg(Color::Cyan),
1571 ),
1572 ]),
1573 ];
1574 if state.enter_armed {
1575 detail.push(Line::from(Span::styled(
1576 " Press Enter again for the summary.",
1577 Style::default().fg(Color::Green),
1578 )));
1579 }
1580 frame.render_widget(
1581 Paragraph::new(detail)
1582 .wrap(Wrap { trim: true })
1583 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1584 chunks[2],
1585 );
1586 frame.render_widget(
1587 footer(&[
1588 ("↑↓", "move"),
1589 ("Enter Enter", "review and finish"),
1590 ("q", "cancel"),
1591 ]),
1592 chunks[3],
1593 );
1594 return;
1595 }
1596
1597 let row = &state.session.rows[selected_row(state)];
1600 let mut detail = vec![
1601 Line::from(Span::styled(format!(" {}", row.help), Style::default())),
1602 Line::from(vec![
1603 Span::styled(" In plain words ", dim()),
1604 Span::styled(row.plain.to_string(), Style::default().fg(Color::Cyan)),
1605 ]),
1606 ];
1607 let mut facts = vec![
1611 Span::styled(" Default ", dim()),
1612 Span::styled(
1613 crate::output::pad_display(&row.default, 12),
1614 Style::default(),
1615 ),
1616 ];
1617 if let Some(rec) = row.recommended {
1618 facts.push(Span::styled("Recommended ", dim()));
1619 facts.push(Span::styled(
1620 crate::output::pad_display(rec, 12),
1621 Style::default().fg(Color::Green),
1622 ));
1623 facts.push(Span::styled(
1624 if row.takes_advice() == Some(true) {
1625 "— already set"
1626 } else {
1627 "— suggested, not required; everything works without it"
1628 },
1629 dim(),
1630 ));
1631 }
1632 detail.push(Line::from(facts));
1633 if row.is_new {
1634 detail.push(Line::from(Span::styled(
1635 " New in this version — it has been applying its default since the upgrade.",
1636 Style::default().fg(Color::Magenta),
1637 )));
1638 }
1639 if let Some(why) = &state.error {
1640 detail.push(Line::from(Span::styled(
1641 format!(" {why}"),
1642 Style::default().fg(Color::Red),
1643 )));
1644 }
1645 frame.render_widget(
1646 Paragraph::new(detail)
1647 .wrap(Wrap { trim: true })
1648 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1649 chunks[2],
1650 );
1651
1652 let keys: &[(&str, &str)] = if state.editing.is_some() {
1653 &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
1654 } else {
1655 &[
1656 ("↑↓", "move"),
1657 ("Space", "change"),
1658 ("r", "reset"),
1659 ("End", "finish"),
1660 ("q", "cancel"),
1661 ]
1662 };
1663 frame.render_widget(footer(keys), chunks[3]);
1664}
1665
1666fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
1667 let chunks = Layout::vertical([
1668 Constraint::Length(3),
1669 Constraint::Min(5),
1670 Constraint::Length(4),
1671 Constraint::Length(2),
1672 ])
1673 .split(frame.area());
1674
1675 let off = state.picker_active.iter().filter(|a| !**a).count();
1676 frame.render_widget(
1677 header(
1678 "Adapters",
1679 &format!(
1680 "Unchecked adapters are left alone entirely — not scanned, not counted, \
1681 not pruned. {off} off.",
1682 ),
1683 ),
1684 chunks[0],
1685 );
1686
1687 let selected = state.picker_list.selected();
1688 let items: Vec<ListItem> = state
1689 .picker_entries
1690 .iter()
1691 .enumerate()
1692 .map(|(line, entry)| match entry {
1693 PickerEntry::Group { label, members } => {
1694 let on = members.iter().filter(|&&i| state.picker_active[i]).count();
1695 let mark = if on == members.len() {
1696 "[x]"
1697 } else if on == 0 {
1698 "[ ]"
1699 } else {
1700 "[-]"
1703 };
1704 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1705 let shared = members.first().and_then(|&i| state.picker_days[i]);
1706 let window = if editing_here && state.picker_field == PickerField::Days {
1709 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1710 } else if members.iter().all(|&i| state.picker_days[i] == shared) {
1711 shared.map(|d| format!("{d}d")).unwrap_or_default()
1712 } else {
1713 "mixed".to_string()
1714 };
1715 let capped: Vec<usize> = members
1716 .iter()
1717 .copied()
1718 .filter(|&i| {
1719 state
1720 .session
1721 .capped_adapters
1722 .contains(&state.session.adapters[i])
1723 })
1724 .collect();
1725 let shared_cap = capped.first().and_then(|&i| state.picker_caps[i]);
1726 let cap = if editing_here && state.picker_field == PickerField::Cap {
1727 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1728 } else if capped.is_empty() {
1729 String::new()
1730 } else if capped.iter().all(|&i| state.picker_caps[i] == shared_cap) {
1731 shared_cap.map(|g| format!("{g}G")).unwrap_or_default()
1732 } else {
1733 "mixed".to_string()
1734 };
1735 ListItem::new(Line::from(vec![
1736 Span::styled(
1737 format!("{mark} {}", crate::output::pad_display(label, 22)),
1738 Style::default()
1739 .fg(Color::Cyan)
1740 .add_modifier(Modifier::BOLD),
1741 ),
1742 Span::styled(
1743 crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
1744 dim(),
1745 ),
1746 Span::styled(crate::output::pad_display(&window, 10), dim()),
1747 Span::styled(cap, dim()),
1748 ]))
1749 }
1750 PickerEntry::Adapter(i) => {
1751 let name = state.session.adapters[*i];
1752 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1753 let shown = if editing_here && state.picker_field == PickerField::Days {
1754 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1755 } else {
1756 state.picker_days[*i]
1757 .map(|d| format!("{d}d"))
1758 .unwrap_or_else(|| "default".to_string())
1759 };
1760 let cap = if editing_here && state.picker_field == PickerField::Cap {
1764 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1765 } else if !state.session.capped_adapters.contains(&name) {
1766 String::new()
1767 } else {
1768 state.picker_caps[*i]
1769 .map(|g| format!("{g}G"))
1770 .unwrap_or_else(|| "no cap".to_string())
1771 };
1772 let mut spans = vec![
1773 if state.picker_active[*i] {
1774 Span::styled(
1775 " [x] ",
1776 Style::default()
1777 .fg(Color::Green)
1778 .add_modifier(Modifier::BOLD),
1779 )
1780 } else {
1781 Span::styled(" [ ] ", dim())
1782 },
1783 Span::styled(crate::output::pad_display(name, 18), Style::default()),
1784 Span::styled(
1785 crate::output::pad_display(&shown, 10),
1786 if state.picker_days[*i].is_some() {
1787 Style::default().fg(Color::Yellow)
1788 } else {
1789 dim()
1790 },
1791 ),
1792 Span::styled(
1793 crate::output::pad_display(&cap, 10),
1794 if state.picker_caps[*i].is_some() {
1795 Style::default().fg(Color::Yellow)
1796 } else {
1797 dim()
1798 },
1799 ),
1800 ];
1801 if state.session.opt_in_adapters.contains(&name) {
1802 spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1806 }
1807 ListItem::new(Line::from(spans))
1808 }
1809 })
1810 .collect();
1811
1812 let list = List::new(items)
1813 .block(
1814 Block::default()
1815 .title(" Checked adapters stay active idle cache cap ")
1816 .borders(Borders::ALL)
1817 .border_style(dim()),
1818 )
1819 .highlight_style(
1820 Style::default()
1821 .bg(Color::Rgb(30, 40, 60))
1822 .add_modifier(Modifier::BOLD),
1823 )
1824 .highlight_symbol("▶ ");
1825 frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1826
1827 let mut detail = vec![Line::from(Span::styled(
1828 " Space toggles one adapter, or a whole language from its heading. d sets how \
1829 many days that adapter — or that language — must be idle first; an empty value \
1830 puts it back on the global window. c caps that ecosystem's download cache in \
1831 GiB — reported by `devp caches`, and emptied only when you run \
1832 `devp caches clear --over-cap`, never on a schedule.",
1833 dim(),
1834 ))];
1835 if let Some(why) = &state.error {
1836 detail.push(Line::from(Span::styled(
1837 format!(" {why}"),
1838 Style::default().fg(Color::Red),
1839 )));
1840 }
1841 frame.render_widget(
1842 Paragraph::new(detail)
1843 .wrap(Wrap { trim: true })
1844 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1845 chunks[2],
1846 );
1847
1848 let keys: &[(&str, &str)] = match (state.picker_editing.is_some(), state.picker_field) {
1849 (true, PickerField::Days) => &[
1850 ("digits", "days"),
1851 ("Enter", "accept"),
1852 ("empty", "use the global window"),
1853 ("Esc", "abandon"),
1854 ],
1855 (true, PickerField::Cap) => &[
1856 ("digits", "GiB"),
1857 ("Enter", "accept"),
1858 ("empty", "no cap"),
1859 ("Esc", "abandon"),
1860 ],
1861 (false, _) => &[
1862 ("↑↓", "move"),
1863 ("Space", "toggle"),
1864 ("d", "idle days"),
1865 ("c", "cache cap"),
1866 ("a", "all on"),
1867 ("n", "all off"),
1868 ("Enter", "accept"),
1869 ("Esc", "back"),
1870 ],
1871 };
1872 frame.render_widget(footer(keys), chunks[3]);
1873}
1874
1875fn render_summary(frame: &mut Frame, state: &State<'_>) {
1876 let chunks = Layout::vertical([
1877 Constraint::Length(3),
1878 Constraint::Min(5),
1879 Constraint::Length(2),
1880 ])
1881 .split(frame.area());
1882
1883 let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1884 frame.render_widget(
1885 header(
1886 "Summary",
1887 if changed.is_empty() {
1888 "Nothing changed. The defaults stay in place."
1889 } else {
1890 "These are the only values that will be written."
1891 },
1892 ),
1893 chunks[0],
1894 );
1895
1896 let mut lines: Vec<Line> = changed
1897 .iter()
1898 .map(|row| {
1899 Line::from(vec![
1900 Span::styled(
1901 format!(" {}", crate::output::pad_display(row.key, 28)),
1902 Style::default(),
1903 ),
1904 Span::styled(row.original.clone(), dim()),
1905 Span::styled(" → ", dim()),
1906 Span::styled(
1907 row.value.clone(),
1908 Style::default()
1909 .fg(Color::Yellow)
1910 .add_modifier(Modifier::BOLD),
1911 ),
1912 ])
1913 })
1914 .collect();
1915 if lines.is_empty() {
1916 lines.push(Line::from(Span::styled(
1917 " Every setting is still at the value it had when this opened.",
1918 dim(),
1919 )));
1920 }
1921 lines.push(Line::from(""));
1922 lines.push(Line::from(Span::styled(
1923 format!(" {}", state.session.standing),
1924 Style::default().fg(Color::Green),
1925 )));
1926
1927 frame.render_widget(
1928 Paragraph::new(lines).wrap(Wrap { trim: true }).block(
1929 Block::default()
1930 .title(" About to be saved ")
1931 .borders(Borders::ALL)
1932 .border_style(dim()),
1933 ),
1934 chunks[1],
1935 );
1936
1937 frame.render_widget(
1938 footer(&[
1939 ("Enter", "save"),
1940 ("Esc", "back"),
1941 ("q", "discard everything"),
1942 ]),
1943 chunks[2],
1944 );
1945}
1946
1947#[cfg(test)]
1948mod tests {
1949 use super::*;
1950
1951 fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
1952 categorised_row(key, "Settings", control, value)
1953 }
1954
1955 fn categorised_row(
1957 key: &'static str,
1958 category: &'static str,
1959 control: Control,
1960 value: &str,
1961 ) -> ConfigRow {
1962 ConfigRow {
1963 key,
1964 category,
1965 help: "help",
1966 plain: "plain",
1967 control,
1968 value: value.to_string(),
1969 original: value.to_string(),
1970 default: value.to_string(),
1971 recommended: None,
1972 is_new: false,
1973 }
1974 }
1975
1976 fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
1977 ConfigSession {
1978 declaration: Vec::new(),
1979 standing: String::new(),
1980 suggestions: Vec::new(),
1981 rows,
1982 adapters,
1983 opt_in_adapters: &[],
1984 capped_adapters: &["npm", "pnpm", "cargo", "go"],
1985 groups: &[("Test", &["npm", "cargo", "go"])],
1986 validate: &|key, v| {
1987 let number = if key == "adapter_idle_days" || key == "cache_max_gb" {
1990 v.split_once('=').map(|(_, d)| d).unwrap_or("")
1991 } else {
1992 v
1993 };
1994 number
1995 .parse::<u64>()
1996 .map(|_| ())
1997 .map_err(|_| "not a number".to_string())
1998 },
1999 title: "test",
2000 uninvited: None,
2001 }
2002 }
2003
2004 fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
2005 let setting_entries = settings_entries(&s.rows);
2006 let mut list = ListState::default();
2007 list.select(first_row(&setting_entries));
2010 let mut picker_list = ListState::default();
2011 picker_list.select(Some(0));
2012 State {
2013 picker_active: vec![true; s.adapters.len()],
2014 picker_days: vec![None; s.adapters.len()],
2015 picker_caps: vec![None; s.adapters.len()],
2016 picker_entries: build_entries(s.adapters, s.groups),
2017 setting_entries,
2018 picker_editing: None,
2019 picker_field: PickerField::Days,
2020 session: s,
2021 screen: Screen::Settings,
2022 list,
2023 editing: None,
2024 error: None,
2025 picker_list,
2026 decl_list: ListState::default(),
2027 sugg_list: ListState::default(),
2028 enter_armed: false,
2029 }
2030 }
2031
2032 fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
2037 st.screen = screen;
2038 let mut terminal =
2039 Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
2040 terminal.draw(|frame| render(frame, st)).expect("draw");
2041 terminal
2042 .backend()
2043 .buffer()
2044 .content()
2045 .iter()
2046 .map(|cell| cell.symbol())
2047 .collect()
2048 }
2049
2050 #[test]
2051 fn every_screen_draws() {
2052 let adapters: &[&'static str] = &["npm", "cargo"];
2053 let mut st = state(session(
2054 vec![
2055 row("idle_days", Control::Number, "14"),
2056 row("disabled_adapters", Control::Adapters, "(none)"),
2057 ],
2058 adapters,
2059 ));
2060 st.session.declaration.push(DeclarationLine {
2061 mark: '+',
2062 subject: "Lockfile verification".to_string(),
2063 state: "Required before every delete".to_string(),
2064 });
2065 st.session.standing = "Nothing has been deleted.".to_string();
2066
2067 let decl = screenshot(&mut st, Screen::Declaration);
2068 assert!(decl.contains("Lockfile verification"));
2069 assert!(decl.contains("Nothing has been deleted."));
2070
2071 let settings = screenshot(&mut st, Screen::Settings);
2072 assert!(settings.contains("idle_days"));
2073
2074 st.picker_entries = build_entries(st.session.adapters, st.session.groups);
2075 st.picker_days[1] = Some(45);
2076 let picker = screenshot(&mut st, Screen::Adapters);
2077 assert!(picker.contains("cargo"));
2078 assert!(picker.contains("Test"), "the language heading is missing");
2079 assert!(picker.contains("45d"), "the idle window is missing");
2080
2081 let summary = screenshot(&mut st, Screen::Summary);
2084 assert!(summary.contains("still at the value"));
2085 }
2086
2087 #[test]
2088 fn the_declaration_no_longer_leaves_on_y() {
2089 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2093 st.screen = Screen::Declaration;
2094 assert!(handle_key(&mut st, KeyCode::Char('y')).is_none());
2095 assert_eq!(st.screen, Screen::Declaration);
2096 }
2097
2098 #[test]
2099 fn finishing_takes_two_presses_of_enter() {
2100 let mut st = state(session(
2103 vec![row("auto_update", Control::Toggle, "false")],
2104 &[],
2105 ));
2106 handle_key(&mut st, KeyCode::End);
2107 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2108 assert_eq!(st.screen, Screen::Settings, "one press must not leave");
2109 assert!(st.enter_armed);
2110 assert!(handle_key(&mut st, KeyCode::Enter).is_none());
2111 assert_eq!(st.screen, Screen::Summary);
2112
2113 st.screen = Screen::Settings;
2115 handle_key(&mut st, KeyCode::End);
2116 handle_key(&mut st, KeyCode::Enter);
2117 handle_key(&mut st, KeyCode::Up);
2118 handle_key(&mut st, KeyCode::End);
2119 handle_key(&mut st, KeyCode::Enter);
2120 assert_eq!(
2121 st.screen,
2122 Screen::Settings,
2123 "a keypress in between must disarm the first Enter"
2124 );
2125 }
2126
2127 #[test]
2128 fn the_finish_line_is_not_a_setting() {
2129 let mut st = state(session(
2133 vec![row("auto_update", Control::Toggle, "false")],
2134 &[],
2135 ));
2136 handle_key(&mut st, KeyCode::End);
2137 handle_key(&mut st, KeyCode::Char(' '));
2138 handle_key(&mut st, KeyCode::Char('r'));
2139 assert_eq!(st.session.rows[0].value, "false");
2140 assert!(!st.session.rows[0].changed());
2141 }
2142
2143 #[test]
2144 fn a_refused_value_is_not_stored() {
2145 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2146 handle_key(&mut st, KeyCode::Enter); handle_key(&mut st, KeyCode::Backspace);
2148 handle_key(&mut st, KeyCode::Backspace); handle_key(&mut st, KeyCode::Enter);
2150 assert_eq!(st.session.rows[0].value, "14");
2151 assert!(st.error.is_some(), "the reason was not shown");
2152 assert!(st.editing.is_some(), "the editor closed on a refusal");
2153 }
2154
2155 #[test]
2156 fn an_accepted_value_replaces_the_old_one() {
2157 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
2158 handle_key(&mut st, KeyCode::Enter);
2159 handle_key(&mut st, KeyCode::Backspace);
2160 handle_key(&mut st, KeyCode::Backspace);
2161 handle_key(&mut st, KeyCode::Char('3'));
2162 handle_key(&mut st, KeyCode::Char('0'));
2163 handle_key(&mut st, KeyCode::Enter);
2164 assert_eq!(st.session.rows[0].value, "30");
2165 assert!(st.session.rows[0].changed());
2166 }
2167
2168 #[test]
2169 fn unchecking_an_adapter_writes_it_to_the_deny_list() {
2170 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2171 let mut st = state(session(
2172 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2173 adapters,
2174 ));
2175 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.screen, Screen::Adapters);
2177 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2180 handle_key(&mut st, KeyCode::Enter);
2181 assert_eq!(st.session.rows[0].value, "cargo");
2182 assert_eq!(st.screen, Screen::Settings);
2183 }
2184
2185 #[test]
2186 fn every_adapter_appears_under_exactly_one_heading() {
2187 let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
2190 let groups: &[(&'static str, &'static [&'static str])] =
2191 &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2192 let entries = build_entries(adapters, groups);
2193 let headings: Vec<&str> = entries
2194 .iter()
2195 .filter_map(|e| match e {
2196 PickerEntry::Group { label, .. } => Some(*label),
2197 PickerEntry::Adapter(_) => None,
2198 })
2199 .collect();
2200 assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
2201
2202 let mut listed: Vec<usize> = entries
2203 .iter()
2204 .filter_map(|e| match e {
2205 PickerEntry::Adapter(i) => Some(*i),
2206 PickerEntry::Group { .. } => None,
2207 })
2208 .collect();
2209 listed.sort_unstable();
2210 assert_eq!(
2211 listed,
2212 vec![0, 1, 2],
2213 "an adapter was dropped from the list"
2214 );
2215 }
2216
2217 #[test]
2218 fn a_heading_turns_its_whole_language_off_in_one_press() {
2219 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2220 let mut st = state(session(
2221 vec![row("disabled_adapters", Control::Adapters, "(none)")],
2222 adapters,
2223 ));
2224 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2225 handle_key(&mut st, KeyCode::Enter);
2226 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![false, false, true]);
2228 handle_key(&mut st, KeyCode::Char(' '));
2231 assert_eq!(st.picker_active, vec![true, true, true]);
2232 }
2233
2234 #[test]
2235 fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
2236 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
2237 let mut st = state(session(
2238 vec![
2239 row("disabled_adapters", Control::Adapters, "(none)"),
2240 row("adapter_idle_days", Control::AdapterDays, "(none)"),
2241 ],
2242 adapters,
2243 ));
2244 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
2245 handle_key(&mut st, KeyCode::Enter);
2246 handle_key(&mut st, KeyCode::Char('d')); handle_key(&mut st, KeyCode::Char('3'));
2248 handle_key(&mut st, KeyCode::Char('0'));
2249 handle_key(&mut st, KeyCode::Enter);
2250 assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
2251
2252 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
2254
2255 handle_key(&mut st, KeyCode::Enter);
2258 handle_key(&mut st, KeyCode::Char('d'));
2259 handle_key(&mut st, KeyCode::Backspace);
2260 handle_key(&mut st, KeyCode::Backspace);
2261 handle_key(&mut st, KeyCode::Enter);
2262 handle_key(&mut st, KeyCode::Enter);
2263 assert_eq!(st.session.rows[1].value, "(none)");
2264 }
2265
2266 #[test]
2267 fn a_cache_cap_typed_on_a_heading_reaches_only_the_adapters_that_have_a_cache() {
2268 let adapters: &[&'static str] = &["npm", "pnpm", "venv"];
2272 let mut st = state(session(
2273 vec![
2274 row("disabled_adapters", Control::Adapters, "(none)"),
2275 row("cache_max_gb", Control::CacheCaps, "(none)"),
2276 ],
2277 adapters,
2278 ));
2279 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Python", &["venv"])];
2280 handle_key(&mut st, KeyCode::Enter);
2281 handle_key(&mut st, KeyCode::Char('c')); handle_key(&mut st, KeyCode::Char('1'));
2283 handle_key(&mut st, KeyCode::Char('0'));
2284 handle_key(&mut st, KeyCode::Enter);
2285 assert_eq!(st.picker_caps, vec![Some(10), Some(10), None]);
2286
2287 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pnpm=10");
2289 }
2290
2291 #[test]
2292 fn a_cache_cap_is_cleared_by_emptying_it() {
2293 let adapters: &[&'static str] = &["npm"];
2294 let mut st = state(session(
2295 vec![
2296 row("disabled_adapters", Control::Adapters, "(none)"),
2297 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2298 ],
2299 adapters,
2300 ));
2301 st.session.groups = &[("JavaScript", &["npm"])];
2302 handle_key(&mut st, KeyCode::Enter);
2303 assert_eq!(st.picker_caps, vec![Some(10)]);
2306 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2308 handle_key(&mut st, KeyCode::Backspace);
2309 handle_key(&mut st, KeyCode::Backspace);
2310 handle_key(&mut st, KeyCode::Enter);
2311 handle_key(&mut st, KeyCode::Enter);
2312 assert_eq!(st.session.rows[1].value, "(none)");
2313 }
2314
2315 #[test]
2316 fn a_cap_on_a_cache_with_no_adapter_survives_the_checklist() {
2317 let adapters: &[&'static str] = &["npm"];
2322 let mut st = state(session(
2323 vec![
2324 row("disabled_adapters", Control::Adapters, "(none)"),
2325 row("cache_max_gb", Control::CacheCaps, "npm=10,pip=20"),
2326 ],
2327 adapters,
2328 ));
2329 st.session.groups = &[("JavaScript", &["npm"])];
2330 handle_key(&mut st, KeyCode::Enter);
2331 handle_key(&mut st, KeyCode::Enter);
2332 assert_eq!(st.session.rows[1].value, "npm=10,pip=20");
2333 }
2334
2335 #[test]
2336 fn typing_a_cap_where_there_is_no_cache_says_so_instead_of_dropping_it() {
2337 let adapters: &[&'static str] = &["venv"];
2338 let mut st = state(session(
2339 vec![
2340 row("disabled_adapters", Control::Adapters, "(none)"),
2341 row("cache_max_gb", Control::CacheCaps, "(none)"),
2342 ],
2343 adapters,
2344 ));
2345 st.session.groups = &[("Python", &["venv"])];
2346 handle_key(&mut st, KeyCode::Enter);
2347 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
2349 assert!(st.picker_editing.is_none(), "no editor opened");
2350 let err = st.error.clone().expect("the refusal is explained");
2351 assert!(err.contains("devp caches"), "{err}");
2352 }
2353
2354 #[test]
2355 fn the_checklist_draws_the_cache_cap_beside_the_idle_window() {
2356 let adapters: &[&'static str] = &["npm", "venv"];
2359 let mut st = state(session(
2360 vec![
2361 row("disabled_adapters", Control::Adapters, "(none)"),
2362 row("adapter_idle_days", Control::AdapterDays, "npm=30"),
2363 row("cache_max_gb", Control::CacheCaps, "npm=10"),
2364 ],
2365 adapters,
2366 ));
2367 st.session.groups = &[("JavaScript", &["npm"]), ("Python", &["venv"])];
2368 handle_key(&mut st, KeyCode::Enter);
2369 let picker = screenshot(&mut st, Screen::Adapters);
2370 assert!(picker.contains("30d"), "the idle window is drawn");
2371 assert!(picker.contains("10G"), "the cap is drawn");
2372 assert!(picker.contains("no cap") || picker.contains("10G"));
2375 assert!(picker.contains("cache cap"), "the column is labelled");
2376 }
2377
2378 #[test]
2379 fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
2380 let adapters: &[&'static str] = &["npm", "cargo"];
2383 let opt_in: &[&'static str] = &["cargo"];
2384 let mut st = state(session(
2385 vec![
2386 row("disabled_adapters", Control::Adapters, "(none)"),
2387 row("enable_cargo", Control::Toggle, "false"),
2388 ],
2389 adapters,
2390 ));
2391 st.session.opt_in_adapters = opt_in;
2392 st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
2393
2394 handle_key(&mut st, KeyCode::Enter);
2395 assert_eq!(st.picker_active, vec![true, false]);
2398 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
2402 handle_key(&mut st, KeyCode::Enter);
2403 assert_eq!(st.session.rows[0].value, "(none)");
2404 assert_eq!(st.session.rows[1].value, "true");
2405 }
2406
2407 #[test]
2408 fn the_checklist_opens_showing_what_is_already_disabled() {
2409 let adapters: &[&'static str] = &["npm", "cargo", "go"];
2412 let mut st = state(session(
2413 vec![row("disabled_adapters", Control::Adapters, "go")],
2414 adapters,
2415 ));
2416 handle_key(&mut st, KeyCode::Enter);
2417 assert_eq!(st.picker_active, vec![true, true, false]);
2418 handle_key(&mut st, KeyCode::Enter);
2419 assert_eq!(st.session.rows[0].value, "go");
2420 }
2421
2422 #[test]
2423 fn cancelling_reports_cancelled_rather_than_an_empty_save() {
2424 let mut st = state(session(
2427 vec![row("auto_update", Control::Toggle, "false")],
2428 &[],
2429 ));
2430 assert!(matches!(
2431 handle_key(&mut st, KeyCode::Char('q')),
2432 Some(Outcome::Cancelled)
2433 ));
2434 }
2435
2436 #[test]
2437 fn only_changed_rows_are_saved() {
2438 let mut st = state(session(
2439 vec![
2440 row("auto_update", Control::Toggle, "false"),
2441 row("auto_config", Control::Toggle, "false"),
2442 ],
2443 &[],
2444 ));
2445 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::End); handle_key(&mut st, KeyCode::Enter); handle_key(&mut st, KeyCode::Enter); let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2450 panic!("expected a save");
2451 };
2452 assert_eq!(changed.len(), 1);
2453 assert_eq!(changed[0].key, "auto_update");
2454 assert_eq!(changed[0].value, "true");
2455 }
2456
2457 #[test]
2458 fn reset_puts_a_row_back_without_touching_the_others() {
2459 let mut st = state(session(
2460 vec![
2461 row("auto_update", Control::Toggle, "false"),
2462 row("auto_config", Control::Toggle, "true"),
2463 ],
2464 &[],
2465 ));
2466 handle_key(&mut st, KeyCode::Char(' '));
2467 assert!(st.session.rows[0].changed());
2468 handle_key(&mut st, KeyCode::Char('r'));
2469 assert!(!st.session.rows[0].changed());
2470 assert_eq!(st.session.rows[1].value, "true");
2471 }
2472
2473 #[test]
2474 fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
2475 let mut rows = [
2476 categorised_row("idle_days", "Scope", Control::Number, "14"),
2477 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2478 categorised_row("auto_config", "Updates", Control::Toggle, "false"),
2479 ];
2480 let entries = settings_entries(&rows);
2481 assert_eq!(entries.len(), 6);
2483 assert_eq!(
2484 opening_index(&entries, &rows),
2485 1,
2486 "with nothing new, start at the first row — never on a heading"
2487 );
2488 rows[2].is_new = true;
2489 assert_eq!(
2490 opening_index(&entries, &rows),
2491 4,
2492 "an index into the drawn list, not into the rows"
2493 );
2494 }
2495
2496 #[test]
2497 fn the_cursor_never_lands_on_a_heading() {
2498 let rows = vec![
2499 categorised_row("idle_days", "Scope", Control::Number, "14"),
2500 categorised_row("auto_update", "Updates", Control::Toggle, "false"),
2501 ];
2502 let entries = settings_entries(&rows);
2503 assert_eq!(entries.len(), 5, "two rows, two headings, one finish line");
2504
2505 for forward in [true, false] {
2507 let mut at = first_row(&entries).expect("a row");
2508 for _ in 0..entries.len() * 2 {
2509 at = step(&entries, at, forward);
2510 assert!(
2511 matches!(entries[at], SettingEntry::Row(_) | SettingEntry::Finish),
2512 "stopped on entry {at}, which is a heading"
2513 );
2514 }
2515 }
2516
2517 let last = last_stop(&entries).expect("a stop");
2519 assert_eq!(step(&entries, last, true), first_row(&entries).unwrap());
2520 assert_eq!(step(&entries, first_row(&entries).unwrap(), false), last);
2521 }
2522
2523 #[test]
2524 fn a_run_of_one_category_gets_one_heading() {
2525 let rows = vec![
2526 categorised_row("a", "Scope", Control::Toggle, "false"),
2527 categorised_row("b", "Scope", Control::Toggle, "false"),
2528 categorised_row("c", "Scope", Control::Toggle, "false"),
2529 ];
2530 assert_eq!(settings_entries(&rows).len(), 5);
2532 }
2533
2534 fn suggestion(key: &'static str, cautious: bool) -> Suggestion {
2535 Suggestion {
2536 key,
2537 label: "label",
2538 help: "help",
2539 plain: "plain",
2540 why: "why",
2541 value: "true",
2542 cautious,
2543 }
2544 }
2545
2546 #[test]
2547 fn the_safe_tier_arrives_accepted_and_the_cautious_one_does_not() {
2548 let adapters: &[&str] = &["npm"];
2552 let mut s = session(
2553 vec![
2554 row("enable_cargo", Control::Toggle, "false"),
2555 row("allow_manifest_rewrite", Control::Toggle, "false"),
2556 ],
2557 adapters,
2558 );
2559 s.suggestions = vec![
2560 suggestion("enable_cargo", false),
2561 suggestion("allow_manifest_rewrite", true),
2562 ];
2563 let mut st = state(s);
2564 preaccept_recommended(&mut st);
2565
2566 assert_eq!(
2567 row_value(&st.session.rows, "enable_cargo").as_deref(),
2568 Some("true"),
2569 "the safe tier should already be on"
2570 );
2571 assert_eq!(
2572 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2573 Some("false"),
2574 "the cautious tier must still be a deliberate choice"
2575 );
2576
2577 st.screen = Screen::Suggestions;
2580 st.sugg_list.select(Some(0));
2581 assert!(suggestions_key(&mut st, KeyCode::Char('r')).is_none());
2582 assert_eq!(
2583 row_value(&st.session.rows, "enable_cargo").as_deref(),
2584 Some("false")
2585 );
2586 }
2587
2588 #[test]
2589 fn a_recommended_row_says_so_and_names_the_fresh_default() {
2590 let adapters: &[&str] = &["npm"];
2591 let mut rows = vec![row("enable_cargo", Control::Toggle, "false")];
2592 rows[0].recommended = Some("true");
2593 let mut st = state(session(rows, adapters));
2594
2595 let shot = screenshot(&mut st, Screen::Settings);
2596 assert!(
2597 shot.contains("REC"),
2598 "the badge is the only thing on the row \
2599 that says a recommendation exists"
2600 );
2601 assert!(
2602 shot.contains("Default"),
2603 "a value nobody chose is unreadable without the one they would have got"
2604 );
2605 assert!(shot.contains("not required"));
2609 }
2610
2611 #[test]
2612 fn accept_all_stops_at_the_cautious_tier() {
2613 let adapters: &[&str] = &["npm"];
2617 let mut s = session(
2618 vec![
2619 row("enable_cargo", Control::Toggle, "false"),
2620 row("allow_manifest_rewrite", Control::Toggle, "false"),
2621 ],
2622 adapters,
2623 );
2624 s.suggestions = vec![
2625 suggestion("enable_cargo", false),
2626 suggestion("allow_manifest_rewrite", true),
2627 ];
2628 let mut st = state(s);
2629 st.screen = Screen::Suggestions;
2630 st.sugg_list.select(Some(0));
2631
2632 assert!(suggestions_key(&mut st, KeyCode::Char('a')).is_none());
2633 assert_eq!(
2634 row_value(&st.session.rows, "enable_cargo").as_deref(),
2635 Some("true")
2636 );
2637 assert_eq!(
2638 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2639 Some("false")
2640 );
2641
2642 st.sugg_list.select(Some(1));
2644 suggestions_key(&mut st, KeyCode::Char(' '));
2645 assert_eq!(
2646 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2647 Some("true")
2648 );
2649 }
2650
2651 #[test]
2652 fn undoing_a_suggestion_puts_back_what_the_setting_had() {
2653 let adapters: &[&str] = &["npm"];
2654 let mut s = session(
2655 vec![row("enable_cargo", Control::Toggle, "false")],
2656 adapters,
2657 );
2658 s.suggestions = vec![suggestion("enable_cargo", false)];
2659 let mut st = state(s);
2660 st.screen = Screen::Suggestions;
2661 st.sugg_list.select(Some(0));
2662
2663 suggestions_key(&mut st, KeyCode::Char(' '));
2664 assert!(accepted(&st, 0));
2665 suggestions_key(&mut st, KeyCode::Char(' '));
2666 assert!(!accepted(&st, 0));
2667 assert_eq!(
2668 row_value(&st.session.rows, "enable_cargo").as_deref(),
2669 Some("false")
2670 );
2671 assert!(!st.session.rows[0].changed());
2673 }
2674
2675 #[test]
2676 fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
2677 let adapters: &[&str] = &["npm"];
2680 let s = session(vec![row("idle_days", Control::Number, "30")], adapters);
2681 let mut st = state(s);
2682 st.screen = Screen::Declaration;
2683 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2684 assert_eq!(st.screen, Screen::Settings);
2685 }
2686
2687 #[test]
2688 fn the_first_run_reaches_the_suggestions_first() {
2689 let adapters: &[&str] = &["npm"];
2690 let mut s = session(
2691 vec![row("enable_cargo", Control::Toggle, "false")],
2692 adapters,
2693 );
2694 s.suggestions = vec![suggestion("enable_cargo", false)];
2695 let mut st = state(s);
2696 st.screen = Screen::Declaration;
2697 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2698 assert_eq!(st.screen, Screen::Suggestions);
2699 }
2700}