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 Number,
33 Adapters,
35 AdapterDays,
41 CacheCaps,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum PickerField {
52 Days,
54 Cap,
56}
57
58#[derive(Debug, Clone)]
60pub struct ConfigRow {
61 pub key: &'static str,
62 pub help: &'static str,
63 pub plain: &'static str,
68 pub control: Control,
69 pub value: String,
71 pub original: String,
73 pub is_new: bool,
75}
76
77impl ConfigRow {
78 pub fn changed(&self) -> bool {
79 self.value != self.original
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct DeclarationLine {
86 pub mark: char,
89 pub subject: String,
90 pub state: String,
91}
92
93#[derive(Debug, Clone)]
100pub struct Suggestion {
101 pub key: &'static str,
102 pub label: &'static str,
104 pub help: &'static str,
106 pub plain: &'static str,
108 pub why: &'static str,
110 pub value: &'static str,
112 pub cautious: bool,
118}
119
120pub enum Outcome {
122 Save(Vec<ConfigRow>),
124 KeepAll,
126 Cancelled,
128}
129
130pub struct ConfigSession<'a> {
132 pub declaration: Vec<DeclarationLine>,
134 pub standing: String,
136 pub rows: Vec<ConfigRow>,
137 pub suggestions: Vec<Suggestion>,
140 pub adapters: &'a [&'static str],
142 pub opt_in_adapters: &'a [&'static str],
144 pub capped_adapters: &'a [&'static str],
154 pub groups: &'a [(&'static str, &'static [&'static str])],
157 pub validate: &'a dyn Fn(&str, &str) -> std::result::Result<(), String>,
160 pub title: &'a str,
162}
163
164fn opening_index(rows: &[ConfigRow]) -> usize {
168 rows.iter().position(|r| r.is_new).unwrap_or(0)
169}
170
171#[derive(Debug, PartialEq, Eq, Clone, Copy)]
172enum Screen {
173 Declaration,
174 Suggestions,
175 Settings,
176 Adapters,
177 Summary,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182enum PickerEntry {
183 Group {
186 label: &'static str,
187 members: Vec<usize>,
188 },
189 Adapter(usize),
191}
192
193fn build_entries(
200 adapters: &[&'static str],
201 groups: &[(&'static str, &'static [&'static str])],
202) -> Vec<PickerEntry> {
203 let mut entries = Vec::new();
204 let mut placed = vec![false; adapters.len()];
205
206 for (label, names) in groups {
207 let members: Vec<usize> = names
208 .iter()
209 .filter_map(|name| adapters.iter().position(|a| a == name))
210 .collect();
211 if members.is_empty() {
212 continue;
213 }
214 for &i in &members {
215 placed[i] = true;
216 }
217 entries.push(PickerEntry::Group {
218 label,
219 members: members.clone(),
220 });
221 entries.extend(members.into_iter().map(PickerEntry::Adapter));
222 }
223
224 let rest: Vec<usize> = (0..adapters.len()).filter(|&i| !placed[i]).collect();
225 if !rest.is_empty() {
226 entries.push(PickerEntry::Group {
227 label: "Other",
228 members: rest.clone(),
229 });
230 entries.extend(rest.into_iter().map(PickerEntry::Adapter));
231 }
232 entries
233}
234
235fn row_value(rows: &[ConfigRow], key: &str) -> Option<String> {
237 rows.iter().find(|r| r.key == key).map(|r| r.value.clone())
238}
239
240fn set_row(rows: &mut [ConfigRow], key: &str, value: String) {
242 if let Some(row) = rows.iter_mut().find(|r| r.key == key) {
243 row.value = value;
244 }
245}
246
247struct State<'a> {
248 session: ConfigSession<'a>,
249 screen: Screen,
250 list: ListState,
251 editing: Option<String>,
253 error: Option<String>,
255 picker_active: Vec<bool>,
257 picker_days: Vec<Option<u64>>,
259 picker_caps: Vec<Option<u64>>,
262 picker_entries: Vec<PickerEntry>,
266 picker_editing: Option<String>,
268 picker_field: PickerField,
270 picker_list: ListState,
271 decl_list: ListState,
273 sugg_list: ListState,
275}
276
277pub fn run(session: ConfigSession<'_>) -> Result<Outcome> {
279 if session.rows.is_empty() {
280 return Ok(Outcome::KeepAll);
281 }
282
283 let mut list = ListState::default();
284 list.select(Some(opening_index(&session.rows)));
285
286 let mut picker_list = ListState::default();
287 picker_list.select(Some(0));
288
289 let mut decl_list = ListState::default();
290 decl_list.select(Some(0));
291
292 let mut sugg_list = ListState::default();
293 sugg_list.select(Some(0));
294
295 let mut state = State {
296 picker_active: vec![true; session.adapters.len()],
297 picker_days: vec![None; session.adapters.len()],
298 picker_caps: vec![None; session.adapters.len()],
299 picker_entries: Vec::new(),
300 picker_editing: None,
301 picker_field: PickerField::Days,
302 session,
303 screen: Screen::Declaration,
304 list,
305 editing: None,
306 error: None,
307 picker_list,
308 decl_list,
309 sugg_list,
310 };
311
312 let mut tui = Tui::new()?;
315 tui.drain_stale_input(Duration::from_millis(300));
316 event_loop(&mut tui.terminal, &mut state)
317}
318
319fn event_loop(
320 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
321 state: &mut State<'_>,
322) -> Result<Outcome> {
323 loop {
324 terminal.draw(|frame| render(frame, state))?;
325
326 if !event::poll(Duration::from_millis(100))? {
327 continue;
328 }
329 let Event::Key(key) = event::read()? else {
330 continue;
331 };
332 if key.kind == KeyEventKind::Release {
335 continue;
336 }
337 if key.modifiers.contains(KeyModifiers::CONTROL)
340 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
341 {
342 return Ok(Outcome::Cancelled);
343 }
344
345 if let Some(outcome) = handle_key(state, key.code) {
346 return Ok(outcome);
347 }
348 }
349}
350
351fn handle_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
353 match state.screen {
354 Screen::Declaration => declaration_key(state, code),
355 Screen::Suggestions => suggestions_key(state, code),
356 Screen::Settings => settings_key(state, code),
357 Screen::Adapters => adapters_key(state, code),
358 Screen::Summary => summary_key(state, code),
359 }
360}
361
362fn declaration_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
363 let len = state.session.declaration.len().max(1);
364 let current = state.decl_list.selected().unwrap_or(0);
365 match code {
366 KeyCode::Up | KeyCode::Char('k') => {
368 state.decl_list.select(Some(current.saturating_sub(1)));
369 None
370 }
371 KeyCode::Down | KeyCode::Char('j') => {
372 state.decl_list.select(Some((current + 1).min(len - 1)));
373 None
374 }
375 KeyCode::Char('y') | KeyCode::Char('Y') => Some(Outcome::KeepAll),
378 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('c') | KeyCode::Char('C') => {
379 state.screen = if state.session.suggestions.is_empty() {
380 Screen::Settings
381 } else {
382 Screen::Suggestions
383 };
384 None
385 }
386 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
387 _ => None,
388 }
389}
390
391fn accepted(state: &State<'_>, index: usize) -> bool {
398 let s = &state.session.suggestions[index];
399 row_value(&state.session.rows, s.key).as_deref() == Some(s.value)
400}
401
402fn apply_suggestion(state: &mut State<'_>, index: usize, accept: bool) {
406 let (key, value) = {
407 let s = &state.session.suggestions[index];
408 (s.key, s.value)
409 };
410 let restore = state
411 .session
412 .rows
413 .iter()
414 .find(|r| r.key == key)
415 .map(|r| r.original.clone());
416 let Some(restore) = restore else { return };
417 let next = if accept { value.to_string() } else { restore };
418 set_row(&mut state.session.rows, key, next);
419}
420
421fn suggestions_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
422 let len = state.session.suggestions.len();
423 let current = state.sugg_list.selected().unwrap_or(0);
424 match code {
425 KeyCode::Up | KeyCode::Char('k') => {
426 state
427 .sugg_list
428 .select(Some(if current == 0 { len - 1 } else { current - 1 }))
429 }
430 KeyCode::Down | KeyCode::Char('j') => {
431 state
432 .sugg_list
433 .select(Some(if current + 1 >= len { 0 } else { current + 1 }))
434 }
435 KeyCode::Char(' ') => {
436 let now = accepted(state, current);
437 apply_suggestion(state, current, !now);
438 }
439 KeyCode::Char('a') | KeyCode::Char('A') => {
443 for i in 0..len {
444 if !state.session.suggestions[i].cautious {
445 apply_suggestion(state, i, true);
446 }
447 }
448 }
449 KeyCode::Char('r') | KeyCode::Char('R') => {
450 for i in 0..len {
451 apply_suggestion(state, i, false);
452 }
453 }
454 KeyCode::Enter | KeyCode::Char('c') | KeyCode::Char('C') => {
455 state.screen = Screen::Settings;
456 }
457 KeyCode::Char('y') | KeyCode::Char('Y') => state.screen = Screen::Summary,
460 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
461 _ => {}
462 }
463 None
464}
465
466fn settings_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
467 if state.editing.is_some() {
469 return number_edit_key(state, code);
470 }
471
472 let len = state.session.rows.len();
473 let current = state.list.selected().unwrap_or(0);
474 match code {
475 KeyCode::Up | KeyCode::Char('k') => {
476 state.error = None;
477 state
478 .list
479 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
480 }
481 KeyCode::Down | KeyCode::Char('j') => {
482 state.error = None;
483 state
484 .list
485 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
486 }
487 KeyCode::Home | KeyCode::Char('g') => state.list.select(Some(0)),
488 KeyCode::End | KeyCode::Char('G') => state.list.select(Some(len - 1)),
489 KeyCode::Char(' ') | KeyCode::Enter => activate(state, current),
490 KeyCode::Char('r') | KeyCode::Char('R') => {
491 state.error = None;
492 let row = &mut state.session.rows[current];
493 row.value = row.original.clone();
494 }
495 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') | KeyCode::Char('S') => {
496 state.screen = Screen::Summary;
497 }
498 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => return Some(Outcome::Cancelled),
499 _ => {}
500 }
501 None
502}
503
504fn activate(state: &mut State<'_>, index: usize) {
506 state.error = None;
507 match state.session.rows[index].control {
508 Control::Toggle => {
509 let row = &mut state.session.rows[index];
510 row.value = if row.value == "true" {
511 "false".to_string()
512 } else {
513 "true".to_string()
514 };
515 }
516 Control::Number => state.editing = Some(state.session.rows[index].value.clone()),
517 Control::Adapters | Control::AdapterDays | Control::CacheCaps => open_picker(state),
518 }
519}
520
521fn open_picker(state: &mut State<'_>) {
527 let rows = &state.session.rows;
528 let disabled = parse_list(&row_value(rows, "disabled_adapters").unwrap_or_default());
529 let days = parse_days(&row_value(rows, "adapter_idle_days").unwrap_or_default());
530 let caps = parse_days(&row_value(rows, "cache_max_gb").unwrap_or_default());
531
532 state.picker_active = state
533 .session
534 .adapters
535 .iter()
536 .map(|name| {
537 if disabled.iter().any(|d| d == name) {
538 return false;
539 }
540 if state.session.opt_in_adapters.contains(name) {
544 return row_value(rows, &format!("enable_{name}")).as_deref() == Some("true");
545 }
546 true
547 })
548 .collect();
549 state.picker_days = state
550 .session
551 .adapters
552 .iter()
553 .map(|name| days.iter().find(|(n, _)| n == name).map(|(_, d)| *d))
554 .collect();
555
556 state.picker_caps = state
557 .session
558 .adapters
559 .iter()
560 .map(|name| {
561 if !state.session.capped_adapters.contains(name) {
562 return None;
563 }
564 caps.iter().find(|(n, _)| n == name).map(|(_, g)| *g)
565 })
566 .collect();
567
568 state.picker_entries = build_entries(state.session.adapters, state.session.groups);
569 state.picker_editing = None;
570 state.picker_field = PickerField::Days;
571 state.picker_list.select(Some(0));
572 state.screen = Screen::Adapters;
573}
574
575fn number_edit_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
576 let index = state.list.selected().unwrap_or(0);
577 match code {
578 KeyCode::Char(c) if c.is_ascii_digit() => {
579 if let Some(buf) = state.editing.as_mut() {
580 buf.push(c);
581 }
582 }
583 KeyCode::Backspace => {
584 if let Some(buf) = state.editing.as_mut() {
585 buf.pop();
586 }
587 }
588 KeyCode::Enter => {
589 let typed = state.editing.clone().unwrap_or_default();
590 let key = state.session.rows[index].key;
591 match (state.session.validate)(key, typed.trim()) {
592 Ok(()) => {
593 state.session.rows[index].value = typed.trim().to_string();
594 state.editing = None;
595 state.error = None;
596 }
597 Err(why) => state.error = Some(why),
600 }
601 }
602 KeyCode::Esc => {
603 state.editing = None;
604 state.error = None;
605 }
606 _ => {}
607 }
608 None
609}
610
611fn adapters_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
612 if state.picker_editing.is_some() {
613 return picker_number_key(state, code);
614 }
615
616 let len = state.picker_entries.len().max(1);
617 let current = state.picker_list.selected().unwrap_or(0);
618 match code {
619 KeyCode::Up | KeyCode::Char('k') => {
620 state.error = None;
621 state
622 .picker_list
623 .select(Some(if current == 0 { len - 1 } else { current - 1 }));
624 }
625 KeyCode::Down | KeyCode::Char('j') => {
626 state.error = None;
627 state
628 .picker_list
629 .select(Some(if current + 1 >= len { 0 } else { current + 1 }));
630 }
631 KeyCode::Char(' ') => match state.picker_entries.get(current).cloned() {
634 Some(PickerEntry::Adapter(i)) => state.picker_active[i] = !state.picker_active[i],
635 Some(PickerEntry::Group { members, .. }) => {
636 let target = !members.iter().any(|&i| state.picker_active[i]);
637 for i in members {
638 state.picker_active[i] = target;
639 }
640 }
641 None => {}
642 },
643 KeyCode::Char('d') | KeyCode::Char('D') => {
644 state.error = None;
645 let seed = match state.picker_entries.get(current) {
646 Some(PickerEntry::Adapter(i)) => state.picker_days[*i],
647 Some(PickerEntry::Group { members, .. }) => {
650 let first = members.first().and_then(|&i| state.picker_days[i]);
651 if members.iter().all(|&i| state.picker_days[i] == first) {
652 first
653 } else {
654 None
655 }
656 }
657 None => None,
658 };
659 state.picker_field = PickerField::Days;
660 state.picker_editing = Some(seed.map(|d| d.to_string()).unwrap_or_default());
661 }
662 KeyCode::Char('c') | KeyCode::Char('C') => {
663 state.error = None;
664 let targets = capped_targets(state, current);
668 if targets.is_empty() {
669 state.error = Some(
670 "No cache of that name for dev-prune to size. `devp caches` lists the ones \
671 there are; `devp config set cache_max_gb` caps them."
672 .to_string(),
673 );
674 return None;
675 }
676 let first = targets.first().and_then(|&i| state.picker_caps[i]);
677 let seed = if targets.iter().all(|&i| state.picker_caps[i] == first) {
678 first
679 } else {
680 None
681 };
682 state.picker_field = PickerField::Cap;
683 state.picker_editing = Some(seed.map(|g| g.to_string()).unwrap_or_default());
684 }
685 KeyCode::Char('a') | KeyCode::Char('A') => state.picker_active.fill(true),
686 KeyCode::Char('n') | KeyCode::Char('N') => state.picker_active.fill(false),
687 KeyCode::Enter => commit_picker(state),
688 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => state.screen = Screen::Settings,
689 _ => {}
690 }
691 None
692}
693
694fn capped_targets(state: &State<'_>, line: usize) -> Vec<usize> {
701 let members: Vec<usize> = match state.picker_entries.get(line) {
702 Some(PickerEntry::Adapter(i)) => vec![*i],
703 Some(PickerEntry::Group { members, .. }) => members.clone(),
704 None => Vec::new(),
705 };
706 members
707 .into_iter()
708 .filter(|&i| {
709 state
710 .session
711 .capped_adapters
712 .contains(&state.session.adapters[i])
713 })
714 .collect()
715}
716
717fn picker_number_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
721 let current = state.picker_list.selected().unwrap_or(0);
722 match code {
723 KeyCode::Char(c) if c.is_ascii_digit() => {
724 if let Some(buf) = state.picker_editing.as_mut() {
725 buf.push(c);
726 }
727 }
728 KeyCode::Backspace => {
729 if let Some(buf) = state.picker_editing.as_mut() {
730 buf.pop();
731 }
732 }
733 KeyCode::Enter => {
734 let typed = state.picker_editing.clone().unwrap_or_default();
735 let typed = typed.trim().to_string();
736 let key = match state.picker_field {
737 PickerField::Days => "adapter_idle_days",
738 PickerField::Cap => "cache_max_gb",
739 };
740 let targets: Vec<usize> = match state.picker_field {
741 PickerField::Days => match state.picker_entries.get(current) {
742 Some(PickerEntry::Adapter(i)) => vec![*i],
743 Some(PickerEntry::Group { members, .. }) => members.clone(),
744 None => Vec::new(),
745 },
746 PickerField::Cap => capped_targets(state, current),
747 };
748 let value = if typed.is_empty() {
749 None
750 } else {
751 let Some(&first) = targets.first() else {
752 state.picker_editing = None;
753 return None;
754 };
755 let probe = format!("{}={typed}", state.session.adapters[first]);
756 if let Err(why) = (state.session.validate)(key, &probe) {
759 state.error = Some(why);
760 return None;
761 }
762 typed.parse::<u64>().ok()
763 };
764 for i in targets {
765 match state.picker_field {
766 PickerField::Days => state.picker_days[i] = value,
767 PickerField::Cap => state.picker_caps[i] = value,
768 }
769 }
770 state.picker_editing = None;
771 state.error = None;
772 }
773 KeyCode::Esc => {
774 state.picker_editing = None;
775 state.error = None;
776 }
777 _ => {}
778 }
779 None
780}
781
782fn commit_picker(state: &mut State<'_>) {
788 let adapters = state.session.adapters;
789 let opt_in = state.session.opt_in_adapters;
790
791 let disabled: Vec<&str> = adapters
792 .iter()
793 .enumerate()
794 .filter(|(i, name)| !state.picker_active[*i] && !opt_in.contains(name))
795 .map(|(_, name)| *name)
796 .collect();
797 let disabled = if disabled.is_empty() {
800 "(none)".to_string()
801 } else {
802 disabled.join(",")
803 };
804
805 let mut days: Vec<String> = adapters
806 .iter()
807 .enumerate()
808 .filter_map(|(i, name)| state.picker_days[i].map(|d| format!("{name}={d}")))
809 .collect();
810 days.sort_unstable();
814 let days = if days.is_empty() {
815 "(none)".to_string()
816 } else {
817 days.join(",")
818 };
819
820 let existing = parse_days(&row_value(&state.session.rows, "cache_max_gb").unwrap_or_default());
825 let mut caps: Vec<String> = existing
826 .iter()
827 .filter(|(name, _)| !adapters.iter().any(|a| a == name))
828 .map(|(name, gb)| format!("{name}={gb}"))
829 .collect();
830 caps.extend(
831 adapters
832 .iter()
833 .enumerate()
834 .filter_map(|(i, name)| state.picker_caps[i].map(|g| format!("{name}={g}"))),
835 );
836 caps.sort_unstable();
837 let caps = if caps.is_empty() {
838 "(none)".to_string()
839 } else {
840 caps.join(",")
841 };
842
843 let switches: Vec<(String, String)> = adapters
844 .iter()
845 .enumerate()
846 .filter(|(_, name)| opt_in.contains(name))
847 .map(|(i, name)| (format!("enable_{name}"), state.picker_active[i].to_string()))
848 .collect();
849
850 let rows = &mut state.session.rows;
851 set_row(rows, "disabled_adapters", disabled);
852 set_row(rows, "adapter_idle_days", days);
853 set_row(rows, "cache_max_gb", caps);
854 for (key, value) in switches {
855 set_row(rows, &key, value);
856 }
857 state.screen = Screen::Settings;
858}
859
860fn summary_key(state: &mut State<'_>, code: KeyCode) -> Option<Outcome> {
861 match code {
862 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('s') => {
863 let changed: Vec<ConfigRow> = state
864 .session
865 .rows
866 .iter()
867 .filter(|r| r.changed())
868 .cloned()
869 .collect();
870 if changed.is_empty() {
871 Some(Outcome::KeepAll)
872 } else {
873 Some(Outcome::Save(changed))
874 }
875 }
876 KeyCode::Esc | KeyCode::Backspace => {
877 state.screen = Screen::Settings;
878 None
879 }
880 KeyCode::Char('q') | KeyCode::Char('Q') => Some(Outcome::Cancelled),
881 _ => None,
882 }
883}
884
885fn parse_list(value: &str) -> Vec<String> {
887 if value.trim().eq_ignore_ascii_case("(none)") {
888 return Vec::new();
889 }
890 value
891 .split(',')
892 .map(|s| s.trim().to_lowercase())
893 .filter(|s| !s.is_empty())
894 .collect()
895}
896
897fn parse_days(value: &str) -> Vec<(String, u64)> {
903 if value.trim().eq_ignore_ascii_case("(none)") {
904 return Vec::new();
905 }
906 value
907 .split(',')
908 .filter_map(|entry| {
909 let (name, days) = entry.trim().split_once('=')?;
910 Some((name.trim().to_lowercase(), days.trim().parse().ok()?))
911 })
912 .collect()
913}
914
915fn render(frame: &mut Frame, state: &mut State<'_>) {
920 match state.screen {
921 Screen::Declaration => render_declaration(frame, state),
922 Screen::Suggestions => render_suggestions(frame, state),
923 Screen::Settings => render_settings(frame, state),
924 Screen::Adapters => render_adapters(frame, state),
925 Screen::Summary => render_summary(frame, state),
926 }
927}
928
929fn dim() -> Style {
930 Style::default().fg(Color::DarkGray)
931}
932
933fn header(title: &str, subtitle: &str) -> Paragraph<'static> {
934 Paragraph::new(vec![
935 Line::from(Span::styled(
936 title.to_string(),
937 Style::default()
938 .fg(Color::Cyan)
939 .add_modifier(Modifier::BOLD),
940 )),
941 Line::from(Span::styled(subtitle.to_string(), dim())),
942 ])
943}
944
945fn footer(keys: &[(&str, &str)]) -> Paragraph<'static> {
946 let mut spans = Vec::new();
947 for (i, (key, what)) in keys.iter().enumerate() {
948 if i > 0 {
949 spans.push(Span::styled(" ", dim()));
950 }
951 spans.push(Span::styled(
952 key.to_string(),
953 Style::default().add_modifier(Modifier::BOLD),
954 ));
955 spans.push(Span::styled(format!(" {what}"), dim()));
956 }
957 Paragraph::new(Line::from(spans))
958 .block(Block::default().borders(Borders::TOP).border_style(dim()))
959}
960
961fn render_declaration(frame: &mut Frame, state: &mut State<'_>) {
962 let chunks = Layout::vertical([
963 Constraint::Length(3),
964 Constraint::Min(5),
965 Constraint::Length(3),
966 Constraint::Length(2),
967 ])
968 .split(frame.area());
969
970 frame.render_widget(
971 header(
972 state.session.title,
973 "What this tool is allowed to do on this machine, before it does any of it.",
974 ),
975 chunks[0],
976 );
977
978 let items: Vec<ListItem> = state
979 .session
980 .declaration
981 .iter()
982 .map(|d| {
983 if d.mark == '#' {
984 return ListItem::new(Line::from(Span::styled(
985 format!(" {}", d.subject),
986 Style::default()
987 .fg(Color::Cyan)
988 .add_modifier(Modifier::BOLD),
989 )));
990 }
991 let (mark_style, symbol) = match d.mark {
992 '!' => (Style::default().fg(Color::Yellow), "!"),
993 '+' => (Style::default().fg(Color::Green), "✓"),
994 _ => (dim(), " "),
995 };
996 ListItem::new(Line::from(vec![
997 Span::styled(format!(" {symbol} "), mark_style),
998 Span::styled(crate::output::pad_display(&d.subject, 26), Style::default()),
999 Span::styled(d.state.clone(), dim()),
1000 ]))
1001 })
1002 .collect();
1003
1004 frame.render_stateful_widget(
1007 List::new(items).block(
1008 Block::default()
1009 .title(" Declaration ")
1010 .borders(Borders::ALL)
1011 .border_style(dim()),
1012 ),
1013 chunks[1],
1014 &mut state.decl_list,
1015 );
1016
1017 frame.render_widget(
1018 Paragraph::new(Line::from(Span::styled(
1019 format!(" {}", state.session.standing),
1020 Style::default().fg(Color::Green),
1021 )))
1022 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1023 chunks[2],
1024 );
1025
1026 frame.render_widget(
1027 footer(&[
1028 ("↑↓", "read"),
1029 ("y", "keep all defaults and go"),
1030 ("Enter", "configure"),
1031 ("q", "cancel"),
1032 ]),
1033 chunks[3],
1034 );
1035}
1036
1037fn render_suggestions(frame: &mut Frame, state: &mut State<'_>) {
1044 if state.session.suggestions.is_empty() {
1047 state.screen = Screen::Settings;
1048 return render_settings(frame, state);
1049 }
1050 let chunks = Layout::vertical([
1051 Constraint::Length(3),
1052 Constraint::Min(5),
1053 Constraint::Length(7),
1054 Constraint::Length(2),
1055 ])
1056 .split(frame.area());
1057
1058 let on = (0..state.session.suggestions.len())
1059 .filter(|&i| accepted(state, i))
1060 .count();
1061 frame.render_widget(
1062 header(
1063 "Suggested settings",
1064 &format!(
1069 "{} of {} accepted \u{2014} press \u{2191}\u{2193} to read what each one does \
1070 before deciding. Every one is off until you accept it.",
1071 on,
1072 state.session.suggestions.len()
1073 ),
1074 ),
1075 chunks[0],
1076 );
1077
1078 let selected = state
1079 .sugg_list
1080 .selected()
1081 .unwrap_or(0)
1082 .min(state.session.suggestions.len() - 1);
1083 let mut items: Vec<ListItem> = Vec::new();
1084 let mut tier_shown = false;
1085 for (i, s) in state.session.suggestions.iter().enumerate() {
1086 let mut lines = Vec::new();
1090 if s.cautious && !tier_shown {
1091 tier_shown = true;
1092 lines.push(Line::from(Span::styled(
1093 " Worth turning on once you know what it does",
1094 Style::default()
1095 .fg(Color::Yellow)
1096 .add_modifier(Modifier::BOLD),
1097 )));
1098 }
1099 let mark = if accepted(state, i) {
1100 Span::styled(
1101 "[x] ",
1102 Style::default()
1103 .fg(Color::Green)
1104 .add_modifier(Modifier::BOLD),
1105 )
1106 } else {
1107 Span::styled("[ ] ", dim())
1108 };
1109 lines.push(Line::from(vec![
1110 mark,
1111 Span::styled(
1112 crate::output::pad_display(s.label, 28),
1113 if selected == i {
1114 Style::default().fg(Color::White)
1115 } else {
1116 Style::default()
1117 },
1118 ),
1119 Span::styled(s.key.to_string(), dim()),
1120 ]));
1121 items.push(ListItem::new(lines));
1122 }
1123
1124 frame.render_stateful_widget(
1125 List::new(items)
1126 .block(
1127 Block::default()
1128 .title(" Suggested ")
1129 .borders(Borders::ALL)
1130 .border_style(dim()),
1131 )
1132 .highlight_style(
1133 Style::default()
1134 .bg(Color::Rgb(30, 40, 60))
1135 .add_modifier(Modifier::BOLD),
1136 )
1137 .highlight_symbol("\u{25b6} "),
1138 chunks[1],
1139 &mut state.sugg_list,
1140 );
1141
1142 let s = &state.session.suggestions[selected];
1143 frame.render_widget(
1144 Paragraph::new(vec![
1145 Line::from(Span::styled(format!(" {}", s.help), Style::default())),
1146 Line::from(""),
1147 Line::from(vec![
1148 Span::styled(" In plain words ", dim()),
1149 Span::styled(s.plain.to_string(), Style::default().fg(Color::Cyan)),
1150 ]),
1151 Line::from(""),
1152 Line::from(vec![
1153 Span::styled(" Why we suggest it ", dim()),
1154 Span::styled(s.why.to_string(), Style::default().fg(Color::Green)),
1155 ]),
1156 ])
1157 .wrap(Wrap { trim: true })
1158 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1159 chunks[2],
1160 );
1161
1162 frame.render_widget(
1163 footer(&[
1164 ("\u{2191}\u{2193}", "read"),
1165 ("Space", "accept one"),
1166 ("a", "accept all suggested"),
1167 ("r", "undo"),
1168 ("Enter", "all settings"),
1169 ("y", "done"),
1170 ]),
1171 chunks[3],
1172 );
1173}
1174
1175fn render_settings(frame: &mut Frame, state: &mut State<'_>) {
1176 let chunks = Layout::vertical([
1177 Constraint::Length(3),
1178 Constraint::Min(5),
1179 Constraint::Length(6),
1180 Constraint::Length(2),
1181 ])
1182 .split(frame.area());
1183
1184 let changed = state.session.rows.iter().filter(|r| r.changed()).count();
1185 let new = state.session.rows.iter().filter(|r| r.is_new).count();
1186 let subtitle = match (changed, new) {
1187 (0, 0) => "Nothing changed yet.".to_string(),
1188 (c, 0) => format!("{c} changed."),
1189 (0, n) => format!("{n} new in this version."),
1190 (c, n) => format!("{c} changed, {n} new in this version."),
1191 };
1192 frame.render_widget(header(state.session.title, &subtitle), chunks[0]);
1193
1194 let selected = state.list.selected();
1195 let items: Vec<ListItem> = state
1196 .session
1197 .rows
1198 .iter()
1199 .enumerate()
1200 .map(|(i, row)| {
1201 let control = match row.control {
1202 Control::Toggle if row.value == "true" => Span::styled(
1203 "[x] ",
1204 Style::default()
1205 .fg(Color::Green)
1206 .add_modifier(Modifier::BOLD),
1207 ),
1208 Control::Toggle => Span::styled("[ ] ", dim()),
1209 Control::Number => Span::styled("123 ", dim()),
1210 Control::Adapters | Control::AdapterDays | Control::CacheCaps => {
1211 Span::styled("••• ", dim())
1212 }
1213 };
1214
1215 let shown = if state.editing.is_some() && selected == Some(i) {
1216 format!("{}_", state.editing.clone().unwrap_or_default())
1217 } else {
1218 row.value.clone()
1219 };
1220
1221 let mut spans = vec![
1222 control,
1223 Span::styled(
1224 crate::output::pad_display(row.key, 28),
1225 if selected == Some(i) {
1226 Style::default().fg(Color::White)
1227 } else {
1228 Style::default()
1229 },
1230 ),
1231 Span::styled(
1232 crate::output::pad_display(&shown, 20),
1233 if row.changed() {
1234 Style::default().fg(Color::Yellow)
1235 } else {
1236 Style::default().fg(Color::Cyan)
1237 },
1238 ),
1239 ];
1240 if row.is_new {
1241 spans.push(Span::styled(
1242 "NEW ",
1243 Style::default()
1244 .fg(Color::Magenta)
1245 .add_modifier(Modifier::BOLD),
1246 ));
1247 }
1248 if row.changed() {
1249 spans.push(Span::styled(format!("was {}", row.original), dim()));
1250 }
1251 ListItem::new(Line::from(spans))
1252 })
1253 .collect();
1254
1255 let list = List::new(items)
1256 .block(
1257 Block::default()
1258 .title(" Settings ")
1259 .borders(Borders::ALL)
1260 .border_style(dim()),
1261 )
1262 .highlight_style(
1263 Style::default()
1264 .bg(Color::Rgb(30, 40, 60))
1265 .add_modifier(Modifier::BOLD),
1266 )
1267 .highlight_symbol("▶ ");
1268 frame.render_stateful_widget(list, chunks[1], &mut state.list);
1269
1270 let index = selected.unwrap_or(0);
1273 let row = &state.session.rows[index];
1274 let mut detail = vec![
1275 Line::from(Span::styled(format!(" {}", row.help), Style::default())),
1276 Line::from(vec![
1277 Span::styled(" In plain words ", dim()),
1278 Span::styled(row.plain.to_string(), Style::default().fg(Color::Cyan)),
1279 ]),
1280 ];
1281 if row.is_new {
1282 detail.push(Line::from(Span::styled(
1283 " New in this version — it has been applying its default since the upgrade.",
1284 Style::default().fg(Color::Magenta),
1285 )));
1286 }
1287 if let Some(why) = &state.error {
1288 detail.push(Line::from(Span::styled(
1289 format!(" {why}"),
1290 Style::default().fg(Color::Red),
1291 )));
1292 }
1293 frame.render_widget(
1294 Paragraph::new(detail)
1295 .wrap(Wrap { trim: true })
1296 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1297 chunks[2],
1298 );
1299
1300 let keys: &[(&str, &str)] = if state.editing.is_some() {
1301 &[("digits", "type"), ("Enter", "accept"), ("Esc", "abandon")]
1302 } else {
1303 &[
1304 ("↑↓", "move"),
1305 ("Space", "change"),
1306 ("r", "reset"),
1307 ("y", "done"),
1308 ("q", "cancel"),
1309 ]
1310 };
1311 frame.render_widget(footer(keys), chunks[3]);
1312}
1313
1314fn render_adapters(frame: &mut Frame, state: &mut State<'_>) {
1315 let chunks = Layout::vertical([
1316 Constraint::Length(3),
1317 Constraint::Min(5),
1318 Constraint::Length(4),
1319 Constraint::Length(2),
1320 ])
1321 .split(frame.area());
1322
1323 let off = state.picker_active.iter().filter(|a| !**a).count();
1324 frame.render_widget(
1325 header(
1326 "Adapters",
1327 &format!(
1328 "Unchecked adapters are left alone entirely — not scanned, not counted, \
1329 not pruned. {off} off.",
1330 ),
1331 ),
1332 chunks[0],
1333 );
1334
1335 let selected = state.picker_list.selected();
1336 let items: Vec<ListItem> = state
1337 .picker_entries
1338 .iter()
1339 .enumerate()
1340 .map(|(line, entry)| match entry {
1341 PickerEntry::Group { label, members } => {
1342 let on = members.iter().filter(|&&i| state.picker_active[i]).count();
1343 let mark = if on == members.len() {
1344 "[x]"
1345 } else if on == 0 {
1346 "[ ]"
1347 } else {
1348 "[-]"
1351 };
1352 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1353 let shared = members.first().and_then(|&i| state.picker_days[i]);
1354 let window = if editing_here && state.picker_field == PickerField::Days {
1357 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1358 } else if members.iter().all(|&i| state.picker_days[i] == shared) {
1359 shared.map(|d| format!("{d}d")).unwrap_or_default()
1360 } else {
1361 "mixed".to_string()
1362 };
1363 let capped: Vec<usize> = members
1364 .iter()
1365 .copied()
1366 .filter(|&i| {
1367 state
1368 .session
1369 .capped_adapters
1370 .contains(&state.session.adapters[i])
1371 })
1372 .collect();
1373 let shared_cap = capped.first().and_then(|&i| state.picker_caps[i]);
1374 let cap = if editing_here && state.picker_field == PickerField::Cap {
1375 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1376 } else if capped.is_empty() {
1377 String::new()
1378 } else if capped.iter().all(|&i| state.picker_caps[i] == shared_cap) {
1379 shared_cap.map(|g| format!("{g}G")).unwrap_or_default()
1380 } else {
1381 "mixed".to_string()
1382 };
1383 ListItem::new(Line::from(vec![
1384 Span::styled(
1385 format!("{mark} {}", crate::output::pad_display(label, 22)),
1386 Style::default()
1387 .fg(Color::Cyan)
1388 .add_modifier(Modifier::BOLD),
1389 ),
1390 Span::styled(
1391 crate::output::pad_display(&format!("{on}/{}", members.len()), 8),
1392 dim(),
1393 ),
1394 Span::styled(crate::output::pad_display(&window, 10), dim()),
1395 Span::styled(cap, dim()),
1396 ]))
1397 }
1398 PickerEntry::Adapter(i) => {
1399 let name = state.session.adapters[*i];
1400 let editing_here = state.picker_editing.is_some() && selected == Some(line);
1401 let shown = if editing_here && state.picker_field == PickerField::Days {
1402 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1403 } else {
1404 state.picker_days[*i]
1405 .map(|d| format!("{d}d"))
1406 .unwrap_or_else(|| "default".to_string())
1407 };
1408 let cap = if editing_here && state.picker_field == PickerField::Cap {
1412 format!("{}_", state.picker_editing.clone().unwrap_or_default())
1413 } else if !state.session.capped_adapters.contains(&name) {
1414 String::new()
1415 } else {
1416 state.picker_caps[*i]
1417 .map(|g| format!("{g}G"))
1418 .unwrap_or_else(|| "no cap".to_string())
1419 };
1420 let mut spans = vec![
1421 if state.picker_active[*i] {
1422 Span::styled(
1423 " [x] ",
1424 Style::default()
1425 .fg(Color::Green)
1426 .add_modifier(Modifier::BOLD),
1427 )
1428 } else {
1429 Span::styled(" [ ] ", dim())
1430 },
1431 Span::styled(crate::output::pad_display(name, 18), Style::default()),
1432 Span::styled(
1433 crate::output::pad_display(&shown, 10),
1434 if state.picker_days[*i].is_some() {
1435 Style::default().fg(Color::Yellow)
1436 } else {
1437 dim()
1438 },
1439 ),
1440 Span::styled(
1441 crate::output::pad_display(&cap, 10),
1442 if state.picker_caps[*i].is_some() {
1443 Style::default().fg(Color::Yellow)
1444 } else {
1445 dim()
1446 },
1447 ),
1448 ];
1449 if state.session.opt_in_adapters.contains(&name) {
1450 spans.push(Span::styled("opt-in — rebuilt, not downloaded", dim()));
1454 }
1455 ListItem::new(Line::from(spans))
1456 }
1457 })
1458 .collect();
1459
1460 let list = List::new(items)
1461 .block(
1462 Block::default()
1463 .title(" Checked adapters stay active idle cache cap ")
1464 .borders(Borders::ALL)
1465 .border_style(dim()),
1466 )
1467 .highlight_style(
1468 Style::default()
1469 .bg(Color::Rgb(30, 40, 60))
1470 .add_modifier(Modifier::BOLD),
1471 )
1472 .highlight_symbol("▶ ");
1473 frame.render_stateful_widget(list, chunks[1], &mut state.picker_list);
1474
1475 let mut detail = vec![Line::from(Span::styled(
1476 " Space toggles one adapter, or a whole language from its heading. d sets how \
1477 many days that adapter — or that language — must be idle first; an empty value \
1478 puts it back on the global window. c caps that ecosystem's download cache in \
1479 GiB — reported by `devp caches`, and emptied only when you run \
1480 `devp caches clear --over-cap`, never on a schedule.",
1481 dim(),
1482 ))];
1483 if let Some(why) = &state.error {
1484 detail.push(Line::from(Span::styled(
1485 format!(" {why}"),
1486 Style::default().fg(Color::Red),
1487 )));
1488 }
1489 frame.render_widget(
1490 Paragraph::new(detail)
1491 .wrap(Wrap { trim: true })
1492 .block(Block::default().borders(Borders::ALL).border_style(dim())),
1493 chunks[2],
1494 );
1495
1496 let keys: &[(&str, &str)] = match (state.picker_editing.is_some(), state.picker_field) {
1497 (true, PickerField::Days) => &[
1498 ("digits", "days"),
1499 ("Enter", "accept"),
1500 ("empty", "use the global window"),
1501 ("Esc", "abandon"),
1502 ],
1503 (true, PickerField::Cap) => &[
1504 ("digits", "GiB"),
1505 ("Enter", "accept"),
1506 ("empty", "no cap"),
1507 ("Esc", "abandon"),
1508 ],
1509 (false, _) => &[
1510 ("↑↓", "move"),
1511 ("Space", "toggle"),
1512 ("d", "idle days"),
1513 ("c", "cache cap"),
1514 ("a", "all on"),
1515 ("n", "all off"),
1516 ("Enter", "accept"),
1517 ("Esc", "back"),
1518 ],
1519 };
1520 frame.render_widget(footer(keys), chunks[3]);
1521}
1522
1523fn render_summary(frame: &mut Frame, state: &State<'_>) {
1524 let chunks = Layout::vertical([
1525 Constraint::Length(3),
1526 Constraint::Min(5),
1527 Constraint::Length(2),
1528 ])
1529 .split(frame.area());
1530
1531 let changed: Vec<&ConfigRow> = state.session.rows.iter().filter(|r| r.changed()).collect();
1532 frame.render_widget(
1533 header(
1534 "Summary",
1535 if changed.is_empty() {
1536 "Nothing changed. The defaults stay in place."
1537 } else {
1538 "These are the only values that will be written."
1539 },
1540 ),
1541 chunks[0],
1542 );
1543
1544 let mut lines: Vec<Line> = changed
1545 .iter()
1546 .map(|row| {
1547 Line::from(vec![
1548 Span::styled(
1549 format!(" {}", crate::output::pad_display(row.key, 28)),
1550 Style::default(),
1551 ),
1552 Span::styled(row.original.clone(), dim()),
1553 Span::styled(" → ", dim()),
1554 Span::styled(
1555 row.value.clone(),
1556 Style::default()
1557 .fg(Color::Yellow)
1558 .add_modifier(Modifier::BOLD),
1559 ),
1560 ])
1561 })
1562 .collect();
1563 if lines.is_empty() {
1564 lines.push(Line::from(Span::styled(
1565 " Every setting is still at the value it had when this opened.",
1566 dim(),
1567 )));
1568 }
1569 lines.push(Line::from(""));
1570 lines.push(Line::from(Span::styled(
1571 format!(" {}", state.session.standing),
1572 Style::default().fg(Color::Green),
1573 )));
1574
1575 frame.render_widget(
1576 Paragraph::new(lines).wrap(Wrap { trim: true }).block(
1577 Block::default()
1578 .title(" About to be saved ")
1579 .borders(Borders::ALL)
1580 .border_style(dim()),
1581 ),
1582 chunks[1],
1583 );
1584
1585 frame.render_widget(
1586 footer(&[
1587 ("Enter", "save"),
1588 ("Esc", "back"),
1589 ("q", "discard everything"),
1590 ]),
1591 chunks[2],
1592 );
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597 use super::*;
1598
1599 fn row(key: &'static str, control: Control, value: &str) -> ConfigRow {
1600 ConfigRow {
1601 key,
1602 help: "help",
1603 plain: "plain",
1604 control,
1605 value: value.to_string(),
1606 original: value.to_string(),
1607 is_new: false,
1608 }
1609 }
1610
1611 fn session<'a>(rows: Vec<ConfigRow>, adapters: &'a [&'static str]) -> ConfigSession<'a> {
1612 ConfigSession {
1613 declaration: Vec::new(),
1614 standing: String::new(),
1615 suggestions: Vec::new(),
1616 rows,
1617 adapters,
1618 opt_in_adapters: &[],
1619 capped_adapters: &["npm", "pnpm", "cargo", "go"],
1620 groups: &[("Test", &["npm", "cargo", "go"])],
1621 validate: &|key, v| {
1622 let number = if key == "adapter_idle_days" || key == "cache_max_gb" {
1625 v.split_once('=').map(|(_, d)| d).unwrap_or("")
1626 } else {
1627 v
1628 };
1629 number
1630 .parse::<u64>()
1631 .map(|_| ())
1632 .map_err(|_| "not a number".to_string())
1633 },
1634 title: "test",
1635 }
1636 }
1637
1638 fn state<'a>(s: ConfigSession<'a>) -> State<'a> {
1639 let mut list = ListState::default();
1640 list.select(Some(0));
1641 let mut picker_list = ListState::default();
1642 picker_list.select(Some(0));
1643 State {
1644 picker_active: vec![true; s.adapters.len()],
1645 picker_days: vec![None; s.adapters.len()],
1646 picker_caps: vec![None; s.adapters.len()],
1647 picker_entries: build_entries(s.adapters, s.groups),
1648 picker_editing: None,
1649 picker_field: PickerField::Days,
1650 session: s,
1651 screen: Screen::Settings,
1652 list,
1653 editing: None,
1654 error: None,
1655 picker_list,
1656 decl_list: ListState::default(),
1657 sugg_list: ListState::default(),
1658 }
1659 }
1660
1661 fn screenshot(st: &mut State<'_>, screen: Screen) -> String {
1666 st.screen = screen;
1667 let mut terminal =
1668 Terminal::new(ratatui::backend::TestBackend::new(100, 30)).expect("test backend");
1669 terminal.draw(|frame| render(frame, st)).expect("draw");
1670 terminal
1671 .backend()
1672 .buffer()
1673 .content()
1674 .iter()
1675 .map(|cell| cell.symbol())
1676 .collect()
1677 }
1678
1679 #[test]
1680 fn every_screen_draws() {
1681 let adapters: &[&'static str] = &["npm", "cargo"];
1682 let mut st = state(session(
1683 vec![
1684 row("idle_days", Control::Number, "14"),
1685 row("disabled_adapters", Control::Adapters, "(none)"),
1686 ],
1687 adapters,
1688 ));
1689 st.session.declaration.push(DeclarationLine {
1690 mark: '+',
1691 subject: "Lockfile verification".to_string(),
1692 state: "Required before every delete".to_string(),
1693 });
1694 st.session.standing = "Nothing has been deleted.".to_string();
1695
1696 let decl = screenshot(&mut st, Screen::Declaration);
1697 assert!(decl.contains("Lockfile verification"));
1698 assert!(decl.contains("Nothing has been deleted."));
1699
1700 let settings = screenshot(&mut st, Screen::Settings);
1701 assert!(settings.contains("idle_days"));
1702
1703 st.picker_entries = build_entries(st.session.adapters, st.session.groups);
1704 st.picker_days[1] = Some(45);
1705 let picker = screenshot(&mut st, Screen::Adapters);
1706 assert!(picker.contains("cargo"));
1707 assert!(picker.contains("Test"), "the language heading is missing");
1708 assert!(picker.contains("45d"), "the idle window is missing");
1709
1710 let summary = screenshot(&mut st, Screen::Summary);
1713 assert!(summary.contains("still at the value"));
1714 }
1715
1716 #[test]
1717 fn y_on_the_declaration_still_means_yes_to_everything() {
1718 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1721 st.screen = Screen::Declaration;
1722 assert!(matches!(
1723 handle_key(&mut st, KeyCode::Char('y')),
1724 Some(Outcome::KeepAll)
1725 ));
1726 }
1727
1728 #[test]
1729 fn a_refused_value_is_not_stored() {
1730 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1731 handle_key(&mut st, KeyCode::Enter); handle_key(&mut st, KeyCode::Backspace);
1733 handle_key(&mut st, KeyCode::Backspace); handle_key(&mut st, KeyCode::Enter);
1735 assert_eq!(st.session.rows[0].value, "14");
1736 assert!(st.error.is_some(), "the reason was not shown");
1737 assert!(st.editing.is_some(), "the editor closed on a refusal");
1738 }
1739
1740 #[test]
1741 fn an_accepted_value_replaces_the_old_one() {
1742 let mut st = state(session(vec![row("idle_days", Control::Number, "14")], &[]));
1743 handle_key(&mut st, KeyCode::Enter);
1744 handle_key(&mut st, KeyCode::Backspace);
1745 handle_key(&mut st, KeyCode::Backspace);
1746 handle_key(&mut st, KeyCode::Char('3'));
1747 handle_key(&mut st, KeyCode::Char('0'));
1748 handle_key(&mut st, KeyCode::Enter);
1749 assert_eq!(st.session.rows[0].value, "30");
1750 assert!(st.session.rows[0].changed());
1751 }
1752
1753 #[test]
1754 fn unchecking_an_adapter_writes_it_to_the_deny_list() {
1755 let adapters: &[&'static str] = &["npm", "cargo", "go"];
1756 let mut st = state(session(
1757 vec![row("disabled_adapters", Control::Adapters, "(none)")],
1758 adapters,
1759 ));
1760 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.screen, Screen::Adapters);
1762 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
1765 handle_key(&mut st, KeyCode::Enter);
1766 assert_eq!(st.session.rows[0].value, "cargo");
1767 assert_eq!(st.screen, Screen::Settings);
1768 }
1769
1770 #[test]
1771 fn every_adapter_appears_under_exactly_one_heading() {
1772 let adapters: &[&'static str] = &["npm", "cargo", "mystery"];
1775 let groups: &[(&'static str, &'static [&'static str])] =
1776 &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1777 let entries = build_entries(adapters, groups);
1778 let headings: Vec<&str> = entries
1779 .iter()
1780 .filter_map(|e| match e {
1781 PickerEntry::Group { label, .. } => Some(*label),
1782 PickerEntry::Adapter(_) => None,
1783 })
1784 .collect();
1785 assert_eq!(headings, vec!["JavaScript", "Rust", "Other"]);
1786
1787 let mut listed: Vec<usize> = entries
1788 .iter()
1789 .filter_map(|e| match e {
1790 PickerEntry::Adapter(i) => Some(*i),
1791 PickerEntry::Group { .. } => None,
1792 })
1793 .collect();
1794 listed.sort_unstable();
1795 assert_eq!(
1796 listed,
1797 vec![0, 1, 2],
1798 "an adapter was dropped from the list"
1799 );
1800 }
1801
1802 #[test]
1803 fn a_heading_turns_its_whole_language_off_in_one_press() {
1804 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1805 let mut st = state(session(
1806 vec![row("disabled_adapters", Control::Adapters, "(none)")],
1807 adapters,
1808 ));
1809 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1810 handle_key(&mut st, KeyCode::Enter);
1811 handle_key(&mut st, KeyCode::Char(' ')); assert_eq!(st.picker_active, vec![false, false, true]);
1813 handle_key(&mut st, KeyCode::Char(' '));
1816 assert_eq!(st.picker_active, vec![true, true, true]);
1817 }
1818
1819 #[test]
1820 fn an_idle_window_typed_on_a_heading_reaches_every_adapter_under_it() {
1821 let adapters: &[&'static str] = &["npm", "pnpm", "cargo"];
1822 let mut st = state(session(
1823 vec![
1824 row("disabled_adapters", Control::Adapters, "(none)"),
1825 row("adapter_idle_days", Control::AdapterDays, "(none)"),
1826 ],
1827 adapters,
1828 ));
1829 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Rust", &["cargo"])];
1830 handle_key(&mut st, KeyCode::Enter);
1831 handle_key(&mut st, KeyCode::Char('d')); handle_key(&mut st, KeyCode::Char('3'));
1833 handle_key(&mut st, KeyCode::Char('0'));
1834 handle_key(&mut st, KeyCode::Enter);
1835 assert_eq!(st.picker_days, vec![Some(30), Some(30), None]);
1836
1837 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=30,pnpm=30");
1839
1840 handle_key(&mut st, KeyCode::Enter);
1843 handle_key(&mut st, KeyCode::Char('d'));
1844 handle_key(&mut st, KeyCode::Backspace);
1845 handle_key(&mut st, KeyCode::Backspace);
1846 handle_key(&mut st, KeyCode::Enter);
1847 handle_key(&mut st, KeyCode::Enter);
1848 assert_eq!(st.session.rows[1].value, "(none)");
1849 }
1850
1851 #[test]
1852 fn a_cache_cap_typed_on_a_heading_reaches_only_the_adapters_that_have_a_cache() {
1853 let adapters: &[&'static str] = &["npm", "pnpm", "venv"];
1857 let mut st = state(session(
1858 vec![
1859 row("disabled_adapters", Control::Adapters, "(none)"),
1860 row("cache_max_gb", Control::CacheCaps, "(none)"),
1861 ],
1862 adapters,
1863 ));
1864 st.session.groups = &[("JavaScript", &["npm", "pnpm"]), ("Python", &["venv"])];
1865 handle_key(&mut st, KeyCode::Enter);
1866 handle_key(&mut st, KeyCode::Char('c')); handle_key(&mut st, KeyCode::Char('1'));
1868 handle_key(&mut st, KeyCode::Char('0'));
1869 handle_key(&mut st, KeyCode::Enter);
1870 assert_eq!(st.picker_caps, vec![Some(10), Some(10), None]);
1871
1872 handle_key(&mut st, KeyCode::Enter); assert_eq!(st.session.rows[1].value, "npm=10,pnpm=10");
1874 }
1875
1876 #[test]
1877 fn a_cache_cap_is_cleared_by_emptying_it() {
1878 let adapters: &[&'static str] = &["npm"];
1879 let mut st = state(session(
1880 vec![
1881 row("disabled_adapters", Control::Adapters, "(none)"),
1882 row("cache_max_gb", Control::CacheCaps, "npm=10"),
1883 ],
1884 adapters,
1885 ));
1886 st.session.groups = &[("JavaScript", &["npm"])];
1887 handle_key(&mut st, KeyCode::Enter);
1888 assert_eq!(st.picker_caps, vec![Some(10)]);
1891 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
1893 handle_key(&mut st, KeyCode::Backspace);
1894 handle_key(&mut st, KeyCode::Backspace);
1895 handle_key(&mut st, KeyCode::Enter);
1896 handle_key(&mut st, KeyCode::Enter);
1897 assert_eq!(st.session.rows[1].value, "(none)");
1898 }
1899
1900 #[test]
1901 fn a_cap_on_a_cache_with_no_adapter_survives_the_checklist() {
1902 let adapters: &[&'static str] = &["npm"];
1907 let mut st = state(session(
1908 vec![
1909 row("disabled_adapters", Control::Adapters, "(none)"),
1910 row("cache_max_gb", Control::CacheCaps, "npm=10,pip=20"),
1911 ],
1912 adapters,
1913 ));
1914 st.session.groups = &[("JavaScript", &["npm"])];
1915 handle_key(&mut st, KeyCode::Enter);
1916 handle_key(&mut st, KeyCode::Enter);
1917 assert_eq!(st.session.rows[1].value, "npm=10,pip=20");
1918 }
1919
1920 #[test]
1921 fn typing_a_cap_where_there_is_no_cache_says_so_instead_of_dropping_it() {
1922 let adapters: &[&'static str] = &["venv"];
1923 let mut st = state(session(
1924 vec![
1925 row("disabled_adapters", Control::Adapters, "(none)"),
1926 row("cache_max_gb", Control::CacheCaps, "(none)"),
1927 ],
1928 adapters,
1929 ));
1930 st.session.groups = &[("Python", &["venv"])];
1931 handle_key(&mut st, KeyCode::Enter);
1932 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char('c'));
1934 assert!(st.picker_editing.is_none(), "no editor opened");
1935 let err = st.error.clone().expect("the refusal is explained");
1936 assert!(err.contains("devp caches"), "{err}");
1937 }
1938
1939 #[test]
1940 fn the_checklist_draws_the_cache_cap_beside_the_idle_window() {
1941 let adapters: &[&'static str] = &["npm", "venv"];
1944 let mut st = state(session(
1945 vec![
1946 row("disabled_adapters", Control::Adapters, "(none)"),
1947 row("adapter_idle_days", Control::AdapterDays, "npm=30"),
1948 row("cache_max_gb", Control::CacheCaps, "npm=10"),
1949 ],
1950 adapters,
1951 ));
1952 st.session.groups = &[("JavaScript", &["npm"]), ("Python", &["venv"])];
1953 handle_key(&mut st, KeyCode::Enter);
1954 let picker = screenshot(&mut st, Screen::Adapters);
1955 assert!(picker.contains("30d"), "the idle window is drawn");
1956 assert!(picker.contains("10G"), "the cap is drawn");
1957 assert!(picker.contains("no cap") || picker.contains("10G"));
1960 assert!(picker.contains("cache cap"), "the column is labelled");
1961 }
1962
1963 #[test]
1964 fn an_opt_in_adapter_is_governed_by_its_own_switch_not_the_deny_list() {
1965 let adapters: &[&'static str] = &["npm", "cargo"];
1968 let opt_in: &[&'static str] = &["cargo"];
1969 let mut st = state(session(
1970 vec![
1971 row("disabled_adapters", Control::Adapters, "(none)"),
1972 row("enable_cargo", Control::Toggle, "false"),
1973 ],
1974 adapters,
1975 ));
1976 st.session.opt_in_adapters = opt_in;
1977 st.session.groups = &[("JavaScript", &["npm"]), ("Rust", &["cargo"])];
1978
1979 handle_key(&mut st, KeyCode::Enter);
1980 assert_eq!(st.picker_active, vec![true, false]);
1983 handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Down); handle_key(&mut st, KeyCode::Char(' '));
1987 handle_key(&mut st, KeyCode::Enter);
1988 assert_eq!(st.session.rows[0].value, "(none)");
1989 assert_eq!(st.session.rows[1].value, "true");
1990 }
1991
1992 #[test]
1993 fn the_checklist_opens_showing_what_is_already_disabled() {
1994 let adapters: &[&'static str] = &["npm", "cargo", "go"];
1997 let mut st = state(session(
1998 vec![row("disabled_adapters", Control::Adapters, "go")],
1999 adapters,
2000 ));
2001 handle_key(&mut st, KeyCode::Enter);
2002 assert_eq!(st.picker_active, vec![true, true, false]);
2003 handle_key(&mut st, KeyCode::Enter);
2004 assert_eq!(st.session.rows[0].value, "go");
2005 }
2006
2007 #[test]
2008 fn cancelling_reports_cancelled_rather_than_an_empty_save() {
2009 let mut st = state(session(
2012 vec![row("auto_update", Control::Toggle, "false")],
2013 &[],
2014 ));
2015 assert!(matches!(
2016 handle_key(&mut st, KeyCode::Char('q')),
2017 Some(Outcome::Cancelled)
2018 ));
2019 }
2020
2021 #[test]
2022 fn only_changed_rows_are_saved() {
2023 let mut st = state(session(
2024 vec![
2025 row("auto_update", Control::Toggle, "false"),
2026 row("auto_config", Control::Toggle, "false"),
2027 ],
2028 &[],
2029 ));
2030 handle_key(&mut st, KeyCode::Char(' ')); handle_key(&mut st, KeyCode::Char('y')); let Some(Outcome::Save(changed)) = handle_key(&mut st, KeyCode::Enter) else {
2033 panic!("expected a save");
2034 };
2035 assert_eq!(changed.len(), 1);
2036 assert_eq!(changed[0].key, "auto_update");
2037 assert_eq!(changed[0].value, "true");
2038 }
2039
2040 #[test]
2041 fn reset_puts_a_row_back_without_touching_the_others() {
2042 let mut st = state(session(
2043 vec![
2044 row("auto_update", Control::Toggle, "false"),
2045 row("auto_config", Control::Toggle, "true"),
2046 ],
2047 &[],
2048 ));
2049 handle_key(&mut st, KeyCode::Char(' '));
2050 assert!(st.session.rows[0].changed());
2051 handle_key(&mut st, KeyCode::Char('r'));
2052 assert!(!st.session.rows[0].changed());
2053 assert_eq!(st.session.rows[1].value, "true");
2054 }
2055
2056 #[test]
2057 fn the_view_opens_on_the_first_setting_the_user_has_never_seen() {
2058 let mut rows = [
2059 row("idle_days", Control::Number, "14"),
2060 row("auto_update", Control::Toggle, "false"),
2061 row("auto_config", Control::Toggle, "false"),
2062 ];
2063 assert_eq!(
2064 opening_index(&rows),
2065 0,
2066 "with nothing new, start at the top"
2067 );
2068 rows[2].is_new = true;
2069 assert_eq!(opening_index(&rows), 2);
2070 }
2071
2072 fn suggestion(key: &'static str, cautious: bool) -> Suggestion {
2073 Suggestion {
2074 key,
2075 label: "label",
2076 help: "help",
2077 plain: "plain",
2078 why: "why",
2079 value: "true",
2080 cautious,
2081 }
2082 }
2083
2084 #[test]
2085 fn accept_all_stops_at_the_cautious_tier() {
2086 let adapters: &[&str] = &["npm"];
2090 let mut s = session(
2091 vec![
2092 row("enable_cargo", Control::Toggle, "false"),
2093 row("allow_manifest_rewrite", Control::Toggle, "false"),
2094 ],
2095 adapters,
2096 );
2097 s.suggestions = vec![
2098 suggestion("enable_cargo", false),
2099 suggestion("allow_manifest_rewrite", true),
2100 ];
2101 let mut st = state(s);
2102 st.screen = Screen::Suggestions;
2103 st.sugg_list.select(Some(0));
2104
2105 assert!(suggestions_key(&mut st, KeyCode::Char('a')).is_none());
2106 assert_eq!(
2107 row_value(&st.session.rows, "enable_cargo").as_deref(),
2108 Some("true")
2109 );
2110 assert_eq!(
2111 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2112 Some("false")
2113 );
2114
2115 st.sugg_list.select(Some(1));
2117 suggestions_key(&mut st, KeyCode::Char(' '));
2118 assert_eq!(
2119 row_value(&st.session.rows, "allow_manifest_rewrite").as_deref(),
2120 Some("true")
2121 );
2122 }
2123
2124 #[test]
2125 fn undoing_a_suggestion_puts_back_what_the_setting_had() {
2126 let adapters: &[&str] = &["npm"];
2127 let mut s = session(
2128 vec![row("enable_cargo", Control::Toggle, "false")],
2129 adapters,
2130 );
2131 s.suggestions = vec![suggestion("enable_cargo", false)];
2132 let mut st = state(s);
2133 st.screen = Screen::Suggestions;
2134 st.sugg_list.select(Some(0));
2135
2136 suggestions_key(&mut st, KeyCode::Char(' '));
2137 assert!(accepted(&st, 0));
2138 suggestions_key(&mut st, KeyCode::Char(' '));
2139 assert!(!accepted(&st, 0));
2140 assert_eq!(
2141 row_value(&st.session.rows, "enable_cargo").as_deref(),
2142 Some("false")
2143 );
2144 assert!(!st.session.rows[0].changed());
2146 }
2147
2148 #[test]
2149 fn the_suggestions_screen_is_skipped_when_there_is_nothing_to_suggest() {
2150 let adapters: &[&str] = &["npm"];
2153 let s = session(vec![row("idle_days", Control::Number, "30")], adapters);
2154 let mut st = state(s);
2155 st.screen = Screen::Declaration;
2156 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2157 assert_eq!(st.screen, Screen::Settings);
2158 }
2159
2160 #[test]
2161 fn the_first_run_reaches_the_suggestions_first() {
2162 let adapters: &[&str] = &["npm"];
2163 let mut s = session(
2164 vec![row("enable_cargo", Control::Toggle, "false")],
2165 adapters,
2166 );
2167 s.suggestions = vec![suggestion("enable_cargo", false)];
2168 let mut st = state(s);
2169 st.screen = Screen::Declaration;
2170 assert!(declaration_key(&mut st, KeyCode::Enter).is_none());
2171 assert_eq!(st.screen, Screen::Suggestions);
2172 }
2173}