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, detect_build_system};
20use crate::graph::provenance::ClasspathProvenance;
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}
50
51/// Depth of classpath analysis.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ClasspathDepth {
54    /// Only direct dependencies.
55    Shallow,
56    /// All transitive dependencies.
57    Full,
58}
59
60impl Default for ClasspathConfig {
61    fn default() -> Self {
62        Self {
63            enabled: false,
64            depth: ClasspathDepth::Full,
65            build_system_override: None,
66            classpath_file: None,
67            force: false,
68            timeout_secs: 60,
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Pipeline result
75// ---------------------------------------------------------------------------
76
77/// Result of the classpath pipeline.
78#[derive(Debug)]
79pub struct ClasspathPipelineResult {
80    /// The built classpath index.
81    pub index: ClasspathIndex,
82    /// Provenance information for each JAR.
83    pub provenance: Vec<ClasspathProvenance>,
84    /// Number of JARs scanned.
85    pub jars_scanned: usize,
86    /// Number of classes parsed.
87    pub classes_parsed: usize,
88    /// Whether results came from cache.
89    pub from_cache: bool,
90}
91
92// ---------------------------------------------------------------------------
93// Main entry point
94// ---------------------------------------------------------------------------
95
96/// Run the full classpath pipeline: detect → resolve → scan/cache → build index.
97///
98/// This is the main entry point called from the CLI when classpath analysis
99/// is enabled. The returned [`ClasspathPipelineResult`] contains the
100/// [`ClasspathIndex`] and provenance data needed by the graph emitter.
101///
102/// # Steps
103///
104/// 1. **Detect** the build system (or use the override / manual file).
105/// 2. **Resolve** the classpath via the appropriate build tool resolver.
106/// 3. **Scan** each JAR file for `.class` entries, using the [`StubCache`]
107///    for incremental re-use. JARs are scanned in parallel via rayon.
108/// 4. **Build** a merged [`ClasspathIndex`] from all collected stubs.
109/// 5. **Persist** the index and provenance to `.sqry/classpath/` for
110///    subsequent builds that skip the resolve step.
111///
112/// # Errors
113///
114/// Returns [`ClasspathError`] if detection, resolution, scanning, or
115/// persistence fails.
116pub fn run_classpath_pipeline(
117    project_root: &Path,
118    config: &ClasspathConfig,
119) -> ClasspathResult<ClasspathPipelineResult> {
120    info!("Starting classpath pipeline for {}", project_root.display());
121
122    // ── Step 1: Resolve classpath entries ───────────────────────────────
123    let resolved_classpaths = if let Some(ref classpath_file) = config.classpath_file {
124        resolve_from_manual_file(classpath_file)?
125    } else {
126        resolve_from_build_system(project_root, config)?
127    };
128
129    // Flatten all entries across modules.
130    let all_entries: Vec<&ClasspathEntry> = resolved_classpaths
131        .iter()
132        .flat_map(|cp| &cp.entries)
133        .collect();
134
135    // Apply depth filtering.
136    let entries_to_scan: Vec<&ClasspathEntry> = match config.depth {
137        ClasspathDepth::Full => all_entries,
138        ClasspathDepth::Shallow => all_entries.into_iter().filter(|e| e.is_direct).collect(),
139    };
140
141    info!(
142        "Classpath resolved: {} entries ({} after depth filtering)",
143        resolved_classpaths
144            .iter()
145            .map(|cp| cp.entries.len())
146            .sum::<usize>(),
147        entries_to_scan.len(),
148    );
149
150    // Deduplicate by JAR path (same JAR may appear in multiple modules).
151    let unique_jar_paths = deduplicate_jar_paths(&entries_to_scan);
152    info!("{} unique JAR files to scan", unique_jar_paths.len());
153
154    // ── Step 2: Scan JARs (parallel, with stub cache) ──────────────────
155    let stub_cache = StubCache::new(project_root);
156    let scan_results = scan_jars_parallel(&unique_jar_paths, &stub_cache, config.force);
157
158    let mut all_stubs: Vec<ClassStub> = Vec::new();
159    let mut jars_scanned: usize = 0;
160    let mut jars_from_cache: usize = 0;
161
162    for result in &scan_results {
163        match result {
164            JarScanOutcome::Scanned { jar_path, stubs } => {
165                let jar_str = jar_path.display().to_string();
166                for stub in stubs {
167                    let mut s = stub.clone();
168                    // Ensure source_jar is set even if scan_jar already set it,
169                    // and for cached stubs that may predate the field.
170                    if s.source_jar.is_none() {
171                        s.source_jar = Some(jar_str.clone());
172                    }
173                    all_stubs.push(s);
174                }
175                jars_scanned += 1;
176            }
177            JarScanOutcome::Cached { jar_path, stubs } => {
178                let jar_str = jar_path.display().to_string();
179                for stub in stubs {
180                    let mut s = stub.clone();
181                    if s.source_jar.is_none() {
182                        s.source_jar = Some(jar_str.clone());
183                    }
184                    all_stubs.push(s);
185                }
186                jars_from_cache += 1;
187            }
188            JarScanOutcome::Failed { jar_path, error } => {
189                warn!("Failed to scan JAR {}: {error}", jar_path.display());
190            }
191        }
192    }
193
194    let classes_parsed = all_stubs.len();
195    info!(
196        "Scanned {} JARs ({} from cache, {} fresh), {} classes total",
197        jars_scanned + jars_from_cache,
198        jars_from_cache,
199        jars_scanned,
200        classes_parsed,
201    );
202
203    // ── Step 3: Build provenance ───────────────────────────────────────
204    let provenance = build_provenance(&entries_to_scan);
205
206    // ── Step 4: Build index ────────────────────────────────────────────
207    let index = ClasspathIndex::build(all_stubs);
208    info!(
209        "Built classpath index: {} classes, {} packages",
210        index.classes.len(),
211        index.package_index.len(),
212    );
213
214    // ── Step 5: Persist index and provenance ───────────────────────────
215    let sqry_classpath_dir = project_root.join(".sqry").join("classpath");
216    persist_artifacts(&sqry_classpath_dir, &index, &provenance)?;
217
218    Ok(ClasspathPipelineResult {
219        index,
220        provenance,
221        jars_scanned: jars_scanned + jars_from_cache,
222        classes_parsed,
223        from_cache: jars_from_cache > 0 && jars_scanned == 0,
224    })
225}
226
227// ---------------------------------------------------------------------------
228// Resolution strategies
229// ---------------------------------------------------------------------------
230
231/// Read a manual classpath file (one JAR path per line).
232///
233/// Lines that are empty or start with `#` are skipped (comments).
234fn resolve_from_manual_file(classpath_file: &Path) -> ClasspathResult<Vec<ResolvedClasspath>> {
235    info!("Reading manual classpath from {}", classpath_file.display());
236
237    let file = std::fs::File::open(classpath_file).map_err(|e| {
238        ClasspathError::ResolutionFailed(format!(
239            "Cannot open classpath file {}: {e}",
240            classpath_file.display()
241        ))
242    })?;
243
244    let reader = BufReader::new(file);
245    let mut entries = Vec::new();
246
247    for line in reader.lines() {
248        let line = line.map_err(|e| {
249            ClasspathError::ResolutionFailed(format!(
250                "Error reading classpath file {}: {e}",
251                classpath_file.display()
252            ))
253        })?;
254        let trimmed = line.trim();
255
256        // Skip empty lines and comments.
257        if trimmed.is_empty() || trimmed.starts_with('#') {
258            continue;
259        }
260
261        let jar_path = PathBuf::from(trimmed);
262        if !jar_path.exists() {
263            warn!(
264                "Classpath file entry does not exist: {}",
265                jar_path.display()
266            );
267            // Still include it — the scanner will report the error.
268        }
269
270        entries.push(ClasspathEntry {
271            jar_path,
272            coordinates: None,
273            is_direct: true, // Manual entries treated as direct.
274            source_jar: None,
275        });
276    }
277
278    info!("Manual classpath file: {} entries", entries.len());
279
280    Ok(vec![ResolvedClasspath {
281        module_name: "manual".to_string(),
282        entries,
283    }])
284}
285
286/// Detect the build system and resolve the classpath via the appropriate resolver.
287fn resolve_from_build_system(
288    project_root: &Path,
289    config: &ClasspathConfig,
290) -> ClasspathResult<Vec<ResolvedClasspath>> {
291    let detection = detect_build_system(project_root, config.build_system_override.as_deref());
292
293    let build_system = detection.build_system.ok_or_else(|| {
294        ClasspathError::DetectionFailed(
295            "No JVM build system detected. Use --build-system to specify one, \
296             or --classpath-file to provide a manual classpath."
297                .to_string(),
298        )
299    })?;
300
301    info!("Detected build system: {build_system:?}");
302
303    let resolve_config = ResolveConfig {
304        project_root: project_root.to_path_buf(),
305        timeout_secs: config.timeout_secs,
306        cache_path: Some(
307            project_root
308                .join(".sqry")
309                .join("classpath")
310                .join("resolved-classpath.json"),
311        ),
312    };
313
314    match build_system {
315        BuildSystem::Gradle => crate::resolve::gradle::resolve_gradle_classpath(&resolve_config),
316        BuildSystem::Maven => crate::resolve::maven::resolve_maven_classpath(&resolve_config),
317        BuildSystem::Bazel => crate::resolve::bazel::resolve_bazel_classpath(&resolve_config),
318        BuildSystem::Sbt => crate::resolve::sbt::resolve_sbt_classpath(&resolve_config),
319    }
320}
321
322// ---------------------------------------------------------------------------
323// JAR scanning
324// ---------------------------------------------------------------------------
325
326/// Outcome of scanning a single JAR file.
327enum JarScanOutcome {
328    /// JAR was freshly scanned and parsed.
329    Scanned {
330        #[allow(dead_code)] // Used in tests for pattern matching.
331        jar_path: PathBuf,
332        stubs: Vec<ClassStub>,
333    },
334    /// Stubs were loaded from the stub cache (JAR hash matched).
335    Cached {
336        #[allow(dead_code)] // Used in tests for pattern matching.
337        jar_path: PathBuf,
338        stubs: Vec<ClassStub>,
339    },
340    /// JAR could not be scanned.
341    Failed { jar_path: PathBuf, error: String },
342}
343
344/// Deduplicate JAR paths, preserving the first occurrence.
345fn deduplicate_jar_paths(entries: &[&ClasspathEntry]) -> Vec<PathBuf> {
346    let mut seen = std::collections::HashSet::new();
347    let mut unique = Vec::new();
348
349    for entry in entries {
350        if seen.insert(&entry.jar_path) {
351            unique.push(entry.jar_path.clone());
352        }
353    }
354
355    unique
356}
357
358/// Scan JAR files in parallel using rayon, with stub cache for incremental builds.
359///
360/// Each JAR is either loaded from the stub cache (if the JAR's SHA-256 hash
361/// matches a cached entry) or freshly scanned. Freshly scanned stubs are
362/// written to the cache for future use.
363fn scan_jars_parallel(
364    jar_paths: &[PathBuf],
365    stub_cache: &StubCache,
366    force: bool,
367) -> Vec<JarScanOutcome> {
368    jar_paths
369        .par_iter()
370        .map(|jar_path| scan_single_jar(jar_path, stub_cache, force))
371        .collect()
372}
373
374/// Scan a single JAR file, using the stub cache when possible.
375fn scan_single_jar(jar_path: &Path, stub_cache: &StubCache, force: bool) -> JarScanOutcome {
376    // Try cache first (unless force is set).
377    if !force && let Some(cached_stubs) = stub_cache.get(jar_path) {
378        debug!(
379            "Cache hit for {} ({} stubs)",
380            jar_path.display(),
381            cached_stubs.len()
382        );
383        return JarScanOutcome::Cached {
384            jar_path: jar_path.to_path_buf(),
385            stubs: cached_stubs,
386        };
387    }
388
389    // Fresh scan.
390    match scan_jar(jar_path) {
391        Ok(stubs) => {
392            debug!("Scanned {} ({} classes)", jar_path.display(), stubs.len());
393
394            // Write to cache (non-fatal on error).
395            if let Err(e) = stub_cache.put(jar_path, &stubs) {
396                warn!("Failed to cache stubs for {}: {e}", jar_path.display());
397            }
398
399            JarScanOutcome::Scanned {
400                jar_path: jar_path.to_path_buf(),
401                stubs,
402            }
403        }
404        Err(e) => JarScanOutcome::Failed {
405            jar_path: jar_path.to_path_buf(),
406            error: e.to_string(),
407        },
408    }
409}
410
411// ---------------------------------------------------------------------------
412// Provenance construction
413// ---------------------------------------------------------------------------
414
415/// Build provenance records from classpath entries.
416fn build_provenance(entries: &[&ClasspathEntry]) -> Vec<ClasspathProvenance> {
417    entries
418        .iter()
419        .map(|entry| ClasspathProvenance {
420            jar_path: entry.jar_path.clone(),
421            coordinates: entry.coordinates.clone(),
422            is_direct: entry.is_direct,
423        })
424        .collect()
425}
426
427// ---------------------------------------------------------------------------
428// Persistence
429// ---------------------------------------------------------------------------
430
431/// Persist the classpath index and provenance to `.sqry/classpath/`.
432fn persist_artifacts(
433    classpath_dir: &Path,
434    index: &ClasspathIndex,
435    provenance: &[ClasspathProvenance],
436) -> ClasspathResult<()> {
437    std::fs::create_dir_all(classpath_dir).map_err(|e| {
438        ClasspathError::IndexError(format!(
439            "Cannot create classpath directory {}: {e}",
440            classpath_dir.display()
441        ))
442    })?;
443
444    // Persist index.
445    let index_path = classpath_dir.join("index.sqry");
446    index.save(&index_path)?;
447    info!("Saved classpath index to {}", index_path.display());
448
449    // Persist provenance.
450    let provenance_path = classpath_dir.join("provenance.json");
451    let provenance_json = serde_json::to_string_pretty(provenance)
452        .map_err(|e| ClasspathError::IndexError(format!("Cannot serialize provenance: {e}")))?;
453    std::fs::write(&provenance_path, provenance_json).map_err(|e| {
454        ClasspathError::IndexError(format!(
455            "Cannot write provenance to {}: {e}",
456            provenance_path.display()
457        ))
458    })?;
459    info!("Saved provenance to {}", provenance_path.display());
460
461    Ok(())
462}
463
464// ---------------------------------------------------------------------------
465// Tests
466// ---------------------------------------------------------------------------
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use std::io::Write;
472    use tempfile::TempDir;
473    use zip::write::SimpleFileOptions;
474
475    /// Build a minimal valid .class file for testing.
476    fn build_minimal_class(class_name: &str) -> Vec<u8> {
477        let mut bytes = Vec::new();
478
479        // Magic
480        bytes.extend_from_slice(&0xCAFE_BABEu32.to_be_bytes());
481        // Minor version
482        bytes.extend_from_slice(&0u16.to_be_bytes());
483        // Major version (52 = Java 8)
484        bytes.extend_from_slice(&52u16.to_be_bytes());
485
486        // Constant pool: 5 entries
487        let class_bytes = class_name.as_bytes();
488        let object_bytes = b"java/lang/Object";
489
490        let cp_count: u16 = 5;
491        bytes.extend_from_slice(&cp_count.to_be_bytes());
492
493        // #1: CONSTANT_Utf8 <class_name>
494        bytes.push(1);
495        bytes.extend_from_slice(&(class_bytes.len() as u16).to_be_bytes());
496        bytes.extend_from_slice(class_bytes);
497
498        // #2: CONSTANT_Class -> #1
499        bytes.push(7);
500        bytes.extend_from_slice(&1u16.to_be_bytes());
501
502        // #3: CONSTANT_Utf8 "java/lang/Object"
503        bytes.push(1);
504        bytes.extend_from_slice(&(object_bytes.len() as u16).to_be_bytes());
505        bytes.extend_from_slice(object_bytes);
506
507        // #4: CONSTANT_Class -> #3
508        bytes.push(7);
509        bytes.extend_from_slice(&3u16.to_be_bytes());
510
511        // Access flags: ACC_PUBLIC | ACC_SUPER
512        bytes.extend_from_slice(&0x0021u16.to_be_bytes());
513        // This class: #2
514        bytes.extend_from_slice(&2u16.to_be_bytes());
515        // Super class: #4
516        bytes.extend_from_slice(&4u16.to_be_bytes());
517        // Interfaces count: 0
518        bytes.extend_from_slice(&0u16.to_be_bytes());
519        // Fields count: 0
520        bytes.extend_from_slice(&0u16.to_be_bytes());
521        // Methods count: 0
522        bytes.extend_from_slice(&0u16.to_be_bytes());
523        // Attributes count: 0
524        bytes.extend_from_slice(&0u16.to_be_bytes());
525
526        bytes
527    }
528
529    /// Create an in-memory JAR (ZIP) file containing test classes.
530    fn build_test_jar(entries: &[(&str, &[u8])]) -> Vec<u8> {
531        let mut buf = Vec::new();
532        {
533            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
534            let options =
535                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
536            for (name, data) in entries {
537                writer.start_file(*name, options).unwrap();
538                writer.write_all(data).unwrap();
539            }
540            writer.finish().unwrap();
541        }
542        buf
543    }
544
545    /// Write a test JAR file to disk and return its path.
546    fn write_test_jar(dir: &Path, name: &str, classes: &[(&str, &[u8])]) -> PathBuf {
547        let jar_bytes = build_test_jar(classes);
548        let jar_path = dir.join(name);
549        std::fs::write(&jar_path, &jar_bytes).unwrap();
550        jar_path
551    }
552
553    // ── Default config tests ───────────────────────────────────────────
554
555    #[test]
556    fn test_default_config() {
557        let config = ClasspathConfig::default();
558        assert!(!config.enabled);
559        assert_eq!(config.depth, ClasspathDepth::Full);
560        assert!(config.build_system_override.is_none());
561        assert!(config.classpath_file.is_none());
562        assert!(!config.force);
563        assert_eq!(config.timeout_secs, 60);
564    }
565
566    // ── Manual classpath file tests ────────────────────────────────────
567
568    #[test]
569    fn test_resolve_from_manual_file_basic() {
570        let tmp = TempDir::new().unwrap();
571
572        // Create some fake JAR files.
573        let jar_a = tmp.path().join("a.jar");
574        let jar_b = tmp.path().join("b.jar");
575        std::fs::write(&jar_a, b"fake jar a").unwrap();
576        std::fs::write(&jar_b, b"fake jar b").unwrap();
577
578        // Write classpath file.
579        let cp_file = tmp.path().join("classpath.txt");
580        std::fs::write(
581            &cp_file,
582            format!("{}\n{}\n", jar_a.display(), jar_b.display()),
583        )
584        .unwrap();
585
586        let result = resolve_from_manual_file(&cp_file).unwrap();
587        assert_eq!(result.len(), 1);
588        assert_eq!(result[0].module_name, "manual");
589        assert_eq!(result[0].entries.len(), 2);
590        assert!(result[0].entries[0].is_direct);
591        assert!(result[0].entries[1].is_direct);
592    }
593
594    #[test]
595    fn test_resolve_from_manual_file_skips_comments_and_blanks() {
596        let tmp = TempDir::new().unwrap();
597        let jar_a = tmp.path().join("a.jar");
598        std::fs::write(&jar_a, b"fake jar a").unwrap();
599
600        let cp_file = tmp.path().join("classpath.txt");
601        std::fs::write(
602            &cp_file,
603            format!(
604                "# This is a comment\n\n{}\n\n# Another comment\n",
605                jar_a.display()
606            ),
607        )
608        .unwrap();
609
610        let result = resolve_from_manual_file(&cp_file).unwrap();
611        assert_eq!(result[0].entries.len(), 1);
612    }
613
614    #[test]
615    fn test_resolve_from_manual_file_nonexistent_file() {
616        let result = resolve_from_manual_file(Path::new("/nonexistent/classpath.txt"));
617        assert!(result.is_err());
618        let err = result.unwrap_err().to_string();
619        assert!(err.contains("Cannot open classpath file"));
620    }
621
622    #[test]
623    fn test_resolve_from_manual_file_nonexistent_jars_included() {
624        let tmp = TempDir::new().unwrap();
625        let cp_file = tmp.path().join("classpath.txt");
626        std::fs::write(&cp_file, "/nonexistent/jar.jar\n").unwrap();
627
628        let result = resolve_from_manual_file(&cp_file).unwrap();
629        assert_eq!(result[0].entries.len(), 1);
630        assert_eq!(
631            result[0].entries[0].jar_path,
632            PathBuf::from("/nonexistent/jar.jar")
633        );
634    }
635
636    // ── Deduplication tests ────────────────────────────────────────────
637
638    #[test]
639    fn test_deduplicate_jar_paths() {
640        let entries = vec![
641            ClasspathEntry {
642                jar_path: PathBuf::from("/a.jar"),
643                coordinates: None,
644                is_direct: true,
645                source_jar: None,
646            },
647            ClasspathEntry {
648                jar_path: PathBuf::from("/b.jar"),
649                coordinates: None,
650                is_direct: true,
651                source_jar: None,
652            },
653            ClasspathEntry {
654                jar_path: PathBuf::from("/a.jar"),
655                coordinates: None,
656                is_direct: false,
657                source_jar: None,
658            },
659        ];
660        let refs: Vec<&ClasspathEntry> = entries.iter().collect();
661        let unique = deduplicate_jar_paths(&refs);
662        assert_eq!(unique.len(), 2);
663        assert_eq!(unique[0], PathBuf::from("/a.jar"));
664        assert_eq!(unique[1], PathBuf::from("/b.jar"));
665    }
666
667    // ── Provenance construction tests ──────────────────────────────────
668
669    #[test]
670    fn test_build_provenance() {
671        let entries = [
672            ClasspathEntry {
673                jar_path: PathBuf::from("/guava.jar"),
674                coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
675                is_direct: true,
676                source_jar: None,
677            },
678            ClasspathEntry {
679                jar_path: PathBuf::from("/commons.jar"),
680                coordinates: None,
681                is_direct: false,
682                source_jar: None,
683            },
684        ];
685        let refs: Vec<&ClasspathEntry> = entries.iter().collect();
686        let prov = build_provenance(&refs);
687
688        assert_eq!(prov.len(), 2);
689        assert_eq!(prov[0].jar_path, PathBuf::from("/guava.jar"));
690        assert_eq!(
691            prov[0].coordinates,
692            Some("com.google.guava:guava:33.0.0".to_string())
693        );
694        assert!(prov[0].is_direct);
695        assert!(!prov[1].is_direct);
696        assert!(prov[1].coordinates.is_none());
697    }
698
699    // ── Scan + cache integration tests ─────────────────────────────────
700
701    #[test]
702    fn test_scan_single_jar_fresh() {
703        let tmp = TempDir::new().unwrap();
704        let class_a = build_minimal_class("com/example/Foo");
705        let jar_path = write_test_jar(
706            tmp.path(),
707            "test.jar",
708            &[("com/example/Foo.class", &class_a)],
709        );
710
711        let cache = StubCache::new(tmp.path());
712        let outcome = scan_single_jar(&jar_path, &cache, false);
713
714        match outcome {
715            JarScanOutcome::Scanned { stubs, .. } => {
716                assert_eq!(stubs.len(), 1);
717                assert_eq!(stubs[0].fqn, "com.example.Foo");
718            }
719            other => panic!("Expected Scanned, got {:?}", outcome_name(&other)),
720        }
721    }
722
723    #[test]
724    fn test_scan_single_jar_cached() {
725        let tmp = TempDir::new().unwrap();
726        let class_a = build_minimal_class("com/example/Bar");
727        let jar_path = write_test_jar(
728            tmp.path(),
729            "test.jar",
730            &[("com/example/Bar.class", &class_a)],
731        );
732
733        let cache = StubCache::new(tmp.path());
734
735        // First scan populates cache.
736        let outcome = scan_single_jar(&jar_path, &cache, false);
737        assert!(matches!(outcome, JarScanOutcome::Scanned { .. }));
738
739        // Second scan should hit cache.
740        let outcome = scan_single_jar(&jar_path, &cache, false);
741        match outcome {
742            JarScanOutcome::Cached { stubs, .. } => {
743                assert_eq!(stubs.len(), 1);
744                assert_eq!(stubs[0].fqn, "com.example.Bar");
745            }
746            other => panic!("Expected Cached, got {:?}", outcome_name(&other)),
747        }
748    }
749
750    #[test]
751    fn test_scan_single_jar_force_bypasses_cache() {
752        let tmp = TempDir::new().unwrap();
753        let class_a = build_minimal_class("com/example/Baz");
754        let jar_path = write_test_jar(
755            tmp.path(),
756            "test.jar",
757            &[("com/example/Baz.class", &class_a)],
758        );
759
760        let cache = StubCache::new(tmp.path());
761
762        // Populate cache.
763        let _ = scan_single_jar(&jar_path, &cache, false);
764
765        // Force should bypass cache.
766        let outcome = scan_single_jar(&jar_path, &cache, true);
767        assert!(
768            matches!(outcome, JarScanOutcome::Scanned { .. }),
769            "force=true should bypass cache"
770        );
771    }
772
773    #[test]
774    fn test_scan_single_jar_nonexistent() {
775        let tmp = TempDir::new().unwrap();
776        let cache = StubCache::new(tmp.path());
777        let outcome = scan_single_jar(Path::new("/nonexistent.jar"), &cache, false);
778        assert!(
779            matches!(outcome, JarScanOutcome::Failed { .. }),
780            "Should fail for nonexistent JAR"
781        );
782    }
783
784    // ── Parallel scan tests ────────────────────────────────────────────
785
786    #[test]
787    #[allow(clippy::match_same_arms)] // Arms separated for documentation clarity
788    #[allow(clippy::match_wildcard_for_single_variants)] // Wildcard covers future variants
789    fn test_scan_jars_parallel_multiple() {
790        let tmp = TempDir::new().unwrap();
791        let class_a = build_minimal_class("com/example/A");
792        let class_b = build_minimal_class("com/example/B");
793
794        let jar_a = write_test_jar(tmp.path(), "a.jar", &[("com/example/A.class", &class_a)]);
795        let jar_b = write_test_jar(tmp.path(), "b.jar", &[("com/example/B.class", &class_b)]);
796
797        let cache = StubCache::new(tmp.path());
798        let results = scan_jars_parallel(&[jar_a, jar_b], &cache, false);
799
800        assert_eq!(results.len(), 2);
801        let total_stubs: usize = results
802            .iter()
803            .filter_map(|r| match r {
804                #[allow(clippy::match_same_arms)] // Pipeline stage arms separated for traceability
805                JarScanOutcome::Scanned { stubs, .. } | JarScanOutcome::Cached { stubs, .. } => {
806                    Some(stubs.len())
807                }
808                _ => None,
809            })
810            .sum();
811        assert_eq!(total_stubs, 2);
812    }
813
814    // ── Persistence tests ──────────────────────────────────────────────
815
816    #[test]
817    fn test_persist_artifacts_roundtrip() {
818        let tmp = TempDir::new().unwrap();
819        let classpath_dir = tmp.path().join("classpath");
820
821        let index = ClasspathIndex::build(vec![]);
822        let provenance = vec![ClasspathProvenance {
823            jar_path: PathBuf::from("/test.jar"),
824            coordinates: Some("test:test:1.0".to_string()),
825            is_direct: true,
826        }];
827
828        persist_artifacts(&classpath_dir, &index, &provenance).unwrap();
829
830        // Verify index file exists and is loadable.
831        let index_path = classpath_dir.join("index.sqry");
832        assert!(index_path.exists());
833        let loaded_index = ClasspathIndex::load(&index_path).unwrap();
834        assert_eq!(loaded_index.classes.len(), 0);
835
836        // Verify provenance file exists and is valid JSON.
837        let prov_path = classpath_dir.join("provenance.json");
838        assert!(prov_path.exists());
839        let prov_json = std::fs::read_to_string(&prov_path).unwrap();
840        let loaded_prov: Vec<ClasspathProvenance> = serde_json::from_str(&prov_json).unwrap();
841        assert_eq!(loaded_prov.len(), 1);
842        assert_eq!(
843            loaded_prov[0].coordinates,
844            Some("test:test:1.0".to_string())
845        );
846    }
847
848    // ── Depth filtering tests ──────────────────────────────────────────
849
850    #[test]
851    fn test_depth_shallow_filters_transitive() {
852        let tmp = TempDir::new().unwrap();
853
854        let class_d = build_minimal_class("com/example/Direct");
855        let class_t = build_minimal_class("com/example/Transitive");
856
857        let jar_d = write_test_jar(
858            tmp.path(),
859            "direct.jar",
860            &[("com/example/Direct.class", &class_d)],
861        );
862        let jar_t = write_test_jar(
863            tmp.path(),
864            "transitive.jar",
865            &[("com/example/Transitive.class", &class_t)],
866        );
867
868        // Write a manual classpath file.
869        let cp_file = tmp.path().join("classpath.txt");
870        std::fs::write(
871            &cp_file,
872            format!("{}\n{}\n", jar_d.display(), jar_t.display()),
873        )
874        .unwrap();
875
876        // Manually create resolved classpaths with mixed direct/transitive.
877        let entries = [
878            ClasspathEntry {
879                jar_path: jar_d,
880                coordinates: None,
881                is_direct: true,
882                source_jar: None,
883            },
884            ClasspathEntry {
885                jar_path: jar_t,
886                coordinates: None,
887                is_direct: false,
888                source_jar: None,
889            },
890        ];
891        let all_refs: Vec<&ClasspathEntry> = entries.iter().collect();
892
893        // Full depth should include both.
894        let full: Vec<&ClasspathEntry> = all_refs.clone();
895        assert_eq!(full.len(), 2);
896
897        // Shallow depth should only include direct.
898        let shallow: Vec<&ClasspathEntry> = all_refs.into_iter().filter(|e| e.is_direct).collect();
899        assert_eq!(shallow.len(), 1);
900        assert!(shallow[0].is_direct);
901    }
902
903    // ── Full pipeline test with manual file ────────────────────────────
904
905    #[test]
906    fn test_full_pipeline_with_manual_file() {
907        let tmp = TempDir::new().unwrap();
908
909        let class_a = build_minimal_class("com/example/Alpha");
910        let class_b = build_minimal_class("com/example/Beta");
911
912        let jar_path = write_test_jar(
913            tmp.path(),
914            "deps.jar",
915            &[
916                ("com/example/Alpha.class", &class_a),
917                ("com/example/Beta.class", &class_b),
918            ],
919        );
920
921        // Write classpath file.
922        let cp_file = tmp.path().join("classpath.txt");
923        std::fs::write(&cp_file, format!("{}\n", jar_path.display())).unwrap();
924
925        let config = ClasspathConfig {
926            enabled: true,
927            depth: ClasspathDepth::Full,
928            build_system_override: None,
929            classpath_file: Some(cp_file),
930            force: false,
931            timeout_secs: 30,
932        };
933
934        let result = run_classpath_pipeline(tmp.path(), &config).unwrap();
935        assert_eq!(result.jars_scanned, 1);
936        assert_eq!(result.classes_parsed, 2);
937        assert_eq!(result.index.classes.len(), 2);
938        assert!(result.index.lookup_fqn("com.example.Alpha").is_some());
939        assert!(result.index.lookup_fqn("com.example.Beta").is_some());
940        assert_eq!(result.provenance.len(), 1);
941
942        // Verify persistence.
943        let index_path = tmp.path().join(".sqry/classpath/index.sqry");
944        assert!(index_path.exists());
945        let prov_path = tmp.path().join(".sqry/classpath/provenance.json");
946        assert!(prov_path.exists());
947    }
948
949    #[test]
950    fn test_pipeline_no_build_system_returns_error() {
951        let tmp = TempDir::new().unwrap();
952        let config = ClasspathConfig {
953            enabled: true,
954            ..ClasspathConfig::default()
955        };
956
957        let result = run_classpath_pipeline(tmp.path(), &config);
958        assert!(result.is_err());
959        let err = result.unwrap_err().to_string();
960        assert!(
961            err.contains("No JVM build system detected"),
962            "Expected detection error, got: {err}"
963        );
964    }
965
966    // ── Helper for test output ─────────────────────────────────────────
967
968    fn outcome_name(outcome: &JarScanOutcome) -> &'static str {
969        match outcome {
970            JarScanOutcome::Scanned { .. } => "Scanned",
971            JarScanOutcome::Cached { .. } => "Cached",
972            JarScanOutcome::Failed { .. } => "Failed",
973        }
974    }
975}