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