watchcat 0.3.0 → 0.6.1

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,13 +1,10 @@
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
8
  use std::{path::Path, time::Duration, sync::{Arc, atomic::{AtomicBool, Ordering}}};
12
9
 
13
10
  mod event;
@@ -20,93 +17,135 @@ struct WatchcatWatcher {
20
17
  tx: crossbeam_channel::Sender<bool>,
21
18
  rx: crossbeam_channel::Receiver<bool>,
22
19
  terminated: Arc<AtomicBool>,
20
+ cmd_tx: crossbeam_channel::Sender<Command>,
21
+ cmd_rx: crossbeam_channel::Receiver<Command>,
23
22
  }
24
23
 
25
24
  #[derive(Debug)]
26
25
  enum WatcherEnum {
27
- #[allow(dead_code)]
28
26
  Poll(PollWatcher),
29
- #[allow(dead_code)]
30
27
  Recommended(RecommendedWatcher),
31
28
  }
32
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
+
33
58
  impl WatchcatWatcher {
34
59
  fn new() -> Self {
35
60
  let (tx_executor, rx_executor) = unbounded::<bool>();
61
+ let (cmd_tx, cmd_rx) = unbounded::<Command>();
36
62
  Self {
37
63
  tx: tx_executor,
38
64
  rx: rx_executor,
39
65
  terminated: Arc::new(AtomicBool::new(false)),
66
+ cmd_tx,
67
+ cmd_rx,
40
68
  }
41
69
  }
42
70
 
43
71
  fn close(&self) {
44
72
  self.terminated.store(true, Ordering::SeqCst);
45
- self.tx.send(true).unwrap()
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);
46
79
  }
47
80
 
48
81
  fn watch(&self, args: &[Value]) -> Result<bool, Error> {
49
- if !block_given() {
50
- 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"));
51
86
  }
52
87
 
53
- 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)?;
54
89
  let mode = if recursive {
55
90
  RecursiveMode::Recursive
56
91
  } else {
57
92
  RecursiveMode::NonRecursive
58
93
  };
59
94
 
60
- // Clone necessary data for the call_without_gvl
61
95
  let terminated = self.terminated.clone();
62
96
  let rx_clone = self.rx.clone();
97
+ let cmd_rx = self.cmd_rx.clone();
63
98
 
64
- // Start the file watching with GVL released
65
- if debounce >= 0 {
66
- Self::watch_with_debounce_threaded(
67
- pathnames, mode, ignore_remove, debounce, terminated, rx_clone
68
- )
69
- } else {
70
- Self::watch_without_debounce_threaded(
71
- pathnames, mode, force_polling, poll_interval, ignore_remove, terminated, rx_clone
72
- )
73
- }
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
+ )
74
102
  }
75
103
 
76
- fn watch_without_debounce_threaded(
104
+ #[allow(clippy::too_many_arguments)]
105
+ fn watch_threaded(
77
106
  pathnames: Vec<String>,
78
107
  mode: RecursiveMode,
79
108
  force_polling: bool,
80
109
  poll_interval: u64,
81
110
  ignore_remove: bool,
111
+ ignore_access: bool,
112
+ ignore_create: bool,
113
+ ignore_modify: bool,
82
114
  terminated: Arc<AtomicBool>,
83
- rx: crossbeam_channel::Receiver<bool>
115
+ rx: crossbeam_channel::Receiver<bool>,
116
+ cmd_rx: crossbeam_channel::Receiver<Command>,
117
+ ruby: &Ruby
84
118
  ) -> Result<bool, Error> {
85
- call_without_gvl(move || {
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 || {
86
125
  let (tx, watcher_rx) = unbounded();
87
126
  // This variable is needed to keep `watcher` active.
88
- let _watcher = match force_polling {
127
+ let mut _watcher = match force_polling {
89
128
  true => {
90
129
  let delay = Duration::from_millis(poll_interval);
91
130
  let config = notify::Config::default().with_poll_interval(delay);
92
131
  let mut watcher = PollWatcher::new(tx, config)
93
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
132
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
94
133
  for pathname in &pathnames {
95
134
  let path = Path::new(pathname);
96
135
  watcher
97
136
  .watch(path, mode)
98
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
137
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
99
138
  }
100
139
  WatcherEnum::Poll(watcher)
101
140
  }
102
141
  false => {
103
142
  let mut watcher = RecommendedWatcher::new(tx, Config::default())
104
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
143
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
105
144
  for pathname in &pathnames {
106
145
  let path = Path::new(pathname);
107
146
  watcher
108
147
  .watch(path, mode)
109
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
148
+ .map_err(|e| WatchFailure::Arg(e.to_string()))?;
110
149
  }
111
150
  WatcherEnum::Recommended(watcher)
112
151
  }
@@ -121,6 +160,23 @@ impl WatchcatWatcher {
121
160
  recv(rx) -> _res => {
122
161
  break Ok(true);
123
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
+ }
171
+ }
172
+ Command::Unwatch(paths) => {
173
+ for p in &paths {
174
+ let _ = watcher_unwatch(&mut _watcher, Path::new(p));
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
124
180
  recv(watcher_rx) -> res => {
125
181
  match res {
126
182
  Ok(event) => {
@@ -136,102 +192,65 @@ impl WatchcatWatcher {
136
192
  continue;
137
193
  }
138
194
 
195
+ let macos_ambiguous_metadata_touch = cfg!(target_os = "macos")
196
+ && matches!(
197
+ event.kind,
198
+ notify::event::EventKind::Modify(
199
+ notify::event::ModifyKind::Metadata(
200
+ notify::event::MetadataKind::Any
201
+ )
202
+ )
203
+ );
204
+ if ignore_access
205
+ && (matches!(
206
+ event.kind,
207
+ notify::event::EventKind::Access(_)
208
+ ) || macos_ambiguous_metadata_touch)
209
+ {
210
+ continue;
211
+ }
212
+ if ignore_create && matches!(event.kind, notify::event::EventKind::Create(_)) {
213
+ continue;
214
+ }
215
+ if ignore_modify && matches!(event.kind, notify::event::EventKind::Modify(_)) {
216
+ continue;
217
+ }
218
+
139
219
  // Yield to Ruby with GVL
140
- let result = call_with_gvl(|_| {
141
- yield_value::<(Vec<String>, Vec<String>, String), Value>(
220
+ let result: Result<Value, String> = call_with_gvl(|ruby| {
221
+ ruby.yield_value::<(Vec<String>, Vec<String>, String), Value>(
142
222
  (WatchatEvent::convert_kind(&event.kind), paths, format!("{:?}", event.kind))
143
- )
223
+ ).map_err(|e| e.to_string())
144
224
  });
145
225
 
146
- if result.is_err() {
147
- break Err(Error::new(magnus::exception::runtime_error(), "Error yielding to Ruby block"));
226
+ if let Err(msg) = result {
227
+ break Err(WatchFailure::Runtime(format!("Error yielding to Ruby block: {msg}")));
148
228
  }
149
229
  }
150
230
  Err(e) => {
151
- break Err(Error::new(magnus::exception::runtime_error(), e.to_string()));
231
+ break Err(WatchFailure::Runtime(e.to_string()));
152
232
  }
153
233
  }
154
234
  }
155
235
  Err(e) => {
156
- break Err(Error::new(magnus::exception::runtime_error(), e.to_string()));
236
+ break Err(WatchFailure::Runtime(e.to_string()));
157
237
  }
158
238
  }
159
239
  }
160
240
  }
161
241
  }
162
- })
163
- }
164
-
165
- fn watch_with_debounce_threaded(
166
- pathnames: Vec<String>,
167
- mode: RecursiveMode,
168
- ignore_remove: bool,
169
- debounce: i64,
170
- terminated: Arc<AtomicBool>,
171
- rx: crossbeam_channel::Receiver<bool>
172
- ) -> Result<bool, Error> {
173
- call_without_gvl(move || {
174
- let (tx, watcher_rx) = unbounded();
175
- let mut debouncer = new_debouncer(Duration::from_millis(debounce.try_into().unwrap()), tx).unwrap();
176
- for pathname in &pathnames {
177
- let path = Path::new(pathname);
178
- debouncer
179
- .watcher()
180
- .watch(path, mode)
181
- .map_err(|e| Error::new(magnus::exception::arg_error(), e.to_string()))?;
182
- }
183
-
184
- loop {
185
- if terminated.load(Ordering::SeqCst) {
186
- break Ok(true);
187
- }
188
-
189
- select! {
190
- recv(rx) -> _res => {
191
- break Ok(true);
192
- }
193
- recv(watcher_rx) -> res => {
194
- match res {
195
- Ok(events) => {
196
- match events {
197
- Ok(events) => {
198
- for event in events.iter() {
199
- if ignore_remove && !Path::new(&event.path).exists() {
200
- continue;
201
- }
202
-
203
- // Yield to Ruby with GVL
204
- let result = call_with_gvl(|_| {
205
- yield_value::<(Vec<String>, Vec<String>, String), Value>(
206
- (vec![], vec![event.path.to_string_lossy().into_owned()], format!("{:?}", event.kind))
207
- )
208
- });
242
+ });
209
243
 
210
- if result.is_err() {
211
- return Err(Error::new(magnus::exception::runtime_error(), "Error yielding to Ruby block"));
212
- }
213
- }
214
- }
215
- Err(e) => {
216
- break Err(Error::new(magnus::exception::runtime_error(), e.to_string()));
217
- }
218
- }
219
- }
220
- Err(e) => {
221
- break Err(Error::new(magnus::exception::runtime_error(), e.to_string()));
222
- }
223
- }
224
- }
225
- }
226
- }
244
+ result.map_err(|err| match err {
245
+ WatchFailure::Arg(msg) => Error::new(ruby.exception_arg_error(), msg),
246
+ WatchFailure::Runtime(msg) => Error::new(ruby.exception_runtime_error(), msg),
227
247
  })
228
248
  }
229
249
 
230
250
  #[allow(clippy::let_unit_value, clippy::type_complexity)]
231
- fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool, i64), Error> {
251
+ fn parse_args(args: &[Value]) -> Result<(Vec<String>, bool, bool, u64, bool, bool, bool, bool), Error> {
232
252
  type KwArgBool = Option<Option<bool>>;
233
253
  type KwArgU64 = Option<Option<u64>>;
234
- type KwArgi64 = Option<Option<i64>>;
235
254
 
236
255
  let args = scan_args(args)?;
237
256
  let (paths,): (Vec<String>,) = args.required;
@@ -243,9 +262,9 @@ impl WatchcatWatcher {
243
262
  let kwargs = get_kwargs(
244
263
  args.keywords,
245
264
  &[],
246
- &["recursive", "force_polling", "poll_interval", "ignore_remove", "debounce"],
265
+ &["recursive", "force_polling", "poll_interval", "ignore_remove", "ignore_access", "ignore_create", "ignore_modify"],
247
266
  )?;
248
- let (recursive, force_polling, poll_interval, ignore_remove, debounce): (KwArgBool, KwArgBool, KwArgU64, KwArgBool, KwArgi64) =
267
+ let (recursive, force_polling, poll_interval, ignore_remove, ignore_access, ignore_create, ignore_modify): (KwArgBool, KwArgBool, KwArgU64, KwArgBool, KwArgBool, KwArgBool, KwArgBool) =
249
268
  kwargs.optional;
250
269
  let _: () = kwargs.required;
251
270
  let _: () = kwargs.splat;
@@ -256,19 +275,76 @@ impl WatchcatWatcher {
256
275
  force_polling.flatten().unwrap_or(false),
257
276
  poll_interval.flatten().unwrap_or(200),
258
277
  ignore_remove.flatten().unwrap_or(false),
259
- debounce.flatten().unwrap_or(-1),
278
+ ignore_access.flatten().unwrap_or(false),
279
+ ignore_create.flatten().unwrap_or(false),
280
+ ignore_modify.flatten().unwrap_or(false),
260
281
  ))
261
282
  }
283
+
284
+ fn add(&self, args: &[Value]) -> Result<bool, Error> {
285
+ let (paths, recursive) = Self::parse_add_args(args)?;
286
+ // `send` only fails when every receiver is disconnected, but `self`
287
+ // holds `cmd_rx` for the whole lifetime of this object, so it cannot
288
+ // fail here. If the watch loop has already stopped, the command is
289
+ // simply buffered and never applied (a harmless no-op).
290
+ let _ = self.cmd_tx.send(Command::Watch(paths, recursive));
291
+ Ok(true)
292
+ }
293
+
294
+ fn unwatch(&self, args: &[Value]) -> Result<bool, Error> {
295
+ let paths = Self::parse_unwatch_args(args)?;
296
+ // See `add`: `send` cannot fail while `self` retains `cmd_rx`.
297
+ let _ = self.cmd_tx.send(Command::Unwatch(paths));
298
+ Ok(true)
299
+ }
300
+
301
+ #[allow(clippy::let_unit_value)]
302
+ fn parse_add_args(args: &[Value]) -> Result<(Vec<String>, bool), Error> {
303
+ type KwArgBool = Option<Option<bool>>;
304
+
305
+ let args = scan_args(args)?;
306
+ let (paths,): (Vec<String>,) = args.required;
307
+ let _: () = args.optional;
308
+ let _: () = args.splat;
309
+ let _: () = args.trailing;
310
+ let _: () = args.block;
311
+
312
+ let kwargs = get_kwargs(args.keywords, &[], &["recursive"])?;
313
+ let (recursive,): (KwArgBool,) = kwargs.optional;
314
+ let _: () = kwargs.required;
315
+ let _: () = kwargs.splat;
316
+
317
+ Ok((paths, recursive.flatten().unwrap_or(true)))
318
+ }
319
+
320
+ #[allow(clippy::let_unit_value)]
321
+ fn parse_unwatch_args(args: &[Value]) -> Result<Vec<String>, Error> {
322
+ let args = scan_args(args)?;
323
+ let (paths,): (Vec<String>,) = args.required;
324
+ let _: () = args.optional;
325
+ let _: () = args.splat;
326
+ let _: () = args.trailing;
327
+ let _: () = args.block;
328
+
329
+ let kwargs = get_kwargs::<&str, (), (), ()>(args.keywords, &[], &[])?;
330
+ let _: () = kwargs.optional;
331
+ let _: () = kwargs.required;
332
+ let _: () = kwargs.splat;
333
+
334
+ Ok(paths)
335
+ }
262
336
  }
263
337
 
264
338
  #[magnus::init]
265
- fn init() -> Result<(), Error> {
266
- let module = define_module("Watchcat")?;
339
+ fn init(ruby: &Ruby) -> Result<(), Error> {
340
+ let module = ruby.define_module("Watchcat")?;
267
341
 
268
- let watcher_class = module.define_class("Watcher", object())?;
342
+ let watcher_class = module.define_class("Watcher", ruby.class_object())?;
269
343
  watcher_class.define_singleton_method("new", function!(WatchcatWatcher::new, 0))?;
270
344
  watcher_class.define_method("watch", method!(WatchcatWatcher::watch, -1))?;
271
345
  watcher_class.define_method("close", method!(WatchcatWatcher::close, 0))?;
346
+ watcher_class.define_method("add", method!(WatchcatWatcher::add, -1))?;
347
+ watcher_class.define_method("unwatch", method!(WatchcatWatcher::unwatch, -1))?;
272
348
 
273
349
  Ok(())
274
350
  }
@@ -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
@@ -0,0 +1,52 @@
1
+ require "optparse"
2
+ require_relative "cli/watcher"
3
+ require_relative "cli/config"
4
+ require_relative "cli/action_executor"
5
+
6
+ module Watchcat
7
+ module CLI
8
+ class Error < StandardError; end
9
+ class << self
10
+ def start(argv)
11
+ options = parse(argv)
12
+
13
+ if options[:init]
14
+ Config.generate_template(options[:init])
15
+ return
16
+ end
17
+
18
+ config = Config.load(options[:config])
19
+ watcher = Watcher.new(config)
20
+ watcher.start
21
+ rescue => e
22
+ raise Error, "Failed to start Watchcat: #{e.message}"
23
+ end
24
+
25
+ def parse(argv)
26
+ options = { config: 'watchcat.yml' }
27
+ OptionParser.new do |opts|
28
+ opts.banner = "Usage: watchcat [options]"
29
+
30
+ opts.on("-C", "--config PATH", "Path to the config file. Default is 'watchcat.yml'.") do |v|
31
+ options[:config] = v
32
+ end
33
+
34
+ opts.on("--init PATH", "Generate a template config file at the specified path") do |v|
35
+ options[:init] = v
36
+ end
37
+
38
+ opts.on("-h", "--help", "Show this help message") do
39
+ puts opts
40
+ exit
41
+ end
42
+ end.parse!(argv)
43
+
44
+ if !options[:init] && options[:config].nil?
45
+ raise OptionParser::MissingArgument.new("-C")
46
+ end
47
+
48
+ options
49
+ end
50
+ end
51
+ end
52
+ end