summaryrefslogtreecommitdiffstats
path: root/src/core/process
diff options
context:
space:
mode:
authorTim Oram <dev@mitmaro.ca>2021-07-05 10:58:46 -0230
committerTim Oram <dev@mitmaro.ca>2021-07-05 16:27:53 -0230
commit772ba7e40a023f780054a6b689780bccd1b47e44 (patch)
treee9ee71b12d0788e0dbb7b021ed1ba8eb44262496 /src/core/process
parent6de625d1e1de3de86aa0544a0221c0644c2a40f3 (diff)
Move process module into core
Diffstat (limited to 'src/core/process')
-rw-r--r--src/core/process/mod.rs202
-rw-r--r--src/core/process/tests.rs487
2 files changed, 689 insertions, 0 deletions
diff --git a/src/core/process/mod.rs b/src/core/process/mod.rs
new file mode 100644
index 0000000..c5429a7
--- /dev/null
+++ b/src/core/process/mod.rs
@@ -0,0 +1,202 @@
+#[cfg(test)]
+mod tests;
+
+use std::{process::Command, thread};
+
+use anyhow::{anyhow, Result};
+use display::Tui;
+use input::{Event, EventHandler, MetaEvent};
+use todo_file::TodoFile;
+use view::{spawn_view_thread, RenderContext, View, ViewSender};
+
+use crate::module::{ExitStatus, Modules, ProcessResult, State};
+
+pub struct Process {
+ event_handler: EventHandler,
+ exit_status: Option<ExitStatus>,
+ rebase_todo: TodoFile,
+ render_context: RenderContext,
+ state: State,
+ threads: Vec<thread::JoinHandle<()>>,
+ view_sender: ViewSender,
+}
+
+impl Process {
+ pub(crate) fn new<C: Tui + Send + 'static>(
+ rebase_todo: TodoFile,
+ event_handler: EventHandler,
+ view: View<C>,
+ ) -> Self {
+ #[allow(deprecated)]
+ let view_size = view.get_view_size();
+ let mut threads = vec![];
+
+ let (view_sender, view_thread) = spawn_view_thread(view);
+ threads.push(view_thread);
+
+ Self {
+ event_handler,
+ exit_status: None,
+ rebase_todo,
+ render_context: RenderContext::new(view_size.width() as u16, view_size.height() as u16),
+ state: State::List,
+ threads,
+ view_sender,
+ }
+ }
+
+ pub(crate) fn run(&mut self, mut modules: Modules) -> Result<ExitStatus> {
+ if self.view_sender.start().is_err() {
+ self.exit_status = Some(ExitStatus::StateError);
+ return Ok(ExitStatus::StateError);
+ }
+
+ self.handle_process_result(
+ &mut modules,
+ &ProcessResult::new().event(Event::Resize(
+ self.render_context.width() as u16,
+ self.render_context.height() as u16,
+ )),
+ );
+ self.activate(&mut modules, State::List);
+ while self.exit_status.is_none() {
+ let view_data = modules.build_view_data(self.state, &self.render_context, &self.rebase_todo);
+ if self.view_sender.render(view_data).is_err() {
+ self.exit_status = Some(ExitStatus::StateError);
+ continue;
+ }
+ loop {
+ if self.view_sender.is_poisoned() {
+ self.exit_status = Some(ExitStatus::StateError);
+ break;
+ }
+ let result = modules.handle_input(
+ self.state,
+ &self.event_handler,
+ &self.view_sender,
+ &mut self.rebase_todo,
+ );
+
+ if let Some(event) = result.event {
+ if event != Event::None {
+ self.handle_process_result(&mut modules, &result);
+ break;
+ }
+ }
+ }
+ }
+ if self.view_sender.stop().is_err() {
+ return Ok(ExitStatus::StateError);
+ }
+ if let Some(status) = self.exit_status {
+ eprintln!("Status: {:?}", status);
+ if status != ExitStatus::Kill {
+ self.rebase_todo.write_file()?;
+ }
+ }
+
+ if self.view_sender.end().is_err() {
+ return Ok(ExitStatus::StateError);
+ }
+
+ while let Some(handle) = self.threads.pop() {
+ if handle.join().is_err() {
+ return Ok(ExitStatus::StateError);
+ }
+ }
+
+ Ok(self.exit_status.unwrap_or(ExitStatus::Good))
+ }
+
+ fn handle_process_result(&mut self, modules: &mut Modules, result: &ProcessResult) {
+ let previous_state = self.state;
+
+ // render context and view_sender need a size update early
+ if let Some(Event::Resize(width, height)) = result.event {
+ self.view_sender.resize(width, height);
+ self.render_context.update(width, height);
+ }
+
+ if let Some(exit_status) = result.exit_status {
+ self.exit_status = Some(exit_status);
+ }
+
+ if let Some(ref error) = result.error {
+ self.state = State::Error;
+ modules.error(self.state, error);
+ self.activate(modules, result.state.unwrap_or(previous_state));
+ }
+ else if let Some(new_state) = result.state {
+ if new_state != self.state {
+ modules.deactivate(self.state);
+ self.state = new_state;
+ self.activate(modules, previous_state);
+ }
+ }
+
+ match result.event {
+ Some(Event::Meta(MetaEvent::Exit)) => {
+ self.exit_status = Some(ExitStatus::Abort);
+ },
+ Some(Event::Meta(MetaEvent::Kill)) => {
+ self.exit_status = Some(ExitStatus::Kill);
+ },
+ Some(Event::Resize(..)) => {
+ if self.state != State::WindowSizeError && self.render_context.is_window_too_small() {
+ self.state = State::WindowSizeError;
+ self.activate(modules, previous_state);
+ }
+ },
+ _ => {},
+ };
+
+ if let Some(ref external_command) = result.external_command {
+ match self.run_command(external_command) {
+ Ok(meta_event) => self.event_handler.push_event(Event::from(meta_event)),
+ Err(err) => {
+ self.handle_process_result(
+ modules,
+ &ProcessResult::new().state(State::List).error(err.context(format!(
+ "Unable to run {} {}",
+ external_command.0,
+ external_command.1.join(" ")
+ ))),
+ );
+ },
+ }
+ }
+ }
+
+ fn run_command(&mut self, external_command: &(String, Vec<String>)) -> Result<MetaEvent> {
+ self.view_sender.stop()?;
+
+ let mut cmd = Command::new(external_command.0.clone());
+ cmd.args(external_command.1.clone());
+
+ let result = cmd
+ .status()
+ .map(|status| {
+ if status.success() {
+ MetaEvent::ExternalCommandSuccess
+ }
+ else {
+ MetaEvent::ExternalCommandError
+ }
+ })
+ .map_err(|err| anyhow!(err));
+
+ self.view_sender.start()?;
+
+ result
+ }
+
+ fn activate(&mut self, modules: &mut Modules, previous_state: State) {
+ let result = modules.activate(self.state, &self.rebase_todo, previous_state);
+ // always trigger a resize on activate, for modules that track size
+ self.event_handler.push_event(Event::Resize(
+ self.render_context.width() as u16,
+ self.render_context.height() as u16,
+ ));
+ self.handle_process_result(modules, &result);
+ }
+}
diff --git a/src/core/process/tests.rs b/src/core/process/tests.rs
new file mode 100644
index 0000000..75c3910
--- /dev/null
+++ b/src/core/process/tests.rs
@@ -0,0 +1,487 @@
+use std::{path::Path, sync::atomic::Ordering};
+
+use anyhow::anyhow;
+use config::testutil::create_theme;
+use display::{testutil::CrossTerm, Display, Size};
+use input::InputOptions;
+use view::{assert_rendered_output, ViewData};
+
+use super::*;
+use crate::{
+ error::Error,
+ module::{testutil::module_test, Module},
+ window_size_error::WindowSizeError,
+};
+
+struct TestModule {
+ pub event_callback: Box<dyn Fn(&EventHandler, &ViewSender, &mut TodoFile) -> ProcessResult>,
+ pub view_data: ViewData,
+ pub view_data_callback: Box<dyn Fn(&mut ViewData)>,
+}
+
+impl TestModule {
+ fn new() -> Self {
+ Self {
+ event_callback: Box::new(|_, _, _| ProcessResult::new().event(Event::from(MetaEvent::Kill))),
+ view_data: ViewData::new(|_| {}),
+ view_data_callback: Box::new(|_| {}),
+ }
+ }
+}
+
+impl Module for TestModule {
+ fn build_view_data(&mut self, _render_context: &RenderContext, _rebase_todo: &TodoFile) -> &ViewData {
+ (self.view_data_callback)(&mut self.view_data);
+ &self.view_data
+ }
+
+ fn handle_events(
+ &mut self,
+ event_handler: &EventHandler,
+ view_sender: &ViewSender,
+ todo_file: &mut TodoFile,
+ ) -> ProcessResult {
+ (self.event_callback)(event_handler, view_sender, todo_file)
+ }
+}
+
+fn create_crossterm() -> CrossTerm {
+ let mut crossterm = CrossTerm::new();
+ crossterm.set_size(Size::new(100, 300));
+ crossterm
+}
+
+fn create_modules() -> Modules {
+ let mut modules = Modules::new();
+ modules.register_module(State::Error, Error::new());
+ modules.register_module(State::WindowSizeError, WindowSizeError::new());
+ modules
+}
+
+#[test]
+fn view_start_error() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ modules.register_module(State::List, TestModule::new());
+ while process.view_sender.end().is_ok() {}
+ assert_eq!(process.run(modules).unwrap(), ExitStatus::StateError);
+ });
+}
+
+#[test]
+fn window_too_small_on_start() {
+ module_test(&["pick aaa comment"], &[Event::from(MetaEvent::Exit)], |test_context| {
+ let mut crossterm = create_crossterm();
+ crossterm.set_size(Size::new(1, 1));
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let modules = create_modules();
+ process.run(modules).unwrap();
+ assert_eq!(process.state, State::WindowSizeError);
+ });
+}
+
+#[test]
+fn render_error() {
+ module_test(&["pick aaa comment"], &[Event::from(MetaEvent::Exit)], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ let mut test_module = TestModule::new();
+ let sender = process.view_sender.clone();
+ test_module.view_data_callback = Box::new(move |_| while sender.end().is_ok() {});
+ modules.register_module(State::List, test_module);
+ assert_eq!(process.run(modules).unwrap(), ExitStatus::StateError);
+ });
+}
+
+#[test]
+fn view_sender_is_poisoned() {
+ module_test(&["pick aaa comment"], &[Event::from(MetaEvent::Exit)], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ let test_module = TestModule::new();
+ process.view_sender.clone_poisoned().store(true, Ordering::Relaxed);
+ modules.register_module(State::List, test_module);
+ assert_eq!(process.run(modules).unwrap(), ExitStatus::StateError);
+ });
+}
+
+#[test]
+fn stop_error() {
+ module_test(&["pick aaa comment"], &[Event::from(MetaEvent::Exit)], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ let mut test_module = TestModule::new();
+ let sender = process.view_sender.clone();
+ test_module.event_callback = Box::new(move |_, _, _| {
+ while sender.end().is_ok() {}
+ ProcessResult::new().event(Event::from(MetaEvent::Exit))
+ });
+ modules.register_module(State::List, test_module);
+ assert_eq!(process.run(modules).unwrap(), ExitStatus::StateError);
+ });
+}
+
+#[test]
+fn handle_exit_event_that_is_not_kill() {
+ module_test(&["pick aaa comment"], &[], |mut test_context| {
+ test_context.rebase_todo_file.write_file().unwrap();
+ test_context.rebase_todo_file.set_lines(vec![]);
+ let mut shadow_rebase_file = test_context.new_todo_file();
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ let mut test_module = TestModule::new();
+ test_module.event_callback = Box::new(|_, _, _| {
+ ProcessResult::new()
+ .event(Event::from(MetaEvent::Rebase))
+ .exit_status(ExitStatus::Good)
+ });
+ modules.register_module(State::List, test_module);
+ assert_eq!(process.run(modules).unwrap(), ExitStatus::Good);
+ shadow_rebase_file.load_file().unwrap();
+ assert!(shadow_rebase_file.is_empty());
+ });
+}
+
+#[test]
+fn handle_exit_event_that_is_kill() {
+ module_test(&["pick aaa comment"], &[], |mut test_context| {
+ test_context.rebase_todo_file.write_file().unwrap();
+ test_context.rebase_todo_file.set_lines(vec![]);
+ let mut shadow_rebase_file = test_context.new_todo_file();
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ let mut test_module = TestModule::new();
+ test_module.event_callback = Box::new(|_, _, _| {
+ ProcessResult::new()
+ .event(Event::from(MetaEvent::Kill))
+ .exit_status(ExitStatus::Kill)
+ });
+ modules.register_module(State::List, test_module);
+ assert_eq!(process.run(modules).unwrap(), ExitStatus::Kill);
+ shadow_rebase_file.load_file().unwrap();
+ assert!(!shadow_rebase_file.is_empty());
+ });
+}
+
+#[test]
+fn handle_process_result_error() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ let result = ProcessResult::new().error(anyhow!("Test error"));
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.state, State::Error);
+ });
+}
+
+#[test]
+fn handle_process_result_new_state() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ modules.register_module(State::List, TestModule::new());
+ let result = ProcessResult::new().state(State::Error);
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.state, State::Error);
+ });
+}
+
+#[test]
+fn handle_process_result_state_same() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ modules.register_module(State::List, TestModule::new());
+ let result = ProcessResult::new().state(State::List);
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.state, State::List);
+ });
+}
+
+#[test]
+fn handle_process_result_exit_event() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let result = ProcessResult::new().event(Event::from(MetaEvent::Exit));
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.exit_status, Some(ExitStatus::Abort));
+ });
+}
+
+#[test]
+fn handle_process_result_kill_event() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let result = ProcessResult::new().event(Event::from(MetaEvent::Kill));
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.exit_status, Some(ExitStatus::Kill));
+ });
+}
+
+#[test]
+fn handle_process_result_resize_event_not_too_small() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let result = ProcessResult::new().event(Event::Resize(100, 200));
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.render_context.width(), 100);
+ assert_eq!(process.render_context.height(), 200);
+ assert_eq!(process.state, State::List);
+ });
+}
+#[test]
+fn handle_process_result_resize_event_too_small() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let result = ProcessResult::new().event(Event::Resize(10, 20));
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.render_context.width(), 10);
+ assert_eq!(process.render_context.height(), 20);
+ assert_eq!(process.state, State::WindowSizeError);
+ });
+}
+
+#[test]
+fn handle_process_result_other_event() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let mut modules = create_modules();
+ let result = ProcessResult::new().event(Event::from('a'));
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.exit_status, None);
+ assert_eq!(process.state, State::List);
+ });
+}
+
+#[test]
+fn handle_process_result_external_command_not_executable() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let command = String::from(
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("test")
+ .join("not-executable.sh")
+ .to_str()
+ .unwrap(),
+ );
+ let result = ProcessResult::new().external_command(command.clone(), vec![]);
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.state, State::Error);
+ let view_data = modules.build_view_data(process.state, &process.render_context, &process.rebase_todo);
+ assert_rendered_output!(
+ view_data,
+ "{TITLE}",
+ "{BODY}",
+ format!("{{Normal}}Unable to run {} ", command),
+ if cfg!(windows) {
+ "{Normal}%1 is not a valid Win32 application. (os error 193)"
+ }
+ else {
+ "{Normal}Permission denied (os error 13)"
+ },
+ "{TRAILING}",
+ "{IndicatorColor}Press any key to continue"
+ );
+ });
+}
+
+#[test]
+fn handle_process_result_external_command_executable_not_found() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let command = String::from(
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("test")
+ .join("not-found.sh")
+ .to_str()
+ .unwrap(),
+ );
+ let result = ProcessResult::new().external_command(command.clone(), vec![]);
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(process.state, State::Error);
+ let view_data = modules.build_view_data(process.state, &process.render_context, &process.rebase_todo);
+ assert_rendered_output!(
+ view_data,
+ "{TITLE}",
+ "{BODY}",
+ format!("{{Normal}}Unable to run {} ", command),
+ if cfg!(windows) {
+ "{Normal}The system cannot find the file specified. (os error 2)"
+ }
+ else {
+ "{Normal}No such file or directory (os error 2)"
+ },
+ "{TRAILING}",
+ "{IndicatorColor}Press any key to continue"
+ );
+ });
+}
+
+#[test]
+fn handle_process_result_external_command_status_success() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let command = String::from("true");
+ let result = ProcessResult::new().external_command(command, vec![]);
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(
+ process.event_handler.read_event(&InputOptions::new(), |e, _| e),
+ Event::from(MetaEvent::ExternalCommandSuccess)
+ );
+ });
+}
+
+#[test]
+fn handle_process_result_external_command_status_error() {
+ module_test(&["pick aaa comment"], &[], |test_context| {
+ let crossterm = create_crossterm();
+ let display = Display::new(crossterm, &create_theme());
+ let view = View::new(display, "~", "?");
+ let mut modules = create_modules();
+ let mut process = Process::new(
+ test_context.rebase_todo_file,
+ test_context.event_handler_context.event_handler,
+ view,
+ );
+ let command = String::from("false");
+ let result = ProcessResult::new().external_command(command, vec![]);
+ process.handle_process_result(&mut modules, &result);
+ assert_eq!(
+ process.event_handler.read_event(&InputOptions::new(), |e, _| e),
+ Event::from(MetaEvent::ExternalCommandError)
+ );
+ });
+}