1use std::time::{Duration, Instant};
11
12use anyhow::Result;
13use ratatui::DefaultTerminal;
14use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
15
16use crate::backend::Backend;
17use crate::kernel::{self, KernelState};
18use crate::logs::{self, LogLine};
19use crate::model::{DEFAULT_ID, Installed, Listing, Pick, validate_arg};
20use crate::system::system_command;
21use crate::ui;
22use crate::watch::Watcher;
23
24const TICK: Duration = Duration::from_millis(250);
26const REFRESH_EVERY: Duration = Duration::from_secs(5);
30const ACTION_DEBOUNCE: Duration = Duration::from_millis(500);
34const MESSAGE_TTL: Duration = Duration::from_secs(8);
37
38#[derive(Clone, Debug, PartialEq)]
43enum PendingAction {
44 Start(String),
45 Run(String, Vec<String>),
46 Stop,
47 Monitor,
48}
49
50#[derive(Default, Clone, Debug, PartialEq)]
54pub struct Input {
55 pub buffer: String,
56 pub cursor: usize,
57}
58
59impl Input {
60 fn byte_cursor(&self) -> usize {
61 self.buffer
62 .char_indices()
63 .nth(self.cursor)
64 .map_or(self.buffer.len(), |(idx, _)| idx)
65 }
66
67 fn len_chars(&self) -> usize {
68 self.buffer.chars().count()
69 }
70
71 fn insert(&mut self, c: char) {
72 let at = self.byte_cursor();
73 self.buffer.insert(at, c);
74 self.cursor += 1;
75 }
76
77 fn backspace(&mut self) {
78 if self.cursor > 0 {
79 self.cursor -= 1;
80 let at = self.byte_cursor();
81 self.buffer.remove(at);
82 }
83 }
84
85 fn delete(&mut self) {
86 let at = self.byte_cursor();
87 if at < self.buffer.len() {
88 self.buffer.remove(at);
89 }
90 }
91
92 fn edit(&mut self, key: KeyEvent) {
93 match key.code {
94 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => self.insert(c),
95 KeyCode::Backspace => self.backspace(),
96 KeyCode::Delete => self.delete(),
97 KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
98 KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len_chars()),
99 KeyCode::Home => self.cursor = 0,
100 KeyCode::End => self.cursor = self.len_chars(),
101 _ => {}
102 }
103 }
104}
105
106#[derive(Clone, Copy, PartialEq, Debug)]
108pub enum View {
109 Recommended,
110 All,
111 Logs,
112}
113
114pub struct Message {
115 pub text: String,
116 pub is_error: bool,
117 shown_at: Instant,
118}
119
120pub struct App {
121 backend: Box<dyn Backend>,
122 pub listing: Option<Listing>,
126 pub listing_error: Option<String>,
127 pub kernel: Option<KernelState>,
129 read_kernel: Box<dyn Fn() -> Option<KernelState>>,
130 kernel_disputed: bool,
131 watcher: Option<Watcher>,
132 pub selected: usize,
133 pub sched_selected: usize,
134 pub args: Option<Input>,
138 pub all_warned: bool,
140 pub message: Option<Message>,
141 last_action: Option<Instant>,
142 pub view: View,
143 pub log_previous_boot: bool,
144 pub log_lines: Vec<LogLine>,
145 pub log_scroll: usize,
146 pub log_page: usize,
147 pending_action: Option<PendingAction>,
148 should_quit: bool,
149}
150
151impl App {
152 pub fn new(backend: Box<dyn Backend>) -> Self {
153 Self {
154 backend,
155 listing: None,
156 listing_error: None,
157 kernel: None,
158 read_kernel: Box::new(|| kernel::read().unwrap_or(None)),
159 kernel_disputed: false,
160 watcher: None,
161 selected: 0,
162 sched_selected: 0,
163 args: None,
164 all_warned: false,
165 message: None,
166 last_action: None,
167 view: View::Recommended,
168 log_previous_boot: false,
169 log_lines: Vec::new(),
170 log_scroll: 0,
171 log_page: 20,
172 pending_action: None,
173 should_quit: false,
174 }
175 }
176
177 pub fn backend_label(&self) -> String {
178 self.backend.label()
179 }
180
181 pub fn recommended(&self) -> &[Pick] {
182 self.listing
183 .as_ref()
184 .map_or(&[], |l| l.recommended.as_slice())
185 }
186
187 pub fn all(&self) -> &[Installed] {
188 self.listing.as_ref().map_or(&[], |l| l.all.as_slice())
189 }
190
191 pub fn selected_pick(&self) -> Option<&Pick> {
192 self.recommended().get(self.selected)
193 }
194
195 pub fn selected_scheduler(&self) -> Option<&Installed> {
196 self.all().get(self.sched_selected)
197 }
198
199 pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
200 let mut last_refresh = Instant::now();
201 let mut primed = false;
202 while !self.should_quit {
203 terminal.draw(|frame| ui::draw(frame, self))?;
204
205 if !primed {
208 primed = true;
209 self.refresh();
210 self.sync_selection_to_running();
211 self.start_watching();
212 last_refresh = Instant::now();
213 continue;
214 }
215
216 if self
217 .message
218 .as_ref()
219 .is_some_and(|m| !m.is_error && m.shown_at.elapsed() >= MESSAGE_TTL)
220 {
221 self.message = None;
222 }
223
224 if let Some(action) = self.pending_action.take() {
225 self.run_action(action, &mut terminal)?;
226 last_refresh = Instant::now();
227 continue;
228 }
229
230 if event::poll(TICK)?
231 && let Event::Key(key) = event::read()?
232 && key.kind == KeyEventKind::Press
233 {
234 self.on_key(key);
235 }
236
237 self.kernel = (self.read_kernel)();
238
239 let files_changed = self.watcher.as_mut().is_some_and(Watcher::changed);
240 if files_changed
241 || self.reconcile_kernel_dispute()
242 || last_refresh.elapsed() >= REFRESH_EVERY
243 {
244 self.refresh();
245 last_refresh = Instant::now();
246 }
247 }
248 Ok(())
249 }
250
251 fn start_watching(&mut self) {
252 let config = self.listing.as_ref().and_then(|l| l.config.clone());
253 match Watcher::new(&Watcher::files_for(config.as_deref())) {
254 Ok(watcher) => self.watcher = Some(watcher),
255 Err(err) => self.error(&format!("file watching unavailable ({err:#}); polling")),
256 }
257 }
258
259 fn run_monitor(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
261 let Ok(mut command) = system_command("scxtop") else {
262 self.error(
263 "scxtop not found in systemd's default search path; install it (cargo install \
264scxtop, or your distro's scx tools package)",
265 );
266 return Ok(());
267 };
268 ratatui::restore();
269 let result = command.status();
270 *terminal = ratatui::init();
271 terminal.clear()?;
272 match result {
273 Ok(status) if status.success() => self.info("scxtop exited"),
274 Ok(status) => self.error(&format!(
275 "scxtop exited with {status}; it needs root/CAP_BPF"
276 )),
277 Err(err) => self.error(&format!("failed to launch scxtop: {err}")),
278 }
279 Ok(())
280 }
281
282 fn queue(&mut self, action: PendingAction) {
283 self.info("working...");
284 self.pending_action = Some(action);
285 }
286
287 fn privileged(
293 &mut self,
294 terminal: &mut DefaultTerminal,
295 call: impl FnOnce(&dyn Backend) -> Result<String>,
296 ) -> Result<Result<String>> {
297 if !self.backend.escalates() {
298 return Ok(call(self.backend.as_ref()));
299 }
300 ratatui::restore();
301 let result = call(self.backend.as_ref());
302 *terminal = ratatui::init();
303 terminal.clear()?;
304 Ok(result)
305 }
306
307 fn run_action(&mut self, action: PendingAction, terminal: &mut DefaultTerminal) -> Result<()> {
308 let outcome = match action {
309 PendingAction::Start(id) => self
310 .privileged(terminal, |b| b.start(&id))?
311 .map(|report| format!("{report}; see State and Kernel for what attached")),
312 PendingAction::Run(sched, args) => {
313 self.privileged(terminal, |b| b.run(&sched, &args))?
314 }
315 PendingAction::Stop => self.privileged(terminal, |b| b.stop())?,
316 PendingAction::Monitor => return self.run_monitor(terminal),
317 };
318 match outcome {
319 Ok(text) => self.info(&text),
320 Err(err) => self.error(&format!("{err:#}")),
321 }
322 self.refresh();
323 Ok(())
324 }
325
326 fn on_key(&mut self, key: KeyEvent) {
327 if self.args.is_some() {
328 self.on_key_args(key);
329 return;
330 }
331 match self.view {
332 View::Recommended => self.on_key_recommended(key),
333 View::All => self.on_key_all(key),
334 View::Logs => self.on_key_logs(key),
335 }
336 }
337
338 fn on_key_args(&mut self, key: KeyEvent) {
339 let Some(input) = self.args.as_mut() else {
340 return;
341 };
342 match key.code {
343 KeyCode::Esc => self.args = None,
344 KeyCode::Enter => self.submit_args(),
345 _ => input.edit(key),
346 }
347 }
348
349 fn submit_args(&mut self) {
350 let Some(input) = self.args.clone() else {
351 return;
352 };
353 let args: Vec<String> = input.buffer.split_whitespace().map(str::to_owned).collect();
354 if let Some(err) = args.iter().find_map(|arg| validate_arg(arg).err()) {
355 self.error(&format!("{err:#}"));
356 return;
357 }
358 let Some(sched) = self.selected_scheduler().map(|s| s.name.clone()) else {
359 return;
360 };
361 if self.action_allowed() {
362 self.args = None;
363 self.queue(PendingAction::Run(sched, args));
364 }
365 }
366
367 fn on_key_recommended(&mut self, key: KeyEvent) {
368 match key.code {
369 KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
370 KeyCode::Char('l') => self.open_logs(),
371 KeyCode::Char('A') => self.open_all(),
372 KeyCode::Char('t') => {
373 if self.action_allowed() {
374 self.pending_action = Some(PendingAction::Monitor);
375 }
376 }
377 KeyCode::Down | KeyCode::Char('j') => {
378 self.selected = step(self.selected, self.recommended().len(), true);
379 }
380 KeyCode::Up | KeyCode::Char('k') => {
381 self.selected = step(self.selected, self.recommended().len(), false);
382 }
383 KeyCode::Enter => self.start_selected_pick(),
384 KeyCode::Char('s') => self.stop_running(),
385 KeyCode::Char('R') => {
386 self.refresh();
387 if self.listing.is_some() {
388 self.info("refreshed");
389 }
390 }
391 _ => {}
392 }
393 }
394
395 fn on_key_all(&mut self, key: KeyEvent) {
396 match key.code {
397 KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('A') => {
398 self.view = View::Recommended;
399 }
400 KeyCode::Char('l') => self.open_logs(),
401 KeyCode::Char('t') => {
402 if self.action_allowed() {
403 self.pending_action = Some(PendingAction::Monitor);
404 }
405 }
406 KeyCode::Down | KeyCode::Char('j') => {
407 self.sched_selected = step(self.sched_selected, self.all().len(), true);
408 }
409 KeyCode::Up | KeyCode::Char('k') => {
410 self.sched_selected = step(self.sched_selected, self.all().len(), false);
411 }
412 KeyCode::Char('a') => {
413 if self.selected_scheduler().is_some() {
414 self.args = Some(Input::default());
415 }
416 }
417 KeyCode::Enter => {
418 if let Some(sched) = self.selected_scheduler().map(|s| s.name.clone())
419 && self.action_allowed()
420 {
421 self.queue(PendingAction::Start(sched));
422 }
423 }
424 KeyCode::Char('s') => self.stop_running(),
425 KeyCode::Char('R') => {
426 self.refresh();
427 if self.listing.is_some() {
428 self.info("refreshed");
429 }
430 }
431 _ => {}
432 }
433 }
434
435 fn on_key_logs(&mut self, key: KeyEvent) {
436 match key.code {
437 KeyCode::Char('q' | 'l') | KeyCode::Esc => self.view = View::Recommended,
438 KeyCode::Up | KeyCode::Char('k') => self.log_scroll = self.log_scroll.saturating_add(1),
439 KeyCode::Down | KeyCode::Char('j') => {
440 self.log_scroll = self.log_scroll.saturating_sub(1);
441 }
442 KeyCode::PageUp => self.log_scroll = self.log_scroll.saturating_add(self.log_page),
443 KeyCode::PageDown => self.log_scroll = self.log_scroll.saturating_sub(self.log_page),
444 KeyCode::Char('g') => self.log_scroll = usize::MAX,
445 KeyCode::Char('G') => self.log_scroll = 0,
446 KeyCode::Char('b') => {
447 self.log_previous_boot = !self.log_previous_boot;
448 self.reload_logs();
449 }
450 KeyCode::Char('R') => self.reload_logs(),
451 _ => {}
452 }
453 }
454
455 fn open_all(&mut self) {
456 self.view = View::All;
457 if !self.all_warned {
458 self.all_warned = true;
459 self.info(
460 "all schedulers: one unsuited to the workload can perform worse than the \
461kernel's default; the recommended ones are marked",
462 );
463 }
464 }
465
466 fn open_logs(&mut self) {
467 self.view = View::Logs;
468 self.reload_logs();
469 }
470
471 fn reload_logs(&mut self) {
472 match logs::fetch(logs::UNIT, self.log_previous_boot) {
473 Ok(fetch) => {
474 let boot = if self.log_previous_boot { "-1" } else { "0" };
475 let tail_note = if fetch.truncated {
476 "; older entries omitted, see journalctl for the full log"
477 } else {
478 ""
479 };
480 self.info(&format!(
481 "loaded {} lines from {} (boot {boot}){tail_note}",
482 fetch.lines.len(),
483 logs::UNIT
484 ));
485 self.log_lines = fetch.lines;
486 self.log_scroll = 0;
487 }
488 Err(err) => {
489 self.log_lines = Vec::new();
490 self.error(&format!("{err:#}"));
491 }
492 }
493 }
494
495 fn sync_selection_to_running(&mut self) {
499 let Some(listing) = &self.listing else {
500 return;
501 };
502 let Some(name) = listing.current.scheduler.as_deref() else {
503 return;
504 };
505 if let Some(idx) = listing.recommended.iter().position(|p| p.id == name) {
506 self.selected = idx;
507 }
508 if let Some(idx) = listing.all.iter().position(|s| s.name == name) {
509 self.sched_selected = idx;
510 }
511 }
512
513 fn action_allowed(&mut self) -> bool {
514 let now = Instant::now();
515 if self
516 .last_action
517 .is_some_and(|last| now.duration_since(last) < ACTION_DEBOUNCE)
518 {
519 return false;
520 }
521 self.last_action = Some(now);
522 true
523 }
524
525 fn start_selected_pick(&mut self) {
529 let Some(pick) = self.selected_pick() else {
530 return;
531 };
532 if !pick.available {
533 self.error(&format!("{} is not installed", pick.id));
534 return;
535 }
536 let id = pick.id.clone();
537 if self.action_allowed() {
538 self.queue(PendingAction::Start(id));
539 }
540 }
541
542 fn stop_running(&mut self) {
545 let active = self.listing.as_ref().is_some_and(|l| l.current.unit.active);
546 if !active {
547 self.info("nothing is running");
548 return;
549 }
550 if self.action_allowed() {
551 self.queue(PendingAction::Stop);
552 }
553 }
554
555 fn refresh(&mut self) {
557 match self.backend.list() {
558 Ok(listing) => {
559 self.selected = self
560 .selected
561 .min(listing.recommended.len().saturating_sub(1));
562 self.sched_selected = self.sched_selected.min(listing.all.len().saturating_sub(1));
563 self.listing = Some(listing);
564 self.listing_error = None;
565 }
566 Err(err) => {
567 self.listing = None;
568 self.listing_error = Some(format!("{err:#}"));
569 }
570 }
571 self.kernel = (self.read_kernel)();
572 }
573
574 pub fn kernel_warning(&self) -> Option<String> {
576 let kernel = self.kernel.as_ref()?;
577 let current = &self.listing.as_ref()?.current;
578 let running = current.running.as_ref().map(|r| r.sched.as_str());
579 match (current.unit.active, running, kernel.enabled()) {
580 (false, _, true) => {
581 Some("a scheduler is attached outside of scx.service's control".to_owned())
582 }
583 (true, running, false) => Some(format!(
584 "scx.service is active ({}), but the kernel shows no attached scheduler",
585 running.unwrap_or("process unknown")
586 )),
587 (true, Some(sched), true) if !kernel.matches(sched) => {
588 let ops = kernel.ops.as_deref().unwrap_or("?");
589 Some(format!(
590 "scx.service runs {sched}, but the kernel ops name is \"{ops}\""
591 ))
592 }
593 _ => None,
594 }
595 }
596
597 fn reconcile_kernel_dispute(&mut self) -> bool {
599 let disputed = self.kernel_warning().is_some();
600 let fire = disputed && !self.kernel_disputed;
601 self.kernel_disputed = disputed;
602 fire
603 }
604
605 pub fn warn(&mut self, text: &str) {
608 self.error(text);
609 }
610
611 fn info(&mut self, text: &str) {
612 self.message = Some(Message {
613 text: text.to_owned(),
614 is_error: false,
615 shown_at: Instant::now(),
616 });
617 }
618
619 fn error(&mut self, text: &str) {
620 self.message = Some(Message {
621 text: text.to_owned(),
622 is_error: true,
623 shown_at: Instant::now(),
624 });
625 }
626}
627
628fn step(idx: usize, len: usize, forward: bool) -> usize {
629 if len == 0 {
630 0
631 } else if forward {
632 (idx + 1) % len
633 } else {
634 (idx + len - 1) % len
635 }
636}
637
638pub fn is_running(listing: &Listing, id: &str) -> bool {
641 listing.current.scheduler.as_deref() == Some(id)
642}
643
644pub fn is_selected(listing: &Listing, id: &str) -> bool {
647 match &listing.current.next {
648 Some(next) => !next.overridden && next.args.is_empty() && next.name() == id,
649 None => id == DEFAULT_ID,
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use std::cell::RefCell;
656 use std::rc::Rc;
657
658 use crate::model::{Current, Running, Unit};
659 use anyhow::anyhow;
660
661 use super::*;
662
663 #[derive(Debug, PartialEq)]
664 enum Call {
665 Start(String),
666 Run(String, Vec<String>),
667 Stop,
668 }
669
670 struct Stub {
671 listing: Rc<RefCell<Listing>>,
672 broken: Rc<RefCell<bool>>,
673 calls: Rc<RefCell<Vec<Call>>>,
674 }
675
676 fn pick(id: &str, available: bool) -> Pick {
677 Pick {
678 id: id.into(),
679 label: id.into(),
680 description: String::new(),
681 path: (id != DEFAULT_ID && available).then(|| format!("/usr/bin/{id}")),
682 available,
683 }
684 }
685
686 fn installed(name: &str, recommended: bool) -> Installed {
687 Installed {
688 name: name.into(),
689 path: format!("/usr/bin/{name}"),
690 recommended,
691 }
692 }
693
694 fn listing() -> Listing {
695 Listing {
696 version: crate::model::OUTPUT_VERSION,
697 source: "/usr/share/scx/catalog.json".into(),
698 customized: false,
699 recommended: vec![
700 pick("default", true),
701 pick("scx_cosmos", true),
702 pick("scx_eevdf", false),
703 ],
704 all: vec![
705 installed("scx_bpfland", false),
706 installed("scx_cosmos", true),
707 ],
708 config: Some("/etc/default/scx".into()),
709 current: Current {
710 version: crate::model::OUTPUT_VERSION,
711 scheduler: Some("scx_cosmos".into()),
712 unit: Unit {
713 active: true,
714 enabled: true,
715 },
716 running: Some(Running {
717 sched: "scx_cosmos".into(),
718 args: Vec::new(),
719 }),
720 next: Some(crate::model::Next {
721 sched: "/usr/bin/scx_cosmos".into(),
722 args: Vec::new(),
723 overridden: false,
724 }),
725 kernel: None,
726 failure: None,
727 config_error: None,
728 },
729 }
730 }
731
732 impl Backend for Stub {
733 fn label(&self) -> String {
734 "stub".into()
735 }
736 fn list(&self) -> Result<Listing> {
737 if *self.broken.borrow() {
738 return Err(anyhow!("list broken"));
739 }
740 Ok(self.listing.borrow().clone())
741 }
742 fn start(&self, id: &str) -> Result<String> {
743 self.calls.borrow_mut().push(Call::Start(id.into()));
744 let mut l = self.listing.borrow_mut();
745 l.current.unit.active = id != DEFAULT_ID;
746 l.current.scheduler = Some(id.into());
747 Ok(format!("started {id}"))
748 }
749 fn run(&self, sched: &str, args: &[String]) -> Result<String> {
750 self.calls
751 .borrow_mut()
752 .push(Call::Run(sched.into(), args.to_vec()));
753 Ok("running".into())
754 }
755 fn stop(&self) -> Result<String> {
756 self.calls.borrow_mut().push(Call::Stop);
757 let mut l = self.listing.borrow_mut();
758 l.current.unit.active = false;
759 l.current.running = None;
760 l.current.scheduler = Some(DEFAULT_ID.into());
761 Ok("stopped".into())
762 }
763 fn escalates(&self) -> bool {
764 false
765 }
766 }
767
768 struct Harness {
769 app: App,
770 calls: Rc<RefCell<Vec<Call>>>,
771 listing: Rc<RefCell<Listing>>,
772 broken: Rc<RefCell<bool>>,
773 }
774
775 fn app() -> Harness {
776 let listing = Rc::new(RefCell::new(listing()));
777 let calls = Rc::new(RefCell::new(Vec::new()));
778 let broken = Rc::new(RefCell::new(false));
779 let stub = Stub {
780 listing: listing.clone(),
781 broken: broken.clone(),
782 calls: calls.clone(),
783 };
784 let mut app = App::new(Box::new(stub));
785 app.read_kernel = Box::new(|| None);
786 app.refresh();
787 app.sync_selection_to_running();
788 Harness {
789 app,
790 calls,
791 listing,
792 broken,
793 }
794 }
795
796 fn key(code: KeyCode) -> KeyEvent {
797 KeyEvent::new(code, KeyModifiers::NONE)
798 }
799
800 fn drain(app: &mut App) {
801 if let Some(action) = app.pending_action.take() {
804 let outcome = match action {
805 PendingAction::Start(id) => app.backend.start(&id),
806 PendingAction::Run(s, a) => app.backend.run(&s, &a),
807 PendingAction::Stop => app.backend.stop(),
808 PendingAction::Monitor => Ok(String::new()),
809 };
810 assert!(outcome.is_ok());
811 app.refresh();
812 }
813 }
814
815 #[test]
816 fn startup_selects_the_running_scheduler_in_both_lists() {
817 let Harness { app, .. } = app();
818 assert_eq!(app.selected_pick().unwrap().id, "scx_cosmos");
819 assert_eq!(app.selected_scheduler().unwrap().name, "scx_cosmos");
820 }
821
822 #[test]
823 fn enter_starts_the_selected_entry_but_not_an_unavailable_one() {
824 let Harness { mut app, calls, .. } = app();
825 app.on_key(key(KeyCode::Enter));
826 drain(&mut app);
827 assert_eq!(*calls.borrow(), vec![Call::Start("scx_cosmos".into())]);
828
829 app.last_action = None;
830 app.on_key(key(KeyCode::Down));
831 assert_eq!(app.selected_pick().unwrap().id, "scx_eevdf");
832 app.on_key(key(KeyCode::Enter));
833 assert!(app.pending_action.is_none());
834 assert!(app.message.as_ref().unwrap().is_error);
835 assert_eq!(calls.borrow().len(), 1);
836
837 app.last_action = None;
839 app.on_key(key(KeyCode::Down));
840 assert_eq!(app.selected_pick().unwrap().id, "default");
841 app.on_key(key(KeyCode::Enter));
842 drain(&mut app);
843 assert_eq!(calls.borrow().last(), Some(&Call::Start("default".into())));
844 }
845
846 #[test]
847 fn start_is_debounced() {
848 let Harness { mut app, calls, .. } = app();
849 app.on_key(key(KeyCode::Enter));
850 drain(&mut app);
851 app.on_key(key(KeyCode::Enter));
852 assert!(
853 app.pending_action.is_none(),
854 "second press within the window is swallowed"
855 );
856 assert_eq!(calls.borrow().len(), 1);
857 }
858
859 #[test]
860 fn stop_keeps_the_selection_and_marks_it() {
861 let Harness { mut app, calls, .. } = app();
862 app.on_key(key(KeyCode::Char('s')));
863 drain(&mut app);
864 assert_eq!(*calls.borrow(), vec![Call::Stop]);
865 let listing = app.listing.as_ref().unwrap();
866 assert!(!listing.current.unit.active);
867 assert!(is_running(listing, "default"));
868 assert!(is_selected(listing, "scx_cosmos"));
869 assert!(!is_selected(listing, "default"));
870
871 app.last_action = None;
873 app.on_key(key(KeyCode::Char('s')));
874 assert!(app.pending_action.is_none());
875 assert!(!app.message.as_ref().unwrap().is_error);
876 assert_eq!(calls.borrow().len(), 1);
877 }
878
879 #[test]
880 fn the_selection_marker_ignores_runs_and_overrides() {
881 let Harness { listing, .. } = app();
882 let mut l = listing.borrow().clone();
883 assert!(is_selected(&l, "scx_cosmos"));
884 l.current.next.as_mut().unwrap().args = vec!["-v".into()];
885 assert!(!is_selected(&l, "scx_cosmos"), "a run is not a selection");
886 l.current.next.as_mut().unwrap().args.clear();
887 l.current.next.as_mut().unwrap().overridden = true;
888 assert!(!is_selected(&l, "scx_cosmos"));
889 l.current.next = None;
890 assert!(is_selected(&l, "default"));
891 }
892
893 #[test]
894 fn the_full_list_starts_with_defaults_or_runs_with_typed_options() {
895 let Harness { mut app, calls, .. } = app();
896 app.on_key(key(KeyCode::Char('A')));
897 assert_eq!(app.view, View::All);
898 app.on_key(key(KeyCode::Up));
899 assert_eq!(app.selected_scheduler().unwrap().name, "scx_bpfland");
900 app.on_key(key(KeyCode::Enter));
901 drain(&mut app);
902 assert_eq!(*calls.borrow(), vec![Call::Start("scx_bpfland".into())]);
903
904 app.last_action = None;
905 app.on_key(key(KeyCode::Char('a')));
906 for c in "-m performance".chars() {
907 app.on_key(key(KeyCode::Char(c)));
908 }
909 app.on_key(key(KeyCode::Enter));
910 drain(&mut app);
911 assert_eq!(
912 calls.borrow().last(),
913 Some(&Call::Run(
914 "scx_bpfland".into(),
915 vec!["-m".into(), "performance".into()]
916 ))
917 );
918 assert!(app.args.is_none());
919 }
920
921 #[test]
922 fn typed_options_are_validated_before_anything_runs() {
923 let Harness { mut app, calls, .. } = app();
924 app.on_key(key(KeyCode::Char('A')));
925 app.on_key(key(KeyCode::Char('a')));
926 for c in "$(id)".chars() {
927 app.on_key(key(KeyCode::Char(c)));
928 }
929 app.on_key(key(KeyCode::Enter));
930 assert!(app.args.is_some(), "the field stays open to fix");
931 assert!(app.message.as_ref().unwrap().is_error);
932 assert!(calls.borrow().is_empty());
933 app.on_key(key(KeyCode::Char('q')));
935 assert!(!app.should_quit);
936 app.on_key(key(KeyCode::Esc));
937 assert!(app.args.is_none());
938 }
939
940 #[test]
941 fn a_failed_listing_clears_the_snapshot_and_keeps_selection_sane() {
942 let Harness {
943 mut app,
944 listing,
945 broken,
946 ..
947 } = app();
948 *broken.borrow_mut() = true;
949 app.refresh();
950 assert!(app.listing.is_none());
951 assert!(app.listing_error.is_some());
952 assert!(app.selected_pick().is_none());
953 app.on_key(key(KeyCode::Enter));
954 assert!(app.pending_action.is_none());
955
956 *broken.borrow_mut() = false;
957 listing.borrow_mut().recommended.truncate(1);
958 app.selected = 2;
959 app.refresh();
960 assert_eq!(app.selected, 0);
961 }
962
963 #[test]
964 fn kernel_warnings_follow_the_unit_and_the_kernel() {
965 let Harness { mut app, .. } = app();
966 app.kernel = Some(KernelState {
967 state: "enabled".into(),
968 ops: Some("rusty".into()),
969 });
970 assert!(app.kernel_warning().unwrap().contains("ops name"));
971 app.kernel = Some(KernelState {
972 state: "disabled".into(),
973 ops: None,
974 });
975 assert!(
976 app.kernel_warning()
977 .unwrap()
978 .contains("no attached scheduler")
979 );
980 assert!(app.reconcile_kernel_dispute());
981 assert!(!app.reconcile_kernel_dispute(), "edge-triggered");
982 }
983}