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