itsi-scheduler 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/ext/itsi_acme/Cargo.toml +1 -1
  4. data/ext/itsi_scheduler/Cargo.toml +1 -1
  5. data/ext/itsi_server/Cargo.toml +3 -1
  6. data/ext/itsi_server/src/lib.rs +6 -1
  7. data/ext/itsi_server/src/ruby_types/itsi_body_proxy/mod.rs +2 -0
  8. data/ext/itsi_server/src/ruby_types/itsi_grpc_call.rs +4 -4
  9. data/ext/itsi_server/src/ruby_types/itsi_grpc_response_stream/mod.rs +14 -13
  10. data/ext/itsi_server/src/ruby_types/itsi_http_request.rs +64 -33
  11. data/ext/itsi_server/src/ruby_types/itsi_http_response.rs +151 -152
  12. data/ext/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +422 -110
  13. data/ext/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +62 -15
  14. data/ext/itsi_server/src/ruby_types/itsi_server.rs +1 -1
  15. data/ext/itsi_server/src/server/binds/listener.rs +45 -7
  16. data/ext/itsi_server/src/server/frame_stream.rs +142 -0
  17. data/ext/itsi_server/src/server/http_message_types.rs +142 -9
  18. data/ext/itsi_server/src/server/io_stream.rs +28 -5
  19. data/ext/itsi_server/src/server/lifecycle_event.rs +1 -1
  20. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +2 -3
  21. data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +8 -10
  22. data/ext/itsi_server/src/server/middleware_stack/middlewares/cors.rs +2 -3
  23. data/ext/itsi_server/src/server/middleware_stack/middlewares/csp.rs +3 -3
  24. data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response/default_responses.rs +54 -56
  25. data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +5 -7
  26. data/ext/itsi_server/src/server/middleware_stack/middlewares/etag.rs +5 -5
  27. data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +7 -10
  28. data/ext/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +2 -3
  29. data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +1 -2
  30. data/ext/itsi_server/src/server/middleware_stack/middlewares/static_response.rs +4 -6
  31. data/ext/itsi_server/src/server/mod.rs +1 -0
  32. data/ext/itsi_server/src/server/process_worker.rs +3 -4
  33. data/ext/itsi_server/src/server/serve_strategy/acceptor.rs +16 -12
  34. data/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +83 -31
  35. data/ext/itsi_server/src/server/serve_strategy/single_mode.rs +166 -142
  36. data/ext/itsi_server/src/server/signal.rs +37 -9
  37. data/ext/itsi_server/src/server/thread_worker.rs +84 -69
  38. data/ext/itsi_server/src/services/itsi_http_service.rs +43 -43
  39. data/ext/itsi_server/src/services/static_file_server.rs +28 -47
  40. data/lib/itsi/scheduler/version.rb +1 -1
  41. metadata +2 -1
@@ -1,80 +1,136 @@
1
1
  use derive_more::Debug;
2
2
  use globset::{Glob, GlobSet, GlobSetBuilder};
3
3
  use magnus::error::Result;
4
- use nix::unistd::{close, fork, pipe, read};
4
+ use nix::unistd::{close, dup, fork, pipe, read, write};
5
5
  use notify::event::ModifyKind;
6
- use notify::{Event, RecursiveMode, Watcher};
7
- use notify::{EventKind, RecommendedWatcher};
6
+ use notify::{Event, EventKind, RecursiveMode, Watcher};
7
+ use parking_lot::Mutex;
8
+ use std::collections::{HashMap, HashSet};
9
+ use std::fs;
10
+ use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd};
8
11
  use std::path::Path;
9
- use std::sync::mpsc::Sender;
12
+ use std::path::PathBuf;
13
+ use std::process::Command;
14
+
15
+ use std::sync::{mpsc, Arc};
16
+ use std::thread;
10
17
  use std::time::{Duration, Instant};
11
- use std::{collections::HashSet, fs};
12
- use std::{
13
- os::fd::{AsRawFd, IntoRawFd, OwnedFd},
14
- path::PathBuf,
15
- process::Command,
16
- sync::mpsc,
17
- thread::{self},
18
- };
19
- use tracing::debug;
20
-
21
- /// Represents a set of patterns and commands.
18
+ use tracing::{error, info};
19
+
22
20
  #[derive(Debug, Clone)]
23
21
  struct PatternGroup {
24
22
  base_dir: PathBuf,
25
23
  glob_set: GlobSet,
26
- pattern: String,
27
24
  commands: Vec<Vec<String>>,
25
+ pattern: String,
28
26
  last_triggered: Option<Instant>,
29
27
  }
30
28
 
31
- /// Extracts the base directory from a wildcard pattern by taking the portion up to the first
32
- /// component that contains a wildcard character.
33
- fn extract_and_canonicalize_base_dir(pattern: &str) -> PathBuf {
34
- if !(pattern.contains("*") || pattern.contains("?") || pattern.contains('[')) {
35
- if let Ok(metadata) = fs::metadata(pattern) {
36
- if metadata.is_dir() {
37
- return fs::canonicalize(pattern).unwrap();
38
- }
39
- if metadata.is_file() {
40
- return fs::canonicalize(pattern)
41
- .unwrap()
42
- .parent()
43
- .unwrap()
44
- .to_path_buf();
45
- }
46
- }
29
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30
+ pub enum WatcherCommand {
31
+ Stop,
32
+ ConfigError,
33
+ Continue,
34
+ }
35
+
36
+ #[derive(Debug)]
37
+ pub struct WatcherPipes {
38
+ pub read_fd: OwnedFd,
39
+ pub write_fd: OwnedFd,
40
+ }
41
+
42
+ impl AsRawFd for WatcherPipes {
43
+ fn as_raw_fd(&self) -> RawFd {
44
+ self.read_fd.as_raw_fd()
47
45
  }
46
+ }
47
+
48
+ impl Drop for WatcherPipes {
49
+ fn drop(&mut self) {
50
+ let _ = send_watcher_command(&self.write_fd, WatcherCommand::Stop);
51
+ let _ = close(self.read_fd.as_raw_fd());
52
+ let _ = close(self.write_fd.as_raw_fd());
53
+ }
54
+ }
48
55
 
56
+ fn extract_and_canonicalize_base_dir(pattern: &str) -> (PathBuf, String) {
49
57
  let path = Path::new(pattern);
50
58
  let mut base = PathBuf::new();
59
+ let mut remaining_components = Vec::new();
60
+ let mut found_glob = false;
61
+
51
62
  for comp in path.components() {
52
63
  let comp_str = comp.as_os_str().to_string_lossy();
53
- if comp_str.contains('*') || comp_str.contains('?') || comp_str.contains('[') {
54
- break;
64
+ if !found_glob
65
+ && (comp_str.contains('*') || comp_str.contains('?') || comp_str.contains('['))
66
+ {
67
+ found_glob = true;
68
+ remaining_components.push(comp_str.to_string());
69
+ } else if found_glob {
70
+ remaining_components.push(comp_str.to_string());
55
71
  } else {
56
72
  base.push(comp);
57
73
  }
58
74
  }
59
- // If no base was built, default to "."
60
- let base = if base.as_os_str().is_empty() || !base.exists() {
75
+
76
+ let base = if base.as_os_str().is_empty() {
61
77
  PathBuf::from(".")
62
78
  } else {
63
79
  base
64
80
  };
81
+ let base = fs::canonicalize(&base).unwrap_or(base);
82
+ let remaining_pattern = remaining_components.join("/");
83
+
84
+ (base, remaining_pattern)
85
+ }
65
86
 
66
- // Canonicalize to get the absolute path.
67
- fs::canonicalize(&base).unwrap_or(base)
87
+ const DEBOUNCE_DURATION: Duration = Duration::from_millis(300);
88
+ const EVENT_DEDUP_DURATION: Duration = Duration::from_millis(50);
89
+ const AUTO_RECOVERY_TIMEOUT: Duration = Duration::from_secs(5);
90
+
91
+ fn serialize_command(cmd: WatcherCommand) -> u8 {
92
+ match cmd {
93
+ WatcherCommand::Stop => 0,
94
+ WatcherCommand::ConfigError => 1,
95
+ WatcherCommand::Continue => 2,
96
+ }
68
97
  }
69
98
 
70
- /// Minimum time between triggering the same pattern group (debounce time)
71
- const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);
99
+ fn deserialize_command(byte: u8) -> Option<WatcherCommand> {
100
+ match byte {
101
+ 0 => Some(WatcherCommand::Stop),
102
+ 1 => Some(WatcherCommand::ConfigError),
103
+ 2 => Some(WatcherCommand::Continue),
104
+ _ => None,
105
+ }
106
+ }
72
107
 
73
- pub fn watch_groups(pattern_groups: Vec<(String, Vec<Vec<String>>)>) -> Result<Option<OwnedFd>> {
74
- let (r_fd, w_fd): (OwnedFd, OwnedFd) = pipe().map_err(|e| {
108
+ pub fn send_watcher_command(fd: &OwnedFd, cmd: WatcherCommand) -> Result<()> {
109
+ let buf = [serialize_command(cmd)];
110
+ match write(fd, &buf) {
111
+ Ok(_) => Ok(()),
112
+ Err(e) => Err(magnus::Error::new(
113
+ magnus::exception::standard_error(),
114
+ format!("Failed to send command to watcher: {}", e),
115
+ )),
116
+ }
117
+ }
118
+
119
+ pub fn watch_groups(
120
+ pattern_groups: Vec<(String, Vec<Vec<String>>)>,
121
+ ) -> Result<Option<WatcherPipes>> {
122
+ // Create bidirectional pipes for communication
123
+ let (parent_read_fd, child_write_fd): (OwnedFd, OwnedFd) = pipe().map_err(|e| {
75
124
  magnus::Error::new(
76
125
  magnus::exception::standard_error(),
77
- format!("Failed to create watcher pipe: {}", e),
126
+ format!("Failed to create parent read pipe: {}", e),
127
+ )
128
+ })?;
129
+
130
+ let (child_read_fd, parent_write_fd): (OwnedFd, OwnedFd) = pipe().map_err(|e| {
131
+ magnus::Error::new(
132
+ magnus::exception::standard_error(),
133
+ format!("Failed to create child read pipe: {}", e),
78
134
  )
79
135
  })?;
80
136
 
@@ -88,17 +144,41 @@ pub fn watch_groups(pattern_groups: Vec<(String, Vec<Vec<String>>)>) -> Result<O
88
144
  }?;
89
145
 
90
146
  if fork_result.is_child() {
91
- let _ = close(w_fd.into_raw_fd());
147
+ // Child process - close the parent ends of the pipes
148
+ let _ = close(parent_read_fd.into_raw_fd());
149
+ let _ = close(parent_write_fd.into_raw_fd());
150
+
151
+ let _child_read_fd_clone =
152
+ unsafe { OwnedFd::from_raw_fd(dup(child_read_fd.as_raw_fd()).unwrap()) };
153
+ let child_write_fd_clone =
154
+ unsafe { OwnedFd::from_raw_fd(dup(child_write_fd.as_raw_fd()).unwrap()) };
155
+
156
+ let command_channel = Arc::new(Mutex::new(None));
157
+ let command_channel_clone = command_channel.clone();
158
+
159
+ // Thread to read commands from parent
92
160
  thread::spawn(move || {
93
161
  let mut buf = [0u8; 1];
94
162
  loop {
95
- match read(r_fd.as_raw_fd(), &mut buf) {
163
+ match read(child_read_fd.as_raw_fd(), &mut buf) {
96
164
  Ok(0) => {
165
+ info!("Parent closed command pipe, exiting watcher");
97
166
  std::process::exit(0);
98
167
  }
99
- Ok(_) => {}
100
- Err(_) => {
101
- std::process::exit(0);
168
+ Ok(_) => {
169
+ if let Some(cmd) = deserialize_command(buf[0]) {
170
+ info!("Received command from parent: {:?}", cmd);
171
+ *command_channel_clone.lock() = Some(cmd);
172
+
173
+ if matches!(cmd, WatcherCommand::Stop) {
174
+ info!("Received stop command, exiting watcher");
175
+ std::process::exit(0);
176
+ }
177
+ }
178
+ }
179
+ Err(e) => {
180
+ error!("Error reading from command pipe: {}", e);
181
+ std::process::exit(1);
102
182
  }
103
183
  }
104
184
  }
@@ -106,11 +186,19 @@ pub fn watch_groups(pattern_groups: Vec<(String, Vec<Vec<String>>)>) -> Result<O
106
186
 
107
187
  let mut groups = Vec::new();
108
188
  for (pattern, commands) in pattern_groups.into_iter() {
109
- let base_dir = extract_and_canonicalize_base_dir(&pattern);
110
- let glob = Glob::new(pattern.trim_start_matches("./")).map_err(|e| {
189
+ let (base_dir, remaining_pattern) = extract_and_canonicalize_base_dir(&pattern);
190
+ info!(
191
+ "Watching base directory {:?} with pattern {:?} (original: {:?})",
192
+ base_dir, remaining_pattern, pattern
193
+ );
194
+
195
+ let glob = Glob::new(&remaining_pattern).map_err(|e| {
111
196
  magnus::Error::new(
112
197
  magnus::exception::standard_error(),
113
- format!("Failed to create watch glob: {}", e),
198
+ format!(
199
+ "Failed to create watch glob for pattern '{}': {}",
200
+ remaining_pattern, e
201
+ ),
114
202
  )
115
203
  })?;
116
204
  let glob_set = GlobSetBuilder::new().add(glob).build().map_err(|e| {
@@ -119,115 +207,339 @@ pub fn watch_groups(pattern_groups: Vec<(String, Vec<Vec<String>>)>) -> Result<O
119
207
  format!("Failed to create watch glob set: {}", e),
120
208
  )
121
209
  })?;
210
+
122
211
  groups.push(PatternGroup {
123
212
  base_dir,
124
213
  glob_set,
125
- pattern,
126
214
  commands,
215
+ pattern: remaining_pattern,
127
216
  last_triggered: None,
128
217
  });
129
218
  }
130
219
 
131
- // Create a channel and a watcher.
220
+ // Create a channel and a watcher
132
221
  let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
222
+ let startup_time = Instant::now();
133
223
  let sender = tx.clone();
134
- fn event_fn(sender: Sender<notify::Result<Event>>) -> impl Fn(notify::Result<Event>) {
135
- move |res| match res {
136
- Ok(event) => {
137
- sender.send(Ok(event)).unwrap();
138
- }
139
- Err(e) => println!("watch error: {:?}", e),
224
+
225
+ let event_fn = move |res: notify::Result<Event>| {
226
+ if let Ok(event) = res {
227
+ sender.send(Ok(event)).unwrap_or_else(|e| {
228
+ error!("Failed to send event: {}", e);
229
+ });
230
+ } else if let Err(e) = res {
231
+ error!("Watch error: {:?}", e);
140
232
  }
141
- }
233
+ };
234
+
235
+ let mut watched_paths = HashSet::new();
236
+ let mut watcher = notify::recommended_watcher(event_fn).expect("Failed to create watcher");
142
237
 
143
- let mut watched_dirs = HashSet::new();
144
- let mut watcher: RecommendedWatcher =
145
- notify::recommended_watcher(event_fn(sender)).expect("Failed to create watcher");
146
238
  for group in &groups {
147
- if watched_dirs.insert(group.base_dir.clone()) {
148
- debug!("Watching {}{}", group.base_dir.display(), group.pattern);
239
+ if watched_paths.insert(group.base_dir.clone()) {
240
+ let recursive = if group.pattern.is_empty() {
241
+ RecursiveMode::NonRecursive
242
+ } else {
243
+ RecursiveMode::Recursive
244
+ };
245
+
149
246
  watcher
150
- .watch(&group.base_dir, RecursiveMode::Recursive)
247
+ .watch(&group.base_dir, recursive)
151
248
  .expect("Failed to add watch");
152
249
  }
153
250
  }
154
251
 
155
- // Main event loop.
252
+ // Wait briefly to avoid initial event storm
253
+ thread::sleep(Duration::from_millis(100));
254
+
255
+ // State management
256
+ let mut recent_events: HashMap<(PathBuf, EventKind), Instant> = HashMap::new();
257
+ let restart_state = Arc::new(Mutex::new(None::<Instant>));
258
+
259
+ // Main event loop
156
260
  for res in rx {
157
261
  match res {
158
262
  Ok(event) => {
159
263
  if !matches!(event.kind, EventKind::Modify(ModifyKind::Data(_))) {
160
264
  continue;
161
265
  }
266
+
162
267
  let now = Instant::now();
268
+
269
+ // Skip startup events
270
+ if now.duration_since(startup_time) < Duration::from_millis(500) {
271
+ continue;
272
+ }
273
+
274
+ // Deduplicate events
275
+ let mut should_process = true;
276
+ for path in &event.paths {
277
+ let event_key = (path.clone(), event.kind);
278
+ if let Some(&last_seen) = recent_events.get(&event_key) {
279
+ if now.duration_since(last_seen) < EVENT_DEDUP_DURATION {
280
+ should_process = false;
281
+ break;
282
+ }
283
+ }
284
+ recent_events.insert(event_key, now);
285
+ }
286
+
287
+ if !should_process {
288
+ continue;
289
+ }
290
+
291
+ // Clean up old entries
292
+ recent_events
293
+ .retain(|_, &mut time| now.duration_since(time) < Duration::from_secs(1));
294
+
295
+ // Check restart state
296
+ let should_skip = {
297
+ let state = restart_state.lock();
298
+ if let Some(restart_time) = *state {
299
+ now.duration_since(restart_time) < Duration::from_millis(500)
300
+ } else {
301
+ false
302
+ }
303
+ };
304
+
305
+ if should_skip {
306
+ continue;
307
+ }
308
+
309
+ // Process commands from parent
310
+ let command_to_process = {
311
+ let mut command_guard = command_channel.lock();
312
+ let cmd = *command_guard;
313
+ *command_guard = None;
314
+ cmd
315
+ };
316
+
317
+ if let Some(cmd) = command_to_process {
318
+ match cmd {
319
+ WatcherCommand::ConfigError => {
320
+ info!("Received config error notification, resuming file watching");
321
+ *restart_state.lock() = None;
322
+ for group in &mut groups {
323
+ group.last_triggered = None;
324
+ }
325
+ recent_events.clear();
326
+ }
327
+ WatcherCommand::Continue => {
328
+ info!("Received continue notification, resuming file watching");
329
+ *restart_state.lock() = None;
330
+ }
331
+ WatcherCommand::Stop => { /* Handled in command thread */ }
332
+ }
333
+ }
334
+
335
+ // Process file events
163
336
  for group in &mut groups {
337
+ // Apply debounce
338
+ if let Some(last_triggered) = group.last_triggered {
339
+ if now.duration_since(last_triggered) < DEBOUNCE_DURATION {
340
+ continue;
341
+ }
342
+ }
343
+
164
344
  for path in event.paths.iter() {
165
- if let Ok(rel_path) = path.strip_prefix(&group.base_dir) {
166
- if group.glob_set.is_match(rel_path)
167
- || rel_path.to_str().is_some_and(|s| s == group.pattern)
168
- {
169
- debug!("Matched pattern: {:?}", group.pattern);
170
- // Check if we should debounce this event
171
- if let Some(last_triggered) = group.last_triggered {
172
- if now.duration_since(last_triggered) < DEBOUNCE_DURATION {
173
- // Skip this event as we've recently triggered for this pattern
174
- continue;
175
- }
345
+ let matches = if group.pattern.is_empty() {
346
+ path == &group.base_dir
347
+ } else if let Ok(rel_path) = path.strip_prefix(&group.base_dir) {
348
+ group.glob_set.is_match(rel_path)
349
+ } else {
350
+ false
351
+ };
352
+
353
+ if matches {
354
+ group.last_triggered = Some(now);
355
+
356
+ // Execute commands
357
+ for command in &group.commands {
358
+ if command.is_empty() {
359
+ continue;
176
360
  }
177
361
 
178
- // Update the last triggered time
179
- group.last_triggered = Some(now);
362
+ // Check for shell command or restart/reload
363
+ let is_shell_command = command.len() == 1
364
+ && (command[0].contains("&&")
365
+ || command[0].contains("||")
366
+ || command[0].contains("|")
367
+ || command[0].contains(";"));
180
368
 
181
- // Execute the commands for this group.
182
- for command in &group.commands {
183
- if command.is_empty() {
369
+ let is_restart = command
370
+ .windows(2)
371
+ .any(|w| w[0] == "itsi" && w[1] == "restart")
372
+ || (is_shell_command
373
+ && command[0].contains("itsi restart"));
374
+
375
+ let is_reload = command
376
+ .windows(2)
377
+ .any(|w| w[0] == "itsi" && w[1] == "reload")
378
+ || (is_shell_command && command[0].contains("itsi reload"));
379
+
380
+ // Handle restart/reload
381
+ if is_restart || is_reload {
382
+ let cmd_type =
383
+ if is_restart { "restart" } else { "reload" };
384
+ let mut should_run = false;
385
+
386
+ {
387
+ let mut state = restart_state.lock();
388
+ if let Some(last_time) = *state {
389
+ if now.duration_since(last_time)
390
+ < Duration::from_secs(3)
391
+ {
392
+ info!(
393
+ "Ignoring {} command - too soon",
394
+ cmd_type
395
+ );
396
+ } else {
397
+ *state = Some(now);
398
+ should_run = true;
399
+ }
400
+ } else {
401
+ *state = Some(now);
402
+ should_run = true;
403
+ }
404
+ }
405
+
406
+ if !should_run {
184
407
  continue;
185
408
  }
409
+
410
+ // Notify parent (optional)
411
+ let _ = write(&child_write_fd_clone, &[3]);
412
+ }
413
+
414
+ // Build and execute command
415
+ let mut cmd = if is_shell_command {
416
+ let mut shell_cmd = Command::new("sh");
417
+ shell_cmd.arg("-c").arg(command.join(" "));
418
+ shell_cmd
419
+ } else {
186
420
  let mut cmd = Command::new(&command[0]);
187
421
  if command.len() > 1 {
188
422
  cmd.args(&command[1..]);
189
423
  }
190
- debug!(
191
- "Executing command: {:?} due to change in {:?}",
192
- command, path
193
- );
194
- match cmd.spawn() {
195
- Ok(mut child) => {
196
- if let Err(e) = child.wait() {
197
- eprintln!(
198
- "Command {:?} failed: {:?}",
199
- command, e
200
- );
201
- }
424
+ cmd
425
+ };
426
+
427
+ match cmd.spawn() {
428
+ Ok(mut child) => {
429
+ if let Err(e) = child.wait() {
430
+ error!("Command {:?} failed: {:?}", command, e);
202
431
  }
203
- Err(e) => {
204
- eprintln!(
205
- "Failed to execute command {:?}: {:?}",
206
- command, e
207
- );
432
+
433
+ if is_restart || is_reload {
434
+ info!("Itsi command submitted, waiting for parent response");
435
+
436
+ // Set auto-recovery timer
437
+ let restart_state_clone =
438
+ Arc::clone(&restart_state);
439
+ let now_clone = now;
440
+ thread::spawn(move || {
441
+ thread::sleep(AUTO_RECOVERY_TIMEOUT);
442
+ let mut state = restart_state_clone.lock();
443
+ if let Some(restart_time) = *state {
444
+ if now_clone.duration_since(restart_time)
445
+ < Duration::from_secs(1)
446
+ {
447
+ info!("Auto-recovering from potential restart failure");
448
+ *state = None;
449
+ }
450
+ }
451
+ });
208
452
  }
209
453
  }
454
+ Err(e) => {
455
+ error!(
456
+ "Failed to execute command {:?}: {:?}",
457
+ command, e
458
+ );
459
+ }
210
460
  }
211
- break;
212
461
  }
462
+ break;
213
463
  }
214
464
  }
215
465
  }
216
466
  }
217
- Err(e) => println!("Watch error: {:?}", e),
467
+ Err(e) => error!("Watch error: {:?}", e),
218
468
  }
219
469
  }
220
470
 
221
- // Clean up the watches.
222
- for group in &groups {
223
- watcher
224
- .unwatch(&group.base_dir)
225
- .expect("Failed to remove watch");
226
- }
471
+ // Clean up
227
472
  drop(watcher);
228
473
  std::process::exit(0);
229
474
  } else {
230
- let _ = close(r_fd.into_raw_fd());
231
- Ok(Some(w_fd))
475
+ // Parent process - close the child ends of the pipes
476
+ let _ = close(child_read_fd.into_raw_fd());
477
+ let _ = close(child_write_fd.into_raw_fd());
478
+
479
+ // Create a paired structure to return
480
+ let watcher_pipes = WatcherPipes {
481
+ read_fd: parent_read_fd,
482
+ write_fd: parent_write_fd,
483
+ };
484
+
485
+ Ok(Some(watcher_pipes))
486
+ }
487
+ }
488
+
489
+ #[cfg(test)]
490
+ mod tests {
491
+ use std::env;
492
+
493
+ use super::*;
494
+
495
+ #[test]
496
+ fn test_extract_patterns() {
497
+ // Save current dir to restore later
498
+ let original_dir = env::current_dir().unwrap();
499
+
500
+ // Create a temp dir and work from there for consistent results
501
+ let temp_dir = env::temp_dir().join("itsi_test_patterns");
502
+ let _ = fs::create_dir_all(&temp_dir);
503
+ env::set_current_dir(&temp_dir).unwrap();
504
+
505
+ // Test glob patterns
506
+ let (base, pattern) = extract_and_canonicalize_base_dir("assets/*/**.tsx");
507
+ assert!(base.ends_with("assets"));
508
+ assert_eq!(pattern, "*/**.tsx");
509
+
510
+ let (base, pattern) = extract_and_canonicalize_base_dir("./assets/*/**.tsx");
511
+ assert!(base.ends_with("assets"));
512
+ assert_eq!(pattern, "*/**.tsx");
513
+
514
+ // Test non-glob patterns - exact files should have empty pattern
515
+ let (base, pattern) = extract_and_canonicalize_base_dir("foo/bar.txt");
516
+ assert!(base.ends_with("bar.txt"));
517
+ assert_eq!(pattern, "");
518
+
519
+ // Test current directory patterns
520
+ let (base, pattern) = extract_and_canonicalize_base_dir("*.txt");
521
+ assert_eq!(base, temp_dir.canonicalize().unwrap());
522
+ assert_eq!(pattern, "*.txt");
523
+
524
+ // Test file in current directory
525
+ let (base, pattern) = extract_and_canonicalize_base_dir("test.txt");
526
+ assert!(base.ends_with("test.txt"));
527
+ assert_eq!(pattern, "");
528
+
529
+ // Restore original directory and clean up
530
+ env::set_current_dir(original_dir).unwrap();
531
+ let _ = fs::remove_dir_all(&temp_dir);
532
+ }
533
+
534
+ #[test]
535
+ fn test_watcher_commands() {
536
+ assert_eq!(serialize_command(WatcherCommand::Stop), 0);
537
+ assert_eq!(serialize_command(WatcherCommand::ConfigError), 1);
538
+ assert_eq!(serialize_command(WatcherCommand::Continue), 2);
539
+
540
+ assert_eq!(deserialize_command(0), Some(WatcherCommand::Stop));
541
+ assert_eq!(deserialize_command(1), Some(WatcherCommand::ConfigError));
542
+ assert_eq!(deserialize_command(2), Some(WatcherCommand::Continue));
543
+ assert_eq!(deserialize_command(99), None);
232
544
  }
233
545
  }