use std::{ borrow::Cow, collections::HashMap, env::current_dir, ffi::{OsStr, OsString}, fs::File, path::Path, process::Stdio, sync::Arc, }; use clearscreen::ClearScreen; use miette::{miette, IntoDiagnostic, Report, Result}; use notify_rust::Notification; use tokio::{process::Command as TokioCommand, time::sleep}; use tracing::{debug, debug_span, error}; use watchexec::{ command::{Command, Program, Shell, SpawnOptions}, error::RuntimeError, job::{CommandState, Job}, sources::fs::Watcher, Config, ErrorHook, Id, }; use watchexec_events::{Event, Keyboard, ProcessEnd, Tag}; use watchexec_signals::Signal; use crate::state::State; use crate::{ args::{Args, ClearMode, EmitEvents, OnBusyUpdate}, state::RotatingTempFile, }; pub fn make_config(args: &Args, state: &State) -> Result { let _span = debug_span!("args-runtime").entered(); let config = Config::default(); config.on_error(|err: ErrorHook| { if let RuntimeError::IoError { about: "waiting on process group", .. } = err.error { // "No child processes" and such // these are often spurious, so condemn them to -v only error!("{}", err.error); return; } if cfg!(debug_assertions) { eprintln!("[[{:?}]]", err.error); } eprintln!("[[Error (not fatal)]]\n{}", Report::new(err.error)); }); config.pathset(if args.paths.is_empty() { vec![current_dir().into_diagnostic()?] } else if args.paths.len() == 1 && args .paths .first() .map_or(false, |p| p == Path::new("/dev/null")) { // special case: /dev/null means "don't start the fs event source" Vec::new() } else { args.paths.clone() }); config.throttle(args.debounce.0); config.keyboard_events(args.stdin_quit); if let Some(interval) = args.poll { config.file_watcher(Watcher::Poll(interval.0)); } let clear = args.screen_clear; let delay_run = args.delay_run.map(|ts| ts.0); let on_busy = args.on_busy_update; let signal = args.signal; let stop_signal = args.stop_signal; let stop_timeout = args.stop_timeout.0; let once = args.once; let notif = args.notify; let print_events = args.print_events; let emit_events_to = args.emit_events_to; let emit_file = state.emit_file.clone(); let workdir = Arc::new(args.workdir.clone()); let mut add_envs = HashMap::new(); for pair in &args.env { if let Some((k, v)) = pair.split_once('=') { add_envs.insert(k.to_owned(), OsString::from(v)); } else { return Err(miette!("{pair} is not in key=value format")); } } debug!( ?add_envs, "additional environment variables to add to command" ); let id = Id::default(); let command = interpret_command_args(args)?; config.on_action_async(move |mut action| { let add_envs = add_envs.clone(); let command = command.clone(); let emit_file = emit_file.clone(); let workdir = workdir.clone(); Box::new(async move { let add_envs = add_envs.clone(); let command = command.clone(); let emit_file = emit_file.clone(); let workdir = workdir.clone(); let job = action.get_or_create_job(id, move || command.clone()); let events = action.events.clone(); job.set_spawn_hook(move |command, _| { let add_envs = add_envs.clone(); let emit_file = emit_file.clone(); let events = events.clone(); if let Some(ref workdir) = workdir.as_ref() { debug!(?workdir, "set command workdir"); command.current_dir(workdir); } emit_events_to_command(command, events, emit_file, emit_events_to, add_envs); }); let show_events = || { if print_events { for (n, event) in action.events.iter().enumerate() { eprintln!("[EVENT {n}] {event}"); } } }; if once { show_events(); if let Some(delay) = delay_run { job.run_async(move |_| { Box::new(async move { sleep(delay).await; }) }); } // this blocks the event loop, but also this is a debug feature so i don't care job.start().await; job.to_wait().await; action.quit(); return action; } let is_keyboard_eof = action .events .iter() .any(|e| e.tags.contains(&Tag::Keyboard(Keyboard::Eof))); if is_keyboard_eof { show_events(); action.quit(); return action; } let signals: Vec = action.signals().collect(); // if we got a terminate or interrupt signal, quit if signals.contains(&Signal::Terminate) || signals.contains(&Signal::Interrupt) { show_events(); action.quit(); return action; } // pass all other signals on for signal in signals { job.signal(signal); } // clear the screen before printing events if let Some(mode) = clear { match mode { ClearMode::Clear => { clearscreen::clear().ok(); } ClearMode::Reset => { for cs in [ ClearScreen::WindowsCooked, ClearScreen::WindowsVt, ClearScreen::VtLeaveAlt, ClearScreen::VtWellDone, ClearScreen::default(), ] { cs.clear().ok(); } } } } show_events(); if let Some(delay) = delay_run { job.run_async(move |_| { Box::new(async move { sleep(delay).await; }) }); } job.run_async({ let job = job.clone(); move |context| { let job = job.clone(); let is_running = matches!(context.current, CommandState::Running { .. }); Box::new(async move { let innerjob = job.clone(); if is_running { match on_busy { OnBusyUpdate::DoNothing => {} OnBusyUpdate::Signal => { job.signal(if cfg!(windows) { Signal::ForceStop } else { stop_signal.or(signal).unwrap_or(Signal::Terminate) }); } OnBusyUpdate::Restart if cfg!(windows) => { job.restart(); job.run(move |context| { setup_process( innerjob.clone(), context.command.clone(), notif, ) }); } OnBusyUpdate::Restart => { job.restart_with_signal( stop_signal.unwrap_or(Signal::Terminate), stop_timeout, ); job.run(move |context| { setup_process( innerjob.clone(), context.command.clone(), notif, ) }); } OnBusyUpdate::Queue => { let job = job.clone(); tokio::spawn(async move { job.to_wait().await; job.start(); job.run(move |context| { setup_process( innerjob.clone(), context.command.clone(), notif, ) }); }); } } } else { job.start(); job.run(move |context| { setup_process(innerjob.clone(), context.command.clone(), notif) }); } }) } }); action }) }); Ok(config) } fn interpret_command_args(args: &Args) -> Result> { let mut cmd = args.command.clone(); if cmd.is_empty() { panic!("(clap) Bug: command is not present"); } let shell = match if args.no_shell || args.no_shell_long { None } else { args.shell.as_deref().or(Some("default")) } { Some("") => return Err(RuntimeError::CommandShellEmptyShell).into_diagnostic(), Some("none") | None => None, #[cfg(windows)] Some("default") | Some("cmd") | Some("cmd.exe") | Some("CMD") | Some("CMD.EXE") => { Some(Shell::cmd()) } #[cfg(not(windows))] Some("default") => Some(Shell::new("sh")), #[cfg(windows)] Some("powershell") => Some(Shell::new(available_powershell())), Some(other) => { let sh = other.split_ascii_whitespace().collect::>(); // UNWRAP: checked by Some("") #[allow(clippy::unwrap_used)] let (shprog, shopts) = sh.split_first().unwrap(); Some(Shell { prog: shprog.into(), options: shopts.iter().map(|s| (*s).to_string()).collect(), program_option: Some(Cow::Borrowed(OsStr::new("-c"))), }) } }; let program = if let Some(shell) = shell { Program::Shell { shell, command: cmd.join(" "), args: Vec::new(), } } else { Program::Exec { prog: cmd.remove(0).into(), args: cmd, } }; Ok(Arc::new(Command { program, options: SpawnOptions { grouped: !args.no_process_group, ..Default::default() }, })) } #[cfg(windows)] fn available_powershell() -> String { todo!("figure out if powershell.exe is available, and use that, otherwise use pwsh.exe") } fn setup_process(job: Job, command: Arc, notif: bool) { if notif { Notification::new() .summary("Watchexec: change detected") .body(&format!("Running {command}")) .show() .map_or_else( |err| { eprintln!("[[Failed to send desktop notification: {err}]]"); }, drop, ); } tokio::spawn(async move { job.to_wait().await; job.run(move |context| end_of_process(context.current, notif)); }); } fn end_of_process(state: &CommandState, notif: bool) { let CommandState::Finished { status, started, finished, } = state else { return; }; let duration = *finished - *started; let msg = match status { ProcessEnd::ExitError(code) => { format!("Command exited with {code}, lasted {duration:?}") } ProcessEnd::ExitSignal(sig) => { format!("Command killed by {sig:?}, lasted {duration:?}") } ProcessEnd::ExitStop(sig) => { format!("Command stopped by {sig:?}, lasted {duration:?}") } ProcessEnd::Continued => format!("Command continued, lasted {duration:?}"), ProcessEnd::Exception(ex) => { format!("Command ended by exception {ex:#x}, lasted {duration:?}") } ProcessEnd::Success => format!("Command was successful, lasted {duration:?}"), }; if notif { Notification::new() .summary("Watchexec: command ended") .body(&msg) .show() .map_or_else( |err| { eprintln!("[[Failed to send desktop notification: {err}]]"); }, drop, ); } } fn emit_events_to_command( command: &mut TokioCommand, events: Arc<[Event]>, emit_file: RotatingTempFile, emit_events_to: EmitEvents, mut add_envs: HashMap, ) { use crate::emits::*; let mut stdin = None; match emit_events_to { EmitEvents::Environment => { add_envs.extend(emits_to_environment(&events)); } EmitEvents::Stdin => match emits_to_file(&emit_file, &events) .and_then(|path| File::open(path).into_diagnostic()) { Ok(file) => { stdin.replace(Stdio::from(file)); } Err(err) => { error!("Failed to write events to stdin, continuing without it: {err}"); } }, EmitEvents::File => match emits_to_file(&emit_file, &events) { Ok(path) => { add_envs.insert("WATCHEXEC_EVENTS_FILE".into(), path.into()); } Err(err) => { error!("Failed to write WATCHEXEC_EVENTS_FILE, continuing without it: {err}"); } }, EmitEvents::JsonStdin => match emits_to_json_file(&emit_file, &events) .and_then(|path| File::open(path).into_diagnostic()) { Ok(file) => { stdin.replace(Stdio::from(file)); } Err(err) => { error!("Failed to write events to stdin, continuing without it: {err}"); } }, EmitEvents::JsonFile => match emits_to_json_file(&emit_file, &events) { Ok(path) => { add_envs.insert("WATCHEXEC_EVENTS_FILE".into(), path.into()); } Err(err) => { error!("Failed to write WATCHEXEC_EVENTS_FILE, continuing without it: {err}"); } }, EmitEvents::None => {} } for (k, v) in add_envs { debug!(?k, ?v, "inserting environment variable"); command.env(k, v); } if let Some(stdin) = stdin { debug!("set command stdin"); command.stdin(stdin); } }