1use 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#[derive(Debug, Clone)]
30pub struct ClasspathConfig {
31 pub enabled: bool,
33 pub depth: ClasspathDepth,
35 pub build_system_override: Option<String>,
37 pub classpath_file: Option<PathBuf>,
42 pub force: bool,
44 pub timeout_secs: u64,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ClasspathDepth {
51 Shallow,
53 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#[derive(Debug)]
76pub struct ClasspathPipelineResult {
77 pub index: ClasspathIndex,
79 pub provenance: Vec<ClasspathProvenance>,
81 pub jars_scanned: usize,
83 pub classes_parsed: usize,
85 pub from_cache: bool,
87}
88
89pub 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 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 let all_entries: Vec<&ClasspathEntry> = resolved_classpaths
128 .iter()
129 .flat_map(|cp| &cp.entries)
130 .collect();
131
132 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 let unique_jar_paths = deduplicate_jar_paths(&entries_to_scan);
149 info!("{} unique JAR files to scan", unique_jar_paths.len());
150
151 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 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 let provenance = build_provenance(&entries_to_scan);
202
203 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 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
224fn 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 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 }
266
267 entries.push(ClasspathEntry {
268 jar_path,
269 coordinates: None,
270 is_direct: true, 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
283fn 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
319enum JarScanOutcome {
325 Scanned {
327 #[allow(dead_code)] jar_path: PathBuf,
329 stubs: Vec<ClassStub>,
330 },
331 Cached {
333 #[allow(dead_code)] jar_path: PathBuf,
335 stubs: Vec<ClassStub>,
336 },
337 Failed { jar_path: PathBuf, error: String },
339}
340
341fn 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
355fn 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
371fn scan_single_jar(jar_path: &Path, stub_cache: &StubCache, force: bool) -> JarScanOutcome {
373 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 match scan_jar(jar_path) {
388 Ok(stubs) => {
389 debug!("Scanned {} ({} classes)", jar_path.display(), stubs.len());
390
391 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
408fn 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
424fn 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 let index_path = classpath_dir.join("index.sqry");
443 index.save(&index_path)?;
444 info!("Saved classpath index to {}", index_path.display());
445
446 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#[cfg(test)]
466mod tests {
467 use super::*;
468 use std::io::Write;
469 use tempfile::TempDir;
470 use zip::write::SimpleFileOptions;
471
472 fn build_minimal_class(class_name: &str) -> Vec<u8> {
474 let mut bytes = Vec::new();
475
476 bytes.extend_from_slice(&0xCAFE_BABEu32.to_be_bytes());
478 bytes.extend_from_slice(&0u16.to_be_bytes());
480 bytes.extend_from_slice(&52u16.to_be_bytes());
482
483 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 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 bytes.push(7);
497 bytes.extend_from_slice(&1u16.to_be_bytes());
498
499 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 bytes.push(7);
506 bytes.extend_from_slice(&3u16.to_be_bytes());
507
508 bytes.extend_from_slice(&0x0021u16.to_be_bytes());
510 bytes.extend_from_slice(&2u16.to_be_bytes());
512 bytes.extend_from_slice(&4u16.to_be_bytes());
514 bytes.extend_from_slice(&0u16.to_be_bytes());
516 bytes.extend_from_slice(&0u16.to_be_bytes());
518 bytes.extend_from_slice(&0u16.to_be_bytes());
520 bytes.extend_from_slice(&0u16.to_be_bytes());
522
523 bytes
524 }
525
526 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 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 #[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 #[test]
566 fn test_resolve_from_manual_file_basic() {
567 let tmp = TempDir::new().unwrap();
568
569 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 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 #[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 #[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 #[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 let outcome = scan_single_jar(&jar_path, &cache, false);
734 assert!(matches!(outcome, JarScanOutcome::Scanned { .. }));
735
736 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 let _ = scan_single_jar(&jar_path, &cache, false);
761
762 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 #[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 #[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 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 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 #[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 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 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 let full: Vec<&ClasspathEntry> = all_refs.clone();
889 assert_eq!(full.len(), 2);
890
891 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 #[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 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 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 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}