1use crate::args::Cli;
4use crate::plugin_defaults::{self, PluginSelectionMode};
5use crate::progress::{CliProgressReporter, CliStepProgressReporter, StepRunner};
6use anyhow::{Context, Result};
7use sqry_core::graph::unified::analysis::ReachabilityStrategy;
8use sqry_core::graph::unified::build::BuildResult;
9use sqry_core::graph::unified::build::entrypoint::{AnalysisStrategySummary, get_git_head_commit};
10use sqry_core::graph::unified::persistence::{GraphStorage, load_header_from_path};
11use sqry_core::json_response::IndexStatus;
12use sqry_core::progress::{SharedReporter, no_op_reporter};
13use std::fs;
14use std::io::{BufRead, BufReader, IsTerminal, Write};
15use std::path::Path;
16#[cfg(feature = "jvm-classpath")]
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::time::Instant;
20
21#[derive(serde::Serialize)]
26struct ThreadPoolMetrics {
27 thread_pool_creations: u64,
28}
29
30#[cfg_attr(not(feature = "jvm-classpath"), allow(dead_code))]
31#[derive(Clone, Copy, Debug)]
32pub(crate) struct ClasspathCliOptions<'a> {
33 pub enabled: bool,
34 pub depth: crate::args::ClasspathDepthArg,
35 pub classpath_file: Option<&'a Path>,
36 pub build_system: Option<&'a str>,
37 pub force_classpath: bool,
38}
39
40#[cfg(feature = "jvm-classpath")]
41pub(crate) fn run_classpath_pipeline_only(
42 root_path: &Path,
43 classpath_opts: &ClasspathCliOptions<'_>,
44) -> Result<sqry_classpath::pipeline::ClasspathPipelineResult> {
45 use sqry_classpath::pipeline::{ClasspathConfig, ClasspathDepth};
46
47 let depth = match classpath_opts.depth {
48 crate::args::ClasspathDepthArg::Full => ClasspathDepth::Full,
49 crate::args::ClasspathDepthArg::Shallow => ClasspathDepth::Shallow,
50 };
51 let config = ClasspathConfig {
52 enabled: true,
53 depth,
54 build_system_override: classpath_opts.build_system.map(str::to_owned),
55 classpath_file: classpath_opts.classpath_file.map(Path::to_path_buf),
56 force: classpath_opts.force_classpath,
57 timeout_secs: 60,
58 };
59
60 println!("Running JVM classpath analysis...");
61 let result = sqry_classpath::pipeline::run_classpath_pipeline(root_path, &config)
62 .context("Classpath pipeline failed")?;
63 println!(
64 " Classpath: {} JARs scanned, {} classes parsed",
65 result.jars_scanned, result.classes_parsed
66 );
67 Ok(result)
68}
69
70#[cfg(feature = "jvm-classpath")]
71fn create_workspace_classpath_import_edges(
72 graph: &mut sqry_core::graph::unified::concurrent::CodeGraph,
73 classpath_result: &sqry_classpath::pipeline::ClasspathPipelineResult,
74 fqn_to_nodes: &std::collections::HashMap<
75 String,
76 Vec<sqry_classpath::graph::emitter::ClasspathNodeRef>,
77 >,
78) -> (usize, usize, usize, usize) {
79 use sqry_core::graph::unified::edge::EdgeKind;
80 use sqry_core::graph::unified::node::NodeKind;
81
82 let class_fqns: std::collections::HashSet<&str> = classpath_result
83 .index
84 .classes
85 .iter()
86 .map(|class_stub| class_stub.fqn.as_str())
87 .collect();
88 let mut package_index: std::collections::HashMap<
89 String,
90 Vec<&sqry_classpath::graph::emitter::ClasspathNodeRef>,
91 > = std::collections::HashMap::new();
92 for fqn in class_fqns {
93 if let Some(node_refs) = fqn_to_nodes.get(fqn)
94 && let Some((package_name, _)) = fqn.rsplit_once('.')
95 {
96 package_index
97 .entry(package_name.to_owned())
98 .or_default()
99 .extend(node_refs.iter());
100 }
101 }
102
103 let scoped_jars = build_scope_jar_sets(&classpath_result.provenance);
104 let provenance_lookup = build_provenance_lookup(&classpath_result.provenance);
105 let mut existing_imports = Vec::new();
106 for (source_id, source_entry) in graph.nodes().iter() {
107 if source_entry.is_unified_loser() {
112 continue;
113 }
114 for edge in graph.edges().edges_from(source_id) {
115 let EdgeKind::Imports { alias, is_wildcard } = edge.kind.clone() else {
116 continue;
117 };
118 let Some(import_entry) = graph.nodes().get(edge.target) else {
119 continue;
120 };
121 if import_entry.kind != NodeKind::Import || graph.files().is_external(import_entry.file)
122 {
123 continue;
124 }
125 let importer_path = graph
126 .files()
127 .resolve(edge.file)
128 .map(|path| canonicalish_path(path.as_ref()));
129 let import_name = import_entry
130 .qualified_name
131 .and_then(|id| graph.strings().resolve(id))
132 .or_else(|| graph.strings().resolve(import_entry.name))
133 .map(|value| value.to_string());
134 existing_imports.push((
135 source_id,
136 edge.file,
137 alias,
138 is_wildcard,
139 import_name,
140 importer_path,
141 ));
142 }
143 }
144
145 let mut created_edges = 0usize;
146 let mut skipped_member_imports = 0usize;
147 let mut skipped_unscoped_imports = 0usize;
148 let mut skipped_ambiguous_imports = 0usize;
149
150 for (importer_id, file_id, alias, is_wildcard, import_name, importer_path) in existing_imports {
151 let Some(import_name) = import_name else {
152 continue;
153 };
154 if import_name.starts_with("static ") {
155 skipped_member_imports += 1;
156 continue;
157 }
158
159 let Some(resolved) = resolve_allowed_jars(importer_path.as_deref(), &scoped_jars) else {
160 skipped_unscoped_imports += 1;
161 continue;
162 };
163
164 if is_wildcard || import_name.ends_with(".*") || import_name.ends_with("._") {
165 let package_name = import_name
166 .strip_suffix(".*")
167 .or_else(|| import_name.strip_suffix("._"))
168 .unwrap_or(import_name.as_str());
169 if let Some(targets) = package_index.get(package_name) {
170 let filtered_targets =
171 filter_scope_targets(targets.iter().copied().collect(), &resolved.allowed_jars);
172 let grouped_targets = group_targets_by_fqn(filtered_targets);
173 for target_group in grouped_targets.into_values() {
174 let reduced = prefer_direct_targets(
175 target_group,
176 resolved.matched_root.as_deref(),
177 &provenance_lookup,
178 );
179 if reduced.len() > 1 {
180 skipped_ambiguous_imports += 1;
181 continue;
182 }
183 let target_id = reduced[0].node_id;
184 let _delta = graph.edges().add_edge(
185 importer_id,
186 target_id,
187 EdgeKind::Imports { alias, is_wildcard },
188 file_id,
189 );
190 created_edges += 1;
191 }
192 }
193 continue;
194 }
195
196 if let Some(targets) = fqn_to_nodes.get(import_name.as_str()) {
197 let filtered_targets =
198 filter_scope_targets(targets.iter().collect(), &resolved.allowed_jars);
199 let reduced = prefer_direct_targets(
200 filtered_targets,
201 resolved.matched_root.as_deref(),
202 &provenance_lookup,
203 );
204 if reduced.len() > 1 {
205 skipped_ambiguous_imports += 1;
206 continue;
207 }
208 if let Some(target_ref) = reduced.first() {
209 let _delta = graph.edges().add_edge(
210 importer_id,
211 target_ref.node_id,
212 EdgeKind::Imports { alias, is_wildcard },
213 file_id,
214 );
215 created_edges += 1;
216 }
217 }
218 }
219
220 (
221 created_edges,
222 skipped_member_imports,
223 skipped_unscoped_imports,
224 skipped_ambiguous_imports,
225 )
226}
227
228#[cfg(feature = "jvm-classpath")]
229pub(crate) fn inject_classpath_into_graph(
230 graph: &mut sqry_core::graph::unified::concurrent::CodeGraph,
231 classpath_result: &sqry_classpath::pipeline::ClasspathPipelineResult,
232) -> Result<()> {
233 let emission_result = sqry_classpath::graph::emitter::emit_into_code_graph(
234 &classpath_result.index,
235 graph,
236 &classpath_result.provenance,
237 )
238 .map_err(|e| anyhow::anyhow!("Classpath emission error: {e}"))?;
239
240 let (
241 import_edges_created,
242 skipped_member_imports,
243 skipped_unscoped_imports,
244 skipped_ambiguous_imports,
245 ) = create_workspace_classpath_import_edges(
246 graph,
247 classpath_result,
248 &emission_result.fqn_to_nodes,
249 );
250
251 graph.rebuild_indices();
252 println!(
253 " Graph enriched with {} classpath types, {} import edges ({} member/static, {} unscoped, {} ambiguous imports skipped)",
254 classpath_result.index.classes.len(),
255 import_edges_created,
256 skipped_member_imports,
257 skipped_unscoped_imports,
258 skipped_ambiguous_imports,
259 );
260 Ok(())
261}
262
263#[cfg(feature = "jvm-classpath")]
264fn build_scope_jar_sets(
265 provenance: &[sqry_classpath::graph::provenance::ClasspathProvenance],
266) -> Vec<(PathBuf, std::collections::HashSet<PathBuf>)> {
267 let mut by_root: std::collections::HashMap<PathBuf, std::collections::HashSet<PathBuf>> =
268 std::collections::HashMap::new();
269 for entry in provenance {
270 for scope in &entry.scopes {
271 by_root
272 .entry(canonicalish_path(&scope.module_root))
273 .or_default()
274 .insert(entry.jar_path.clone());
275 }
276 }
277
278 let mut scopes: Vec<_> = by_root.into_iter().collect();
279 scopes.sort_by(|a, b| {
280 b.0.components()
281 .count()
282 .cmp(&a.0.components().count())
283 .then_with(|| a.0.cmp(&b.0))
284 });
285 scopes
286}
287
288#[cfg(feature = "jvm-classpath")]
290struct ResolvedScope {
291 allowed_jars: std::collections::HashSet<PathBuf>,
292 matched_root: Option<PathBuf>,
293}
294
295#[cfg(feature = "jvm-classpath")]
296fn resolve_allowed_jars(
297 importer_path: Option<&Path>,
298 scopes: &[(PathBuf, std::collections::HashSet<PathBuf>)],
299) -> Option<ResolvedScope> {
300 let importer_path = importer_path?;
301 for (root, jars) in scopes {
302 if importer_path.starts_with(root) {
303 return Some(ResolvedScope {
304 allowed_jars: jars.clone(),
305 matched_root: Some(root.clone()),
306 });
307 }
308 }
309 if scopes.len() == 1 {
310 return Some(ResolvedScope {
311 allowed_jars: scopes[0].1.clone(),
312 matched_root: Some(scopes[0].0.clone()),
313 });
314 }
315 None
316}
317
318#[cfg(feature = "jvm-classpath")]
321fn build_provenance_lookup(
322 provenance: &[sqry_classpath::graph::provenance::ClasspathProvenance],
323) -> std::collections::HashMap<PathBuf, &sqry_classpath::graph::provenance::ClasspathProvenance> {
324 provenance
325 .iter()
326 .map(|entry| (entry.jar_path.clone(), entry))
327 .collect()
328}
329
330#[cfg(feature = "jvm-classpath")]
334fn prefer_direct_targets<'a>(
335 targets: Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef>,
336 matched_root: Option<&Path>,
337 provenance_lookup: &std::collections::HashMap<
338 PathBuf,
339 &sqry_classpath::graph::provenance::ClasspathProvenance,
340 >,
341) -> Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef> {
342 if targets.len() <= 1 {
343 return targets;
344 }
345
346 let Some(root) = matched_root else {
347 return targets;
348 };
349
350 let direct: Vec<_> = targets
351 .iter()
352 .copied()
353 .filter(|target| {
354 provenance_lookup.get(&target.jar_path).is_some_and(|prov| {
355 prov.scopes
356 .iter()
357 .any(|scope| scope.module_root == root && scope.is_direct)
358 })
359 })
360 .collect();
361
362 if direct.is_empty() || direct.len() == targets.len() {
363 targets
365 } else {
366 direct
367 }
368}
369
370#[cfg(feature = "jvm-classpath")]
371fn filter_scope_targets<'a>(
372 targets: Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef>,
373 allowed_jars: &std::collections::HashSet<PathBuf>,
374) -> Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef> {
375 targets
376 .into_iter()
377 .filter(|target| allowed_jars.contains(&target.jar_path))
378 .collect()
379}
380
381#[cfg(feature = "jvm-classpath")]
382fn group_targets_by_fqn<'a>(
383 targets: Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef>,
384) -> std::collections::HashMap<String, Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef>> {
385 let mut grouped = std::collections::HashMap::new();
386 for target in targets {
387 grouped
388 .entry(target.fqn.clone())
389 .or_insert_with(Vec::new)
390 .push(target);
391 }
392 grouped
393}
394
395#[cfg(feature = "jvm-classpath")]
396fn canonicalish_path(path: &Path) -> PathBuf {
397 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
398}
399
400#[allow(unused_variables, unused_mut)]
401pub(crate) fn build_and_persist_with_optional_classpath(
402 root_path: &Path,
403 resolved_plugins: &plugin_defaults::ResolvedPluginManager,
404 build_config: &sqry_core::graph::unified::build::BuildConfig,
405 build_command: &str,
406 progress: SharedReporter,
407 classpath_opts: Option<&ClasspathCliOptions<'_>>,
408) -> Result<BuildResult> {
409 #[cfg(feature = "jvm-classpath")]
410 let classpath_result = if let Some(classpath_opts) = classpath_opts.filter(|opts| opts.enabled)
411 {
412 Some(run_classpath_pipeline_only(root_path, classpath_opts)?)
413 } else {
414 None
415 };
416
417 #[cfg(not(feature = "jvm-classpath"))]
418 if classpath_opts.is_some_and(|opts| opts.enabled) {
419 eprintln!(
420 "WARNING: --classpath flag requires the 'jvm-classpath' feature. \
421 Rebuild sqry-cli with: cargo build --features jvm-classpath"
422 );
423 }
424
425 let (mut graph, effective_threads) =
426 sqry_core::graph::unified::build::build_unified_graph_with_progress(
427 root_path,
428 &resolved_plugins.plugin_manager,
429 build_config,
430 progress.clone(),
431 )?;
432
433 #[cfg(feature = "jvm-classpath")]
434 if let Some(classpath_result) = &classpath_result {
435 inject_classpath_into_graph(&mut graph, classpath_result)?;
436 }
437
438 let (_graph, build_result) = sqry_core::graph::unified::build::persist_and_analyze_graph(
439 graph,
440 root_path,
441 &resolved_plugins.plugin_manager,
442 build_config,
443 build_command,
444 resolved_plugins.persisted_selection.clone(),
445 progress,
446 effective_threads,
447 )?;
448
449 Ok(build_result)
450}
451
452#[allow(clippy::fn_params_excessive_bools)] #[allow(clippy::too_many_arguments)]
474pub fn run_index(
481 cli: &Cli,
482 path: &str,
483 force: bool,
484 threads: Option<usize>,
485 add_to_gitignore: bool,
486 _no_incremental: bool,
487 _cache_dir: Option<&str>,
488 _no_compress: bool,
489 enable_macro_expansion: bool,
490 cfg_flags: &[String],
491 expand_cache: Option<&std::path::Path>,
492 classpath: bool,
493 _no_classpath: bool,
494 classpath_depth: crate::args::ClasspathDepthArg,
495 classpath_file: Option<&Path>,
496 build_system: Option<&str>,
497 force_classpath: bool,
498) -> Result<()> {
499 if let Some(0) = threads {
500 anyhow::bail!("--threads must be >= 1");
501 }
502
503 let root_path = Path::new(path);
504
505 handle_gitignore(root_path, add_to_gitignore);
506
507 let storage = GraphStorage::new(root_path);
509 if storage.exists() && !force {
510 println!("Index already exists at {}", storage.graph_dir().display());
511 println!("Use --force to rebuild, or run 'sqry update' to update incrementally");
512 return Ok(());
513 }
514
515 if enable_macro_expansion || !cfg_flags.is_empty() || expand_cache.is_some() {
517 log::info!(
518 "Macro boundary config: expansion={enable_macro_expansion}, cfg_flags={cfg_flags:?}, expand_cache={expand_cache:?}",
519 );
520 }
521
522 print_index_build_banner(root_path, threads);
523
524 let start = Instant::now();
525 let mut step_runner = StepRunner::new(!std::io::stderr().is_terminal() && !cli.json);
526
527 let (progress_bar, progress) = create_progress_reporter(cli);
528
529 let build_config = create_build_config(cli, root_path, threads)?;
531 let resolved_plugins =
532 plugin_defaults::resolve_plugin_selection(cli, root_path, PluginSelectionMode::FreshWrite)?;
533 let classpath_opts = ClasspathCliOptions {
534 enabled: classpath,
535 depth: classpath_depth,
536 classpath_file,
537 build_system,
538 force_classpath,
539 };
540 let build_result = step_runner.step("Build unified graph", || -> Result<_> {
541 build_and_persist_with_optional_classpath(
542 root_path,
543 &resolved_plugins,
544 &build_config,
545 "cli:index",
546 progress.clone(),
547 Some(&classpath_opts),
548 )
549 })?;
550
551 finish_progress_bar(progress_bar.as_ref());
552
553 let elapsed = start.elapsed();
554
555 if std::env::var("SQRY_EMIT_THREAD_POOL_METRICS")
557 .ok()
558 .is_some_and(|v| v == "1")
559 {
560 let metrics = ThreadPoolMetrics {
561 thread_pool_creations: 1,
562 };
563 if let Ok(json) = serde_json::to_string(&metrics) {
564 println!("{json}");
565 }
566 }
567
568 if !cli.json {
570 let status = build_graph_status(&storage)?;
571 emit_graph_summary(
572 &storage,
573 &status,
574 &build_result,
575 elapsed,
576 "✓ Index built successfully!",
577 );
578 }
579
580 Ok(())
581}
582
583fn emit_graph_summary(
584 storage: &GraphStorage,
585 status: &IndexStatus,
586 build_result: &BuildResult,
587 elapsed: std::time::Duration,
588 summary_banner: &str,
589) {
590 println!("\n{summary_banner}");
591 println!(
592 " Graph: {} nodes, {} canonical edges ({} raw)",
593 build_result.node_count, build_result.edge_count, build_result.raw_edge_count
594 );
595 println!(
596 " Corpus: {} files across {} languages",
597 build_result.total_files,
598 build_result.file_count.len()
599 );
600 println!(
601 " Top languages: {}",
602 format_top_languages(&build_result.file_count)
603 );
604 println!(
605 " Reachability: {}",
606 format_analysis_strategy_highlights(&build_result.analysis_strategies)
607 );
608 if !build_result.active_plugin_ids.is_empty() {
609 println!(
610 " Active plugins: {}",
611 build_result.active_plugin_ids.join(", ")
612 );
613 }
614 if status.supports_relations {
615 println!(" Relations: Enabled");
616 }
617 println!(" Graph path: {}", storage.graph_dir().display());
618 println!(" Analysis path: {}", storage.analysis_dir().display());
619 println!(" Time taken: {:.2}s", elapsed.as_secs_f64());
620}
621
622fn print_index_build_banner(root_path: &Path, threads: Option<usize>) {
623 if let Some(1) = threads {
624 println!(
625 "Building index for {} (single-threaded)...",
626 root_path.display()
627 );
628 } else if let Some(count) = threads {
629 println!(
630 "Building index for {} using {} threads...",
631 root_path.display(),
632 count
633 );
634 } else {
635 println!("Building index for {} (parallel)...", root_path.display());
636 }
637}
638
639pub(crate) fn create_progress_reporter(
640 cli: &Cli,
641) -> (Option<Arc<CliProgressReporter>>, SharedReporter) {
642 let progress_bar = if std::io::stderr().is_terminal() && !cli.json {
644 Some(Arc::new(CliProgressReporter::new()))
645 } else {
646 None
647 };
648
649 let progress: SharedReporter = if let Some(progress_bar_ref) = &progress_bar {
650 Arc::clone(progress_bar_ref) as SharedReporter
651 } else if cli.json {
652 no_op_reporter()
653 } else {
654 Arc::new(CliStepProgressReporter::new()) as SharedReporter
655 };
656
657 (progress_bar, progress)
658}
659
660fn finish_progress_bar(progress_bar: Option<&Arc<CliProgressReporter>>) {
661 if let Some(progress_bar_ref) = progress_bar {
662 progress_bar_ref.finish();
663 }
664}
665
666fn build_graph_status(storage: &GraphStorage) -> Result<IndexStatus> {
688 if !storage.exists() {
689 return Ok(IndexStatus::not_found());
690 }
691
692 let manifest = storage
694 .load_manifest()
695 .context("Failed to load graph manifest")?;
696
697 let age_seconds = storage
699 .snapshot_age(&manifest)
700 .context("Failed to compute snapshot age")?
701 .as_secs();
702
703 let total_files: Option<usize> =
705 if let Ok(header) = load_header_from_path(storage.snapshot_path()) {
706 Some(header.file_count)
708 } else if !manifest.file_count.is_empty() {
709 Some(manifest.file_count.values().sum())
711 } else {
712 None
714 };
715
716 let trigram_path = storage.graph_dir().join("trigram.idx");
719 let has_trigram = trigram_path.exists();
720
721 Ok(IndexStatus::from_index(
723 storage.graph_dir().display().to_string(),
724 manifest.built_at.clone(),
725 age_seconds,
726 )
727 .symbol_count(manifest.node_count) .file_count_opt(total_files)
729 .has_relations(manifest.edge_count > 0)
730 .has_trigram(has_trigram)
731 .build())
732}
733
734fn write_graph_status_text(
735 streams: &mut crate::output::OutputStreams,
736 status: &IndexStatus,
737 root_path: &Path,
738) -> Result<()> {
739 if status.exists {
740 streams.write_result("✓ Graph snapshot found\n")?;
741 if let Some(path) = &status.path {
742 streams.write_result(&format!(" Path: {path}\n"))?;
743 }
744 if let Some(created_at) = &status.created_at {
745 streams.write_result(&format!(" Built: {created_at}\n"))?;
746 }
747 if let Some(age) = status.age_seconds {
748 streams.write_result(&format!(" Age: {}\n", format_age(age)))?;
749 }
750 if let Some(count) = status.symbol_count {
751 streams.write_result(&format!(" Nodes: {count}\n"))?;
752 }
753 if let Some(count) = status.file_count {
754 streams.write_result(&format!(" Files: {count}\n"))?;
755 }
756 if status.supports_relations {
757 streams.write_result(" Relations: ✓ Available\n")?;
758 }
759 } else {
760 streams.write_result("✗ No graph snapshot found\n")?;
761 streams.write_result("\nTo create a graph snapshot, run:\n")?;
762 streams.write_result(&format!(" sqry index --force {}\n", root_path.display()))?;
763 }
764
765 Ok(())
766}
767
768fn format_age(age_seconds: u64) -> String {
769 let hours = age_seconds / 3600;
770 let days = hours / 24;
771 if days > 0 {
772 format!("{} days, {} hours", days, hours % 24)
773 } else {
774 format!("{hours} hours")
775 }
776}
777
778fn format_top_languages(file_count: &std::collections::HashMap<String, usize>) -> String {
779 if file_count.is_empty() {
780 return "none".to_string();
781 }
782
783 let mut entries: Vec<_> = file_count.iter().collect();
784 entries.sort_by(|(left_name, left_count), (right_name, right_count)| {
785 right_count
786 .cmp(left_count)
787 .then_with(|| left_name.cmp(right_name))
788 });
789
790 entries
791 .into_iter()
792 .take(3)
793 .map(|(language, count)| format!("{language}={count}"))
794 .collect::<Vec<_>>()
795 .join(", ")
796}
797
798fn format_analysis_strategy_highlights(analysis_strategies: &[AnalysisStrategySummary]) -> String {
799 if analysis_strategies.is_empty() {
800 return "not available".to_string();
801 }
802
803 let mut interval_labels = Vec::new();
804 let mut dag_bfs = Vec::new();
805
806 for strategy in analysis_strategies {
807 match strategy.strategy {
808 ReachabilityStrategy::IntervalLabels => interval_labels.push(strategy.edge_kind),
809 ReachabilityStrategy::DagBfs => dag_bfs.push(strategy.edge_kind),
810 }
811 }
812
813 let mut groups = Vec::new();
814 if !interval_labels.is_empty() {
815 groups.push(format!("interval_labels({})", interval_labels.join(",")));
816 }
817 if !dag_bfs.is_empty() {
818 groups.push(format!("dag_bfs({})", dag_bfs.join(",")));
819 }
820
821 groups.join(" | ")
822}
823
824pub(crate) fn create_build_config(
826 cli: &Cli,
827 root_path: &Path,
828 threads: Option<usize>,
829) -> Result<sqry_core::graph::unified::build::BuildConfig> {
830 Ok(sqry_core::graph::unified::build::BuildConfig {
831 max_depth: if cli.max_depth == 0 {
832 None
833 } else {
834 Some(cli.max_depth)
835 },
836 follow_links: cli.follow,
837 include_hidden: cli.hidden,
838 num_threads: threads,
839 label_budget: sqry_core::graph::unified::analysis::resolve_label_budget_config(
840 root_path, None, None, None, false,
841 )?,
842 ..sqry_core::graph::unified::build::BuildConfig::default()
843 })
844}
845
846#[allow(clippy::too_many_arguments)]
857#[allow(clippy::fn_params_excessive_bools)] pub fn run_update(
859 cli: &Cli,
860 path: &str,
861 threads: Option<usize>,
862 show_stats: bool,
863 _no_incremental: bool,
864 _cache_dir: Option<&str>,
865 classpath: bool,
866 _no_classpath: bool,
867 classpath_depth: crate::args::ClasspathDepthArg,
868 classpath_file: Option<&Path>,
869 build_system: Option<&str>,
870 force_classpath: bool,
871) -> Result<()> {
872 let root_path = Path::new(path);
873 let mut step_runner = StepRunner::new(!std::io::stderr().is_terminal() && !cli.json);
874
875 let storage = GraphStorage::new(root_path);
877 if !storage.exists() {
878 anyhow::bail!(
879 "No index found at {}. Run 'sqry index' first.",
880 storage.graph_dir().display()
881 );
882 }
883
884 println!("Updating index for {}...", root_path.display());
885 let start = Instant::now();
886
887 let git_mode_disabled = std::env::var("SQRY_GIT_BACKEND")
889 .ok()
890 .is_some_and(|v| v == "none");
891
892 let current_commit = if git_mode_disabled {
893 None
894 } else {
895 get_git_head_commit(root_path)
896 };
897
898 let using_git_mode = !git_mode_disabled && current_commit.is_some();
900
901 let (progress_bar, progress) = create_progress_reporter(cli);
902
903 let build_config = create_build_config(cli, root_path, threads)?;
905 let resolved_plugins = plugin_defaults::resolve_plugin_selection(
906 cli,
907 root_path,
908 PluginSelectionMode::ExistingWrite,
909 )?;
910 let classpath_opts = ClasspathCliOptions {
911 enabled: classpath,
912 depth: classpath_depth,
913 classpath_file,
914 build_system,
915 force_classpath,
916 };
917 let build_result = step_runner.step("Update unified graph", || -> Result<_> {
918 build_and_persist_with_optional_classpath(
919 root_path,
920 &resolved_plugins,
921 &build_config,
922 "cli:update",
923 progress.clone(),
924 Some(&classpath_opts),
925 )
926 })?;
927
928 finish_progress_bar(progress_bar.as_ref());
929
930 let elapsed = start.elapsed();
931
932 if !cli.json {
934 let status = build_graph_status(&storage)?;
935
936 if using_git_mode {
937 emit_graph_summary(
938 &storage,
939 &status,
940 &build_result,
941 elapsed,
942 "✓ Index updated successfully!",
943 );
944 } else {
945 emit_graph_summary(
946 &storage,
947 &status,
948 &build_result,
949 elapsed,
950 "✓ Index updated successfully (hash-based mode)!",
951 );
952 }
953 }
954
955 if show_stats {
956 println!("(Detailed stats are not available for unified graph update)");
957 }
958
959 Ok(())
960}
961
962#[allow(deprecated)]
963pub fn run_index_status(
973 cli: &Cli,
974 path: &str,
975 _metrics_format: crate::args::MetricsFormat,
976) -> Result<()> {
977 run_graph_status(cli, path)
979}
980
981pub fn run_graph_status(cli: &Cli, path: &str) -> Result<()> {
990 let root_path = Path::new(path);
991 let storage = GraphStorage::new(root_path);
992 let status = build_graph_status(&storage)?;
993
994 let mut streams = crate::output::OutputStreams::with_pager(cli.pager_config());
996
997 if cli.json {
998 let json =
999 serde_json::to_string_pretty(&status).context("Failed to serialize graph status")?;
1000 streams.write_result(&json)?;
1001 } else {
1002 write_graph_status_text(&mut streams, &status, root_path)?;
1003 }
1004
1005 streams.finish_checked()
1006}
1007
1008fn handle_gitignore(path: &Path, add_to_gitignore: bool) {
1010 if let Some(root) = find_git_root(path) {
1011 let gitignore_path = root.join(".gitignore");
1012 let entry = ".sqry-index/";
1013 let mut is_already_indexed = false;
1014
1015 if gitignore_path.exists()
1016 && let Ok(file) = fs::File::open(&gitignore_path)
1017 {
1018 let reader = BufReader::new(file);
1019 if reader.lines().any(|line| {
1020 line.map(|l| l.trim() == ".sqry-index" || l.trim() == ".sqry-index/")
1021 .unwrap_or(false)
1022 }) {
1023 is_already_indexed = true;
1024 }
1025 }
1026
1027 if !is_already_indexed
1028 && add_to_gitignore
1029 && let Ok(mut file) = fs::OpenOptions::new()
1030 .append(true)
1031 .create(true)
1032 .open(&gitignore_path)
1033 && writeln!(file, "\n{entry}").is_ok()
1034 {
1035 println!("Added '{entry}' to .gitignore");
1036 } else if !is_already_indexed {
1037 print_gitignore_warning();
1038 }
1039 }
1040}
1041
1042fn find_git_root(path: &Path) -> Option<&Path> {
1044 let mut current = path;
1045 loop {
1046 if current.join(".git").is_dir() {
1047 return Some(current);
1048 }
1049 if let Some(parent) = current.parent() {
1050 current = parent;
1051 } else {
1052 return None;
1053 }
1054 }
1055}
1056
1057fn print_gitignore_warning() {
1059 eprintln!(
1060 "\n\u{26a0}\u{fe0f} Warning: It is recommended to add the '.sqry-index/' directory to your .gitignore file."
1061 );
1062 eprintln!("This is a generated cache and can become large.\n");
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068 use crate::large_stack_test;
1069 use std::fs;
1070 use tempfile::TempDir;
1071
1072 large_stack_test! {
1073 #[test]
1074 fn test_run_index_basic() {
1075 use crate::args::Cli;
1076 use clap::Parser;
1077
1078 let tmp_cli_workspace = TempDir::new().unwrap();
1079 let file_path = tmp_cli_workspace.path().join("test.rs");
1080 fs::write(&file_path, "fn hello() {}").unwrap();
1081
1082 let cli = Cli::parse_from(["sqry", "index"]);
1083 let result = run_index(
1084 &cli,
1085 tmp_cli_workspace.path().to_str().unwrap(),
1086 false,
1087 None,
1088 false,
1089 false,
1090 None,
1091 false, false, &[], None, false,
1096 false,
1097 crate::args::ClasspathDepthArg::Full,
1098 None,
1099 None,
1100 false,
1101 );
1102 assert!(result.is_ok());
1103
1104 let storage = GraphStorage::new(tmp_cli_workspace.path());
1106 assert!(storage.exists());
1107 }
1108 }
1109
1110 large_stack_test! {
1111 #[test]
1112 fn test_run_index_force_rebuild() {
1113 use crate::args::Cli;
1114 use clap::Parser;
1115
1116 let tmp_cli_workspace = TempDir::new().unwrap();
1117 let file_path = tmp_cli_workspace.path().join("test.rs");
1118 fs::write(&file_path, "fn hello() {}").unwrap();
1119
1120 let cli = Cli::parse_from(["sqry", "index"]);
1121
1122 run_index(
1124 &cli,
1125 tmp_cli_workspace.path().to_str().unwrap(),
1126 false,
1127 None,
1128 false,
1129 false,
1130 None,
1131 false, false, &[], None, false,
1136 false,
1137 crate::args::ClasspathDepthArg::Full,
1138 None,
1139 None,
1140 false,
1141 )
1142 .unwrap();
1143
1144 let result = run_index(
1146 &cli,
1147 tmp_cli_workspace.path().to_str().unwrap(),
1148 false,
1149 None,
1150 false,
1151 false,
1152 None,
1153 false, false, &[], None, false,
1158 false,
1159 crate::args::ClasspathDepthArg::Full,
1160 None,
1161 None,
1162 false,
1163 );
1164 assert!(result.is_ok());
1165
1166 let result = run_index(
1168 &cli,
1169 tmp_cli_workspace.path().to_str().unwrap(),
1170 true,
1171 None,
1172 false,
1173 false,
1174 None,
1175 false, false, &[], None, false,
1180 false,
1181 crate::args::ClasspathDepthArg::Full,
1182 None,
1183 None,
1184 false,
1185 );
1186 assert!(result.is_ok());
1187 }
1188 }
1189
1190 large_stack_test! {
1191 #[test]
1192 fn test_run_update_no_index() {
1193 use crate::args::Cli;
1194 use clap::Parser;
1195
1196 let tmp_cli_workspace = TempDir::new().unwrap();
1197 let cli = Cli::parse_from(["sqry", "update"]);
1198
1199 let result = run_update(
1200 &cli,
1201 tmp_cli_workspace.path().to_str().unwrap(),
1202 None,
1203 false,
1204 false,
1205 None,
1206 false,
1207 false,
1208 crate::args::ClasspathDepthArg::Full,
1209 None,
1210 None,
1211 false,
1212 );
1213 assert!(result.is_err());
1214 assert!(result.unwrap_err().to_string().contains("No index found"));
1215 }
1216 }
1217
1218 large_stack_test! {
1219 #[test]
1220 fn test_run_index_status_no_index() {
1221 use crate::args::Cli;
1222 use clap::Parser;
1223
1224 let tmp_cli_workspace = TempDir::new().unwrap();
1225
1226 let cli = Cli::parse_from(["sqry", "--json"]);
1228
1229 let result = run_index_status(
1231 &cli,
1232 tmp_cli_workspace.path().to_str().unwrap(),
1233 crate::args::MetricsFormat::Json,
1234 );
1235 assert!(
1236 result.is_ok(),
1237 "Index status should not error on missing index"
1238 );
1239
1240 }
1243 }
1244
1245 large_stack_test! {
1246 #[test]
1247 fn test_run_index_status_with_index() {
1248 use crate::args::Cli;
1249 use clap::Parser;
1250
1251 let tmp_cli_workspace = TempDir::new().unwrap();
1252 let file_path = tmp_cli_workspace.path().join("test.rs");
1253 fs::write(&file_path, "fn test_func() {}").unwrap();
1254
1255 let cli = Cli::parse_from(["sqry", "index"]);
1256
1257 run_index(
1259 &cli,
1260 tmp_cli_workspace.path().to_str().unwrap(),
1261 false,
1262 None,
1263 false,
1264 false,
1265 None,
1266 false, false, &[], None, false,
1271 false,
1272 crate::args::ClasspathDepthArg::Full,
1273 None,
1274 None,
1275 false,
1276 )
1277 .unwrap();
1278
1279 let cli = Cli::parse_from(["sqry", "--json"]);
1281 let result = run_index_status(
1282 &cli,
1283 tmp_cli_workspace.path().to_str().unwrap(),
1284 crate::args::MetricsFormat::Json,
1285 );
1286 assert!(
1287 result.is_ok(),
1288 "Index status should succeed with existing index"
1289 );
1290
1291 let storage = GraphStorage::new(tmp_cli_workspace.path());
1293 assert!(storage.exists());
1294
1295 let manifest = storage.load_manifest().unwrap();
1297 assert_eq!(manifest.node_count, 1, "Should have 1 symbol");
1298 }
1299 }
1300
1301 large_stack_test! {
1302 #[test]
1303 fn test_run_update_basic() {
1304 use crate::args::Cli;
1305 use clap::Parser;
1306
1307 let tmp_cli_workspace = TempDir::new().unwrap();
1308 let file_path = tmp_cli_workspace.path().join("test.rs");
1309 fs::write(&file_path, "fn hello() {}").unwrap();
1310
1311 let cli = Cli::parse_from(["sqry", "index"]);
1312
1313 run_index(
1315 &cli,
1316 tmp_cli_workspace.path().to_str().unwrap(),
1317 false,
1318 None,
1319 false,
1320 false,
1321 None,
1322 false, false, &[], None, false,
1327 false,
1328 crate::args::ClasspathDepthArg::Full,
1329 None,
1330 None,
1331 false,
1332 )
1333 .unwrap();
1334
1335 let result = run_update(
1337 &cli,
1338 tmp_cli_workspace.path().to_str().unwrap(),
1339 None,
1340 true,
1341 false,
1342 None,
1343 false,
1344 false,
1345 crate::args::ClasspathDepthArg::Full,
1346 None,
1347 None,
1348 false,
1349 );
1350 assert!(result.is_ok());
1351 }
1352 }
1353
1354 #[test]
1355 fn plugin_manager_registers_elixir_extensions() {
1356 let pm = crate::plugin_defaults::create_plugin_manager();
1357 assert!(
1358 pm.plugin_for_extension("ex").is_some(),
1359 "Elixir .ex extension missing"
1360 );
1361 assert!(
1362 pm.plugin_for_extension("exs").is_some(),
1363 "Elixir .exs extension missing"
1364 );
1365 }
1366
1367 #[test]
1368 fn test_format_top_languages_orders_by_count_then_name() {
1369 let counts = std::collections::HashMap::from([
1370 ("rust".to_string(), 9_usize),
1371 ("python".to_string(), 4_usize),
1372 ("go".to_string(), 4_usize),
1373 ("typescript".to_string(), 2_usize),
1374 ]);
1375
1376 assert_eq!(format_top_languages(&counts), "rust=9, go=4, python=4");
1377 }
1378
1379 #[test]
1380 fn test_format_analysis_strategy_highlights_groups_by_strategy() {
1381 let strategies = vec![
1382 AnalysisStrategySummary {
1383 edge_kind: "calls",
1384 strategy: ReachabilityStrategy::IntervalLabels,
1385 },
1386 AnalysisStrategySummary {
1387 edge_kind: "imports",
1388 strategy: ReachabilityStrategy::DagBfs,
1389 },
1390 AnalysisStrategySummary {
1391 edge_kind: "references",
1392 strategy: ReachabilityStrategy::DagBfs,
1393 },
1394 AnalysisStrategySummary {
1395 edge_kind: "inherits",
1396 strategy: ReachabilityStrategy::IntervalLabels,
1397 },
1398 ];
1399
1400 assert_eq!(
1401 format_analysis_strategy_highlights(&strategies),
1402 "interval_labels(calls,inherits) | dag_bfs(imports,references)"
1403 );
1404 }
1405
1406 #[cfg(feature = "jvm-classpath")]
1407 #[test]
1408 fn test_resolve_allowed_jars_prefers_nearest_scope() {
1409 let scopes = vec![
1410 (
1411 PathBuf::from("/repo/services/app"),
1412 std::collections::HashSet::from([PathBuf::from("/jars/app.jar")]),
1413 ),
1414 (
1415 PathBuf::from("/repo"),
1416 std::collections::HashSet::from([PathBuf::from("/jars/root.jar")]),
1417 ),
1418 ];
1419
1420 let resolved =
1421 resolve_allowed_jars(Some(Path::new("/repo/services/app/src/Main.java")), &scopes)
1422 .expect("nearest scope should resolve");
1423 assert!(
1424 resolved
1425 .allowed_jars
1426 .contains(&PathBuf::from("/jars/app.jar"))
1427 );
1428 assert!(
1429 !resolved
1430 .allowed_jars
1431 .contains(&PathBuf::from("/jars/root.jar"))
1432 );
1433 assert_eq!(
1434 resolved.matched_root.as_deref(),
1435 Some(Path::new("/repo/services/app"))
1436 );
1437 }
1438
1439 #[cfg(feature = "jvm-classpath")]
1440 #[test]
1441 fn test_filter_scope_targets_excludes_out_of_scope_jars() {
1442 let targets = vec![
1443 sqry_classpath::graph::emitter::ClasspathNodeRef {
1444 node_id: sqry_core::graph::unified::node::NodeId::new(1, 0),
1445 fqn: "com.example.Foo".to_string(),
1446 jar_path: PathBuf::from("/jars/app.jar"),
1447 file_id: sqry_core::graph::unified::FileId::new(1),
1448 },
1449 sqry_classpath::graph::emitter::ClasspathNodeRef {
1450 node_id: sqry_core::graph::unified::node::NodeId::new(2, 0),
1451 fqn: "com.example.Foo".to_string(),
1452 jar_path: PathBuf::from("/jars/other.jar"),
1453 file_id: sqry_core::graph::unified::FileId::new(2),
1454 },
1455 ];
1456 let allowed = std::collections::HashSet::from([PathBuf::from("/jars/app.jar")]);
1457
1458 let filtered = filter_scope_targets(targets.iter().collect(), &allowed);
1459 assert_eq!(filtered.len(), 1);
1460 assert_eq!(filtered[0].jar_path, PathBuf::from("/jars/app.jar"));
1461 }
1462
1463 #[cfg(feature = "jvm-classpath")]
1464 #[test]
1465 fn test_prefer_direct_targets_exact_import_direct_wins() {
1466 use sqry_classpath::graph::provenance::{ClasspathProvenance, ClasspathScope};
1467
1468 let targets = vec![
1469 sqry_classpath::graph::emitter::ClasspathNodeRef {
1470 node_id: sqry_core::graph::unified::node::NodeId::new(1, 0),
1471 fqn: "com.example.Foo".to_string(),
1472 jar_path: PathBuf::from("/jars/direct.jar"),
1473 file_id: sqry_core::graph::unified::FileId::new(1),
1474 },
1475 sqry_classpath::graph::emitter::ClasspathNodeRef {
1476 node_id: sqry_core::graph::unified::node::NodeId::new(2, 0),
1477 fqn: "com.example.Foo".to_string(),
1478 jar_path: PathBuf::from("/jars/transitive.jar"),
1479 file_id: sqry_core::graph::unified::FileId::new(2),
1480 },
1481 ];
1482
1483 let provenance = vec![
1484 ClasspathProvenance {
1485 jar_path: PathBuf::from("/jars/direct.jar"),
1486 coordinates: None,
1487 is_direct: true,
1488 scopes: vec![ClasspathScope {
1489 module_name: "app".to_owned(),
1490 module_root: PathBuf::from("/repo/app"),
1491 is_direct: true,
1492 }],
1493 },
1494 ClasspathProvenance {
1495 jar_path: PathBuf::from("/jars/transitive.jar"),
1496 coordinates: None,
1497 is_direct: false,
1498 scopes: vec![ClasspathScope {
1499 module_name: "app".to_owned(),
1500 module_root: PathBuf::from("/repo/app"),
1501 is_direct: false,
1502 }],
1503 },
1504 ];
1505 let lookup = build_provenance_lookup(&provenance);
1506
1507 let result = prefer_direct_targets(
1508 targets.iter().collect(),
1509 Some(Path::new("/repo/app")),
1510 &lookup,
1511 );
1512 assert_eq!(result.len(), 1, "direct jar should win over transitive");
1513 assert_eq!(result[0].jar_path, PathBuf::from("/jars/direct.jar"));
1514 }
1515
1516 #[cfg(feature = "jvm-classpath")]
1517 #[test]
1518 fn test_prefer_direct_targets_wildcard_same_shape() {
1519 use sqry_classpath::graph::provenance::{ClasspathProvenance, ClasspathScope};
1520
1521 let targets = vec![
1524 sqry_classpath::graph::emitter::ClasspathNodeRef {
1525 node_id: sqry_core::graph::unified::node::NodeId::new(10, 0),
1526 fqn: "com.example.Bar".to_string(),
1527 jar_path: PathBuf::from("/jars/direct.jar"),
1528 file_id: sqry_core::graph::unified::FileId::new(10),
1529 },
1530 sqry_classpath::graph::emitter::ClasspathNodeRef {
1531 node_id: sqry_core::graph::unified::node::NodeId::new(11, 0),
1532 fqn: "com.example.Bar".to_string(),
1533 jar_path: PathBuf::from("/jars/transitive.jar"),
1534 file_id: sqry_core::graph::unified::FileId::new(11),
1535 },
1536 ];
1537
1538 let provenance = vec![
1539 ClasspathProvenance {
1540 jar_path: PathBuf::from("/jars/direct.jar"),
1541 coordinates: None,
1542 is_direct: true,
1543 scopes: vec![ClasspathScope {
1544 module_name: "app".to_owned(),
1545 module_root: PathBuf::from("/repo/app"),
1546 is_direct: true,
1547 }],
1548 },
1549 ClasspathProvenance {
1550 jar_path: PathBuf::from("/jars/transitive.jar"),
1551 coordinates: None,
1552 is_direct: false,
1553 scopes: vec![ClasspathScope {
1554 module_name: "app".to_owned(),
1555 module_root: PathBuf::from("/repo/app"),
1556 is_direct: false,
1557 }],
1558 },
1559 ];
1560 let lookup = build_provenance_lookup(&provenance);
1561
1562 let result = prefer_direct_targets(
1563 targets.iter().collect(),
1564 Some(Path::new("/repo/app")),
1565 &lookup,
1566 );
1567 assert_eq!(
1568 result.len(),
1569 1,
1570 "wildcard: direct jar should win over transitive"
1571 );
1572 assert_eq!(result[0].jar_path, PathBuf::from("/jars/direct.jar"));
1573 }
1574
1575 #[cfg(feature = "jvm-classpath")]
1576 #[test]
1577 fn test_prefer_direct_targets_true_ambiguity_two_direct_jars() {
1578 use sqry_classpath::graph::provenance::{ClasspathProvenance, ClasspathScope};
1579
1580 let targets = vec![
1583 sqry_classpath::graph::emitter::ClasspathNodeRef {
1584 node_id: sqry_core::graph::unified::node::NodeId::new(20, 0),
1585 fqn: "com.example.Baz".to_string(),
1586 jar_path: PathBuf::from("/jars/direct-a.jar"),
1587 file_id: sqry_core::graph::unified::FileId::new(20),
1588 },
1589 sqry_classpath::graph::emitter::ClasspathNodeRef {
1590 node_id: sqry_core::graph::unified::node::NodeId::new(21, 0),
1591 fqn: "com.example.Baz".to_string(),
1592 jar_path: PathBuf::from("/jars/direct-b.jar"),
1593 file_id: sqry_core::graph::unified::FileId::new(21),
1594 },
1595 ];
1596
1597 let provenance = vec![
1598 ClasspathProvenance {
1599 jar_path: PathBuf::from("/jars/direct-a.jar"),
1600 coordinates: None,
1601 is_direct: true,
1602 scopes: vec![ClasspathScope {
1603 module_name: "app".to_owned(),
1604 module_root: PathBuf::from("/repo/app"),
1605 is_direct: true,
1606 }],
1607 },
1608 ClasspathProvenance {
1609 jar_path: PathBuf::from("/jars/direct-b.jar"),
1610 coordinates: None,
1611 is_direct: true,
1612 scopes: vec![ClasspathScope {
1613 module_name: "app".to_owned(),
1614 module_root: PathBuf::from("/repo/app"),
1615 is_direct: true,
1616 }],
1617 },
1618 ];
1619 let lookup = build_provenance_lookup(&provenance);
1620
1621 let result = prefer_direct_targets(
1622 targets.iter().collect(),
1623 Some(Path::new("/repo/app")),
1624 &lookup,
1625 );
1626 assert_eq!(
1627 result.len(),
1628 2,
1629 "two direct jars = true ambiguity, both should remain"
1630 );
1631 }
1632}