Skip to main content

sqry_cli/commands/
index.rs

1//! Index command implementation
2
3use 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/// Thread pool creation metrics for diagnostic output.
22///
23/// Emitted as JSON to stdout when `SQRY_EMIT_THREAD_POOL_METRICS=1` is set.
24/// Used for build diagnostics and performance monitoring.
25#[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/// Result of scope resolution for an importer path.
282#[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/// Builds a lookup from JAR path to its provenance entry for O(1) directness
312/// checks during import resolution.
313#[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/// Reduces candidates by preferring direct dependencies over transitive ones
324/// within the matched scope. Returns the full set unchanged if all candidates
325/// share the same directness or if no provenance/scope information is available.
326#[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        // No differentiation possible — return the original set
357        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// format_validation_prometheus removed
446// format_validation_summary removed
447// validation_warning_count removed
448
449/// Run index build command
450///
451/// # Arguments
452///
453/// * `cli` - CLI configuration (for validation flags)
454/// * `path` - Directory to index
455/// * `force` - Force rebuild even if index exists
456/// * `threads` - Number of threads for parallel indexing (None = auto-detect)
457///
458/// # Errors
459///
460/// Returns an error if index build or persistence fails.
461///
462/// # Panics
463///
464/// Panics if the index is missing after a successful build-and-save sequence.
465#[allow(clippy::fn_params_excessive_bools)] // CLI flags map directly to booleans.
466#[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    // Check if graph already exists
495    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    // Log macro boundary analysis configuration
503    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    // Build unified graph using the consolidated pipeline
517    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    // Emit thread pool metrics if requested (diagnostic feature)
543    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    // Report success
556    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    // Create progress reporter (disable when not connected to a TTY)
630    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
653// emit_index_summary removed — logic inlined in run_index
654// handle_update_validation removed — validation moved to core
655// emit_validation_failures removed — validation moved to core
656// handle_validation_strictness removed — validation moved to core
657
658// emit_update_summary removed
659
660// build_index_status removed
661
662// collect_languages removed
663
664// write_index_status_json removed
665// write_index_status_text removed
666// write_index_status_found removed
667// write_index_status_metadata removed
668// write_index_status_missing removed
669// write_validation_report_text removed
670// write_dependency_validation removed
671// write_id_validation removed
672// write_graph_validation removed
673
674fn build_graph_status(storage: &GraphStorage) -> Result<IndexStatus> {
675    if !storage.exists() {
676        return Ok(IndexStatus::not_found());
677    }
678
679    // Load manifest
680    let manifest = storage
681        .load_manifest()
682        .context("Failed to load graph manifest")?;
683
684    // Compute age
685    let age_seconds = storage
686        .snapshot_age(&manifest)
687        .context("Failed to compute snapshot age")?
688        .as_secs();
689
690    // Get file count: prefer snapshot header (fast), fallback to manifest (CLI-built indexes)
691    let total_files: Option<usize> =
692        if let Ok(header) = load_header_from_path(storage.snapshot_path()) {
693            // Read from snapshot header (always accurate)
694            Some(header.file_count)
695        } else if !manifest.file_count.is_empty() {
696            // Fallback: sum manifest file counts (legacy CLI-built indexes)
697            Some(manifest.file_count.values().sum())
698        } else {
699            // No file count available
700            None
701        };
702
703    // Check if trigram index exists in graph storage
704    // Trigram files would be stored alongside the snapshot
705    let trigram_path = storage.graph_dir().join("trigram.idx");
706    let has_trigram = trigram_path.exists();
707
708    // Build status (map graph data to IndexStatus for compatibility)
709    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) // Map nodes → symbols
715    .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
811/// Create a `BuildConfig` from CLI flags.
812pub(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/// Run index update command
834///
835/// # Arguments
836///
837/// * `cli` - CLI configuration (for validation flags)
838/// * `path` - Directory with existing index
839/// * `show_stats` - Show detailed statistics
840///
841/// # Errors
842/// Returns an error if the index cannot be loaded, updated, or validated.
843#[allow(clippy::too_many_arguments)]
844#[allow(clippy::fn_params_excessive_bools)] // CLI flags map directly to booleans.
845pub 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    // Check if graph exists
863    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    // Determine update mode based on git availability
875    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    // Determine if we're using git-aware or hash-based mode
886    let using_git_mode = !git_mode_disabled && current_commit.is_some();
887
888    let (progress_bar, progress) = create_progress_reporter(cli);
889
890    // Update graph using consolidated pipeline
891    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    // Report success with appropriate message based on update mode
920    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)]
950/// Run index status command for programmatic consumers.
951///
952/// # Arguments
953///
954/// * `cli` - CLI configuration
955/// * `path` - Directory to check for index
956///
957/// # Errors
958/// Returns an error if the index status cannot be loaded or rendered.
959pub fn run_index_status(
960    cli: &Cli,
961    path: &str,
962    _metrics_format: crate::args::MetricsFormat,
963) -> Result<()> {
964    // Redirect to graph status as legacy index is removed
965    run_graph_status(cli, path)
966}
967
968/// Run graph status command using unified graph architecture.
969///
970/// This command reports on the state of the unified graph snapshot
971/// stored in `.sqry/graph/` directory instead of the legacy `.sqry-index`.
972///
973/// # Errors
974///
975/// Returns an error if manifest cannot be loaded or output formatting fails.
976pub 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    // Output result (same format as run_index_status for compatibility)
982    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
995/// Handles the .gitignore check and modification.
996fn 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
1029/// Find the root of the git repository by traversing up from the given path.
1030fn 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
1044/// Prints a standard warning message about .gitignore.
1045fn 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, // no_compress
1079            false, // enable_macro_expansion
1080            &[],  // cfg_flags
1081            None, // expand_cache
1082            false,
1083            false,
1084            crate::args::ClasspathDepthArg::Full,
1085            None,
1086            None,
1087            false,
1088        );
1089        assert!(result.is_ok());
1090
1091        // Check index was created
1092        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        // Build initial index
1110        run_index(
1111            &cli,
1112            tmp_cli_workspace.path().to_str().unwrap(),
1113            false,
1114            None,
1115            false,
1116            false,
1117            None,
1118            false, // no_compress
1119            false, // enable_macro_expansion
1120            &[],   // cfg_flags
1121            None,  // expand_cache
1122            false,
1123            false,
1124            crate::args::ClasspathDepthArg::Full,
1125            None,
1126            None,
1127            false,
1128        )
1129        .unwrap();
1130
1131        // Try to rebuild without force (should skip)
1132        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, // no_compress
1141            false, // enable_macro_expansion
1142            &[],   // cfg_flags
1143            None,  // expand_cache
1144            false,
1145            false,
1146            crate::args::ClasspathDepthArg::Full,
1147            None,
1148            None,
1149            false,
1150        );
1151        assert!(result.is_ok());
1152
1153        // Rebuild with force (should succeed)
1154        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, // no_compress
1163            false, // enable_macro_expansion
1164            &[],   // cfg_flags
1165            None,  // expand_cache
1166            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        // Create CLI with JSON flag
1214        let cli = Cli::parse_from(["sqry", "--json"]);
1215
1216        // Should succeed even with no index
1217        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        // The output would be captured via OutputStreams
1228        // We can't easily test the output here, but we verified it doesn't panic
1229    }
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        // Build index first
1245        run_index(
1246            &cli,
1247            tmp_cli_workspace.path().to_str().unwrap(),
1248            false,
1249            None,
1250            false,
1251            false,
1252            None,
1253            false, // no_compress
1254            false, // enable_macro_expansion
1255            &[],   // cfg_flags
1256            None,  // expand_cache
1257            false,
1258            false,
1259            crate::args::ClasspathDepthArg::Full,
1260            None,
1261            None,
1262            false,
1263        )
1264        .unwrap();
1265
1266        // Check status with JSON flag
1267        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        // Verify the index actually exists
1279        let storage = GraphStorage::new(tmp_cli_workspace.path());
1280        assert!(storage.exists());
1281
1282        // Load index and verify it has the symbol
1283        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        // Build initial index
1301        run_index(
1302            &cli,
1303            tmp_cli_workspace.path().to_str().unwrap(),
1304            false,
1305            None,
1306            false,
1307            false,
1308            None,
1309            false, // no_compress
1310            false, // enable_macro_expansion
1311            &[],   // cfg_flags
1312            None,  // expand_cache
1313            false,
1314            false,
1315            crate::args::ClasspathDepthArg::Full,
1316            None,
1317            None,
1318            false,
1319        )
1320        .unwrap();
1321
1322        // Update should succeed
1323        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        // Wildcard imports group by FQN first, then each group goes through
1509        // prefer_direct_targets. Simulate one FQN group with two candidates.
1510        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        // Two direct jars with the same FQN: true ambiguity, should remain
1568        // ambiguous (both returned).
1569        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}