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