@@ -22,6 +22,7 @@ use hyper::service::service_fn;
2222use hyper_util:: rt:: { TokioExecutor , TokioIo } ;
2323use hyper_util:: server:: conn:: auto:: Builder as HttpConnectionBuilder ;
2424use hyper_util:: server:: graceful:: GracefulShutdown ;
25+ use notify:: { RecursiveMode , Watcher } ;
2526use tokio:: signal;
2627use tokio:: sync:: mpsc;
2728
@@ -48,9 +49,15 @@ struct Args {
4849 script : Option < String > ,
4950
5051 /// Run script from command line instead of file
51- #[ clap( short = 'c' , long = "commands" ) ]
52+ #[ clap( short = 'c' , long = "commands" , conflicts_with = "watch" ) ]
5253 commands : Option < String > ,
5354
55+ /// Watch for script changes and reload automatically.
56+ /// For file scripts: watches the script's directory for any changes.
57+ /// For stdin (-): reads null-terminated scripts for hot reload.
58+ #[ clap( short = 'w' , long = "watch" ) ]
59+ watch : bool ,
60+
5461 /// Log format: human (live-updating) or jsonl (structured)
5562 #[ clap( long, default_value = "human" ) ]
5663 log_format : LogFormat ,
@@ -105,6 +112,66 @@ fn create_base_engine(
105112 Ok ( engine)
106113}
107114
115+ /// Spawns a file watcher that watches the script's directory for any changes.
116+ /// When a change is detected, re-reads the script file and sends it.
117+ fn spawn_file_watcher ( script_path : PathBuf , tx : mpsc:: Sender < String > ) {
118+ std:: thread:: spawn ( move || {
119+ let watch_dir = script_path. parent ( ) . unwrap_or ( & script_path) . to_path_buf ( ) ;
120+
121+ let ( raw_tx, raw_rx) = std:: sync:: mpsc:: channel ( ) ;
122+
123+ let mut watcher = notify:: recommended_watcher ( raw_tx) . expect ( "Failed to create watcher" ) ;
124+
125+ watcher
126+ . watch ( & watch_dir, RecursiveMode :: Recursive )
127+ . expect ( "Failed to watch directory" ) ;
128+
129+ // Keep watcher alive
130+ let _watcher = watcher;
131+
132+ // Set to past time so first event isn't debounced
133+ let mut last_reload = std:: time:: Instant :: now ( ) - Duration :: from_secs ( 1 ) ;
134+ let debounce = Duration :: from_millis ( 100 ) ;
135+
136+ for result in raw_rx {
137+ match result {
138+ Ok ( event) => {
139+ // Only react to modifications, not access/open events
140+ use notify:: EventKind ;
141+ let is_modification = matches ! (
142+ event. kind,
143+ EventKind :: Create ( _) | EventKind :: Modify ( _) | EventKind :: Remove ( _)
144+ ) ;
145+ if !is_modification {
146+ continue ;
147+ }
148+
149+ // Debounce rapid events
150+ if last_reload. elapsed ( ) < debounce {
151+ continue ;
152+ }
153+ last_reload = std:: time:: Instant :: now ( ) ;
154+
155+ // Re-read and send the script file
156+ match std:: fs:: read_to_string ( & script_path) {
157+ Ok ( content) => {
158+ if tx. blocking_send ( content) . is_err ( ) {
159+ break ;
160+ }
161+ }
162+ Err ( e) => {
163+ eprintln ! ( "Error reading script file: {e}" ) ;
164+ }
165+ }
166+ }
167+ Err ( e) => {
168+ eprintln ! ( "Watch error: {e:?}" ) ;
169+ }
170+ }
171+ }
172+ } ) ;
173+ }
174+
108175/// Spawns a dedicated OS thread that reads null-terminated scripts from stdin and sends them.
109176/// Uses blocking I/O to avoid async stdin issues with piped input.
110177fn spawn_stdin_reader ( tx : mpsc:: Sender < String > ) {
@@ -419,42 +486,59 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
419486 // Server mode (default)
420487 let addr = args. addr . expect ( "addr required for server mode" ) ;
421488
422- // Determine script source: -c flag, stdin (-), or file
423- let ( script_content, read_stdin) = match ( & args. script , & args. commands ) {
424- ( Some ( _) , Some ( _) ) => {
489+ // Create base engine with commands, signals, and plugins
490+ let base_engine = create_base_engine ( interrupt. clone ( ) , & args. plugins , & args. include_paths ) ?;
491+
492+ // Create channel for scripts
493+ let ( tx, rx) = mpsc:: channel :: < String > ( 1 ) ;
494+
495+ // Determine script source and set up appropriate watcher/reader
496+ match ( & args. script , & args. commands , args. watch ) {
497+ ( Some ( _) , Some ( _) , _) => {
425498 eprintln ! ( "Error: cannot specify both script file and --commands" ) ;
426499 std:: process:: exit ( 1 ) ;
427500 }
428- ( None , None ) => {
501+ ( None , None , _ ) => {
429502 eprintln ! ( "Error: provide a script file or use --commands" ) ;
430503 std:: process:: exit ( 1 ) ;
431504 }
432- ( None , Some ( cmd) ) => ( Some ( cmd. clone ( ) ) , false ) ,
433- ( Some ( path) , None ) if path == "-" => ( None , true ) ,
434- ( Some ( path) , None ) => {
505+ // -c flag: use command content directly (conflicts_with prevents -w)
506+ ( None , Some ( cmd) , false ) => {
507+ tx. send ( cmd. clone ( ) )
508+ . await
509+ . expect ( "channel closed unexpectedly" ) ;
510+ drop ( tx) ;
511+ }
512+ // stdin without -w: error
513+ ( Some ( path) , None , false ) if path == "-" => {
514+ eprintln ! ( "Error: stdin mode (-) requires --watch flag" ) ;
515+ std:: process:: exit ( 1 ) ;
516+ }
517+ // stdin with -w: spawn stdin reader for null-terminated scripts
518+ ( Some ( path) , None , true ) if path == "-" => {
519+ spawn_stdin_reader ( tx) ;
520+ }
521+ // file without -w: read once
522+ ( Some ( path) , None , false ) => {
435523 let content = std:: fs:: read_to_string ( path) . unwrap_or_else ( |e| {
436524 eprintln ! ( "Error reading {path}: {e}" ) ;
437525 std:: process:: exit ( 1 ) ;
438526 } ) ;
439- ( Some ( content) , false )
527+ tx. send ( content) . await . expect ( "channel closed unexpectedly" ) ;
528+ drop ( tx) ;
440529 }
441- } ;
442-
443- // Create base engine with commands, signals, and plugins
444- let base_engine = create_base_engine ( interrupt. clone ( ) , & args. plugins , & args. include_paths ) ?;
445-
446- // Create channel for scripts
447- let ( tx, rx) = mpsc:: channel :: < String > ( 1 ) ;
448-
449- if read_stdin {
450- // Spawn dedicated stdin reader thread
451- spawn_stdin_reader ( tx) ;
452- } else {
453- // Send the script content
454- tx. send ( script_content. unwrap ( ) )
455- . await
456- . expect ( "channel closed unexpectedly" ) ;
457- drop ( tx) ; // Close the channel
530+ // file with -w: read initial content and spawn file watcher
531+ ( Some ( path) , None , true ) => {
532+ let script_path = PathBuf :: from ( path) ;
533+ let content = std:: fs:: read_to_string ( & script_path) . unwrap_or_else ( |e| {
534+ eprintln ! ( "Error reading {path}: {e}" ) ;
535+ std:: process:: exit ( 1 ) ;
536+ } ) ;
537+ tx. send ( content) . await . expect ( "channel closed unexpectedly" ) ;
538+ spawn_file_watcher ( script_path, tx) ;
539+ }
540+ // -c with -w is prevented by clap conflicts_with
541+ ( None , Some ( _) , true ) => unreachable ! ( ) ,
458542 }
459543
460544 serve (
0 commit comments