use crate::args::Cli;
use crate::plugin_defaults::{self, PluginSelectionMode};
use crate::progress::{CliProgressReporter, CliStepProgressReporter, StepRunner};
use anyhow::{Context, Result};
use sqry_core::graph::unified::analysis::ReachabilityStrategy;
use sqry_core::graph::unified::build::BuildResult;
use sqry_core::graph::unified::build::entrypoint::{AnalysisStrategySummary, get_git_head_commit};
use sqry_core::graph::unified::persistence::{GraphStorage, load_header_from_path};
use sqry_core::json_response::IndexStatus;
use sqry_core::progress::{SharedReporter, no_op_reporter};
use std::fs;
use std::io::{BufRead, BufReader, IsTerminal, Write};
use std::path::Path;
#[cfg(feature = "jvm-classpath")]
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
#[derive(serde::Serialize)]
struct ThreadPoolMetrics {
thread_pool_creations: u64,
}
#[cfg_attr(not(feature = "jvm-classpath"), allow(dead_code))]
#[derive(Clone, Copy, Debug)]
pub(crate) struct ClasspathCliOptions<'a> {
pub enabled: bool,
pub depth: crate::args::ClasspathDepthArg,
pub classpath_file: Option<&'a Path>,
pub build_system: Option<&'a str>,
pub force_classpath: bool,
}
#[cfg(feature = "jvm-classpath")]
pub(crate) fn run_classpath_pipeline_only(
root_path: &Path,
classpath_opts: &ClasspathCliOptions<'_>,
) -> Result<Option<sqry_classpath::pipeline::ClasspathPipelineResult>> {
use sqry_classpath::pipeline::{ClasspathConfig, ClasspathDepth};
let depth = match classpath_opts.depth {
crate::args::ClasspathDepthArg::Full => ClasspathDepth::Full,
crate::args::ClasspathDepthArg::Shallow => ClasspathDepth::Shallow,
};
let config = ClasspathConfig {
enabled: true,
depth,
build_system_override: classpath_opts.build_system.map(str::to_owned),
classpath_file: classpath_opts.classpath_file.map(Path::to_path_buf),
force: classpath_opts.force_classpath,
timeout_secs: 60,
};
println!("Running JVM classpath analysis...");
match sqry_classpath::pipeline::run_classpath_pipeline(root_path, &config) {
Ok(result) => {
println!(
" Classpath: {} JARs scanned, {} classes parsed",
result.jars_scanned, result.classes_parsed
);
Ok(Some(result))
}
Err(sqry_classpath::ClasspathError::DetectionFailed(message))
if classpath_opts.build_system.is_none() && classpath_opts.classpath_file.is_none() =>
{
eprintln!(
"WARNING: --classpath requested, but no JVM build system was detected; \
skipping classpath analysis. {message}"
);
Ok(None)
}
Err(error) => Err(error).context("Classpath pipeline failed"),
}
}
#[cfg(feature = "jvm-classpath")]
fn create_workspace_classpath_import_edges(
graph: &mut sqry_core::graph::unified::concurrent::CodeGraph,
classpath_result: &sqry_classpath::pipeline::ClasspathPipelineResult,
fqn_to_nodes: &std::collections::HashMap<
String,
Vec<sqry_classpath::graph::emitter::ClasspathNodeRef>,
>,
) -> (usize, usize, usize, usize) {
use sqry_core::graph::unified::edge::EdgeKind;
use sqry_core::graph::unified::node::NodeKind;
let class_fqns: std::collections::HashSet<&str> = classpath_result
.index
.classes
.iter()
.map(|class_stub| class_stub.fqn.as_str())
.collect();
let mut package_index: std::collections::HashMap<
String,
Vec<&sqry_classpath::graph::emitter::ClasspathNodeRef>,
> = std::collections::HashMap::new();
for fqn in class_fqns {
if let Some(node_refs) = fqn_to_nodes.get(fqn)
&& let Some((package_name, _)) = fqn.rsplit_once('.')
{
package_index
.entry(package_name.to_owned())
.or_default()
.extend(node_refs.iter());
}
}
let scoped_jars = build_scope_jar_sets(&classpath_result.provenance);
let provenance_lookup = build_provenance_lookup(&classpath_result.provenance);
let mut existing_imports = Vec::new();
for (source_id, source_entry) in graph.nodes().iter() {
if source_entry.is_unified_loser() {
continue;
}
for edge in graph.edges().edges_from(source_id) {
let EdgeKind::Imports { alias, is_wildcard } = edge.kind.clone() else {
continue;
};
let Some(import_entry) = graph.nodes().get(edge.target) else {
continue;
};
if import_entry.kind != NodeKind::Import || graph.files().is_external(import_entry.file)
{
continue;
}
let importer_path = graph
.files()
.resolve(edge.file)
.map(|path| canonicalish_path(path.as_ref()));
let import_name = import_entry
.qualified_name
.and_then(|id| graph.strings().resolve(id))
.or_else(|| graph.strings().resolve(import_entry.name))
.map(|value| value.to_string());
existing_imports.push((
source_id,
edge.file,
alias,
is_wildcard,
import_name,
importer_path,
));
}
}
let mut created_edges = 0usize;
let mut skipped_member_imports = 0usize;
let mut skipped_unscoped_imports = 0usize;
let mut skipped_ambiguous_imports = 0usize;
for (importer_id, file_id, alias, is_wildcard, import_name, importer_path) in existing_imports {
let Some(import_name) = import_name else {
continue;
};
if import_name.starts_with("static ") {
skipped_member_imports += 1;
continue;
}
let Some(resolved) = resolve_allowed_jars(importer_path.as_deref(), &scoped_jars) else {
skipped_unscoped_imports += 1;
continue;
};
if is_wildcard || import_name.ends_with(".*") || import_name.ends_with("._") {
let package_name = import_name
.strip_suffix(".*")
.or_else(|| import_name.strip_suffix("._"))
.unwrap_or(import_name.as_str());
if let Some(targets) = package_index.get(package_name) {
let filtered_targets =
filter_scope_targets(targets.to_vec(), &resolved.allowed_jars);
let grouped_targets = group_targets_by_fqn(filtered_targets);
for target_group in grouped_targets.into_values() {
let reduced = prefer_direct_targets(
target_group,
resolved.matched_root.as_deref(),
&provenance_lookup,
);
if reduced.len() > 1 {
skipped_ambiguous_imports += 1;
continue;
}
let target_id = reduced[0].node_id;
let _delta = graph.edges().add_edge(
importer_id,
target_id,
EdgeKind::Imports { alias, is_wildcard },
file_id,
);
created_edges += 1;
}
}
continue;
}
if let Some(targets) = fqn_to_nodes.get(import_name.as_str()) {
let filtered_targets =
filter_scope_targets(targets.iter().collect(), &resolved.allowed_jars);
let reduced = prefer_direct_targets(
filtered_targets,
resolved.matched_root.as_deref(),
&provenance_lookup,
);
if reduced.len() > 1 {
skipped_ambiguous_imports += 1;
continue;
}
if let Some(target_ref) = reduced.first() {
let _delta = graph.edges().add_edge(
importer_id,
target_ref.node_id,
EdgeKind::Imports { alias, is_wildcard },
file_id,
);
created_edges += 1;
}
}
}
(
created_edges,
skipped_member_imports,
skipped_unscoped_imports,
skipped_ambiguous_imports,
)
}
#[cfg(feature = "jvm-classpath")]
pub(crate) fn inject_classpath_into_graph(
graph: &mut sqry_core::graph::unified::concurrent::CodeGraph,
classpath_result: &sqry_classpath::pipeline::ClasspathPipelineResult,
) -> Result<()> {
let emission_result = sqry_classpath::graph::emitter::emit_into_code_graph(
&classpath_result.index,
graph,
&classpath_result.provenance,
)
.map_err(|e| anyhow::anyhow!("Classpath emission error: {e}"))?;
let (
import_edges_created,
skipped_member_imports,
skipped_unscoped_imports,
skipped_ambiguous_imports,
) = create_workspace_classpath_import_edges(
graph,
classpath_result,
&emission_result.fqn_to_nodes,
);
graph.rebuild_indices();
println!(
" Graph enriched with {} classpath types, {} import edges ({} member/static, {} unscoped, {} ambiguous imports skipped)",
classpath_result.index.classes.len(),
import_edges_created,
skipped_member_imports,
skipped_unscoped_imports,
skipped_ambiguous_imports,
);
Ok(())
}
#[cfg(feature = "jvm-classpath")]
fn build_scope_jar_sets(
provenance: &[sqry_classpath::graph::provenance::ClasspathProvenance],
) -> Vec<(PathBuf, std::collections::HashSet<PathBuf>)> {
let mut by_root: std::collections::HashMap<PathBuf, std::collections::HashSet<PathBuf>> =
std::collections::HashMap::new();
for entry in provenance {
for scope in &entry.scopes {
by_root
.entry(canonicalish_path(&scope.module_root))
.or_default()
.insert(entry.jar_path.clone());
}
}
let mut scopes: Vec<_> = by_root.into_iter().collect();
scopes.sort_by(|a, b| {
b.0.components()
.count()
.cmp(&a.0.components().count())
.then_with(|| a.0.cmp(&b.0))
});
scopes
}
#[cfg(feature = "jvm-classpath")]
struct ResolvedScope {
allowed_jars: std::collections::HashSet<PathBuf>,
matched_root: Option<PathBuf>,
}
#[cfg(feature = "jvm-classpath")]
fn resolve_allowed_jars(
importer_path: Option<&Path>,
scopes: &[(PathBuf, std::collections::HashSet<PathBuf>)],
) -> Option<ResolvedScope> {
let importer_path = importer_path?;
for (root, jars) in scopes {
if importer_path.starts_with(root) {
return Some(ResolvedScope {
allowed_jars: jars.clone(),
matched_root: Some(root.clone()),
});
}
}
if scopes.len() == 1 {
return Some(ResolvedScope {
allowed_jars: scopes[0].1.clone(),
matched_root: Some(scopes[0].0.clone()),
});
}
None
}
#[cfg(feature = "jvm-classpath")]
fn build_provenance_lookup(
provenance: &[sqry_classpath::graph::provenance::ClasspathProvenance],
) -> std::collections::HashMap<PathBuf, &sqry_classpath::graph::provenance::ClasspathProvenance> {
provenance
.iter()
.map(|entry| (entry.jar_path.clone(), entry))
.collect()
}
#[cfg(feature = "jvm-classpath")]
fn prefer_direct_targets<'a>(
targets: Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef>,
matched_root: Option<&Path>,
provenance_lookup: &std::collections::HashMap<
PathBuf,
&sqry_classpath::graph::provenance::ClasspathProvenance,
>,
) -> Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef> {
if targets.len() <= 1 {
return targets;
}
let Some(root) = matched_root else {
return targets;
};
let direct: Vec<_> = targets
.iter()
.copied()
.filter(|target| {
provenance_lookup.get(&target.jar_path).is_some_and(|prov| {
prov.scopes
.iter()
.any(|scope| scope.module_root == root && scope.is_direct)
})
})
.collect();
if direct.is_empty() || direct.len() == targets.len() {
targets
} else {
direct
}
}
#[cfg(feature = "jvm-classpath")]
fn filter_scope_targets<'a>(
targets: Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef>,
allowed_jars: &std::collections::HashSet<PathBuf>,
) -> Vec<&'a sqry_classpath::graph::emitter::ClasspathNodeRef> {
targets
.into_iter()
.filter(|target| allowed_jars.contains(&target.jar_path))
.collect()
}
#[cfg(feature = "jvm-classpath")]
fn group_targets_by_fqn(
targets: Vec<&sqry_classpath::graph::emitter::ClasspathNodeRef>,
) -> std::collections::HashMap<String, Vec<&sqry_classpath::graph::emitter::ClasspathNodeRef>> {
let mut grouped = std::collections::HashMap::new();
for target in targets {
grouped
.entry(target.fqn.clone())
.or_insert_with(Vec::new)
.push(target);
}
grouped
}
#[cfg(feature = "jvm-classpath")]
fn canonicalish_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
#[allow(unused_variables, unused_mut)]
pub(crate) fn build_and_persist_with_optional_classpath(
root_path: &Path,
resolved_plugins: &plugin_defaults::ResolvedPluginManager,
build_config: &sqry_core::graph::unified::build::BuildConfig,
build_command: &str,
progress: SharedReporter,
classpath_opts: Option<&ClasspathCliOptions<'_>>,
cache_dir: Option<&Path>,
) -> Result<BuildResult> {
#[cfg(feature = "jvm-classpath")]
let classpath_result = if let Some(classpath_opts) = classpath_opts.filter(|opts| opts.enabled)
{
run_classpath_pipeline_only(root_path, classpath_opts)?
} else {
None
};
#[cfg(not(feature = "jvm-classpath"))]
if classpath_opts.is_some_and(|opts| opts.enabled) {
eprintln!(
"WARNING: --classpath flag requires the 'jvm-classpath' feature. \
Rebuild sqry-cli with: cargo build --features jvm-classpath"
);
}
let (mut graph, effective_threads) =
sqry_core::graph::unified::build::build_unified_graph_with_progress(
root_path,
&resolved_plugins.plugin_manager,
build_config,
progress.clone(),
)?;
#[cfg(feature = "jvm-classpath")]
if let Some(classpath_result) = &classpath_result {
inject_classpath_into_graph(&mut graph, classpath_result)?;
}
if let Some(dir) = cache_dir
&& let Err(err) = persist_hash_index_snapshot(&graph, dir)
{
log::warn!(
"failed to persist hash index to {} ({err}); cache snapshot skipped",
dir.display()
);
}
let (_graph, build_result) = sqry_core::graph::unified::build::persist_and_analyze_graph(
graph,
root_path,
&resolved_plugins.plugin_manager,
build_config,
build_command,
resolved_plugins.persisted_selection.clone(),
progress,
effective_threads,
)?;
Ok(build_result)
}
fn persist_hash_index_snapshot(
graph: &sqry_core::graph::unified::CodeGraph,
cache_dir: &Path,
) -> Result<()> {
use sqry_core::indexing::incremental::{FileHash, HashIndex};
let mut index = HashIndex::new();
let mut hashed = 0usize;
let mut skipped = 0usize;
for (_file_id, path) in graph.files().iter() {
let path_ref: &Path = path.as_ref();
match FileHash::compute(path_ref) {
Ok(hash) => {
index.update(path_ref.to_path_buf(), hash);
hashed += 1;
}
Err(err) => {
log::trace!(
"skipping hash for {} during cache snapshot: {err}",
path_ref.display()
);
skipped += 1;
}
}
}
index.save(cache_dir)?;
log::debug!(
"Persisted hash index snapshot to {}: {hashed} files hashed, {skipped} skipped",
cache_dir.display()
);
Ok(())
}
fn format_validation_prometheus(status: &IndexStatus) -> String {
use std::fmt::Write as _;
let mut output = String::new();
output.push_str("# HELP sqry_index_exists Whether the unified graph index exists on disk\n");
output.push_str("# TYPE sqry_index_exists gauge\n");
let _ = writeln!(output, "sqry_index_exists {}", u8::from(status.exists));
output.push_str("# HELP sqry_index_supports_fuzzy Whether fuzzy search is enabled\n");
output.push_str("# TYPE sqry_index_supports_fuzzy gauge\n");
let _ = writeln!(
output,
"sqry_index_supports_fuzzy {}",
u8::from(status.supports_fuzzy)
);
output
.push_str("# HELP sqry_index_supports_relations Whether relation queries are supported\n");
output.push_str("# TYPE sqry_index_supports_relations gauge\n");
let _ = writeln!(
output,
"sqry_index_supports_relations {}",
u8::from(status.supports_relations)
);
if let Some(symbols) = status.symbol_count {
output.push_str("# HELP sqry_index_symbol_count Total number of indexed symbols\n");
output.push_str("# TYPE sqry_index_symbol_count gauge\n");
let _ = writeln!(output, "sqry_index_symbol_count {symbols}");
}
if let Some(files) = status.file_count {
output.push_str("# HELP sqry_index_file_count Total number of indexed source files\n");
output.push_str("# TYPE sqry_index_file_count gauge\n");
let _ = writeln!(output, "sqry_index_file_count {files}");
}
if let Some(age) = status.age_seconds {
output.push_str("# HELP sqry_index_age_seconds Index age in seconds since creation\n");
output.push_str("# TYPE sqry_index_age_seconds gauge\n");
let _ = writeln!(output, "sqry_index_age_seconds {age}");
}
if let Some(stale) = status.stale {
output.push_str("# HELP sqry_index_stale Whether the index is considered stale\n");
output.push_str("# TYPE sqry_index_stale gauge\n");
let _ = writeln!(output, "sqry_index_stale {}", u8::from(stale));
}
if let Some(relations) = status.cross_language_relation_count {
output.push_str(
"# HELP sqry_index_cross_language_relation_count Total cross-language relations\n",
);
output.push_str("# TYPE sqry_index_cross_language_relation_count gauge\n");
let _ = writeln!(
output,
"sqry_index_cross_language_relation_count {relations}"
);
}
output
}
#[allow(clippy::fn_params_excessive_bools)] #[allow(clippy::too_many_arguments)]
pub fn run_index(
cli: &Cli,
path: &str,
force: bool,
threads: Option<usize>,
add_to_gitignore: bool,
no_incremental: bool,
cache_dir: Option<&str>,
enable_macro_expansion: bool,
cfg_flags: &[String],
expand_cache: Option<&std::path::Path>,
classpath: bool,
_no_classpath: bool,
classpath_depth: crate::args::ClasspathDepthArg,
classpath_file: Option<&Path>,
build_system: Option<&str>,
force_classpath: bool,
allow_nested: bool,
) -> Result<()> {
if let Some(0) = threads {
anyhow::bail!("--threads must be >= 1");
}
let root_path = Path::new(path);
handle_gitignore(root_path, add_to_gitignore);
let storage = GraphStorage::new(root_path);
if storage.exists() && !force && !no_incremental {
println!("Index already exists at {}", storage.graph_dir().display());
println!("Use --force to rebuild, or run 'sqry update' to update incrementally");
return Ok(());
}
if !storage.exists()
&& let Err(e) = sqry_core::workspace::assert_no_ancestor_graph(root_path, allow_nested)
{
anyhow::bail!("{e}");
}
if enable_macro_expansion || !cfg_flags.is_empty() || expand_cache.is_some() {
log::info!(
"Macro boundary config: expansion={enable_macro_expansion}, cfg_flags={cfg_flags:?}, expand_cache={expand_cache:?}",
);
}
print_index_build_banner(root_path, threads);
let start = Instant::now();
let mut step_runner = StepRunner::new(!std::io::stderr().is_terminal() && !cli.json);
let (progress_bar, progress) = create_progress_reporter(cli);
let build_config = create_build_config(cli, root_path, threads)?;
let resolved_plugins =
plugin_defaults::resolve_plugin_selection(cli, root_path, PluginSelectionMode::FreshWrite)?;
let classpath_opts = ClasspathCliOptions {
enabled: classpath,
depth: classpath_depth,
classpath_file,
build_system,
force_classpath,
};
let cache_dir_path = cache_dir.map(Path::new);
let build_result = step_runner.step("Build unified graph", || -> Result<_> {
build_and_persist_with_optional_classpath(
root_path,
&resolved_plugins,
&build_config,
"cli:index",
progress.clone(),
Some(&classpath_opts),
cache_dir_path,
)
})?;
finish_progress_bar(progress_bar.as_ref());
let elapsed = start.elapsed();
if std::env::var("SQRY_EMIT_THREAD_POOL_METRICS")
.ok()
.is_some_and(|v| v == "1")
{
let metrics = ThreadPoolMetrics {
thread_pool_creations: 1,
};
if let Ok(json) = serde_json::to_string(&metrics) {
println!("{json}");
}
}
if !cli.json {
let status = build_graph_status(&storage)?;
emit_graph_summary(
&storage,
&status,
&build_result,
elapsed,
"✓ Index built successfully!",
);
}
Ok(())
}
fn emit_graph_summary(
storage: &GraphStorage,
status: &IndexStatus,
build_result: &BuildResult,
elapsed: std::time::Duration,
summary_banner: &str,
) {
println!("\n{summary_banner}");
println!(
" Graph: {} nodes, {} canonical edges ({} raw)",
build_result.node_count, build_result.edge_count, build_result.raw_edge_count
);
println!(
" Corpus: {} files across {} languages",
build_result.total_files,
build_result.file_count.len()
);
println!(
" Top languages: {}",
format_top_languages(&build_result.file_count)
);
println!(
" Reachability: {}",
format_analysis_strategy_highlights(&build_result.analysis_strategies)
);
if !build_result.active_plugin_ids.is_empty() {
println!(
" Active plugins: {}",
build_result.active_plugin_ids.join(", ")
);
}
if status.supports_relations {
println!(" Relations: Enabled");
}
println!(" Graph path: {}", storage.graph_dir().display());
println!(" Analysis path: {}", storage.analysis_dir().display());
println!(" Time taken: {:.2}s", elapsed.as_secs_f64());
}
fn print_index_build_banner(root_path: &Path, threads: Option<usize>) {
if let Some(1) = threads {
println!(
"Building index for {} (single-threaded)...",
root_path.display()
);
} else if let Some(count) = threads {
println!(
"Building index for {} using {} threads...",
root_path.display(),
count
);
} else {
println!("Building index for {} (parallel)...", root_path.display());
}
}
pub(crate) fn create_progress_reporter(
cli: &Cli,
) -> (Option<Arc<CliProgressReporter>>, SharedReporter) {
let progress_bar = if std::io::stderr().is_terminal() && !cli.json {
Some(Arc::new(CliProgressReporter::new()))
} else {
None
};
let progress: SharedReporter = if let Some(progress_bar_ref) = &progress_bar {
Arc::clone(progress_bar_ref) as SharedReporter
} else if cli.json {
no_op_reporter()
} else {
Arc::new(CliStepProgressReporter::new()) as SharedReporter
};
(progress_bar, progress)
}
fn finish_progress_bar(progress_bar: Option<&Arc<CliProgressReporter>>) {
if let Some(progress_bar_ref) = progress_bar {
progress_bar_ref.finish();
}
}
fn build_graph_status(storage: &GraphStorage) -> Result<IndexStatus> {
let snapshot_exists = storage.snapshot_path().exists();
let manifest_exists = storage.manifest_path().exists();
match (snapshot_exists, manifest_exists) {
(false, false) => return Ok(IndexStatus::not_found()),
(true, false) => {
let header = match load_header_from_path(storage.snapshot_path()) {
Ok(h) => h,
Err(e) => {
return Err(anyhow::anyhow!(
"snapshot present at {} but unreadable: {e}",
storage.snapshot_path().display()
));
}
};
return Ok(IndexStatus::from_index(
storage.graph_dir().display().to_string(),
"unknown (daemon-built; no manifest)".to_string(),
0,
)
.symbol_count(header.node_count)
.file_count_opt(Some(header.file_count))
.has_relations(header.edge_count > 0)
.has_trigram(false)
.build());
}
(false, true) => {
return Err(anyhow::anyhow!(
"manifest at {} but no snapshot at {}; rebuild with `sqry index --force`",
storage.manifest_path().display(),
storage.snapshot_path().display()
));
}
(true, true) => {
}
}
let manifest = storage
.load_manifest()
.context("Failed to load graph manifest")?;
let age_seconds = storage
.snapshot_age(&manifest)
.context("Failed to compute snapshot age")?
.as_secs();
let total_files: Option<usize> =
if let Ok(header) = load_header_from_path(storage.snapshot_path()) {
Some(header.file_count)
} else if !manifest.file_count.is_empty() {
Some(manifest.file_count.values().sum())
} else {
None
};
let trigram_path = storage.graph_dir().join("trigram.idx");
let has_trigram = trigram_path.exists();
Ok(IndexStatus::from_index(
storage.graph_dir().display().to_string(),
manifest.built_at.clone(),
age_seconds,
)
.symbol_count(manifest.node_count) .file_count_opt(total_files)
.has_relations(manifest.edge_count > 0)
.has_trigram(has_trigram)
.build())
}
fn write_graph_status_text(
streams: &mut crate::output::OutputStreams,
status: &IndexStatus,
root_path: &Path,
) -> Result<()> {
if status.exists {
streams.write_result("✓ Graph snapshot found\n")?;
if let Some(path) = &status.path {
streams.write_result(&format!(" Path: {path}\n"))?;
}
if let Some(created_at) = &status.created_at {
streams.write_result(&format!(" Built: {created_at}\n"))?;
}
if let Some(age) = status.age_seconds {
streams.write_result(&format!(" Age: {}\n", format_age(age)))?;
}
if let Some(count) = status.symbol_count {
streams.write_result(&format!(" Nodes: {count}\n"))?;
}
if let Some(count) = status.file_count {
streams.write_result(&format!(" Files: {count}\n"))?;
}
if status.supports_relations {
streams.write_result(" Relations: ✓ Available\n")?;
}
} else {
streams.write_result("✗ No graph snapshot found\n")?;
streams.write_result("\nTo create a graph snapshot, run:\n")?;
streams.write_result(&format!(" sqry index --force {}\n", root_path.display()))?;
}
Ok(())
}
fn format_age(age_seconds: u64) -> String {
let hours = age_seconds / 3600;
let days = hours / 24;
if days > 0 {
format!("{} days, {} hours", days, hours % 24)
} else {
format!("{hours} hours")
}
}
fn format_top_languages(file_count: &std::collections::HashMap<String, usize>) -> String {
if file_count.is_empty() {
return "none".to_string();
}
let mut entries: Vec<_> = file_count.iter().collect();
entries.sort_by(|(left_name, left_count), (right_name, right_count)| {
right_count
.cmp(left_count)
.then_with(|| left_name.cmp(right_name))
});
entries
.into_iter()
.take(3)
.map(|(language, count)| format!("{language}={count}"))
.collect::<Vec<_>>()
.join(", ")
}
fn format_analysis_strategy_highlights(analysis_strategies: &[AnalysisStrategySummary]) -> String {
if analysis_strategies.is_empty() {
return "not available".to_string();
}
let mut interval_labels = Vec::new();
let mut dag_bfs = Vec::new();
for strategy in analysis_strategies {
match strategy.strategy {
ReachabilityStrategy::IntervalLabels => interval_labels.push(strategy.edge_kind),
ReachabilityStrategy::DagBfs => dag_bfs.push(strategy.edge_kind),
}
}
let mut groups = Vec::new();
if !interval_labels.is_empty() {
groups.push(format!("interval_labels({})", interval_labels.join(",")));
}
if !dag_bfs.is_empty() {
groups.push(format!("dag_bfs({})", dag_bfs.join(",")));
}
groups.join(" | ")
}
pub(crate) fn create_build_config(
cli: &Cli,
root_path: &Path,
threads: Option<usize>,
) -> Result<sqry_core::graph::unified::build::BuildConfig> {
Ok(sqry_core::graph::unified::build::BuildConfig {
max_depth: if cli.max_depth == 0 {
None
} else {
Some(cli.max_depth)
},
follow_links: cli.follow,
include_hidden: cli.hidden,
num_threads: threads,
label_budget: sqry_core::graph::unified::analysis::resolve_label_budget_config(
root_path, None, None, None, false,
)?,
..sqry_core::graph::unified::build::BuildConfig::default()
})
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::fn_params_excessive_bools)] pub fn run_update(
cli: &Cli,
path: &str,
threads: Option<usize>,
show_stats: bool,
_no_incremental: bool,
cache_dir: Option<&str>,
classpath: bool,
_no_classpath: bool,
classpath_depth: crate::args::ClasspathDepthArg,
classpath_file: Option<&Path>,
build_system: Option<&str>,
force_classpath: bool,
) -> Result<()> {
let root_path = Path::new(path);
let mut step_runner = StepRunner::new(!std::io::stderr().is_terminal() && !cli.json);
let storage = GraphStorage::new(root_path);
if !storage.exists() {
anyhow::bail!(
"No index found at {}. Run 'sqry index' first.",
storage.graph_dir().display()
);
}
println!("Updating index for {}...", root_path.display());
let start = Instant::now();
let git_mode_disabled = std::env::var("SQRY_GIT_BACKEND")
.ok()
.is_some_and(|v| v == "none");
let current_commit = if git_mode_disabled {
None
} else {
get_git_head_commit(root_path)
};
let using_git_mode = !git_mode_disabled && current_commit.is_some();
let (progress_bar, progress) = create_progress_reporter(cli);
let build_config = create_build_config(cli, root_path, threads)?;
let resolved_plugins = plugin_defaults::resolve_plugin_selection(
cli,
root_path,
PluginSelectionMode::ExistingWrite,
)?;
let classpath_opts = ClasspathCliOptions {
enabled: classpath,
depth: classpath_depth,
classpath_file,
build_system,
force_classpath,
};
let cache_dir_path = cache_dir.map(Path::new);
let build_result = step_runner.step("Update unified graph", || -> Result<_> {
build_and_persist_with_optional_classpath(
root_path,
&resolved_plugins,
&build_config,
"cli:update",
progress.clone(),
Some(&classpath_opts),
cache_dir_path,
)
})?;
finish_progress_bar(progress_bar.as_ref());
let elapsed = start.elapsed();
if !cli.json {
let status = build_graph_status(&storage)?;
if using_git_mode {
emit_graph_summary(
&storage,
&status,
&build_result,
elapsed,
"✓ Index updated successfully!",
);
} else {
emit_graph_summary(
&storage,
&status,
&build_result,
elapsed,
"✓ Index updated successfully (hash-based mode)!",
);
}
}
if show_stats {
println!("(Detailed stats are not available for unified graph update)");
}
Ok(())
}
#[allow(deprecated)]
pub fn run_index_status(
cli: &Cli,
path: &str,
metrics_format: crate::args::MetricsFormat,
) -> Result<()> {
use crate::args::MetricsFormat;
if matches!(metrics_format, MetricsFormat::Prometheus) {
let root_path = Path::new(path);
let storage = GraphStorage::new(root_path);
let status = build_graph_status(&storage)?;
let mut streams = crate::output::OutputStreams::with_pager(cli.pager_config());
let body = format_validation_prometheus(&status);
streams.write_result(&body)?;
return streams.finish_checked();
}
run_graph_status_with_format(cli, path, false)
}
pub fn run_graph_status_with_format(cli: &Cli, path: &str, json_from_format: bool) -> Result<()> {
let root_path = Path::new(path);
let storage = GraphStorage::new(root_path);
let status = build_graph_status(&storage)?;
let mut streams = crate::output::OutputStreams::with_pager(cli.pager_config());
let json_out = cli.json || json_from_format;
if json_out {
let json =
serde_json::to_string_pretty(&status).context("Failed to serialize graph status")?;
streams.write_result(&json)?;
} else {
write_graph_status_text(&mut streams, &status, root_path)?;
}
streams.finish_checked()
}
fn handle_gitignore(path: &Path, add_to_gitignore: bool) {
if let Some(root) = find_git_root(path) {
let gitignore_path = root.join(".gitignore");
let entry = ".sqry-index/";
let mut is_already_indexed = false;
if gitignore_path.exists()
&& let Ok(file) = fs::File::open(&gitignore_path)
{
let reader = BufReader::new(file);
if reader.lines().any(|line| {
line.map(|l| l.trim() == ".sqry-index" || l.trim() == ".sqry-index/")
.unwrap_or(false)
}) {
is_already_indexed = true;
}
}
if !is_already_indexed
&& add_to_gitignore
&& let Ok(mut file) = fs::OpenOptions::new()
.append(true)
.create(true)
.open(&gitignore_path)
&& writeln!(file, "\n{entry}").is_ok()
{
println!("Added '{entry}' to .gitignore");
} else if !is_already_indexed {
print_gitignore_warning();
}
}
}
fn find_git_root(path: &Path) -> Option<&Path> {
let mut current = path;
loop {
if current.join(".git").is_dir() {
return Some(current);
}
if let Some(parent) = current.parent() {
current = parent;
} else {
return None;
}
}
}
fn print_gitignore_warning() {
eprintln!(
"\n\u{26a0}\u{fe0f} Warning: It is recommended to add the '.sqry-index/' directory to your .gitignore file."
);
eprintln!("This is a generated cache and can become large.\n");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::large_stack_test;
use std::fs;
use tempfile::TempDir;
#[cfg(feature = "jvm-classpath")]
#[test]
fn classpath_auto_detection_miss_skips_pipeline() {
let tmp_cli_workspace = TempDir::new().unwrap();
let classpath_opts = ClasspathCliOptions {
enabled: true,
depth: crate::args::ClasspathDepthArg::Full,
classpath_file: None,
build_system: None,
force_classpath: true,
};
let result = run_classpath_pipeline_only(tmp_cli_workspace.path(), &classpath_opts)
.expect("missing JVM build system should be a non-fatal skip");
assert!(result.is_none());
}
large_stack_test! {
#[test]
fn test_run_index_basic() {
use crate::args::Cli;
use clap::Parser;
let tmp_cli_workspace = TempDir::new().unwrap();
let file_path = tmp_cli_workspace.path().join("test.rs");
fs::write(&file_path, "fn hello() {}").unwrap();
let cli = Cli::parse_from(["sqry", "index"]);
let result = run_index(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
false,
None,
false,
false,
None,
false, &[], None, false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, );
assert!(result.is_ok());
let storage = GraphStorage::new(tmp_cli_workspace.path());
assert!(storage.exists());
}
}
large_stack_test! {
#[test]
fn test_run_index_force_rebuild() {
use crate::args::Cli;
use clap::Parser;
let tmp_cli_workspace = TempDir::new().unwrap();
let file_path = tmp_cli_workspace.path().join("test.rs");
fs::write(&file_path, "fn hello() {}").unwrap();
let cli = Cli::parse_from(["sqry", "index"]);
run_index(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
false,
None,
false,
false,
None,
false, &[], None, false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, )
.unwrap();
let result = run_index(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
false,
None,
false,
false,
None,
false, &[], None, false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, );
assert!(result.is_ok());
let result = run_index(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
true,
None,
false,
false,
None,
false, &[], None, false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, );
assert!(result.is_ok());
}
}
large_stack_test! {
#[test]
fn test_run_update_no_index() {
use crate::args::Cli;
use clap::Parser;
let tmp_cli_workspace = TempDir::new().unwrap();
let cli = Cli::parse_from(["sqry", "update"]);
let result = run_update(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
None,
false,
false,
None,
false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No index found"));
}
}
large_stack_test! {
#[test]
fn test_run_index_status_no_index() {
use crate::args::Cli;
use clap::Parser;
let tmp_cli_workspace = TempDir::new().unwrap();
let cli = Cli::parse_from(["sqry", "--json"]);
let result = run_index_status(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
crate::args::MetricsFormat::Json,
);
assert!(
result.is_ok(),
"Index status should not error on missing index"
);
}
}
large_stack_test! {
#[test]
fn test_run_index_status_with_index() {
use crate::args::Cli;
use clap::Parser;
let tmp_cli_workspace = TempDir::new().unwrap();
let file_path = tmp_cli_workspace.path().join("test.rs");
fs::write(&file_path, "fn test_func() {}").unwrap();
let cli = Cli::parse_from(["sqry", "index"]);
run_index(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
false,
None,
false,
false,
None,
false, &[], None, false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, )
.unwrap();
let cli = Cli::parse_from(["sqry", "--json"]);
let result = run_index_status(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
crate::args::MetricsFormat::Json,
);
assert!(
result.is_ok(),
"Index status should succeed with existing index"
);
let storage = GraphStorage::new(tmp_cli_workspace.path());
assert!(storage.exists());
let manifest = storage.load_manifest().unwrap();
assert_eq!(manifest.node_count, 1, "Should have 1 symbol");
}
}
large_stack_test! {
#[test]
fn test_run_update_basic() {
use crate::args::Cli;
use clap::Parser;
let tmp_cli_workspace = TempDir::new().unwrap();
let file_path = tmp_cli_workspace.path().join("test.rs");
fs::write(&file_path, "fn hello() {}").unwrap();
let cli = Cli::parse_from(["sqry", "index"]);
run_index(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
false,
None,
false,
false,
None,
false, &[], None, false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, )
.unwrap();
let result = run_update(
&cli,
tmp_cli_workspace.path().to_str().unwrap(),
None,
true,
false,
None,
false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
);
assert!(result.is_ok());
}
}
large_stack_test! {
#[test]
fn test_no_incremental_triggers_full_rebuild_when_snapshot_exists() {
use crate::args::Cli;
use clap::Parser;
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("rebuild.rs");
fs::write(&file_path, "fn original() {}").unwrap();
let cli = Cli::parse_from(["sqry", "index"]);
run_index(
&cli,
tmp.path().to_str().unwrap(),
false,
None,
false,
false,
None,
false,
&[],
None,
false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, )
.expect("initial build should succeed");
let storage = GraphStorage::new(tmp.path());
assert!(storage.exists(), "snapshot must exist after initial build");
let initial_node_count = storage.load_manifest().unwrap().node_count;
fs::write(&file_path, "fn original() {}\nfn added_symbol() {}").unwrap();
run_index(
&cli,
tmp.path().to_str().unwrap(),
false, None,
false,
true, None,
false,
&[],
None,
false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, )
.expect("--no-incremental must rebuild even when snapshot exists");
let post_rebuild_node_count = storage.load_manifest().unwrap().node_count;
assert!(
post_rebuild_node_count > initial_node_count,
"--no-incremental should rebuild and pick up the new symbol \
(initial={initial_node_count}, post={post_rebuild_node_count})"
);
}
}
#[test]
fn format_validation_prometheus_emits_openmetrics_shape() {
let mut status = IndexStatus::not_found();
status.exists = true;
status.path = Some("/tmp/example/.sqry/graph".into());
status.age_seconds = Some(42);
status.symbol_count = Some(123);
status.file_count = Some(11);
status.supports_relations = true;
status.cross_language_relation_count = Some(9);
status.stale = Some(false);
let body = format_validation_prometheus(&status);
assert!(body.contains("# HELP sqry_index_exists"));
assert!(body.contains("# TYPE sqry_index_exists gauge"));
assert!(body.contains("\nsqry_index_exists 1\n"));
assert!(body.contains("\nsqry_index_supports_relations 1\n"));
assert!(body.contains("\nsqry_index_symbol_count 123\n"));
assert!(body.contains("\nsqry_index_file_count 11\n"));
assert!(body.contains("\nsqry_index_age_seconds 42\n"));
assert!(body.contains("\nsqry_index_stale 0\n"));
assert!(body.contains("\nsqry_index_cross_language_relation_count 9\n"));
}
large_stack_test! {
#[test]
fn run_index_status_prometheus_format_is_accepted() {
use crate::args::{Cli, MetricsFormat};
use clap::Parser;
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("metrics.rs");
fs::write(&file_path, "fn metric_target() {}").unwrap();
let cli = Cli::parse_from(["sqry", "index"]);
run_index(
&cli,
tmp.path().to_str().unwrap(),
false,
None,
false,
false,
None,
false,
&[],
None,
false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, )
.expect("initial build for prometheus test must succeed");
let cli_json = Cli::parse_from(["sqry", "--json"]);
let result = run_index_status(
&cli_json,
tmp.path().to_str().unwrap(),
MetricsFormat::Prometheus,
);
assert!(
result.is_ok(),
"--metrics-format prometheus must succeed: {result:?}"
);
}
}
large_stack_test! {
#[test]
fn run_index_rejects_nested_creation_without_allow_nested() {
use crate::args::Cli;
use clap::Parser;
let tmp = TempDir::new().unwrap();
let proj = tmp.path().join("proj");
fs::create_dir_all(proj.join(".sqry").join("graph")).unwrap();
fs::write(proj.join("Cargo.toml"), "[package]\n").unwrap();
let nested = proj.join("sub");
fs::create_dir_all(&nested).unwrap();
let cli = Cli::parse_from(["sqry", "index"]);
let result = run_index(
&cli,
nested.to_str().unwrap(),
false,
None,
false,
false,
None,
false,
&[],
None,
false,
false,
crate::args::ClasspathDepthArg::Full,
None,
None,
false,
false, );
let err = result.expect_err("nested creation must error without --allow-nested");
let msg = err.to_string();
assert!(
msg.contains("nested .sqry/ index"),
"must surface the nested-index recovery text, got: {msg}"
);
assert!(
msg.contains("--allow-nested"),
"must hint at the --allow-nested escape hatch, got: {msg}"
);
}
}
#[test]
fn plugin_manager_registers_elixir_extensions() {
let pm = crate::plugin_defaults::create_plugin_manager();
assert!(
pm.plugin_for_extension("ex").is_some(),
"Elixir .ex extension missing"
);
assert!(
pm.plugin_for_extension("exs").is_some(),
"Elixir .exs extension missing"
);
}
#[test]
fn test_format_top_languages_orders_by_count_then_name() {
let counts = std::collections::HashMap::from([
("rust".to_string(), 9_usize),
("python".to_string(), 4_usize),
("go".to_string(), 4_usize),
("typescript".to_string(), 2_usize),
]);
assert_eq!(format_top_languages(&counts), "rust=9, go=4, python=4");
}
#[test]
fn test_format_analysis_strategy_highlights_groups_by_strategy() {
let strategies = vec![
AnalysisStrategySummary {
edge_kind: "calls",
strategy: ReachabilityStrategy::IntervalLabels,
},
AnalysisStrategySummary {
edge_kind: "imports",
strategy: ReachabilityStrategy::DagBfs,
},
AnalysisStrategySummary {
edge_kind: "references",
strategy: ReachabilityStrategy::DagBfs,
},
AnalysisStrategySummary {
edge_kind: "inherits",
strategy: ReachabilityStrategy::IntervalLabels,
},
];
assert_eq!(
format_analysis_strategy_highlights(&strategies),
"interval_labels(calls,inherits) | dag_bfs(imports,references)"
);
}
#[cfg(feature = "jvm-classpath")]
#[test]
fn test_resolve_allowed_jars_prefers_nearest_scope() {
let scopes = vec![
(
PathBuf::from("/repo/services/app"),
std::collections::HashSet::from([PathBuf::from("/jars/app.jar")]),
),
(
PathBuf::from("/repo"),
std::collections::HashSet::from([PathBuf::from("/jars/root.jar")]),
),
];
let resolved =
resolve_allowed_jars(Some(Path::new("/repo/services/app/src/Main.java")), &scopes)
.expect("nearest scope should resolve");
assert!(
resolved
.allowed_jars
.contains(&PathBuf::from("/jars/app.jar"))
);
assert!(
!resolved
.allowed_jars
.contains(&PathBuf::from("/jars/root.jar"))
);
assert_eq!(
resolved.matched_root.as_deref(),
Some(Path::new("/repo/services/app"))
);
}
#[cfg(feature = "jvm-classpath")]
#[test]
fn test_filter_scope_targets_excludes_out_of_scope_jars() {
let targets = [
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(1, 0),
fqn: "com.example.Foo".to_string(),
jar_path: PathBuf::from("/jars/app.jar"),
file_id: sqry_core::graph::unified::FileId::new(1),
},
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(2, 0),
fqn: "com.example.Foo".to_string(),
jar_path: PathBuf::from("/jars/other.jar"),
file_id: sqry_core::graph::unified::FileId::new(2),
},
];
let allowed = std::collections::HashSet::from([PathBuf::from("/jars/app.jar")]);
let filtered = filter_scope_targets(targets.iter().collect(), &allowed);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].jar_path, PathBuf::from("/jars/app.jar"));
}
#[cfg(feature = "jvm-classpath")]
#[test]
fn test_prefer_direct_targets_exact_import_direct_wins() {
use sqry_classpath::graph::provenance::{ClasspathProvenance, ClasspathScope};
let targets = [
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(1, 0),
fqn: "com.example.Foo".to_string(),
jar_path: PathBuf::from("/jars/direct.jar"),
file_id: sqry_core::graph::unified::FileId::new(1),
},
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(2, 0),
fqn: "com.example.Foo".to_string(),
jar_path: PathBuf::from("/jars/transitive.jar"),
file_id: sqry_core::graph::unified::FileId::new(2),
},
];
let provenance = vec![
ClasspathProvenance {
jar_path: PathBuf::from("/jars/direct.jar"),
coordinates: None,
is_direct: true,
scopes: vec![ClasspathScope {
module_name: "app".to_owned(),
module_root: PathBuf::from("/repo/app"),
is_direct: true,
}],
},
ClasspathProvenance {
jar_path: PathBuf::from("/jars/transitive.jar"),
coordinates: None,
is_direct: false,
scopes: vec![ClasspathScope {
module_name: "app".to_owned(),
module_root: PathBuf::from("/repo/app"),
is_direct: false,
}],
},
];
let lookup = build_provenance_lookup(&provenance);
let result = prefer_direct_targets(
targets.iter().collect(),
Some(Path::new("/repo/app")),
&lookup,
);
assert_eq!(result.len(), 1, "direct jar should win over transitive");
assert_eq!(result[0].jar_path, PathBuf::from("/jars/direct.jar"));
}
#[cfg(feature = "jvm-classpath")]
#[test]
fn test_prefer_direct_targets_wildcard_same_shape() {
use sqry_classpath::graph::provenance::{ClasspathProvenance, ClasspathScope};
let targets = [
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(10, 0),
fqn: "com.example.Bar".to_string(),
jar_path: PathBuf::from("/jars/direct.jar"),
file_id: sqry_core::graph::unified::FileId::new(10),
},
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(11, 0),
fqn: "com.example.Bar".to_string(),
jar_path: PathBuf::from("/jars/transitive.jar"),
file_id: sqry_core::graph::unified::FileId::new(11),
},
];
let provenance = vec![
ClasspathProvenance {
jar_path: PathBuf::from("/jars/direct.jar"),
coordinates: None,
is_direct: true,
scopes: vec![ClasspathScope {
module_name: "app".to_owned(),
module_root: PathBuf::from("/repo/app"),
is_direct: true,
}],
},
ClasspathProvenance {
jar_path: PathBuf::from("/jars/transitive.jar"),
coordinates: None,
is_direct: false,
scopes: vec![ClasspathScope {
module_name: "app".to_owned(),
module_root: PathBuf::from("/repo/app"),
is_direct: false,
}],
},
];
let lookup = build_provenance_lookup(&provenance);
let result = prefer_direct_targets(
targets.iter().collect(),
Some(Path::new("/repo/app")),
&lookup,
);
assert_eq!(
result.len(),
1,
"wildcard: direct jar should win over transitive"
);
assert_eq!(result[0].jar_path, PathBuf::from("/jars/direct.jar"));
}
#[cfg(feature = "jvm-classpath")]
#[test]
fn test_prefer_direct_targets_true_ambiguity_two_direct_jars() {
use sqry_classpath::graph::provenance::{ClasspathProvenance, ClasspathScope};
let targets = [
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(20, 0),
fqn: "com.example.Baz".to_string(),
jar_path: PathBuf::from("/jars/direct-a.jar"),
file_id: sqry_core::graph::unified::FileId::new(20),
},
sqry_classpath::graph::emitter::ClasspathNodeRef {
node_id: sqry_core::graph::unified::node::NodeId::new(21, 0),
fqn: "com.example.Baz".to_string(),
jar_path: PathBuf::from("/jars/direct-b.jar"),
file_id: sqry_core::graph::unified::FileId::new(21),
},
];
let provenance = vec![
ClasspathProvenance {
jar_path: PathBuf::from("/jars/direct-a.jar"),
coordinates: None,
is_direct: true,
scopes: vec![ClasspathScope {
module_name: "app".to_owned(),
module_root: PathBuf::from("/repo/app"),
is_direct: true,
}],
},
ClasspathProvenance {
jar_path: PathBuf::from("/jars/direct-b.jar"),
coordinates: None,
is_direct: true,
scopes: vec![ClasspathScope {
module_name: "app".to_owned(),
module_root: PathBuf::from("/repo/app"),
is_direct: true,
}],
},
];
let lookup = build_provenance_lookup(&provenance);
let result = prefer_direct_targets(
targets.iter().collect(),
Some(Path::new("/repo/app")),
&lookup,
);
assert_eq!(
result.len(),
2,
"two direct jars = true ambiguity, both should remain"
);
}
}