Skip to main content

sqry_cli/commands/
watch.rs

1//! Watch mode command for real-time index updates
2
3use crate::args::Cli;
4use crate::commands::index::{
5    ClasspathCliOptions, build_and_persist_with_optional_classpath, create_build_config,
6    create_progress_reporter,
7};
8#[cfg(feature = "jvm-classpath")]
9use crate::commands::index::{inject_classpath_into_graph, run_classpath_pipeline_only};
10use crate::plugin_defaults::{self, PluginSelectionMode};
11use anyhow::{Context, Result};
12use sqry_core::graph::unified::persistence::GraphStorage;
13#[cfg(feature = "jvm-classpath")]
14use sqry_core::watch::FileChange;
15use sqry_core::watch::FileWatcher;
16use std::path::PathBuf;
17use std::time::Duration;
18
19/// Execute the watch command.
20///
21/// # Errors
22/// Returns an error if the index cannot be loaded or watch mode fails.
23#[allow(clippy::too_many_arguments)]
24#[allow(clippy::too_many_lines)]
25#[allow(clippy::fn_params_excessive_bools)] // CLI flags map directly to booleans.
26#[allow(clippy::needless_pass_by_value)] // CLI owned args are forwarded and cached directly
27pub fn execute(
28    cli: &Cli,
29    path: Option<String>,
30    threads: Option<usize>,
31    debounce: Option<u64>,
32    _show_stats: bool, // Detailed stats not supported for unified graph yet
33    build_if_missing: bool,
34    classpath: bool,
35    _no_classpath: bool,
36    classpath_depth: crate::args::ClasspathDepthArg,
37    classpath_file: Option<PathBuf>,
38    build_system: Option<String>,
39    force_classpath: bool,
40) -> Result<()> {
41    let root_path = resolve_path(path)?;
42    let build_config = create_build_config(cli, &root_path, threads)?;
43    let classpath_opts = ClasspathCliOptions {
44        enabled: classpath,
45        depth: classpath_depth,
46        classpath_file: classpath_file.as_deref(),
47        build_system: build_system.as_deref(),
48        force_classpath,
49    };
50    #[cfg(feature = "jvm-classpath")]
51    let mut classpath_cache = None;
52
53    // Check if graph exists
54    let storage = GraphStorage::new(&root_path);
55    if !storage.exists() {
56        if build_if_missing {
57            println!("🔨 Building initial graph...");
58            let (_, progress) = create_progress_reporter(cli);
59            let resolved_plugins = plugin_defaults::resolve_plugin_selection(
60                cli,
61                &root_path,
62                PluginSelectionMode::FreshWrite,
63            )?;
64            #[cfg(feature = "jvm-classpath")]
65            {
66                let _build_result = build_and_persist_watch_iteration(
67                    &root_path,
68                    &resolved_plugins,
69                    &build_config,
70                    "cli:watch",
71                    progress,
72                    Some(&classpath_opts),
73                    &mut classpath_cache,
74                    &[],
75                )?;
76            }
77            #[cfg(not(feature = "jvm-classpath"))]
78            {
79                let _build_result = build_and_persist_with_optional_classpath(
80                    &root_path,
81                    &resolved_plugins,
82                    &build_config,
83                    "cli:watch",
84                    progress,
85                    Some(&classpath_opts),
86                    None,
87                )?;
88            }
89        } else {
90            anyhow::bail!(
91                "No index found at {}. Use --build to create one, or run 'sqry index' first.",
92                root_path.display()
93            );
94        }
95    }
96
97    // Create watcher
98    let watcher = FileWatcher::new(&root_path)?;
99    // C004: platform-aware debounce default (matches the CLI help text).
100    // The `SQRY_LIMITS__WATCH__DEBOUNCE_MS` env override wins over the
101    // platform default but is itself overridden by an explicit `--debounce`.
102    let debounce_duration = debounce.map_or_else(default_watch_debounce, Duration::from_millis);
103
104    println!("🔍 Watch mode started");
105    println!("📂 Monitoring: {}", root_path.display());
106    println!("⏱️  Debounce: {}ms", debounce_duration.as_millis());
107    println!();
108    println!("Press Ctrl+C to stop...");
109    println!();
110
111    let (_, progress) = create_progress_reporter(cli);
112
113    loop {
114        // Wait for changes
115        let changes = watcher.wait_with_debounce(debounce_duration)?;
116
117        if changes.is_empty() {
118            continue;
119        }
120
121        println!(
122            "📝 Detected {} file changes, updating graph...",
123            changes.len()
124        );
125        let start = std::time::Instant::now();
126
127        // Full rebuild using consolidated pipeline
128        let resolved_plugins = plugin_defaults::resolve_plugin_selection(
129            cli,
130            &root_path,
131            PluginSelectionMode::ExistingWrite,
132        )?;
133        #[cfg(feature = "jvm-classpath")]
134        let build_result = build_and_persist_watch_iteration(
135            &root_path,
136            &resolved_plugins,
137            &build_config,
138            "cli:watch",
139            progress.clone(),
140            Some(&classpath_opts),
141            &mut classpath_cache,
142            &changes,
143        );
144        #[cfg(not(feature = "jvm-classpath"))]
145        let build_result = build_and_persist_with_optional_classpath(
146            &root_path,
147            &resolved_plugins,
148            &build_config,
149            "cli:watch",
150            progress.clone(),
151            Some(&classpath_opts),
152            None,
153        );
154        match build_result {
155            Ok(_build_result) => {
156                println!("✓ Graph updated in {:.2}s", start.elapsed().as_secs_f64());
157            }
158            Err(e) => {
159                eprintln!("❌ Error updating graph: {e}");
160            }
161        }
162        println!();
163    }
164}
165
166#[cfg(feature = "jvm-classpath")]
167const CLASSPATH_INVALIDATION_FILE_NAMES: &[&str] = &[
168    "build.gradle",
169    "build.gradle.kts",
170    "gradle.properties",
171    "settings.gradle",
172    "settings.gradle.kts",
173    "pom.xml",
174    "build.sbt",
175    "WORKSPACE",
176    "WORKSPACE.bazel",
177    "MODULE.bazel",
178    "gradle-wrapper.properties",
179];
180
181#[cfg(feature = "jvm-classpath")]
182fn classpath_inputs_changed(
183    root_path: &std::path::Path,
184    changes: &[FileChange],
185    classpath_opts: &ClasspathCliOptions<'_>,
186) -> bool {
187    if classpath_opts.force_classpath {
188        return true;
189    }
190
191    let manual_classpath = classpath_opts.classpath_file.map(|path| {
192        if path.is_absolute() {
193            path.to_path_buf()
194        } else {
195            root_path.join(path)
196        }
197    });
198
199    changes.iter().any(|change| {
200        let path = match change {
201            FileChange::Created(path) | FileChange::Modified(path) | FileChange::Deleted(path) => {
202                path
203            }
204        };
205
206        if manual_classpath
207            .as_ref()
208            .is_some_and(|cp_file| path == cp_file)
209        {
210            return true;
211        }
212
213        path.file_name()
214            .and_then(|name| name.to_str())
215            .is_some_and(|name| CLASSPATH_INVALIDATION_FILE_NAMES.contains(&name))
216    })
217}
218
219#[cfg(feature = "jvm-classpath")]
220fn build_and_persist_watch_iteration(
221    root_path: &std::path::Path,
222    resolved_plugins: &crate::plugin_defaults::ResolvedPluginManager,
223    build_config: &sqry_core::graph::unified::build::BuildConfig,
224    build_command: &str,
225    progress: sqry_core::progress::SharedReporter,
226    classpath_opts: Option<&ClasspathCliOptions<'_>>,
227    classpath_cache: &mut Option<sqry_classpath::pipeline::ClasspathPipelineResult>,
228    changes: &[FileChange],
229) -> Result<sqry_core::graph::unified::build::BuildResult> {
230    if let Some(classpath_opts) = classpath_opts.filter(|opts| opts.enabled) {
231        let should_refresh = classpath_cache.is_none()
232            || classpath_inputs_changed(root_path, changes, classpath_opts);
233        if should_refresh {
234            *classpath_cache = run_classpath_pipeline_only(root_path, classpath_opts)?;
235        }
236
237        let (mut graph, effective_threads) =
238            sqry_core::graph::unified::build::build_unified_graph_with_progress(
239                root_path,
240                &resolved_plugins.plugin_manager,
241                build_config,
242                progress.clone(),
243            )?;
244
245        if let Some(classpath_result) = classpath_cache.as_ref() {
246            inject_classpath_into_graph(&mut graph, classpath_result)?;
247        }
248
249        let (_graph, build_result) = sqry_core::graph::unified::build::persist_and_analyze_graph(
250            graph,
251            root_path,
252            &resolved_plugins.plugin_manager,
253            build_config,
254            build_command,
255            resolved_plugins.persisted_selection.clone(),
256            progress,
257            effective_threads,
258        )?;
259        return Ok(build_result);
260    }
261
262    build_and_persist_with_optional_classpath(
263        root_path,
264        resolved_plugins,
265        build_config,
266        build_command,
267        progress,
268        None,
269        None,
270    )
271}
272
273/// Compute the default debounce duration for the file watcher.
274///
275/// Resolution order (highest priority first):
276///
277/// 1. `SQRY_LIMITS__WATCH__DEBOUNCE_MS` env var (parsed as u64 milliseconds).
278/// 2. Platform-specific default: 400 ms on macOS (`FSEvents` coalescing
279///    latency), 100 ms on Linux/Windows (inotify / `ReadDirectoryChangesW`
280///    react more quickly so a tighter debounce keeps interactivity).
281fn default_watch_debounce() -> Duration {
282    if let Ok(raw) = std::env::var("SQRY_LIMITS__WATCH__DEBOUNCE_MS")
283        && let Ok(ms) = raw.trim().parse::<u64>()
284    {
285        return Duration::from_millis(ms);
286    }
287    if cfg!(target_os = "macos") {
288        Duration::from_millis(400)
289    } else {
290        Duration::from_millis(100)
291    }
292}
293
294/// Resolve path argument to absolute `PathBuf`
295fn resolve_path(path: Option<String>) -> Result<PathBuf> {
296    let path_str = path.unwrap_or_else(|| ".".to_string());
297    let path = PathBuf::from(path_str);
298
299    if path.exists() {
300        path.canonicalize().context("Failed to resolve path")
301    } else {
302        anyhow::bail!("Path does not exist: {}", path.display());
303    }
304}
305
306#[cfg(test)]
307mod debounce_tests {
308    use super::default_watch_debounce;
309    use std::time::Duration;
310
311    /// Serialise the env-mutating debounce tests so the two tests do not
312    /// race on `SQRY_LIMITS__WATCH__DEBOUNCE_MS`. `cargo test` parallelises
313    /// by default; without this guard the platform-default test would
314    /// observe the override test's set/unset window and flap.
315    static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
316
317    #[test]
318    fn default_watch_debounce_honours_env_override() {
319        // C004: explicit env override wins over platform default. Use a
320        // sentinel value (777ms) that can't collide with either platform
321        // default (100 / 400). Reset after the assertion to avoid leaking
322        // process state into other tests.
323        let _guard = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
324        unsafe {
325            std::env::set_var("SQRY_LIMITS__WATCH__DEBOUNCE_MS", "777");
326        }
327        assert_eq!(default_watch_debounce(), Duration::from_millis(777));
328        unsafe {
329            std::env::remove_var("SQRY_LIMITS__WATCH__DEBOUNCE_MS");
330        }
331    }
332
333    #[test]
334    fn default_watch_debounce_falls_back_to_platform_default() {
335        let _guard = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
336        unsafe {
337            std::env::remove_var("SQRY_LIMITS__WATCH__DEBOUNCE_MS");
338        }
339        let expected = if cfg!(target_os = "macos") {
340            Duration::from_millis(400)
341        } else {
342            Duration::from_millis(100)
343        };
344        assert_eq!(default_watch_debounce(), expected);
345    }
346}
347
348#[cfg(all(test, feature = "jvm-classpath"))]
349mod tests {
350    use super::*;
351
352    fn classpath_opts<'a>(classpath_file: Option<&'a std::path::Path>) -> ClasspathCliOptions<'a> {
353        ClasspathCliOptions {
354            enabled: true,
355            depth: crate::args::ClasspathDepthArg::Full,
356            classpath_file,
357            build_system: None,
358            force_classpath: false,
359        }
360    }
361
362    #[test]
363    fn classpath_invalidation_includes_gradle_property_files() {
364        let root = std::path::Path::new("/repo");
365        let changes = [
366            FileChange::Modified(root.join("gradle.properties")),
367            FileChange::Modified(root.join("gradle/wrapper/gradle-wrapper.properties")),
368        ];
369
370        assert!(
371            classpath_inputs_changed(root, &changes[..1], &classpath_opts(None)),
372            "gradle.properties should invalidate the classpath cache"
373        );
374        assert!(
375            classpath_inputs_changed(root, &changes[1..], &classpath_opts(None)),
376            "gradle-wrapper.properties should invalidate the classpath cache"
377        );
378    }
379
380    #[test]
381    fn classpath_invalidation_ignores_regular_source_files() {
382        let root = std::path::Path::new("/repo");
383        let changes = vec![FileChange::Modified(root.join("src/Main.java"))];
384        assert!(
385            !classpath_inputs_changed(root, &changes, &classpath_opts(None)),
386            "ordinary source edits should reuse the cached classpath result"
387        );
388    }
389}