watchcat 0.2.0 → 0.6.0

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.
@@ -1,191 +1,241 @@
1
1
  use crossbeam_channel::{select, unbounded};
2
2
  use magnus::{
3
- block::{block_given, yield_value},
4
- class::object,
5
- define_module, function, method,
3
+ function, method,
6
4
  scan_args::{get_kwargs, scan_args},
7
- Error, Module, Object, Value,
5
+ Error, Module, Object, Value, Ruby
8
6
  };
9
7
  use notify::{Config, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
10
- use notify_debouncer_mini::new_debouncer;
11
- use std::{path::Path, time::Duration};
8
+ use std::{path::Path, time::Duration, sync::{Arc, atomic::{AtomicBool, Ordering}}};
12
9
 
13
10
  mod event;
11
+ mod gvl_helpers;
14
12
  use crate::event::WatchatEvent;
13
+ use crate::gvl_helpers::{call_with_gvl, call_without_gvl};
15
14
 
16
15
  #[magnus::wrap(class = "Watchcat::Watcher")]
17
16
  struct WatchcatWatcher {
18
17
  tx: crossbeam_channel::Sender<bool>,
19
18
  rx: crossbeam_channel::Receiver<bool>,
19
+ terminated: Arc<AtomicBool>,
20
+ cmd_tx: crossbeam_channel::Sender<Command>,
21
+ cmd_rx: crossbeam_channel::Receiver<Command>,
20
22
  }
21
23
 
22
24
  #[derive(Debug)]
23
25
  enum WatcherEnum {
24
- #[allow(dead_code)]
25
26
  Poll(PollWatcher),
26
- #[allow(dead_code)]
27
27
  Recommended(RecommendedWatcher),
28
28
  }
29
29
 
30
+ fn watcher_watch(w: &mut WatcherEnum, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
31
+ match w {
32
+ WatcherEnum::Poll(x) => x.watch(path, mode),
33
+ WatcherEnum::Recommended(x) => x.watch(path, mode),
34
+ }
35
+ }
36
+
37
+ fn watcher_unwatch(w: &mut WatcherEnum, path: &Path) -> notify::Result<()> {
38
+ match w {
39
+ WatcherEnum::Poll(x) => x.unwatch(path),
40
+ WatcherEnum::Recommended(x) => x.unwatch(path),
41
+ }
42
+ }
43
+
44
+ // Carries a failure out of the GVL-released section without touching Ruby.
45
+ // `magnus::Error` (and the `Ruby` handle needed to build one) must only be
46
+ // used while the GVL is held, so the actual `magnus::Error` is constructed
47
+ // after control returns from `call_without_gvl`.
48
+ enum WatchFailure {
49
+ Arg(String),
50
+ Runtime(String),
51
+ }
52
+
53
+ enum Command {
54
+ Watch(Vec<String>, bool), // paths, recursive
55
+ Unwatch(Vec<String>), // paths
56
+ }
57
+
30
58
  impl WatchcatWatcher {
31
59
  fn new() -> Self {
32
60
  let (tx_executor, rx_executor) = unbounded::<bool>();
61
+ let (cmd_tx, cmd_rx) = unbounded::<Command>();
33
62
  Self {
34
63
  tx: tx_executor,
35
64
  rx: rx_executor,
65
+ terminated: Arc::new(AtomicBool::new(false)),
66
+ cmd_tx,
67
+ cmd_rx,
36
68
  }
37
69
  }
38
70
 
39
71
  fn close(&self) {
40
- self.tx.send(true).unwrap()
72
+ self.terminated.store(true, Ordering::SeqCst);
73
+ // See `add`/`unwatch`: `send` cannot fail while `self` retains `rx`,
74
+ // but `.unwrap()` would still turn a hypothetical failure into a
75
+ // Rust panic, and a panic crossing the FFI boundary aborts the whole
76
+ // process instead of raising in Ruby. Not worth the risk for a
77
+ // result we already know.
78
+ let _ = self.tx.send(true);
41
79
  }
42
80
 
43
81
  fn watch(&self, args: &[Value]) -> Result<bool, Error> {
44
- if !block_given() {
45
- return Err(Error::new(magnus::exception::arg_error(), "no block given"));
82
+ let ruby = unsafe { Ruby::get_unchecked() };
83
+ let ruby_ref = &ruby;
84
+ if !ruby_ref.block_given() {
85
+ return Err(Error::new(ruby_ref.exception_arg_error(), "no block given"));
46
86
  }
47
87
 
48
- let (pathnames, recursive, force_polling, poll_interval, ignore_remove, debounce) = Self::parse_args(args)?;
88
+ let (pathnames, recursive, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify) = Self::parse_args(args)?;
49
89
  let mode = if recursive {
50
90
  RecursiveMode::Recursive
51
91
  } else {
52
92
  RecursiveMode::NonRecursive
53
93
  };
54
94
 
55
- if debounce >= 0 {
56
- self.watch_with_debounce(pathnames, mode, ignore_remove, debounce)
57
- } else {
58
- self.watch_without_debounce(pathnames, mode, force_polling, poll_interval, ignore_remove)
59
- }
95
+ let terminated = self.terminated.clone();
96
+ let rx_clone = self.rx.clone();
97
+ let cmd_rx = self.cmd_rx.clone();
98
+
99
+ Self::watch_threaded(
100
+ pathnames, mode, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify, terminated, rx_clone, cmd_rx, ruby_ref
101
+ )
60
102
  }
61
103
 
62
- fn watch_without_debounce(&self, pathnames: Vec<String>, mode: RecursiveMode, force_polling: bool, poll_interval: u64, ignore_remove: bool) -> Result<bool, Error> {
63
- let (tx, rx) = unbounded();
64
- // This variable is needed to keep `watcher` active.
65
- let _watcher = match force_polling {
66
- true => {
67
- let delay = Duration::from_millis(poll_interval);
68
- let config = notify::Config::default().with_poll_interval(delay);
69
- let mut watcher = PollWatcher::new(tx, config)
70
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
71
- for pathname in &pathnames {
72
- let path = Path::new(pathname);
73
- watcher
74
- .watch(path, mode)
75
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
104
+ #[allow(clippy::too_many_arguments)]
105
+ fn watch_threaded(
106
+ pathnames: Vec<String>,
107
+ mode: RecursiveMode,
108
+ force_polling: bool,
109
+ poll_interval: u64,
110
+ ignore_remove: bool,
111
+ ignore_access: bool,
112
+ ignore_create: bool,
113
+ ignore_modify: bool,
114
+ terminated: Arc<AtomicBool>,
115
+ rx: crossbeam_channel::Receiver<bool>,
116
+ cmd_rx: crossbeam_channel::Receiver<Command>,
117
+ ruby: &Ruby
118
+ ) -> Result<bool, Error> {
119
+ // `ruby` (and any `magnus::Error`/`ExceptionClass` built from it) must only be
120
+ // touched while the GVL is held, so it is intentionally NOT captured by the
121
+ // `call_without_gvl` closure below. Failures are carried out as plain
122
+ // `WatchFailure` values and converted to a real `magnus::Error` afterwards,
123
+ // once control has returned here with the GVL held again.
124
+ let result: Result<bool, WatchFailure> = call_without_gvl(move || {
125
+ let (tx, watcher_rx) = unbounded();
126
+ // This variable is needed to keep `watcher` active.
127
+ let mut _watcher = match force_polling {
128
+ true => {
129
+ let delay = Duration::from_millis(poll_interval);
130
+ let config = notify::Config::default().with_poll_interval(delay);
131
+ let mut watcher = PollWatcher::new(tx, config)
132
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
133
+ for pathname in &pathnames {
134
+ let path = Path::new(pathname);
135
+ watcher
136
+ .watch(path, mode)
137
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
138
+ }
139
+ WatcherEnum::Poll(watcher)
76
140
  }
77
- WatcherEnum::Poll(watcher)
78
- }
79
- false => {
80
- let mut watcher = RecommendedWatcher::new(tx, Config::default())
81
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
82
- for pathname in &pathnames {
83
- let path = Path::new(pathname);
84
- watcher
85
- .watch(path, mode)
86
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
141
+ false => {
142
+ let mut watcher = RecommendedWatcher::new(tx, Config::default())
143
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
144
+ for pathname in &pathnames {
145
+ let path = Path::new(pathname);
146
+ watcher
147
+ .watch(path, mode)
148
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
149
+ }
150
+ WatcherEnum::Recommended(watcher)
87
151
  }
88
- WatcherEnum::Recommended(watcher)
89
- }
90
- };
152
+ };
91
153
 
92
- loop {
93
- select! {
94
- recv(self.rx) -> _res => {
95
- return Ok(true)
154
+ loop {
155
+ if terminated.load(Ordering::SeqCst) {
156
+ break Ok(true);
96
157
  }
97
- recv(rx) -> res => {
98
- match res {
99
- Ok(event) => {
100
- match event {
101
- Ok(event) => {
102
- let paths = event
103
- .paths
104
- .iter()
105
- .map(|p| p.to_string_lossy().into_owned())
106
- .collect::<Vec<_>>();
107
-
108
- if ignore_remove && matches!(event.kind, notify::event::EventKind::Remove(_)) {
109
- continue;
110
- }
111
158
 
112
- yield_value::<(Vec<String>, Vec<String>, String), Value>(
113
-
114
- (WatchatEvent::convert_kind(&event.kind), paths, format!("{:?}", event.kind))
115
- )?;
159
+ select! {
160
+ recv(rx) -> _res => {
161
+ break Ok(true);
162
+ }
163
+ recv(cmd_rx) -> cmd => {
164
+ if let Ok(cmd) = cmd {
165
+ match cmd {
166
+ Command::Watch(paths, recursive) => {
167
+ let m = if recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
168
+ for p in &paths {
169
+ let _ = watcher_watch(&mut _watcher, Path::new(p), m);
170
+ }
116
171
  }
117
- Err(e) => {
118
- return Err(
119
- Error::new(magnus::exception::runtime_error(), e.to_string())
120
- )
172
+ Command::Unwatch(paths) => {
173
+ for p in &paths {
174
+ let _ = watcher_unwatch(&mut _watcher, Path::new(p));
175
+ }
121
176
  }
122
177
  }
123
178
  }
124
- Err(e) => {
125
- return Err(
126
- Error::new(magnus::exception::runtime_error(), e.to_string())
127
- )
128
- }
129
179
  }
130
- }
131
- }
132
- }
133
- }
180
+ recv(watcher_rx) -> res => {
181
+ match res {
182
+ Ok(event) => {
183
+ match event {
184
+ Ok(event) => {
185
+ let paths = event
186
+ .paths
187
+ .iter()
188
+ .map(|p| p.to_string_lossy().into_owned())
189
+ .collect::<Vec<_>>();
134
190
 
135
- fn watch_with_debounce(&self, pathnames: Vec<String>, mode: RecursiveMode, ignore_remove: bool, debounce: i64) -> Result<bool, Error> {
136
- let (tx, rx) = unbounded();
137
- let mut debouncer = new_debouncer(Duration::from_millis(debounce.try_into().unwrap()), tx).unwrap();
138
- for pathname in &pathnames {
139
- let path = Path::new(pathname);
140
- debouncer
141
- .watcher()
142
- .watch(path, mode)
143
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
144
- }
145
-
146
- loop {
147
- select! {
148
- recv(self.rx) -> _res => {
149
- return Ok(true)
150
- }
151
- recv(rx) -> res => {
152
- match res {
153
- Ok(events) => {
154
- match events {
155
- Ok(events) => {
156
- events.iter().for_each(|event| {
157
- if ignore_remove && !Path::new(&event.path).exists() {
158
- return;
191
+ if ignore_remove && matches!(event.kind, notify::event::EventKind::Remove(_)) {
192
+ continue;
193
+ }
194
+ if ignore_access && matches!(event.kind, notify::event::EventKind::Access(_)) {
195
+ continue;
196
+ }
197
+ if ignore_create && matches!(event.kind, notify::event::EventKind::Create(_)) {
198
+ continue;
199
+ }
200
+ if ignore_modify && matches!(event.kind, notify::event::EventKind::Modify(_)) {
201
+ continue;
159
202
  }
160
203
 
161
- yield_value::<(Vec<String>, Vec<String>, String), Value>(
162
- (vec![], vec![event.path.to_string_lossy().into_owned()], format!("{:?}", event.kind))
163
- ).unwrap();
164
- });
165
- }
166
- Err(e) => {
167
- return Err(
168
- Error::new(magnus::exception::runtime_error(), e.to_string())
169
- )
204
+ // Yield to Ruby with GVL
205
+ let result: Result<Value, String> = call_with_gvl(|ruby| {
206
+ ruby.yield_value::<(Vec<String>, Vec<String>, String), Value>(
207
+ (WatchatEvent::convert_kind(&event.kind), paths, format!("{:?}", event.kind))
208
+ ).map_err(|e| e.to_string())
209
+ });
210
+
211
+ if let Err(msg) = result {
212
+ break Err(WatchFailure::Runtime(format!("Error yielding to Ruby block: {msg}")));
213
+ }
214
+ }
215
+ Err(e) => {
216
+ break Err(WatchFailure::Runtime(e.to_string()));
217
+ }
170
218
  }
171
219
  }
172
- }
173
- Err(e) => {
174
- return Err(
175
- Error::new(magnus::exception::runtime_error(), e.to_string())
176
- )
220
+ Err(e) => {
221
+ break Err(WatchFailure::Runtime(e.to_string()));
222
+ }
177
223
  }
178
224
  }
179
225
  }
180
226
  }
181
- }
227
+ });
228
+
229
+ result.map_err(|err| match err {
230
+ WatchFailure::Arg(msg) => Error::new(ruby.exception_arg_error(), msg),
231
+ WatchFailure::Runtime(msg) => Error::new(ruby.exception_runtime_error(), msg),
232
+ })
182
233
  }
183
234
 
184
235
  #[allow(clippy::let_unit_value, clippy::type_complexity)]
185
- fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool, i64), Error> {
236
+ fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool, bool, bool, bool), Error> {
186
237
  type KwArgBool = Option<Option<bool>>;
187
238
  type KwArgU64 = Option<Option<u64>>;
188
- type KwArgi64 = Option<Option<i64>>;
189
239
 
190
240
  let args = scan_args(args)?;
191
241
  let (paths,): (Vec<String>,) = args.required;
@@ -197,9 +247,9 @@ impl WatchcatWatcher {
197
247
  let kwargs = get_kwargs(
198
248
  args.keywords,
199
249
  &[],
200
- &["recursive", "force_polling", "poll_interval", "ignore_remove", "debounce"],
250
+ &["recursive", "force_polling", "poll_interval", "ignore_remove", "ignore_access", "ignore_create", "ignore_modify"],
201
251
  )?;
202
- let (recursive, force_polling, poll_interval, ignore_remove, debounce): (KwArgBool, KwArgBool, KwArgU64, KwArgBool, KwArgi64) =
252
+ let (recursive, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify): (KwArgBool, KwArgBool, KwArgU64, KwArgBool, KwArgBool, KwArgBool, KwArgBool) =
203
253
  kwargs.optional;
204
254
  let _: () = kwargs.required;
205
255
  let _: () = kwargs.splat;
@@ -210,19 +260,76 @@ impl WatchcatWatcher {
210
260
  force_polling.flatten().unwrap_or(false),
211
261
  poll_interval.flatten().unwrap_or(200),
212
262
  ignore_remove.flatten().unwrap_or(false),
213
- debounce.flatten().unwrap_or(-1),
263
+ ignore_access.flatten().unwrap_or(false),
264
+ ignore_create.flatten().unwrap_or(false),
265
+ ignore_modify.flatten().unwrap_or(false),
214
266
  ))
215
267
  }
268
+
269
+ fn add(&self, args: &[Value]) -> Result<bool, Error> {
270
+ let (paths, recursive) = Self::parse_add_args(args)?;
271
+ // `send` only fails when every receiver is disconnected, but `self`
272
+ // holds `cmd_rx` for the whole lifetime of this object, so it cannot
273
+ // fail here. If the watch loop has already stopped, the command is
274
+ // simply buffered and never applied (a harmless no-op).
275
+ let _ = self.cmd_tx.send(Command::Watch(paths, recursive));
276
+ Ok(true)
277
+ }
278
+
279
+ fn unwatch(&self, args: &[Value]) -> Result<bool, Error> {
280
+ let paths = Self::parse_unwatch_args(args)?;
281
+ // See `add`: `send` cannot fail while `self` retains `cmd_rx`.
282
+ let _ = self.cmd_tx.send(Command::Unwatch(paths));
283
+ Ok(true)
284
+ }
285
+
286
+ #[allow(clippy::let_unit_value)]
287
+ fn parse_add_args(args: &[Value]) -> Result<(Vec<String>, bool), Error> {
288
+ type KwArgBool = Option<Option<bool>>;
289
+
290
+ let args = scan_args(args)?;
291
+ let (paths,): (Vec<String>,) = args.required;
292
+ let _: () = args.optional;
293
+ let _: () = args.splat;
294
+ let _: () = args.trailing;
295
+ let _: () = args.block;
296
+
297
+ let kwargs = get_kwargs(args.keywords, &[], &["recursive"])?;
298
+ let (recursive,): (KwArgBool,) = kwargs.optional;
299
+ let _: () = kwargs.required;
300
+ let _: () = kwargs.splat;
301
+
302
+ Ok((paths, recursive.flatten().unwrap_or(true)))
303
+ }
304
+
305
+ #[allow(clippy::let_unit_value)]
306
+ fn parse_unwatch_args(args: &[Value]) -> Result<Vec<String>, Error> {
307
+ let args = scan_args(args)?;
308
+ let (paths,): (Vec<String>,) = args.required;
309
+ let _: () = args.optional;
310
+ let _: () = args.splat;
311
+ let _: () = args.trailing;
312
+ let _: () = args.block;
313
+
314
+ let kwargs = get_kwargs::<&str, (), (), ()>(args.keywords, &[], &[])?;
315
+ let _: () = kwargs.optional;
316
+ let _: () = kwargs.required;
317
+ let _: () = kwargs.splat;
318
+
319
+ Ok(paths)
320
+ }
216
321
  }
217
322
 
218
323
  #[magnus::init]
219
- fn init() -> Result<(), Error> {
220
- let module = define_module("Watchcat")?;
324
+ fn init(ruby: &Ruby) -> Result<(), Error> {
325
+ let module = ruby.define_module("Watchcat")?;
221
326
 
222
- let watcher_class = module.define_class("Watcher", object())?;
327
+ let watcher_class = module.define_class("Watcher", ruby.class_object())?;
223
328
  watcher_class.define_singleton_method("new", function!(WatchcatWatcher::new, 0))?;
224
329
  watcher_class.define_method("watch", method!(WatchcatWatcher::watch, -1))?;
225
330
  watcher_class.define_method("close", method!(WatchcatWatcher::close, 0))?;
331
+ watcher_class.define_method("add", method!(WatchcatWatcher::add, -1))?;
332
+ watcher_class.define_method("unwatch", method!(WatchcatWatcher::unwatch, -1))?;
226
333
 
227
334
  Ok(())
228
335
  }
@@ -0,0 +1,44 @@
1
+ module Watchcat
2
+ module CLI
3
+ class ActionExecutor
4
+ def initialize(file_path, event)
5
+ @file_path = file_path
6
+ @event = event
7
+ @file_dir = File.dirname(file_path)
8
+ @file_name = File.basename(file_path)
9
+ @file_ext = File.extname(file_path)
10
+ @file_base = File.basename(file_path, @file_ext)
11
+ end
12
+
13
+ def execute(action)
14
+ execute_command(action)
15
+ rescue => e
16
+ puts "Error executing action #{action}: #{e.message}"
17
+ end
18
+
19
+ private
20
+
21
+ def execute_command(action)
22
+ command = substitute_variables(action["command"])
23
+ puts "Executing: #{command}"
24
+
25
+ success = system(command)
26
+ unless success
27
+ puts "Command failed with exit code: #{$?.exitstatus}"
28
+ end
29
+ end
30
+
31
+ def substitute_variables(template)
32
+ return template unless template.is_a?(String)
33
+
34
+ template
35
+ .gsub("{{file_path}}", @file_path)
36
+ .gsub("{{file_dir}}", @file_dir)
37
+ .gsub("{{file_name}}", @file_name)
38
+ .gsub("{{file_base}}", @file_base)
39
+ .gsub("{{file_ext}}", @file_ext)
40
+ .gsub("{{event_type}}", @event.kind.event_type)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,76 @@
1
+ require "psych"
2
+
3
+ module Watchcat
4
+ module CLI
5
+ class Config
6
+ attr_reader :watches
7
+
8
+ def initialize(data)
9
+ @watches = parse_watches(data["watches"] || [])
10
+ end
11
+
12
+ def self.load(file_path)
13
+ unless File.exist?(file_path)
14
+ raise Error, "Configuration file not found: #{file_path}"
15
+ end
16
+
17
+ begin
18
+ data = Psych.load_file(file_path)
19
+ new(data)
20
+ rescue Psych::SyntaxError => e
21
+ raise Error, "Invalid YAML syntax in #{file_path}: #{e.message}"
22
+ end
23
+ end
24
+
25
+ def self.generate_template(file_path)
26
+ template = <<~YAML
27
+ # Watchcat Configuration File
28
+
29
+ watches:
30
+ - path: "./src"
31
+ recursive: true
32
+ debounce: 300
33
+ filters:
34
+ ignore_access: true
35
+ patterns:
36
+ - "*.js"
37
+ - "*.ts"
38
+ - "*.css"
39
+ actions:
40
+ - command: "echo 'File changed: {{file_path}}'"
41
+
42
+ - path: "./docs"
43
+ recursive: true
44
+ filters:
45
+ ignore_access: true
46
+ patterns:
47
+ - "*.md"
48
+ actions:
49
+ - command: "echo 'Documentation updated: {{file_name}}'"
50
+ YAML
51
+
52
+ if File.exist?(file_path)
53
+ raise Error, "File already exists: #{file_path}. Won't overwrite."
54
+ end
55
+
56
+ File.write(file_path, template)
57
+ puts "Config template generated at #{file_path}"
58
+ end
59
+
60
+ private
61
+
62
+ def parse_watches(watches_data)
63
+ watches_data.map do |watch_config|
64
+ {
65
+ path: watch_config["path"],
66
+ recursive: watch_config.fetch("recursive", true),
67
+ patterns: watch_config["patterns"] || [],
68
+ actions: watch_config["actions"] || [],
69
+ debounce: watch_config.fetch("debounce", -1),
70
+ filters: watch_config["filters"]&.transform_keys(&:to_sym) || {},
71
+ }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,83 @@
1
+ module Watchcat
2
+ module CLI
3
+ class Watcher
4
+ def initialize(config)
5
+ @config = config
6
+ @watchers = []
7
+ end
8
+
9
+ def start
10
+ puts "Starting Watchcat file watcher..."
11
+
12
+ @config.watches.each do |watch_config|
13
+ start_watching_path(watch_config)
14
+ end
15
+
16
+ puts "Watchcat is now watching for file changes. Press Ctrl+C to stop."
17
+
18
+ # Keep the main thread alive
19
+ begin
20
+ sleep
21
+ rescue Interrupt
22
+ puts "\nStopping Watchcat..."
23
+ stop
24
+ end
25
+ end
26
+
27
+ def stop
28
+ @watchers.each(&:stop)
29
+ @watchers.clear
30
+ end
31
+
32
+ private
33
+
34
+ def start_watching_path(watch_config)
35
+ path = watch_config[:path]
36
+
37
+ unless File.exist?(path)
38
+ puts "Warning: Path does not exist: #{path}"
39
+ return
40
+ end
41
+
42
+ puts "Watching: #{path} (recursive: #{watch_config[:recursive]}, debounce: #{watch_config[:debounce]}ms)"
43
+
44
+ watcher = Watchcat.watch(
45
+ path,
46
+ recursive: watch_config[:recursive],
47
+ filters: watch_config[:filters],
48
+ debounce: watch_config[:debounce],
49
+ ) do |event|
50
+ handle_file_event(event, watch_config)
51
+ end
52
+
53
+ @watchers << watcher
54
+ end
55
+
56
+ def handle_file_event(event, watch_config)
57
+ event.paths.each do |file_path|
58
+ next unless should_process_file?(file_path, watch_config[:patterns])
59
+
60
+ puts "File changed: #{file_path}"
61
+ execute_actions(file_path, event, watch_config[:actions])
62
+ end
63
+ end
64
+
65
+ def should_process_file?(file_path, patterns)
66
+ return true if patterns.empty?
67
+
68
+ patterns.any? do |pattern|
69
+ File.fnmatch?(pattern, File.basename(file_path)) ||
70
+ File.fnmatch?(pattern, file_path)
71
+ end
72
+ end
73
+
74
+ def execute_actions(file_path, event, actions)
75
+ executor = ActionExecutor.new(file_path, event)
76
+
77
+ actions.each do |action|
78
+ executor.execute(action)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end