Skip to main content

sqry_classpath/
pipeline.rs

1//! Classpath pipeline orchestration.
2//!
3//! Coordinates the full classpath analysis pipeline:
4//! detect → resolve → scan/cache → build index → emit graph nodes.
5//!
6//! This module is the single integration point called from the CLI when the
7//! `jvm-classpath` feature is enabled.
8
9// Classpath scan metrics fit in u32; casts are intentional
10#![allow(clippy::cast_possible_truncation)]
11
12use std::io::{BufRead, BufReader};
13use std::path::{Path, PathBuf};
14
15use log::{debug, info, warn};
16use rayon::prelude::*;
17
18use crate::bytecode::scan_jar;
19use crate::detect::{BuildSystem, discover_build_roots};
20use crate::graph::provenance::{ClasspathProvenance, ClasspathScope};
21use crate::resolve::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
22use crate::stub::cache::StubCache;
23use crate::stub::index::ClasspathIndex;
24use crate::stub::model::ClassStub;
25use crate::{ClasspathError, ClasspathResult};
26
27// ---------------------------------------------------------------------------
28// Configuration
29// ---------------------------------------------------------------------------
30
31/// Configuration for the classpath pipeline.
32#[derive(Debug, Clone)]
33pub struct ClasspathConfig {
34    /// Whether classpath analysis is enabled.
35    pub enabled: bool,
36    /// Depth of classpath analysis.
37    pub depth: ClasspathDepth,
38    /// Override build system (from `--build-system` flag).
39    pub build_system_override: Option<String>,
40    /// Manual classpath file (from `--classpath-file` flag).
41    ///
42    /// When set, skips build system detection and resolution entirely.
43    /// The file should contain one JAR path per line.
44    pub classpath_file: Option<PathBuf>,
45    /// Whether to force classpath resolution even if cached.
46    pub force: bool,
47    /// Subprocess timeout in seconds for build tool resolution.
48    pub timeout_secs: u64,
49    /// Whether the resolver may execute the project's build tooling
50    /// (`gradlew`, `mvn`, `bazel`, `sbt`) to resolve the classpath.
51    ///
52    /// Defaults to `true` for backward compatibility. Set to `false` (via
53    /// `--no-build-tool`) to index a repository without ever running its build
54    /// tooling, which would otherwise execute repository-controlled code. With
55    /// this disabled the pipeline uses a previously cached classpath when one is
56    /// available and otherwise skips classpath resolution for that root.
57    pub allow_build_tool_execution: bool,
58}
59
60/// Depth of classpath analysis.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ClasspathDepth {
63    /// Only direct dependencies.
64    Shallow,
65    /// All transitive dependencies.
66    Full,
67}
68
69impl Default for ClasspathConfig {
70    fn default() -> Self {
71        Self {
72            enabled: false,
73            depth: ClasspathDepth::Full,
74            build_system_override: None,
75            classpath_file: None,
76            force: false,
77            timeout_secs: 60,
78            allow_build_tool_execution: true,
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Pipeline result
85// ---------------------------------------------------------------------------
86
87/// Result of the classpath pipeline.
88#[derive(Debug)]
89pub struct ClasspathPipelineResult {
90    /// The built classpath index.
91    pub index: ClasspathIndex,
92    /// Provenance information for each JAR.
93    pub provenance: Vec<ClasspathProvenance>,
94    /// Resolved classpaths grouped by module/root scope.
95    pub resolved_classpaths: Vec<ResolvedClasspath>,
96    /// Number of JARs scanned.
97    pub jars_scanned: usize,
98    /// Number of classes parsed.
99    pub classes_parsed: usize,
100    /// Whether results came from cache.
101    pub from_cache: bool,
102}
103
104// ---------------------------------------------------------------------------
105// Main entry point
106// ---------------------------------------------------------------------------
107
108/// Run the full classpath pipeline: detect → resolve → scan/cache → build index.
109///
110/// This is the main entry point called from the CLI when classpath analysis
111/// is enabled. The returned [`ClasspathPipelineResult`] contains the
112/// [`ClasspathIndex`] and provenance data needed by the graph emitter.
113///
114/// # Steps
115///
116/// 1. **Detect** the build system (or use the override / manual file).
117/// 2. **Resolve** the classpath via the appropriate build tool resolver.
118/// 3. **Scan** each JAR file for `.class` entries, using the [`StubCache`]
119///    for incremental re-use. JARs are scanned in parallel via rayon.
120/// 4. **Build** a merged [`ClasspathIndex`] from all collected stubs.
121/// 5. **Persist** the index and provenance to `.sqry/classpath/` for
122///    subsequent builds that skip the resolve step.
123///
124/// # Errors
125///
126/// Returns [`ClasspathError`] if detection, resolution, scanning, or
127/// persistence fails.
128pub fn run_classpath_pipeline(
129    project_root: &Path,
130    config: &ClasspathConfig,
131) -> ClasspathResult<ClasspathPipelineResult> {
132    info!("Starting classpath pipeline for {}", project_root.display());
133
134    // ── Step 1: Resolve classpath entries ───────────────────────────────
135    let resolved_classpaths = if let Some(ref classpath_file) = config.classpath_file {
136        resolve_from_manual_file(project_root, classpath_file)?
137    } else {
138        resolve_from_build_system(project_root, config)?
139    };
140
141    // Flatten all entries across modules.
142    let all_entries: Vec<&ClasspathEntry> = resolved_classpaths
143        .iter()
144        .flat_map(|cp| &cp.entries)
145        .collect();
146
147    // Apply depth filtering.
148    let entries_to_scan: Vec<&ClasspathEntry> = match config.depth {
149        ClasspathDepth::Full => all_entries,
150        ClasspathDepth::Shallow => all_entries.into_iter().filter(|e| e.is_direct).collect(),
151    };
152
153    info!(
154        "Classpath resolved: {} entries ({} after depth filtering)",
155        resolved_classpaths
156            .iter()
157            .map(|cp| cp.entries.len())
158            .sum::<usize>(),
159        entries_to_scan.len(),
160    );
161
162    // Deduplicate by JAR path (same JAR may appear in multiple modules).
163    let unique_jar_paths = deduplicate_jar_paths(&entries_to_scan);
164    info!("{} unique JAR files to scan", unique_jar_paths.len());
165
166    // ── Step 2: Scan JARs (parallel, with stub cache) ──────────────────
167    let stub_cache = StubCache::new(project_root);
168    let scan_results = scan_jars_parallel(&unique_jar_paths, &stub_cache, config.force);
169
170    let mut all_stubs: Vec<ClassStub> = Vec::new();
171    let mut jars_scanned: usize = 0;
172    let mut jars_from_cache: usize = 0;
173
174    for result in &scan_results {
175        match result {
176            JarScanOutcome::Scanned { jar_path, stubs } => {
177                let jar_str = jar_path.display().to_string();
178                for stub in stubs {
179                    let mut s = stub.clone();
180                    // Ensure source_jar is set even if scan_jar already set it,
181                    // and for cached stubs that may predate the field.
182                    if s.source_jar.is_none() {
183                        s.source_jar = Some(jar_str.clone());
184                    }
185                    all_stubs.push(s);
186                }
187                jars_scanned += 1;
188            }
189            JarScanOutcome::Cached { jar_path, stubs } => {
190                let jar_str = jar_path.display().to_string();
191                for stub in stubs {
192                    let mut s = stub.clone();
193                    if s.source_jar.is_none() {
194                        s.source_jar = Some(jar_str.clone());
195                    }
196                    all_stubs.push(s);
197                }
198                jars_from_cache += 1;
199            }
200            JarScanOutcome::Failed { jar_path, error } => {
201                warn!("Failed to scan JAR {}: {error}", jar_path.display());
202            }
203        }
204    }
205
206    let classes_parsed = all_stubs.len();
207    info!(
208        "Scanned {} JARs ({} from cache, {} fresh), {} classes total",
209        jars_scanned + jars_from_cache,
210        jars_from_cache,
211        jars_scanned,
212        classes_parsed,
213    );
214
215    // ── Step 3: Build provenance ───────────────────────────────────────
216    let provenance = build_provenance(&resolved_classpaths, config.depth);
217
218    // ── Step 4: Build index ────────────────────────────────────────────
219    let index = ClasspathIndex::build(all_stubs);
220    info!(
221        "Built classpath index: {} classes, {} packages",
222        index.classes.len(),
223        index.package_index.len(),
224    );
225
226    // ── Step 5: Persist index and provenance ───────────────────────────
227    let sqry_classpath_dir = project_root.join(".sqry").join("classpath");
228    persist_artifacts(&sqry_classpath_dir, &index, &provenance)?;
229
230    Ok(ClasspathPipelineResult {
231        index,
232        provenance,
233        resolved_classpaths,
234        jars_scanned: jars_scanned + jars_from_cache,
235        classes_parsed,
236        from_cache: jars_from_cache > 0 && jars_scanned == 0,
237    })
238}
239
240// ---------------------------------------------------------------------------
241// Resolution strategies
242// ---------------------------------------------------------------------------
243
244/// Read a manual classpath file (one JAR path per line).
245///
246/// Lines that are empty or start with `#` are skipped (comments).
247fn resolve_from_manual_file(
248    project_root: &Path,
249    classpath_file: &Path,
250) -> ClasspathResult<Vec<ResolvedClasspath>> {
251    info!("Reading manual classpath from {}", classpath_file.display());
252
253    let file = std::fs::File::open(classpath_file).map_err(|e| {
254        ClasspathError::ResolutionFailed(format!(
255            "Cannot open classpath file {}: {e}",
256            classpath_file.display()
257        ))
258    })?;
259
260    let reader = BufReader::new(file);
261    let mut entries = Vec::new();
262
263    for line in reader.lines() {
264        let line = line.map_err(|e| {
265            ClasspathError::ResolutionFailed(format!(
266                "Error reading classpath file {}: {e}",
267                classpath_file.display()
268            ))
269        })?;
270        let trimmed = line.trim();
271
272        // Skip empty lines and comments.
273        if trimmed.is_empty() || trimmed.starts_with('#') {
274            continue;
275        }
276
277        let jar_path = PathBuf::from(trimmed);
278        if !jar_path.exists() {
279            warn!(
280                "Classpath file entry does not exist: {}",
281                jar_path.display()
282            );
283            // Still include it — the scanner will report the error.
284        }
285
286        entries.push(ClasspathEntry {
287            jar_path,
288            coordinates: None,
289            is_direct: true, // Manual entries treated as direct.
290            source_jar: None,
291        });
292    }
293
294    info!("Manual classpath file: {} entries", entries.len());
295
296    Ok(vec![ResolvedClasspath {
297        module_name: "manual".to_string(),
298        module_root: project_root.to_path_buf(),
299        entries,
300    }])
301}
302
303/// Detect the build system and resolve the classpath via the appropriate resolver.
304fn resolve_from_build_system(
305    project_root: &Path,
306    config: &ClasspathConfig,
307) -> ClasspathResult<Vec<ResolvedClasspath>> {
308    let detected_roots =
309        discover_build_roots(project_root, config.build_system_override.as_deref());
310    if detected_roots.is_empty() {
311        return Err(ClasspathError::DetectionFailed(
312            "No JVM build system detected. Use --build-system to specify one, \
313             or --classpath-file to provide a manual classpath."
314                .to_string(),
315        ));
316    }
317
318    info!("Discovered {} JVM build roots", detected_roots.len());
319    let mut resolved = Vec::new();
320    for detection in detected_roots {
321        let Some(build_system) = detection.build_system else {
322            continue;
323        };
324        info!(
325            "Resolving {:?} classpath in {}",
326            build_system,
327            detection.project_root.display()
328        );
329
330        let resolve_config = ResolveConfig {
331            project_root: detection.project_root.clone(),
332            timeout_secs: config.timeout_secs,
333            cache_path: Some(detection.project_root.join(".sqry").join("classpath")),
334        };
335
336        let mut root_resolved = if config.allow_build_tool_execution {
337            match build_system {
338                BuildSystem::Gradle => {
339                    crate::resolve::gradle::resolve_gradle_classpath(&resolve_config)
340                }
341                BuildSystem::Maven => {
342                    crate::resolve::maven::resolve_maven_classpath(&resolve_config)
343                }
344                BuildSystem::Bazel => {
345                    crate::resolve::bazel::resolve_bazel_classpath(&resolve_config)
346                }
347                BuildSystem::Sbt => crate::resolve::sbt::resolve_sbt_classpath(&resolve_config),
348            }?
349        } else {
350            // Build-tool execution disabled (`--no-build-tool`): never spawn the
351            // repository's build tooling. Use a previously cached classpath when
352            // one exists, otherwise skip this root with a warning rather than
353            // aborting the whole index.
354            match read_cached_classpath(build_system, &resolve_config) {
355                Some(cached) => cached,
356                None => {
357                    warn!(
358                        "Build-tool execution is disabled (--no-build-tool) and no cached \
359                         classpath is available for {} ({:?}); skipping classpath resolution \
360                         for this root. Provide --classpath-file, or resolve once without \
361                         --no-build-tool in a trusted context to populate the cache.",
362                        detection.project_root.display(),
363                        build_system
364                    );
365                    continue;
366                }
367            }
368        };
369        resolved.append(&mut root_resolved);
370    }
371
372    resolved.sort_by(|a, b| {
373        a.module_root
374            .cmp(&b.module_root)
375            .then_with(|| a.module_name.cmp(&b.module_name))
376    });
377    Ok(resolved)
378}
379
380/// Read a previously cached classpath for `build_system` without executing any
381/// build tooling. Returns `None` when no usable cache is present. Used only when
382/// build-tool execution is disabled (`--no-build-tool`).
383fn read_cached_classpath(
384    build_system: BuildSystem,
385    resolve_config: &ResolveConfig,
386) -> Option<Vec<ResolvedClasspath>> {
387    match build_system {
388        BuildSystem::Gradle => crate::resolve::gradle::read_cached_classpath(resolve_config),
389        BuildSystem::Maven => crate::resolve::maven::read_cached_classpath(resolve_config),
390        BuildSystem::Bazel => crate::resolve::bazel::read_cached_classpath(resolve_config),
391        BuildSystem::Sbt => crate::resolve::sbt::read_cached_classpath(resolve_config),
392    }
393}
394
395// ---------------------------------------------------------------------------
396// JAR scanning
397// ---------------------------------------------------------------------------
398
399/// Outcome of scanning a single JAR file.
400enum JarScanOutcome {
401    /// JAR was freshly scanned and parsed.
402    Scanned {
403        #[allow(dead_code)] // Used in tests for pattern matching.
404        jar_path: PathBuf,
405        stubs: Vec<ClassStub>,
406    },
407    /// Stubs were loaded from the stub cache (JAR hash matched).
408    Cached {
409        #[allow(dead_code)] // Used in tests for pattern matching.
410        jar_path: PathBuf,
411        stubs: Vec<ClassStub>,
412    },
413    /// JAR could not be scanned.
414    Failed { jar_path: PathBuf, error: String },
415}
416
417/// Deduplicate JAR paths, preserving the first occurrence.
418fn deduplicate_jar_paths(entries: &[&ClasspathEntry]) -> Vec<PathBuf> {
419    let mut seen = std::collections::HashSet::new();
420    let mut unique = Vec::new();
421
422    for entry in entries {
423        if seen.insert(&entry.jar_path) {
424            unique.push(entry.jar_path.clone());
425        }
426    }
427
428    unique
429}
430
431/// Scan JAR files in parallel using rayon, with stub cache for incremental builds.
432///
433/// Each JAR is either loaded from the stub cache (if the JAR's SHA-256 hash
434/// matches a cached entry) or freshly scanned. Freshly scanned stubs are
435/// written to the cache for future use.
436fn scan_jars_parallel(
437    jar_paths: &[PathBuf],
438    stub_cache: &StubCache,
439    force: bool,
440) -> Vec<JarScanOutcome> {
441    jar_paths
442        .par_iter()
443        .map(|jar_path| scan_single_jar(jar_path, stub_cache, force))
444        .collect()
445}
446
447/// Scan a single JAR file, using the stub cache when possible.
448fn scan_single_jar(jar_path: &Path, stub_cache: &StubCache, force: bool) -> JarScanOutcome {
449    // Try cache first (unless force is set).
450    if !force && let Some(cached_stubs) = stub_cache.get(jar_path) {
451        debug!(
452            "Cache hit for {} ({} stubs)",
453            jar_path.display(),
454            cached_stubs.len()
455        );
456        return JarScanOutcome::Cached {
457            jar_path: jar_path.to_path_buf(),
458            stubs: cached_stubs,
459        };
460    }
461
462    // Fresh scan.
463    match scan_jar(jar_path) {
464        Ok(stubs) => {
465            debug!("Scanned {} ({} classes)", jar_path.display(), stubs.len());
466
467            // Write to cache (non-fatal on error).
468            if let Err(e) = stub_cache.put(jar_path, &stubs) {
469                warn!("Failed to cache stubs for {}: {e}", jar_path.display());
470            }
471
472            JarScanOutcome::Scanned {
473                jar_path: jar_path.to_path_buf(),
474                stubs,
475            }
476        }
477        Err(e) => JarScanOutcome::Failed {
478            jar_path: jar_path.to_path_buf(),
479            error: e.to_string(),
480        },
481    }
482}
483
484// ---------------------------------------------------------------------------
485// Provenance construction
486// ---------------------------------------------------------------------------
487
488/// Build provenance records from classpath entries.
489fn build_provenance(
490    resolved_classpaths: &[ResolvedClasspath],
491    depth: ClasspathDepth,
492) -> Vec<ClasspathProvenance> {
493    let mut by_jar: std::collections::HashMap<PathBuf, ClasspathProvenance> =
494        std::collections::HashMap::new();
495
496    for classpath in resolved_classpaths {
497        for entry in &classpath.entries {
498            if matches!(depth, ClasspathDepth::Shallow) && !entry.is_direct {
499                continue;
500            }
501
502            let provenance =
503                by_jar
504                    .entry(entry.jar_path.clone())
505                    .or_insert_with(|| ClasspathProvenance {
506                        jar_path: entry.jar_path.clone(),
507                        coordinates: entry.coordinates.clone(),
508                        is_direct: entry.is_direct,
509                        scopes: Vec::new(),
510                    });
511
512            if provenance.coordinates.is_none() {
513                provenance.coordinates.clone_from(&entry.coordinates);
514            }
515            provenance.is_direct &= entry.is_direct;
516
517            let scope = ClasspathScope {
518                module_name: classpath.module_name.clone(),
519                module_root: classpath.module_root.clone(),
520                is_direct: entry.is_direct,
521            };
522            if !provenance.scopes.iter().any(|existing| existing == &scope) {
523                provenance.scopes.push(scope);
524            }
525        }
526    }
527
528    let mut result: Vec<_> = by_jar.into_values().collect();
529    result.sort_by(|a, b| a.jar_path.cmp(&b.jar_path));
530    result
531}
532
533// ---------------------------------------------------------------------------
534// Persistence
535// ---------------------------------------------------------------------------
536
537/// Persist the classpath index and provenance to `.sqry/classpath/`.
538fn persist_artifacts(
539    classpath_dir: &Path,
540    index: &ClasspathIndex,
541    provenance: &[ClasspathProvenance],
542) -> ClasspathResult<()> {
543    std::fs::create_dir_all(classpath_dir).map_err(|e| {
544        ClasspathError::IndexError(format!(
545            "Cannot create classpath directory {}: {e}",
546            classpath_dir.display()
547        ))
548    })?;
549
550    // Persist index.
551    let index_path = classpath_dir.join("index.sqry");
552    index.save(&index_path)?;
553    info!("Saved classpath index to {}", index_path.display());
554
555    // Persist provenance.
556    let provenance_path = classpath_dir.join("provenance.json");
557    let provenance_json = serde_json::to_string_pretty(provenance)
558        .map_err(|e| ClasspathError::IndexError(format!("Cannot serialize provenance: {e}")))?;
559    std::fs::write(&provenance_path, provenance_json).map_err(|e| {
560        ClasspathError::IndexError(format!(
561            "Cannot write provenance to {}: {e}",
562            provenance_path.display()
563        ))
564    })?;
565    info!("Saved provenance to {}", provenance_path.display());
566
567    Ok(())
568}
569
570// ---------------------------------------------------------------------------
571// Tests
572// ---------------------------------------------------------------------------
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use std::io::Write;
578    use tempfile::TempDir;
579    use zip::write::SimpleFileOptions;
580
581    /// Build a minimal valid .class file for testing.
582    fn build_minimal_class(class_name: &str) -> Vec<u8> {
583        let mut bytes = Vec::new();
584
585        // Magic
586        bytes.extend_from_slice(&0xCAFE_BABEu32.to_be_bytes());
587        // Minor version
588        bytes.extend_from_slice(&0u16.to_be_bytes());
589        // Major version (52 = Java 8)
590        bytes.extend_from_slice(&52u16.to_be_bytes());
591
592        // Constant pool: 5 entries
593        let class_bytes = class_name.as_bytes();
594        let object_bytes = b"java/lang/Object";
595
596        let cp_count: u16 = 5;
597        bytes.extend_from_slice(&cp_count.to_be_bytes());
598
599        // #1: CONSTANT_Utf8 <class_name>
600        bytes.push(1);
601        bytes.extend_from_slice(&(class_bytes.len() as u16).to_be_bytes());
602        bytes.extend_from_slice(class_bytes);
603
604        // #2: CONSTANT_Class -> #1
605        bytes.push(7);
606        bytes.extend_from_slice(&1u16.to_be_bytes());
607
608        // #3: CONSTANT_Utf8 "java/lang/Object"
609        bytes.push(1);
610        bytes.extend_from_slice(&(object_bytes.len() as u16).to_be_bytes());
611        bytes.extend_from_slice(object_bytes);
612
613        // #4: CONSTANT_Class -> #3
614        bytes.push(7);
615        bytes.extend_from_slice(&3u16.to_be_bytes());
616
617        // Access flags: ACC_PUBLIC | ACC_SUPER
618        bytes.extend_from_slice(&0x0021u16.to_be_bytes());
619        // This class: #2
620        bytes.extend_from_slice(&2u16.to_be_bytes());
621        // Super class: #4
622        bytes.extend_from_slice(&4u16.to_be_bytes());
623        // Interfaces count: 0
624        bytes.extend_from_slice(&0u16.to_be_bytes());
625        // Fields count: 0
626        bytes.extend_from_slice(&0u16.to_be_bytes());
627        // Methods count: 0
628        bytes.extend_from_slice(&0u16.to_be_bytes());
629        // Attributes count: 0
630        bytes.extend_from_slice(&0u16.to_be_bytes());
631
632        bytes
633    }
634
635    /// Create an in-memory JAR (ZIP) file containing test classes.
636    fn build_test_jar(entries: &[(&str, &[u8])]) -> Vec<u8> {
637        let mut buf = Vec::new();
638        {
639            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
640            let options =
641                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
642            for (name, data) in entries {
643                writer.start_file(*name, options).unwrap();
644                writer.write_all(data).unwrap();
645            }
646            writer.finish().unwrap();
647        }
648        buf
649    }
650
651    /// Write a test JAR file to disk and return its path.
652    fn write_test_jar(dir: &Path, name: &str, classes: &[(&str, &[u8])]) -> PathBuf {
653        let jar_bytes = build_test_jar(classes);
654        let jar_path = dir.join(name);
655        std::fs::write(&jar_path, &jar_bytes).unwrap();
656        jar_path
657    }
658
659    // ── Default config tests ───────────────────────────────────────────
660
661    #[test]
662    fn test_default_config() {
663        let config = ClasspathConfig::default();
664        assert!(!config.enabled);
665        assert_eq!(config.depth, ClasspathDepth::Full);
666        assert!(config.build_system_override.is_none());
667        assert!(config.classpath_file.is_none());
668        assert!(!config.force);
669        assert_eq!(config.timeout_secs, 60);
670        // Build-tool execution is allowed by default (opt-out via --no-build-tool).
671        assert!(config.allow_build_tool_execution);
672    }
673
674    // ── Build-tool execution gate (F1: --no-build-tool) ────────────────
675
676    #[test]
677    fn no_build_tool_skips_resolution_without_running_a_build_tool() {
678        // A Gradle root is detected (build.gradle marker) but there is no
679        // wrapper and no cached classpath. With build-tool execution disabled
680        // the pipeline must not spawn anything and must skip the root gracefully
681        // (Ok, empty) rather than aborting the index.
682        let tmp = TempDir::new().unwrap();
683        std::fs::write(tmp.path().join("build.gradle"), "// empty\n").unwrap();
684
685        let config = ClasspathConfig {
686            enabled: true,
687            allow_build_tool_execution: false,
688            ..ClasspathConfig::default()
689        };
690
691        let resolved = resolve_from_build_system(tmp.path(), &config)
692            .expect("disabled build-tool execution must be a graceful skip, not an error");
693        assert!(
694            resolved.is_empty(),
695            "no cached classpath exists, so the disabled root yields nothing"
696        );
697    }
698
699    #[cfg(unix)]
700    #[test]
701    fn no_build_tool_does_not_execute_a_project_wrapper() {
702        use std::os::unix::fs::PermissionsExt;
703
704        // A Gradle root with an executable `gradlew` wrapper that writes a
705        // sentinel file if it is ever run.
706        let tmp = TempDir::new().unwrap();
707        std::fs::write(tmp.path().join("build.gradle"), "// empty\n").unwrap();
708        let sentinel = tmp.path().join("EXECUTED");
709        let gradlew = tmp.path().join("gradlew");
710        std::fs::write(
711            &gradlew,
712            format!("#!/bin/sh\ntouch \"{}\"\nexit 0\n", sentinel.display()),
713        )
714        .unwrap();
715        std::fs::set_permissions(&gradlew, std::fs::Permissions::from_mode(0o755)).unwrap();
716
717        // Disabled: the wrapper must never be executed.
718        let disabled = ClasspathConfig {
719            enabled: true,
720            allow_build_tool_execution: false,
721            ..ClasspathConfig::default()
722        };
723        let _ = resolve_from_build_system(tmp.path(), &disabled);
724        assert!(
725            !sentinel.exists(),
726            "the project build tool must not run under --no-build-tool"
727        );
728
729        // Contrast: with execution enabled the same wrapper DOES run, proving
730        // the sentinel would fire if the gate were bypassed.
731        let enabled = ClasspathConfig {
732            enabled: true,
733            allow_build_tool_execution: true,
734            ..ClasspathConfig::default()
735        };
736        let _ = resolve_from_build_system(tmp.path(), &enabled);
737        assert!(
738            sentinel.exists(),
739            "sanity: the wrapper executes when build-tool execution is enabled"
740        );
741    }
742
743    #[test]
744    fn read_cached_classpath_returns_none_without_a_cache() {
745        // The cache-only readers never spawn and return None when no cache is
746        // present, for every build system.
747        let tmp = TempDir::new().unwrap();
748        let resolve_config = crate::resolve::ResolveConfig {
749            project_root: tmp.path().to_path_buf(),
750            timeout_secs: 30,
751            cache_path: Some(tmp.path().join(".sqry").join("classpath")),
752        };
753        for build_system in [
754            BuildSystem::Gradle,
755            BuildSystem::Maven,
756            BuildSystem::Bazel,
757            BuildSystem::Sbt,
758        ] {
759            assert!(
760                read_cached_classpath(build_system, &resolve_config).is_none(),
761                "{build_system:?} must report no cached classpath when none exists"
762            );
763        }
764    }
765
766    // ── Manual classpath file tests ────────────────────────────────────
767
768    #[test]
769    fn test_resolve_from_manual_file_basic() {
770        let tmp = TempDir::new().unwrap();
771
772        // Create some fake JAR files.
773        let jar_a = tmp.path().join("a.jar");
774        let jar_b = tmp.path().join("b.jar");
775        std::fs::write(&jar_a, b"fake jar a").unwrap();
776        std::fs::write(&jar_b, b"fake jar b").unwrap();
777
778        // Write classpath file.
779        let cp_file = tmp.path().join("classpath.txt");
780        std::fs::write(
781            &cp_file,
782            format!("{}\n{}\n", jar_a.display(), jar_b.display()),
783        )
784        .unwrap();
785
786        let result = resolve_from_manual_file(tmp.path(), &cp_file).unwrap();
787        assert_eq!(result.len(), 1);
788        assert_eq!(result[0].module_name, "manual");
789        assert_eq!(result[0].module_root, tmp.path());
790        assert_eq!(result[0].entries.len(), 2);
791        assert!(result[0].entries[0].is_direct);
792        assert!(result[0].entries[1].is_direct);
793    }
794
795    #[test]
796    fn test_resolve_from_manual_file_skips_comments_and_blanks() {
797        let tmp = TempDir::new().unwrap();
798        let jar_a = tmp.path().join("a.jar");
799        std::fs::write(&jar_a, b"fake jar a").unwrap();
800
801        let cp_file = tmp.path().join("classpath.txt");
802        std::fs::write(
803            &cp_file,
804            format!(
805                "# This is a comment\n\n{}\n\n# Another comment\n",
806                jar_a.display()
807            ),
808        )
809        .unwrap();
810
811        let result = resolve_from_manual_file(tmp.path(), &cp_file).unwrap();
812        assert_eq!(result[0].entries.len(), 1);
813    }
814
815    #[test]
816    fn test_resolve_from_manual_file_nonexistent_file() {
817        let result =
818            resolve_from_manual_file(Path::new("/tmp"), Path::new("/nonexistent/classpath.txt"));
819        assert!(result.is_err());
820        let err = result.unwrap_err().to_string();
821        assert!(err.contains("Cannot open classpath file"));
822    }
823
824    #[test]
825    fn test_resolve_from_manual_file_nonexistent_jars_included() {
826        let tmp = TempDir::new().unwrap();
827        let cp_file = tmp.path().join("classpath.txt");
828        std::fs::write(&cp_file, "/nonexistent/jar.jar\n").unwrap();
829
830        let result = resolve_from_manual_file(tmp.path(), &cp_file).unwrap();
831        assert_eq!(result[0].entries.len(), 1);
832        assert_eq!(
833            result[0].entries[0].jar_path,
834            PathBuf::from("/nonexistent/jar.jar")
835        );
836    }
837
838    // ── Deduplication tests ────────────────────────────────────────────
839
840    #[test]
841    fn test_deduplicate_jar_paths() {
842        let entries = [
843            ClasspathEntry {
844                jar_path: PathBuf::from("/a.jar"),
845                coordinates: None,
846                is_direct: true,
847                source_jar: None,
848            },
849            ClasspathEntry {
850                jar_path: PathBuf::from("/b.jar"),
851                coordinates: None,
852                is_direct: true,
853                source_jar: None,
854            },
855            ClasspathEntry {
856                jar_path: PathBuf::from("/a.jar"),
857                coordinates: None,
858                is_direct: false,
859                source_jar: None,
860            },
861        ];
862        let refs: Vec<&ClasspathEntry> = entries.iter().collect();
863        let unique = deduplicate_jar_paths(&refs);
864        assert_eq!(unique.len(), 2);
865        assert_eq!(unique[0], PathBuf::from("/a.jar"));
866        assert_eq!(unique[1], PathBuf::from("/b.jar"));
867    }
868
869    // ── Provenance construction tests ──────────────────────────────────
870
871    #[test]
872    fn test_build_provenance() {
873        let classpaths = vec![ResolvedClasspath {
874            module_name: "app".to_string(),
875            module_root: PathBuf::from("/repo/app"),
876            entries: vec![
877                ClasspathEntry {
878                    jar_path: PathBuf::from("/guava.jar"),
879                    coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
880                    is_direct: true,
881                    source_jar: None,
882                },
883                ClasspathEntry {
884                    jar_path: PathBuf::from("/commons.jar"),
885                    coordinates: None,
886                    is_direct: false,
887                    source_jar: None,
888                },
889            ],
890        }];
891        let prov = build_provenance(&classpaths, ClasspathDepth::Full);
892
893        assert_eq!(prov.len(), 2);
894        assert_eq!(prov[0].jar_path, PathBuf::from("/commons.jar"));
895        assert_eq!(
896            prov[1].coordinates,
897            Some("com.google.guava:guava:33.0.0".to_string())
898        );
899        assert!(!prov[0].is_direct);
900        assert!(prov[1].is_direct);
901        assert!(prov[0].coordinates.is_none());
902        assert_eq!(prov[1].scopes[0].module_root, PathBuf::from("/repo/app"));
903    }
904
905    #[test]
906    fn test_build_provenance_mixed_directness_same_jar_is_conservative() {
907        let shared_jar = PathBuf::from("/shared.jar");
908        let classpaths = vec![
909            ResolvedClasspath {
910                module_name: "app".to_string(),
911                module_root: PathBuf::from("/repo/app"),
912                entries: vec![ClasspathEntry {
913                    jar_path: shared_jar.clone(),
914                    coordinates: Some("com.example:shared:1.0.0".to_string()),
915                    is_direct: true,
916                    source_jar: None,
917                }],
918            },
919            ResolvedClasspath {
920                module_name: "worker".to_string(),
921                module_root: PathBuf::from("/repo/worker"),
922                entries: vec![ClasspathEntry {
923                    jar_path: shared_jar.clone(),
924                    coordinates: Some("com.example:shared:1.0.0".to_string()),
925                    is_direct: false,
926                    source_jar: None,
927                }],
928            },
929        ];
930        let prov = build_provenance(&classpaths, ClasspathDepth::Full);
931
932        assert_eq!(prov.len(), 1);
933        assert_eq!(prov[0].jar_path, shared_jar);
934        assert!(
935            !prov[0].is_direct,
936            "aggregate directness should be conservative when scopes disagree"
937        );
938        assert!(
939            prov[0].has_direct_scope(),
940            "per-scope metadata should retain the direct scope"
941        );
942        assert_eq!(prov[0].scopes.len(), 2);
943    }
944
945    // ── Scan + cache integration tests ─────────────────────────────────
946
947    #[test]
948    fn test_scan_single_jar_fresh() {
949        let tmp = TempDir::new().unwrap();
950        let class_a = build_minimal_class("com/example/Foo");
951        let jar_path = write_test_jar(
952            tmp.path(),
953            "test.jar",
954            &[("com/example/Foo.class", &class_a)],
955        );
956
957        let cache = StubCache::new(tmp.path());
958        let outcome = scan_single_jar(&jar_path, &cache, false);
959
960        match outcome {
961            JarScanOutcome::Scanned { stubs, .. } => {
962                assert_eq!(stubs.len(), 1);
963                assert_eq!(stubs[0].fqn, "com.example.Foo");
964            }
965            other => panic!("Expected Scanned, got {:?}", outcome_name(&other)),
966        }
967    }
968
969    #[test]
970    fn test_scan_single_jar_cached() {
971        let tmp = TempDir::new().unwrap();
972        let class_a = build_minimal_class("com/example/Bar");
973        let jar_path = write_test_jar(
974            tmp.path(),
975            "test.jar",
976            &[("com/example/Bar.class", &class_a)],
977        );
978
979        let cache = StubCache::new(tmp.path());
980
981        // First scan populates cache.
982        let outcome = scan_single_jar(&jar_path, &cache, false);
983        assert!(matches!(outcome, JarScanOutcome::Scanned { .. }));
984
985        // Second scan should hit cache.
986        let outcome = scan_single_jar(&jar_path, &cache, false);
987        match outcome {
988            JarScanOutcome::Cached { stubs, .. } => {
989                assert_eq!(stubs.len(), 1);
990                assert_eq!(stubs[0].fqn, "com.example.Bar");
991            }
992            other => panic!("Expected Cached, got {:?}", outcome_name(&other)),
993        }
994    }
995
996    #[test]
997    fn test_scan_single_jar_force_bypasses_cache() {
998        let tmp = TempDir::new().unwrap();
999        let class_a = build_minimal_class("com/example/Baz");
1000        let jar_path = write_test_jar(
1001            tmp.path(),
1002            "test.jar",
1003            &[("com/example/Baz.class", &class_a)],
1004        );
1005
1006        let cache = StubCache::new(tmp.path());
1007
1008        // Populate cache.
1009        let _ = scan_single_jar(&jar_path, &cache, false);
1010
1011        // Force should bypass cache.
1012        let outcome = scan_single_jar(&jar_path, &cache, true);
1013        assert!(
1014            matches!(outcome, JarScanOutcome::Scanned { .. }),
1015            "force=true should bypass cache"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_scan_single_jar_nonexistent() {
1021        let tmp = TempDir::new().unwrap();
1022        let cache = StubCache::new(tmp.path());
1023        let outcome = scan_single_jar(Path::new("/nonexistent.jar"), &cache, false);
1024        assert!(
1025            matches!(outcome, JarScanOutcome::Failed { .. }),
1026            "Should fail for nonexistent JAR"
1027        );
1028    }
1029
1030    // ── Parallel scan tests ────────────────────────────────────────────
1031
1032    #[test]
1033    #[allow(clippy::match_same_arms)] // Arms separated for documentation clarity
1034    #[allow(clippy::match_wildcard_for_single_variants)] // Wildcard covers future variants
1035    fn test_scan_jars_parallel_multiple() {
1036        let tmp = TempDir::new().unwrap();
1037        let class_a = build_minimal_class("com/example/A");
1038        let class_b = build_minimal_class("com/example/B");
1039
1040        let jar_a = write_test_jar(tmp.path(), "a.jar", &[("com/example/A.class", &class_a)]);
1041        let jar_b = write_test_jar(tmp.path(), "b.jar", &[("com/example/B.class", &class_b)]);
1042
1043        let cache = StubCache::new(tmp.path());
1044        let results = scan_jars_parallel(&[jar_a, jar_b], &cache, false);
1045
1046        assert_eq!(results.len(), 2);
1047        let total_stubs: usize = results
1048            .iter()
1049            .filter_map(|r| match r {
1050                #[allow(clippy::match_same_arms)] // Pipeline stage arms separated for traceability
1051                JarScanOutcome::Scanned { stubs, .. } | JarScanOutcome::Cached { stubs, .. } => {
1052                    Some(stubs.len())
1053                }
1054                _ => None,
1055            })
1056            .sum();
1057        assert_eq!(total_stubs, 2);
1058    }
1059
1060    // ── Persistence tests ──────────────────────────────────────────────
1061
1062    #[test]
1063    fn test_persist_artifacts_roundtrip() {
1064        let tmp = TempDir::new().unwrap();
1065        let classpath_dir = tmp.path().join("classpath");
1066
1067        let index = ClasspathIndex::build(vec![]);
1068        let provenance = vec![ClasspathProvenance {
1069            jar_path: PathBuf::from("/test.jar"),
1070            coordinates: Some("test:test:1.0".to_string()),
1071            is_direct: true,
1072            scopes: vec![ClasspathScope {
1073                module_name: "manual".to_string(),
1074                module_root: tmp.path().to_path_buf(),
1075                is_direct: true,
1076            }],
1077        }];
1078
1079        persist_artifacts(&classpath_dir, &index, &provenance).unwrap();
1080
1081        // Verify index file exists and is loadable.
1082        let index_path = classpath_dir.join("index.sqry");
1083        assert!(index_path.exists());
1084        let loaded_index = ClasspathIndex::load(&index_path).unwrap();
1085        assert_eq!(loaded_index.classes.len(), 0);
1086
1087        // Verify provenance file exists and is valid JSON.
1088        let prov_path = classpath_dir.join("provenance.json");
1089        assert!(prov_path.exists());
1090        let prov_json = std::fs::read_to_string(&prov_path).unwrap();
1091        let loaded_prov: Vec<ClasspathProvenance> = serde_json::from_str(&prov_json).unwrap();
1092        assert_eq!(loaded_prov.len(), 1);
1093        assert_eq!(
1094            loaded_prov[0].coordinates,
1095            Some("test:test:1.0".to_string())
1096        );
1097    }
1098
1099    // ── Depth filtering tests ──────────────────────────────────────────
1100
1101    #[test]
1102    fn test_depth_shallow_filters_transitive() {
1103        let tmp = TempDir::new().unwrap();
1104
1105        let class_d = build_minimal_class("com/example/Direct");
1106        let class_t = build_minimal_class("com/example/Transitive");
1107
1108        let jar_d = write_test_jar(
1109            tmp.path(),
1110            "direct.jar",
1111            &[("com/example/Direct.class", &class_d)],
1112        );
1113        let jar_t = write_test_jar(
1114            tmp.path(),
1115            "transitive.jar",
1116            &[("com/example/Transitive.class", &class_t)],
1117        );
1118
1119        // Write a manual classpath file.
1120        let cp_file = tmp.path().join("classpath.txt");
1121        std::fs::write(
1122            &cp_file,
1123            format!("{}\n{}\n", jar_d.display(), jar_t.display()),
1124        )
1125        .unwrap();
1126
1127        // Manually create resolved classpaths with mixed direct/transitive.
1128        let entries = [
1129            ClasspathEntry {
1130                jar_path: jar_d,
1131                coordinates: None,
1132                is_direct: true,
1133                source_jar: None,
1134            },
1135            ClasspathEntry {
1136                jar_path: jar_t,
1137                coordinates: None,
1138                is_direct: false,
1139                source_jar: None,
1140            },
1141        ];
1142        let all_refs: Vec<&ClasspathEntry> = entries.iter().collect();
1143
1144        // Full depth should include both.
1145        let full: Vec<&ClasspathEntry> = all_refs.clone();
1146        assert_eq!(full.len(), 2);
1147
1148        // Shallow depth should only include direct.
1149        let shallow: Vec<&ClasspathEntry> = all_refs.into_iter().filter(|e| e.is_direct).collect();
1150        assert_eq!(shallow.len(), 1);
1151        assert!(shallow[0].is_direct);
1152    }
1153
1154    // ── Full pipeline test with manual file ────────────────────────────
1155
1156    #[test]
1157    fn test_full_pipeline_with_manual_file() {
1158        let tmp = TempDir::new().unwrap();
1159
1160        let class_a = build_minimal_class("com/example/Alpha");
1161        let class_b = build_minimal_class("com/example/Beta");
1162
1163        let jar_path = write_test_jar(
1164            tmp.path(),
1165            "deps.jar",
1166            &[
1167                ("com/example/Alpha.class", &class_a),
1168                ("com/example/Beta.class", &class_b),
1169            ],
1170        );
1171
1172        // Write classpath file.
1173        let cp_file = tmp.path().join("classpath.txt");
1174        std::fs::write(&cp_file, format!("{}\n", jar_path.display())).unwrap();
1175
1176        let config = ClasspathConfig {
1177            enabled: true,
1178            depth: ClasspathDepth::Full,
1179            build_system_override: None,
1180            classpath_file: Some(cp_file),
1181            force: false,
1182            timeout_secs: 30,
1183            allow_build_tool_execution: true,
1184        };
1185
1186        let result = run_classpath_pipeline(tmp.path(), &config).unwrap();
1187        assert_eq!(result.jars_scanned, 1);
1188        assert_eq!(result.classes_parsed, 2);
1189        assert_eq!(result.index.classes.len(), 2);
1190        assert!(result.index.lookup_fqn("com.example.Alpha").is_some());
1191        assert!(result.index.lookup_fqn("com.example.Beta").is_some());
1192        assert_eq!(result.provenance.len(), 1);
1193
1194        // Verify persistence.
1195        let index_path = tmp.path().join(".sqry/classpath/index.sqry");
1196        assert!(index_path.exists());
1197        let prov_path = tmp.path().join(".sqry/classpath/provenance.json");
1198        assert!(prov_path.exists());
1199    }
1200
1201    #[test]
1202    fn test_pipeline_no_build_system_returns_error() {
1203        let tmp = TempDir::new().unwrap();
1204        let config = ClasspathConfig {
1205            enabled: true,
1206            ..ClasspathConfig::default()
1207        };
1208
1209        let result = run_classpath_pipeline(tmp.path(), &config);
1210        assert!(result.is_err());
1211        let err = result.unwrap_err().to_string();
1212        assert!(
1213            err.contains("No JVM build system detected"),
1214            "Expected detection error, got: {err}"
1215        );
1216    }
1217
1218    // ── Helper for test output ─────────────────────────────────────────
1219
1220    fn outcome_name(outcome: &JarScanOutcome) -> &'static str {
1221        match outcome {
1222            JarScanOutcome::Scanned { .. } => "Scanned",
1223            JarScanOutcome::Cached { .. } => "Cached",
1224            JarScanOutcome::Failed { .. } => "Failed",
1225        }
1226    }
1227}