1use 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#[allow(clippy::too_many_arguments)]
24#[allow(clippy::too_many_lines)]
25#[allow(clippy::fn_params_excessive_bools)] #[allow(clippy::needless_pass_by_value)] pub fn execute(
28 cli: &Cli,
29 path: Option<String>,
30 threads: Option<usize>,
31 debounce: Option<u64>,
32 _show_stats: bool, 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 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 let watcher = FileWatcher::new(&root_path)?;
99 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 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 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
273fn 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
294fn 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 static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
316
317 #[test]
318 fn default_watch_debounce_honours_env_override() {
319 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}