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                )?;
87            }
88        } else {
89            anyhow::bail!(
90                "No index found at {}. Use --build to create one, or run 'sqry index' first.",
91                root_path.display()
92            );
93        }
94    }
95
96    // Create watcher
97    let watcher = FileWatcher::new(&root_path)?;
98    let debounce_duration = debounce.map_or(Duration::from_millis(500), Duration::from_millis);
99
100    println!("🔍 Watch mode started");
101    println!("📂 Monitoring: {}", root_path.display());
102    println!("⏱️  Debounce: {}ms", debounce_duration.as_millis());
103    println!();
104    println!("Press Ctrl+C to stop...");
105    println!();
106
107    let (_, progress) = create_progress_reporter(cli);
108
109    loop {
110        // Wait for changes
111        let changes = watcher.wait_with_debounce(debounce_duration)?;
112
113        if changes.is_empty() {
114            continue;
115        }
116
117        println!(
118            "📝 Detected {} file changes, updating graph...",
119            changes.len()
120        );
121        let start = std::time::Instant::now();
122
123        // Full rebuild using consolidated pipeline
124        let resolved_plugins = plugin_defaults::resolve_plugin_selection(
125            cli,
126            &root_path,
127            PluginSelectionMode::ExistingWrite,
128        )?;
129        #[cfg(feature = "jvm-classpath")]
130        let build_result = build_and_persist_watch_iteration(
131            &root_path,
132            &resolved_plugins,
133            &build_config,
134            "cli:watch",
135            progress.clone(),
136            Some(&classpath_opts),
137            &mut classpath_cache,
138            &changes,
139        );
140        #[cfg(not(feature = "jvm-classpath"))]
141        let build_result = build_and_persist_with_optional_classpath(
142            &root_path,
143            &resolved_plugins,
144            &build_config,
145            "cli:watch",
146            progress.clone(),
147            Some(&classpath_opts),
148        );
149        match build_result {
150            Ok(_build_result) => {
151                println!("✓ Graph updated in {:.2}s", start.elapsed().as_secs_f64());
152            }
153            Err(e) => {
154                eprintln!("❌ Error updating graph: {e}");
155            }
156        }
157        println!();
158    }
159}
160
161#[cfg(feature = "jvm-classpath")]
162const CLASSPATH_INVALIDATION_FILE_NAMES: &[&str] = &[
163    "build.gradle",
164    "build.gradle.kts",
165    "gradle.properties",
166    "settings.gradle",
167    "settings.gradle.kts",
168    "pom.xml",
169    "build.sbt",
170    "WORKSPACE",
171    "WORKSPACE.bazel",
172    "MODULE.bazel",
173    "gradle-wrapper.properties",
174];
175
176#[cfg(feature = "jvm-classpath")]
177fn classpath_inputs_changed(
178    root_path: &std::path::Path,
179    changes: &[FileChange],
180    classpath_opts: &ClasspathCliOptions<'_>,
181) -> bool {
182    if classpath_opts.force_classpath {
183        return true;
184    }
185
186    let manual_classpath = classpath_opts.classpath_file.map(|path| {
187        if path.is_absolute() {
188            path.to_path_buf()
189        } else {
190            root_path.join(path)
191        }
192    });
193
194    changes.iter().any(|change| {
195        let path = match change {
196            FileChange::Created(path) | FileChange::Modified(path) | FileChange::Deleted(path) => {
197                path
198            }
199        };
200
201        if manual_classpath
202            .as_ref()
203            .is_some_and(|cp_file| path == cp_file)
204        {
205            return true;
206        }
207
208        path.file_name()
209            .and_then(|name| name.to_str())
210            .is_some_and(|name| CLASSPATH_INVALIDATION_FILE_NAMES.contains(&name))
211    })
212}
213
214#[cfg(feature = "jvm-classpath")]
215fn build_and_persist_watch_iteration(
216    root_path: &std::path::Path,
217    resolved_plugins: &crate::plugin_defaults::ResolvedPluginManager,
218    build_config: &sqry_core::graph::unified::build::BuildConfig,
219    build_command: &str,
220    progress: sqry_core::progress::SharedReporter,
221    classpath_opts: Option<&ClasspathCliOptions<'_>>,
222    classpath_cache: &mut Option<sqry_classpath::pipeline::ClasspathPipelineResult>,
223    changes: &[FileChange],
224) -> Result<sqry_core::graph::unified::build::BuildResult> {
225    if let Some(classpath_opts) = classpath_opts.filter(|opts| opts.enabled) {
226        let should_refresh = classpath_cache.is_none()
227            || classpath_inputs_changed(root_path, changes, classpath_opts);
228        if should_refresh {
229            *classpath_cache = Some(run_classpath_pipeline_only(root_path, classpath_opts)?);
230        }
231
232        let (mut graph, effective_threads) =
233            sqry_core::graph::unified::build::build_unified_graph_with_progress(
234                root_path,
235                &resolved_plugins.plugin_manager,
236                build_config,
237                progress.clone(),
238            )?;
239
240        if let Some(classpath_result) = classpath_cache.as_ref() {
241            inject_classpath_into_graph(&mut graph, classpath_result)?;
242        }
243
244        let (_graph, build_result) = sqry_core::graph::unified::build::persist_and_analyze_graph(
245            graph,
246            root_path,
247            &resolved_plugins.plugin_manager,
248            build_config,
249            build_command,
250            resolved_plugins.persisted_selection.clone(),
251            progress,
252            effective_threads,
253        )?;
254        return Ok(build_result);
255    }
256
257    build_and_persist_with_optional_classpath(
258        root_path,
259        resolved_plugins,
260        build_config,
261        build_command,
262        progress,
263        None,
264    )
265}
266
267/// Resolve path argument to absolute `PathBuf`
268fn resolve_path(path: Option<String>) -> Result<PathBuf> {
269    let path_str = path.unwrap_or_else(|| ".".to_string());
270    let path = PathBuf::from(path_str);
271
272    if path.exists() {
273        path.canonicalize().context("Failed to resolve path")
274    } else {
275        anyhow::bail!("Path does not exist: {}", path.display());
276    }
277}
278
279#[cfg(all(test, feature = "jvm-classpath"))]
280mod tests {
281    use super::*;
282
283    fn classpath_opts<'a>(classpath_file: Option<&'a std::path::Path>) -> ClasspathCliOptions<'a> {
284        ClasspathCliOptions {
285            enabled: true,
286            depth: crate::args::ClasspathDepthArg::Full,
287            classpath_file,
288            build_system: None,
289            force_classpath: false,
290        }
291    }
292
293    #[test]
294    fn classpath_invalidation_includes_gradle_property_files() {
295        let root = std::path::Path::new("/repo");
296        let changes = vec![
297            FileChange::Modified(root.join("gradle.properties")),
298            FileChange::Modified(root.join("gradle/wrapper/gradle-wrapper.properties")),
299        ];
300
301        assert!(
302            classpath_inputs_changed(root, &changes[..1], &classpath_opts(None)),
303            "gradle.properties should invalidate the classpath cache"
304        );
305        assert!(
306            classpath_inputs_changed(root, &changes[1..], &classpath_opts(None)),
307            "gradle-wrapper.properties should invalidate the classpath cache"
308        );
309    }
310
311    #[test]
312    fn classpath_invalidation_ignores_regular_source_files() {
313        let root = std::path::Path::new("/repo");
314        let changes = vec![FileChange::Modified(root.join("src/Main.java"))];
315        assert!(
316            !classpath_inputs_changed(root, &changes, &classpath_opts(None)),
317            "ordinary source edits should reuse the cached classpath result"
318        );
319    }
320}