itsi-server 0.1.11 → 0.1.12

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +7 -0
  4. data/Cargo.lock +1536 -45
  5. data/README.md +4 -0
  6. data/_index.md +6 -0
  7. data/exe/itsi +33 -74
  8. data/ext/itsi_error/src/lib.rs +9 -0
  9. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
  10. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
  11. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
  12. data/ext/itsi_error/target/debug/build/rb-sys-49f554618693db24/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
  13. data/ext/itsi_error/target/debug/incremental/itsi_error-1mmt5sux7jb0i/s-h510z7m8v9-0bxu7yd.lock +0 -0
  14. data/ext/itsi_error/target/debug/incremental/itsi_error-2vn3jey74oiw0/s-h5113n0e7e-1v5qzs6.lock +0 -0
  15. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510ykifhe-0tbnep2.lock +0 -0
  16. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510yyocpj-0tz7ug7.lock +0 -0
  17. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510z0xc8g-14ol18k.lock +0 -0
  18. data/ext/itsi_error/target/debug/incremental/itsi_error-3g5qf4y7d54uj/s-h5113n0e7d-1trk8on.lock +0 -0
  19. data/ext/itsi_error/target/debug/incremental/itsi_error-3lpfftm45d3e2/s-h510z7m8r3-1pxp20o.lock +0 -0
  20. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510ykifek-1uxasnk.lock +0 -0
  21. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510yyocki-11u37qm.lock +0 -0
  22. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510z0xc93-0pmy0zm.lock +0 -0
  23. data/ext/itsi_rb_helpers/Cargo.toml +1 -0
  24. data/ext/itsi_rb_helpers/src/heap_value.rs +18 -0
  25. data/ext/itsi_rb_helpers/src/lib.rs +34 -7
  26. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
  27. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
  28. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
  29. data/ext/itsi_rb_helpers/target/debug/build/rb-sys-eb9ed4ff3a60f995/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
  30. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-040pxg6yhb3g3/s-h5113n7a1b-03bwlt4.lock +0 -0
  31. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h51113xnh3-1eik1ip.lock +0 -0
  32. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h5111704jj-0g4rj8x.lock +0 -0
  33. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-1q2d3drtxrzs5/s-h5113n79yl-0bxcqc5.lock +0 -0
  34. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h51113xoox-10de2hp.lock +0 -0
  35. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h5111704w7-0vdq7gq.lock +0 -0
  36. data/ext/itsi_server/Cargo.toml +69 -30
  37. data/ext/itsi_server/src/lib.rs +79 -147
  38. data/ext/itsi_server/src/{body_proxy → ruby_types/itsi_body_proxy}/big_bytes.rs +10 -5
  39. data/ext/itsi_server/src/{body_proxy/itsi_body_proxy.rs → ruby_types/itsi_body_proxy/mod.rs} +22 -3
  40. data/ext/itsi_server/src/ruby_types/itsi_grpc_request.rs +147 -0
  41. data/ext/itsi_server/src/ruby_types/itsi_grpc_response.rs +19 -0
  42. data/ext/itsi_server/src/ruby_types/itsi_grpc_stream/mod.rs +216 -0
  43. data/ext/itsi_server/src/{request/itsi_request.rs → ruby_types/itsi_http_request.rs} +101 -117
  44. data/ext/itsi_server/src/{response/itsi_response.rs → ruby_types/itsi_http_response.rs} +72 -41
  45. data/ext/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +225 -0
  46. data/ext/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +355 -0
  47. data/ext/itsi_server/src/ruby_types/itsi_server.rs +82 -0
  48. data/ext/itsi_server/src/ruby_types/mod.rs +55 -0
  49. data/ext/itsi_server/src/server/bind.rs +13 -5
  50. data/ext/itsi_server/src/server/byte_frame.rs +32 -0
  51. data/ext/itsi_server/src/server/cache_store.rs +74 -0
  52. data/ext/itsi_server/src/server/itsi_service.rs +172 -0
  53. data/ext/itsi_server/src/server/lifecycle_event.rs +3 -0
  54. data/ext/itsi_server/src/server/listener.rs +102 -2
  55. data/ext/itsi_server/src/server/middleware_stack/middleware.rs +153 -0
  56. data/ext/itsi_server/src/server/middleware_stack/middlewares/allow_list.rs +47 -0
  57. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_api_key.rs +58 -0
  58. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +82 -0
  59. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_jwt.rs +321 -0
  60. data/ext/itsi_server/src/server/middleware_stack/middlewares/cache_control.rs +139 -0
  61. data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +300 -0
  62. data/ext/itsi_server/src/server/middleware_stack/middlewares/cors.rs +287 -0
  63. data/ext/itsi_server/src/server/middleware_stack/middlewares/deny_list.rs +48 -0
  64. data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +127 -0
  65. data/ext/itsi_server/src/server/middleware_stack/middlewares/etag.rs +191 -0
  66. data/ext/itsi_server/src/server/middleware_stack/middlewares/grpc_service.rs +72 -0
  67. data/ext/itsi_server/src/server/middleware_stack/middlewares/header_interpretation.rs +85 -0
  68. data/ext/itsi_server/src/server/middleware_stack/middlewares/intrusion_protection.rs +195 -0
  69. data/ext/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +82 -0
  70. data/ext/itsi_server/src/server/middleware_stack/middlewares/mod.rs +82 -0
  71. data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +216 -0
  72. data/ext/itsi_server/src/server/middleware_stack/middlewares/rate_limit.rs +124 -0
  73. data/ext/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +76 -0
  74. data/ext/itsi_server/src/server/middleware_stack/middlewares/request_headers.rs +43 -0
  75. data/ext/itsi_server/src/server/middleware_stack/middlewares/response_headers.rs +34 -0
  76. data/ext/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +93 -0
  77. data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +162 -0
  78. data/ext/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +158 -0
  79. data/ext/itsi_server/src/server/middleware_stack/middlewares/token_source.rs +12 -0
  80. data/ext/itsi_server/src/server/middleware_stack/mod.rs +315 -0
  81. data/ext/itsi_server/src/server/mod.rs +8 -1
  82. data/ext/itsi_server/src/server/process_worker.rs +38 -12
  83. data/ext/itsi_server/src/server/rate_limiter.rs +565 -0
  84. data/ext/itsi_server/src/server/request_job.rs +11 -0
  85. data/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +119 -42
  86. data/ext/itsi_server/src/server/serve_strategy/mod.rs +9 -6
  87. data/ext/itsi_server/src/server/serve_strategy/single_mode.rs +256 -111
  88. data/ext/itsi_server/src/server/signal.rs +19 -0
  89. data/ext/itsi_server/src/server/static_file_server.rs +984 -0
  90. data/ext/itsi_server/src/server/thread_worker.rs +139 -94
  91. data/ext/itsi_server/src/server/types.rs +43 -0
  92. data/ext/itsi_tracing/Cargo.toml +1 -0
  93. data/ext/itsi_tracing/src/lib.rs +216 -45
  94. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0994n8rpvvt9m/s-h510hfz1f6-1kbycmq.lock +0 -0
  95. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0bob7bf4yq34i/s-h5113125h5-0lh4rag.lock +0 -0
  96. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2fcodulrxbbxo/s-h510h2infk-0hp5kjw.lock +0 -0
  97. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2iak63r1woi1l/s-h510h2in4q-0kxfzw1.lock +0 -0
  98. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2kk4qj9gn5dg2/s-h5113124kv-0enwon2.lock +0 -0
  99. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2mwo0yas7dtw4/s-h510hfz1ha-1udgpei.lock +0 -0
  100. data/lib/itsi/{request.rb → http_request.rb} +29 -5
  101. data/lib/itsi/http_response.rb +39 -0
  102. data/lib/itsi/server/Itsi.rb +11 -19
  103. data/lib/itsi/server/config/dsl.rb +506 -0
  104. data/lib/itsi/server/config.rb +103 -8
  105. data/lib/itsi/server/default_app/default_app.rb +38 -0
  106. data/lib/itsi/server/grpc_interface.rb +213 -0
  107. data/lib/itsi/server/rack/handler/itsi.rb +8 -17
  108. data/lib/itsi/server/rack_interface.rb +23 -4
  109. data/lib/itsi/server/scheduler_interface.rb +1 -1
  110. data/lib/itsi/server/scheduler_mode.rb +4 -0
  111. data/lib/itsi/server/signal_trap.rb +7 -1
  112. data/lib/itsi/server/version.rb +1 -1
  113. data/lib/itsi/server.rb +74 -63
  114. data/lib/itsi/standard_headers.rb +86 -0
  115. metadata +84 -15
  116. data/ext/itsi_scheduler/extconf.rb +0 -6
  117. data/ext/itsi_server/src/body_proxy/mod.rs +0 -2
  118. data/ext/itsi_server/src/request/mod.rs +0 -1
  119. data/ext/itsi_server/src/response/mod.rs +0 -1
  120. data/ext/itsi_server/src/server/itsi_server.rs +0 -288
  121. data/lib/itsi/server/options_dsl.rb +0 -401
  122. data/lib/itsi/stream_io.rb +0 -38
  123. /data/lib/itsi/{index.html → server/default_app/index.html} +0 -0
@@ -13,7 +13,7 @@ use itsi_tracing::error;
13
13
  use magnus::error::Result as MagnusResult;
14
14
  use parking_lot::RwLock;
15
15
  use std::{
16
- convert::Infallible,
16
+ collections::HashMap,
17
17
  io,
18
18
  os::{fd::FromRawFd, unix::net::UnixStream},
19
19
  str::FromStr,
@@ -31,31 +31,32 @@ use tokio_stream::wrappers::ReceiverStream;
31
31
  use tokio_util::io::ReaderStream;
32
32
  use tracing::warn;
33
33
 
34
- use crate::server::serve_strategy::single_mode::RunningPhase;
34
+ use crate::server::{
35
+ byte_frame::ByteFrame, serve_strategy::single_mode::RunningPhase, types::HttpResponse,
36
+ };
35
37
 
36
- #[magnus::wrap(class = "Itsi::Response", free_immediately, size)]
38
+ #[magnus::wrap(class = "Itsi::HttpResponse", free_immediately, size)]
37
39
  #[derive(Debug, Clone)]
38
- pub struct ItsiResponse {
40
+ pub struct ItsiHttpResponse {
39
41
  pub data: Arc<ResponseData>,
40
- pub accept: String,
41
42
  }
42
43
 
43
44
  #[derive(Debug)]
44
45
  pub struct ResponseData {
45
- pub response: RwLock<Option<Response<BoxBody<Bytes, Infallible>>>>,
46
- pub response_writer: RwLock<Option<mpsc::Sender<Option<Bytes>>>>,
46
+ pub response: RwLock<Option<HttpResponse>>,
47
+ pub response_writer: RwLock<Option<mpsc::Sender<ByteFrame>>>,
47
48
  pub response_buffer: RwLock<BytesMut>,
48
49
  pub hijacked_socket: RwLock<Option<UnixStream>>,
49
50
  pub parts: Parts,
50
51
  }
51
52
 
52
- impl ItsiResponse {
53
+ impl ItsiHttpResponse {
53
54
  pub async fn build(
54
55
  &self,
55
- first_frame: Option<Bytes>,
56
- receiver: mpsc::Receiver<Option<Bytes>>,
56
+ first_frame: ByteFrame,
57
+ receiver: mpsc::Receiver<ByteFrame>,
57
58
  shutdown_rx: watch::Receiver<RunningPhase>,
58
- ) -> Response<BoxBody<Bytes, Infallible>> {
59
+ ) -> HttpResponse {
59
60
  if self.is_hijacked() {
60
61
  return match self.process_hijacked_response().await {
61
62
  Ok(result) => result,
@@ -65,14 +66,13 @@ impl ItsiResponse {
65
66
  }
66
67
  };
67
68
  }
68
-
69
69
  let mut response = self.data.response.write().take().unwrap();
70
- *response.body_mut() = if first_frame.is_none() {
70
+ *response.body_mut() = if matches!(first_frame, ByteFrame::Empty) {
71
71
  BoxBody::new(Empty::new())
72
- } else if receiver.is_closed() && receiver.is_empty() {
73
- BoxBody::new(Full::new(first_frame.unwrap()))
72
+ } else if matches!(first_frame, ByteFrame::End(_)) {
73
+ BoxBody::new(Full::new(first_frame.into()))
74
74
  } else {
75
- let initial_frame = tokio_stream::once(Ok(Frame::data(first_frame.unwrap())));
75
+ let initial_frame = tokio_stream::once(Ok(Frame::data(Bytes::from(first_frame))));
76
76
  let frame_stream = unfold(
77
77
  (ReceiverStream::new(receiver), shutdown_rx),
78
78
  |(mut receiver, mut shutdown_rx)| async move {
@@ -82,13 +82,14 @@ impl ItsiResponse {
82
82
  loop {
83
83
  tokio::select! {
84
84
  maybe_bytes = receiver.next() => {
85
- if let Some(bytes) = maybe_bytes {
86
- // We assume `bytes` is Some(Bytes) here.
87
- return Some((Ok(Frame::data(bytes.unwrap())), (receiver, shutdown_rx)));
88
- } else {
89
- // Receiver closed, end the stream.
90
- return None;
85
+ match maybe_bytes {
86
+ Some(ByteFrame::Data(bytes)) | Some(ByteFrame::End(bytes)) => {
87
+ return Some((Ok(Frame::data(bytes)), (receiver, shutdown_rx)));
91
88
  }
89
+ _ => {
90
+ return None;
91
+ }
92
+ }
92
93
  },
93
94
  _ = shutdown_rx.changed() => {
94
95
  match *shutdown_rx.borrow() {
@@ -184,7 +185,7 @@ impl ItsiResponse {
184
185
  Ok((headers, status, requires_upgrade, reader))
185
186
  }
186
187
 
187
- pub async fn process_hijacked_response(&self) -> Result<Response<BoxBody<Bytes, Infallible>>> {
188
+ pub async fn process_hijacked_response(&self) -> Result<HttpResponse> {
188
189
  let (headers, status, requires_upgrade, reader) = self.read_hijacked_headers().await?;
189
190
  let mut response = if requires_upgrade {
190
191
  let parts = self.data.parts.clone();
@@ -261,28 +262,31 @@ impl ItsiResponse {
261
262
  }
262
263
  }
263
264
 
264
- pub fn send_frame(&self, frame: Bytes) -> MagnusResult<usize> {
265
- self.send_frame_into(frame, &self.data.response_writer)
265
+ pub fn send_frame(&self, frame: Bytes) -> MagnusResult<()> {
266
+ self.send_frame_into(ByteFrame::Data(frame), &self.data.response_writer)
266
267
  }
267
268
 
268
- pub fn send_and_close(&self, frame: Bytes) -> MagnusResult<usize> {
269
- let result = self.send_frame_into(frame, &self.data.response_writer);
269
+ pub fn recv_frame(&self) {
270
+ // not implemented
271
+ }
272
+
273
+ pub fn send_and_close(&self, frame: Bytes) -> MagnusResult<()> {
274
+ let result = self.send_frame_into(ByteFrame::End(frame), &self.data.response_writer);
270
275
  self.data.response_writer.write().take();
271
276
  result
272
277
  }
273
278
 
274
279
  pub fn send_frame_into(
275
280
  &self,
276
- frame: Bytes,
277
- writer: &RwLock<Option<mpsc::Sender<Option<Bytes>>>>,
278
- ) -> MagnusResult<usize> {
281
+ frame: ByteFrame,
282
+ writer: &RwLock<Option<mpsc::Sender<ByteFrame>>>,
283
+ ) -> MagnusResult<()> {
279
284
  if let Some(writer) = writer.write().as_ref() {
280
- writer
281
- .blocking_send(Some(frame))
282
- .map_err(|_| itsi_error::ItsiError::ClientConnectionClosed)
283
- .ok();
285
+ return Ok(writer
286
+ .blocking_send(frame)
287
+ .map_err(|_| itsi_error::ItsiError::ClientConnectionClosed)?);
284
288
  }
285
- Ok(0)
289
+ Ok(())
286
290
  }
287
291
 
288
292
  pub fn is_hijacked(&self) -> bool {
@@ -293,20 +297,28 @@ impl ItsiResponse {
293
297
  self.data.response_writer.write().take();
294
298
  Ok(true)
295
299
  }
300
+ fn accept_str(&self) -> &str {
301
+ self.data
302
+ .parts
303
+ .headers
304
+ .get("Content-Type")
305
+ .and_then(|hv| hv.to_str().ok()) // handle invalid utf-8
306
+ .unwrap_or("application/x-www-form-urlencoded")
307
+ }
296
308
 
297
309
  pub fn is_html(&self) -> bool {
298
- self.accept.starts_with("text/html")
310
+ self.accept_str().starts_with("text/html")
299
311
  }
300
312
 
301
313
  pub fn is_json(&self) -> bool {
302
- self.accept.starts_with("application/json")
314
+ self.accept_str().starts_with("application/json")
303
315
  }
304
316
 
305
317
  pub fn close_read(&self) -> MagnusResult<bool> {
306
318
  Ok(true)
307
319
  }
308
320
 
309
- pub fn new(parts: Parts, response_writer: mpsc::Sender<Option<Bytes>>, accept: String) -> Self {
321
+ pub fn new(parts: Parts, response_writer: mpsc::Sender<ByteFrame>) -> Self {
310
322
  Self {
311
323
  data: Arc::new(ResponseData {
312
324
  response: RwLock::new(Some(Response::new(BoxBody::new(Empty::new())))),
@@ -315,7 +327,6 @@ impl ItsiResponse {
315
327
  hijacked_socket: RwLock::new(None),
316
328
  parts,
317
329
  }),
318
- accept,
319
330
  }
320
331
  }
321
332
 
@@ -330,6 +341,26 @@ impl ItsiResponse {
330
341
  Ok(())
331
342
  }
332
343
 
344
+ pub fn add_headers(&self, headers: HashMap<Bytes, Vec<Bytes>>) -> MagnusResult<()> {
345
+ if let Some(ref mut resp) = *self.data.response.write() {
346
+ let headers_mut = resp.headers_mut();
347
+ for (name, values) in headers {
348
+ let header_name = HeaderName::from_bytes(&name).map_err(|e| {
349
+ itsi_error::ItsiError::InvalidInput(format!(
350
+ "Invalid header name {:?}: {:?}",
351
+ name, e
352
+ ))
353
+ })?;
354
+ for value in values {
355
+ let header_value = unsafe { HeaderValue::from_maybe_shared_unchecked(value) };
356
+ headers_mut.insert(&header_name, header_value);
357
+ }
358
+ }
359
+ }
360
+
361
+ Ok(())
362
+ }
363
+
333
364
  pub fn set_status(&self, status: u16) -> MagnusResult<()> {
334
365
  if let Some(ref mut resp) = *self.data.response.write() {
335
366
  *resp.status_mut() = StatusCode::from_u16(status).map_err(|e| {
@@ -348,8 +379,8 @@ impl ItsiResponse {
348
379
  *self.data.hijacked_socket.write() = Some(stream);
349
380
  if let Some(writer) = self.data.response_writer.write().as_ref() {
350
381
  writer
351
- .blocking_send(None)
352
- .map_err(|_| itsi_error::ItsiError::ClientConnectionClosed)?;
382
+ .blocking_send(ByteFrame::Empty)
383
+ .map_err(|_| itsi_error::ItsiError::ClientConnectionClosed)?
353
384
  }
354
385
  self.close();
355
386
  Ok(())
@@ -0,0 +1,225 @@
1
+ use derive_more::Debug;
2
+ use globset::{Glob, GlobSet, GlobSetBuilder};
3
+ use magnus::error::Result;
4
+ use nix::unistd::{close, fork, pipe, read};
5
+ use notify::{event::ModifyKind, EventKind, RecommendedWatcher};
6
+ use notify::{Event, RecursiveMode, Watcher};
7
+ use std::path::Path;
8
+ use std::sync::mpsc::Sender;
9
+ use std::time::{Duration, Instant};
10
+ use std::{collections::HashSet, fs};
11
+ use std::{
12
+ os::fd::{AsRawFd, IntoRawFd, OwnedFd},
13
+ path::PathBuf,
14
+ process::Command,
15
+ sync::mpsc,
16
+ thread::{self},
17
+ };
18
+
19
+ /// Represents a set of patterns and commands.
20
+ #[derive(Debug, Clone)]
21
+ struct PatternGroup {
22
+ base_dir: PathBuf,
23
+ glob_set: GlobSet,
24
+ pattern: String,
25
+ commands: Vec<Vec<String>>,
26
+ last_triggered: Option<Instant>,
27
+ }
28
+
29
+ /// Extracts the base directory from a wildcard pattern by taking the portion up to the first
30
+ /// component that contains a wildcard character.
31
+ fn extract_and_canonicalize_base_dir(pattern: &str) -> PathBuf {
32
+ if !(pattern.contains("*") || pattern.contains("?") || pattern.contains('[')) {
33
+ if let Ok(metadata) = fs::metadata(pattern) {
34
+ if metadata.is_dir() {
35
+ return fs::canonicalize(pattern).unwrap();
36
+ }
37
+ if metadata.is_file() {
38
+ return fs::canonicalize(pattern)
39
+ .unwrap()
40
+ .parent()
41
+ .unwrap()
42
+ .to_path_buf();
43
+ }
44
+ }
45
+ }
46
+
47
+ let path = Path::new(pattern);
48
+ let mut base = PathBuf::new();
49
+ for comp in path.components() {
50
+ let comp_str = comp.as_os_str().to_string_lossy();
51
+ if comp_str.contains('*') || comp_str.contains('?') || comp_str.contains('[') {
52
+ break;
53
+ } else {
54
+ base.push(comp);
55
+ }
56
+ }
57
+ // If no base was built, default to "."
58
+ let base = if base.as_os_str().is_empty() || !base.exists() {
59
+ PathBuf::from(".")
60
+ } else {
61
+ base
62
+ };
63
+
64
+ // Canonicalize to get the absolute path.
65
+ fs::canonicalize(&base).unwrap_or(base)
66
+ }
67
+
68
+ /// Minimum time between triggering the same pattern group (debounce time)
69
+ const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);
70
+
71
+ pub fn watch_groups(pattern_groups: Vec<(String, Vec<Vec<String>>)>) -> Result<Option<OwnedFd>> {
72
+ let (r_fd, w_fd): (OwnedFd, OwnedFd) = pipe().map_err(|e| {
73
+ magnus::Error::new(
74
+ magnus::exception::exception(),
75
+ format!("Failed to create watcher pipe: {}", e),
76
+ )
77
+ })?;
78
+
79
+ let fork_result = unsafe {
80
+ fork().map_err(|e| {
81
+ magnus::Error::new(
82
+ magnus::exception::exception(),
83
+ format!("Failed to fork file watcher: {}", e),
84
+ )
85
+ })
86
+ }?;
87
+
88
+ if fork_result.is_child() {
89
+ let _ = close(w_fd.into_raw_fd());
90
+ thread::spawn(move || {
91
+ let mut buf = [0u8; 1];
92
+ loop {
93
+ match read(r_fd.as_raw_fd(), &mut buf) {
94
+ Ok(0) => {
95
+ std::process::exit(0);
96
+ }
97
+ Ok(_) => {}
98
+ Err(_) => {
99
+ std::process::exit(0);
100
+ }
101
+ }
102
+ }
103
+ });
104
+
105
+ let mut groups = Vec::new();
106
+ for (pattern, commands) in pattern_groups.into_iter() {
107
+ let base_dir = extract_and_canonicalize_base_dir(&pattern);
108
+ let glob = Glob::new(&pattern).map_err(|e| {
109
+ magnus::Error::new(
110
+ magnus::exception::exception(),
111
+ format!("Failed to create watch glob: {}", e),
112
+ )
113
+ })?;
114
+ let glob_set = GlobSetBuilder::new().add(glob).build().map_err(|e| {
115
+ magnus::Error::new(
116
+ magnus::exception::exception(),
117
+ format!("Failed to create watch glob set: {}", e),
118
+ )
119
+ })?;
120
+ groups.push(PatternGroup {
121
+ base_dir,
122
+ glob_set,
123
+ pattern,
124
+ commands,
125
+ last_triggered: None,
126
+ });
127
+ }
128
+
129
+ // Create a channel and a watcher.
130
+ let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
131
+ let sender = tx.clone();
132
+ fn event_fn(sender: Sender<notify::Result<Event>>) -> impl Fn(notify::Result<Event>) {
133
+ move |res| match res {
134
+ Ok(event) => {
135
+ sender.send(Ok(event)).unwrap();
136
+ }
137
+ Err(e) => println!("watch error: {:?}", e),
138
+ }
139
+ }
140
+
141
+ let mut watched_dirs = HashSet::new();
142
+ let mut watcher: RecommendedWatcher =
143
+ notify::recommended_watcher(event_fn(sender)).expect("Failed to create watcher");
144
+ for group in &groups {
145
+ if watched_dirs.insert(group.base_dir.clone()) {
146
+ watcher
147
+ .watch(&group.base_dir, RecursiveMode::Recursive)
148
+ .expect("Failed to add watch");
149
+ }
150
+ }
151
+
152
+ // Main event loop.
153
+ for res in rx {
154
+ match res {
155
+ Ok(event) => {
156
+ if !matches!(event.kind, EventKind::Modify(ModifyKind::Metadata(_))) {
157
+ continue;
158
+ }
159
+ let now = Instant::now();
160
+ for group in &mut groups {
161
+ for path in event.paths.iter() {
162
+ if let Ok(rel_path) = path.strip_prefix(&group.base_dir) {
163
+ if group.glob_set.is_match(rel_path)
164
+ || rel_path.to_str().is_some_and(|s| s == group.pattern)
165
+ {
166
+ // Check if we should debounce this event
167
+ if let Some(last_triggered) = group.last_triggered {
168
+ if now.duration_since(last_triggered) < DEBOUNCE_DURATION {
169
+ // Skip this event as we've recently triggered for this pattern
170
+ continue;
171
+ }
172
+ }
173
+
174
+ // Update the last triggered time
175
+ group.last_triggered = Some(now);
176
+
177
+ // Execute the commands for this group.
178
+ for command in &group.commands {
179
+ if command.is_empty() {
180
+ continue;
181
+ }
182
+ let mut cmd = Command::new(&command[0]);
183
+ if command.len() > 1 {
184
+ cmd.args(&command[1..]);
185
+ }
186
+ match cmd.spawn() {
187
+ Ok(mut child) => {
188
+ if let Err(e) = child.wait() {
189
+ eprintln!(
190
+ "Command {:?} failed: {:?}",
191
+ command, e
192
+ );
193
+ }
194
+ }
195
+ Err(e) => {
196
+ eprintln!(
197
+ "Failed to execute command {:?}: {:?}",
198
+ command, e
199
+ );
200
+ }
201
+ }
202
+ }
203
+ break;
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ Err(e) => println!("Watch error: {:?}", e),
210
+ }
211
+ }
212
+
213
+ // Clean up the watches.
214
+ for group in &groups {
215
+ watcher
216
+ .unwatch(&group.base_dir)
217
+ .expect("Failed to remove watch");
218
+ }
219
+ drop(watcher);
220
+ std::process::exit(0);
221
+ } else {
222
+ let _ = close(r_fd.into_raw_fd());
223
+ Ok(Some(w_fd))
224
+ }
225
+ }