1#![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#[derive(Debug, Clone)]
33pub struct ClasspathConfig {
34 pub enabled: bool,
36 pub depth: ClasspathDepth,
38 pub build_system_override: Option<String>,
40 pub classpath_file: Option<PathBuf>,
45 pub force: bool,
47 pub timeout_secs: u64,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ClasspathDepth {
54 Shallow,
56 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#[derive(Debug)]
79pub struct ClasspathPipelineResult {
80 pub index: ClasspathIndex,
82 pub provenance: Vec<ClasspathProvenance>,
84 pub jars_scanned: usize,
86 pub classes_parsed: usize,
88 pub from_cache: bool,
90}
91
92pub 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 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 let all_entries: Vec<&ClasspathEntry> = resolved_classpaths
131 .iter()
132 .flat_map(|cp| &cp.entries)
133 .collect();
134
135 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 let unique_jar_paths = deduplicate_jar_paths(&entries_to_scan);
152 info!("{} unique JAR files to scan", unique_jar_paths.len());
153
154 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 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 let provenance = build_provenance(&entries_to_scan);
205
206 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 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
227fn 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 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 }
269
270 entries.push(ClasspathEntry {
271 jar_path,
272 coordinates: None,
273 is_direct: true, 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
286fn 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
322enum JarScanOutcome {
328 Scanned {
330 #[allow(dead_code)] jar_path: PathBuf,
332 stubs: Vec<ClassStub>,
333 },
334 Cached {
336 #[allow(dead_code)] jar_path: PathBuf,
338 stubs: Vec<ClassStub>,
339 },
340 Failed { jar_path: PathBuf, error: String },
342}
343
344fn 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
358fn 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
374fn scan_single_jar(jar_path: &Path, stub_cache: &StubCache, force: bool) -> JarScanOutcome {
376 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 match scan_jar(jar_path) {
391 Ok(stubs) => {
392 debug!("Scanned {} ({} classes)", jar_path.display(), stubs.len());
393
394 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
411fn 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
427fn 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 let index_path = classpath_dir.join("index.sqry");
446 index.save(&index_path)?;
447 info!("Saved classpath index to {}", index_path.display());
448
449 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#[cfg(test)]
469mod tests {
470 use super::*;
471 use std::io::Write;
472 use tempfile::TempDir;
473 use zip::write::SimpleFileOptions;
474
475 fn build_minimal_class(class_name: &str) -> Vec<u8> {
477 let mut bytes = Vec::new();
478
479 bytes.extend_from_slice(&0xCAFE_BABEu32.to_be_bytes());
481 bytes.extend_from_slice(&0u16.to_be_bytes());
483 bytes.extend_from_slice(&52u16.to_be_bytes());
485
486 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 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 bytes.push(7);
500 bytes.extend_from_slice(&1u16.to_be_bytes());
501
502 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 bytes.push(7);
509 bytes.extend_from_slice(&3u16.to_be_bytes());
510
511 bytes.extend_from_slice(&0x0021u16.to_be_bytes());
513 bytes.extend_from_slice(&2u16.to_be_bytes());
515 bytes.extend_from_slice(&4u16.to_be_bytes());
517 bytes.extend_from_slice(&0u16.to_be_bytes());
519 bytes.extend_from_slice(&0u16.to_be_bytes());
521 bytes.extend_from_slice(&0u16.to_be_bytes());
523 bytes.extend_from_slice(&0u16.to_be_bytes());
525
526 bytes
527 }
528
529 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 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 #[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 #[test]
569 fn test_resolve_from_manual_file_basic() {
570 let tmp = TempDir::new().unwrap();
571
572 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 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 #[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 #[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 #[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 let outcome = scan_single_jar(&jar_path, &cache, false);
737 assert!(matches!(outcome, JarScanOutcome::Scanned { .. }));
738
739 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 let _ = scan_single_jar(&jar_path, &cache, false);
764
765 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 #[test]
787 #[allow(clippy::match_same_arms)] #[allow(clippy::match_wildcard_for_single_variants)] 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)] 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 #[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 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 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 #[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 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 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 let full: Vec<&ClasspathEntry> = all_refs.clone();
895 assert_eq!(full.len(), 2);
896
897 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 #[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 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 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 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}