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, discover_build_roots};
20use crate::graph::provenance::{ClasspathProvenance, ClasspathScope};
21use crate::resolve::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
22use crate::stub::cache::StubCache;
23use crate::stub::index::ClasspathIndex;
24use crate::stub::model::ClassStub;
25use crate::{ClasspathError, ClasspathResult};
26
27#[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 pub allow_build_tool_execution: bool,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ClasspathDepth {
63 Shallow,
65 Full,
67}
68
69impl Default for ClasspathConfig {
70 fn default() -> Self {
71 Self {
72 enabled: false,
73 depth: ClasspathDepth::Full,
74 build_system_override: None,
75 classpath_file: None,
76 force: false,
77 timeout_secs: 60,
78 allow_build_tool_execution: true,
79 }
80 }
81}
82
83#[derive(Debug)]
89pub struct ClasspathPipelineResult {
90 pub index: ClasspathIndex,
92 pub provenance: Vec<ClasspathProvenance>,
94 pub resolved_classpaths: Vec<ResolvedClasspath>,
96 pub jars_scanned: usize,
98 pub classes_parsed: usize,
100 pub from_cache: bool,
102}
103
104pub fn run_classpath_pipeline(
129 project_root: &Path,
130 config: &ClasspathConfig,
131) -> ClasspathResult<ClasspathPipelineResult> {
132 info!("Starting classpath pipeline for {}", project_root.display());
133
134 let resolved_classpaths = if let Some(ref classpath_file) = config.classpath_file {
136 resolve_from_manual_file(project_root, classpath_file)?
137 } else {
138 resolve_from_build_system(project_root, config)?
139 };
140
141 let all_entries: Vec<&ClasspathEntry> = resolved_classpaths
143 .iter()
144 .flat_map(|cp| &cp.entries)
145 .collect();
146
147 let entries_to_scan: Vec<&ClasspathEntry> = match config.depth {
149 ClasspathDepth::Full => all_entries,
150 ClasspathDepth::Shallow => all_entries.into_iter().filter(|e| e.is_direct).collect(),
151 };
152
153 info!(
154 "Classpath resolved: {} entries ({} after depth filtering)",
155 resolved_classpaths
156 .iter()
157 .map(|cp| cp.entries.len())
158 .sum::<usize>(),
159 entries_to_scan.len(),
160 );
161
162 let unique_jar_paths = deduplicate_jar_paths(&entries_to_scan);
164 info!("{} unique JAR files to scan", unique_jar_paths.len());
165
166 let stub_cache = StubCache::new(project_root);
168 let scan_results = scan_jars_parallel(&unique_jar_paths, &stub_cache, config.force);
169
170 let mut all_stubs: Vec<ClassStub> = Vec::new();
171 let mut jars_scanned: usize = 0;
172 let mut jars_from_cache: usize = 0;
173
174 for result in &scan_results {
175 match result {
176 JarScanOutcome::Scanned { jar_path, stubs } => {
177 let jar_str = jar_path.display().to_string();
178 for stub in stubs {
179 let mut s = stub.clone();
180 if s.source_jar.is_none() {
183 s.source_jar = Some(jar_str.clone());
184 }
185 all_stubs.push(s);
186 }
187 jars_scanned += 1;
188 }
189 JarScanOutcome::Cached { jar_path, stubs } => {
190 let jar_str = jar_path.display().to_string();
191 for stub in stubs {
192 let mut s = stub.clone();
193 if s.source_jar.is_none() {
194 s.source_jar = Some(jar_str.clone());
195 }
196 all_stubs.push(s);
197 }
198 jars_from_cache += 1;
199 }
200 JarScanOutcome::Failed { jar_path, error } => {
201 warn!("Failed to scan JAR {}: {error}", jar_path.display());
202 }
203 }
204 }
205
206 let classes_parsed = all_stubs.len();
207 info!(
208 "Scanned {} JARs ({} from cache, {} fresh), {} classes total",
209 jars_scanned + jars_from_cache,
210 jars_from_cache,
211 jars_scanned,
212 classes_parsed,
213 );
214
215 let provenance = build_provenance(&resolved_classpaths, config.depth);
217
218 let index = ClasspathIndex::build(all_stubs);
220 info!(
221 "Built classpath index: {} classes, {} packages",
222 index.classes.len(),
223 index.package_index.len(),
224 );
225
226 let sqry_classpath_dir = project_root.join(".sqry").join("classpath");
228 persist_artifacts(&sqry_classpath_dir, &index, &provenance)?;
229
230 Ok(ClasspathPipelineResult {
231 index,
232 provenance,
233 resolved_classpaths,
234 jars_scanned: jars_scanned + jars_from_cache,
235 classes_parsed,
236 from_cache: jars_from_cache > 0 && jars_scanned == 0,
237 })
238}
239
240fn resolve_from_manual_file(
248 project_root: &Path,
249 classpath_file: &Path,
250) -> ClasspathResult<Vec<ResolvedClasspath>> {
251 info!("Reading manual classpath from {}", classpath_file.display());
252
253 let file = std::fs::File::open(classpath_file).map_err(|e| {
254 ClasspathError::ResolutionFailed(format!(
255 "Cannot open classpath file {}: {e}",
256 classpath_file.display()
257 ))
258 })?;
259
260 let reader = BufReader::new(file);
261 let mut entries = Vec::new();
262
263 for line in reader.lines() {
264 let line = line.map_err(|e| {
265 ClasspathError::ResolutionFailed(format!(
266 "Error reading classpath file {}: {e}",
267 classpath_file.display()
268 ))
269 })?;
270 let trimmed = line.trim();
271
272 if trimmed.is_empty() || trimmed.starts_with('#') {
274 continue;
275 }
276
277 let jar_path = PathBuf::from(trimmed);
278 if !jar_path.exists() {
279 warn!(
280 "Classpath file entry does not exist: {}",
281 jar_path.display()
282 );
283 }
285
286 entries.push(ClasspathEntry {
287 jar_path,
288 coordinates: None,
289 is_direct: true, source_jar: None,
291 });
292 }
293
294 info!("Manual classpath file: {} entries", entries.len());
295
296 Ok(vec![ResolvedClasspath {
297 module_name: "manual".to_string(),
298 module_root: project_root.to_path_buf(),
299 entries,
300 }])
301}
302
303fn resolve_from_build_system(
305 project_root: &Path,
306 config: &ClasspathConfig,
307) -> ClasspathResult<Vec<ResolvedClasspath>> {
308 let detected_roots =
309 discover_build_roots(project_root, config.build_system_override.as_deref());
310 if detected_roots.is_empty() {
311 return Err(ClasspathError::DetectionFailed(
312 "No JVM build system detected. Use --build-system to specify one, \
313 or --classpath-file to provide a manual classpath."
314 .to_string(),
315 ));
316 }
317
318 info!("Discovered {} JVM build roots", detected_roots.len());
319 let mut resolved = Vec::new();
320 for detection in detected_roots {
321 let Some(build_system) = detection.build_system else {
322 continue;
323 };
324 info!(
325 "Resolving {:?} classpath in {}",
326 build_system,
327 detection.project_root.display()
328 );
329
330 let resolve_config = ResolveConfig {
331 project_root: detection.project_root.clone(),
332 timeout_secs: config.timeout_secs,
333 cache_path: Some(detection.project_root.join(".sqry").join("classpath")),
334 };
335
336 let mut root_resolved = if config.allow_build_tool_execution {
337 match build_system {
338 BuildSystem::Gradle => {
339 crate::resolve::gradle::resolve_gradle_classpath(&resolve_config)
340 }
341 BuildSystem::Maven => {
342 crate::resolve::maven::resolve_maven_classpath(&resolve_config)
343 }
344 BuildSystem::Bazel => {
345 crate::resolve::bazel::resolve_bazel_classpath(&resolve_config)
346 }
347 BuildSystem::Sbt => crate::resolve::sbt::resolve_sbt_classpath(&resolve_config),
348 }?
349 } else {
350 match read_cached_classpath(build_system, &resolve_config) {
355 Some(cached) => cached,
356 None => {
357 warn!(
358 "Build-tool execution is disabled (--no-build-tool) and no cached \
359 classpath is available for {} ({:?}); skipping classpath resolution \
360 for this root. Provide --classpath-file, or resolve once without \
361 --no-build-tool in a trusted context to populate the cache.",
362 detection.project_root.display(),
363 build_system
364 );
365 continue;
366 }
367 }
368 };
369 resolved.append(&mut root_resolved);
370 }
371
372 resolved.sort_by(|a, b| {
373 a.module_root
374 .cmp(&b.module_root)
375 .then_with(|| a.module_name.cmp(&b.module_name))
376 });
377 Ok(resolved)
378}
379
380fn read_cached_classpath(
384 build_system: BuildSystem,
385 resolve_config: &ResolveConfig,
386) -> Option<Vec<ResolvedClasspath>> {
387 match build_system {
388 BuildSystem::Gradle => crate::resolve::gradle::read_cached_classpath(resolve_config),
389 BuildSystem::Maven => crate::resolve::maven::read_cached_classpath(resolve_config),
390 BuildSystem::Bazel => crate::resolve::bazel::read_cached_classpath(resolve_config),
391 BuildSystem::Sbt => crate::resolve::sbt::read_cached_classpath(resolve_config),
392 }
393}
394
395enum JarScanOutcome {
401 Scanned {
403 #[allow(dead_code)] jar_path: PathBuf,
405 stubs: Vec<ClassStub>,
406 },
407 Cached {
409 #[allow(dead_code)] jar_path: PathBuf,
411 stubs: Vec<ClassStub>,
412 },
413 Failed { jar_path: PathBuf, error: String },
415}
416
417fn deduplicate_jar_paths(entries: &[&ClasspathEntry]) -> Vec<PathBuf> {
419 let mut seen = std::collections::HashSet::new();
420 let mut unique = Vec::new();
421
422 for entry in entries {
423 if seen.insert(&entry.jar_path) {
424 unique.push(entry.jar_path.clone());
425 }
426 }
427
428 unique
429}
430
431fn scan_jars_parallel(
437 jar_paths: &[PathBuf],
438 stub_cache: &StubCache,
439 force: bool,
440) -> Vec<JarScanOutcome> {
441 jar_paths
442 .par_iter()
443 .map(|jar_path| scan_single_jar(jar_path, stub_cache, force))
444 .collect()
445}
446
447fn scan_single_jar(jar_path: &Path, stub_cache: &StubCache, force: bool) -> JarScanOutcome {
449 if !force && let Some(cached_stubs) = stub_cache.get(jar_path) {
451 debug!(
452 "Cache hit for {} ({} stubs)",
453 jar_path.display(),
454 cached_stubs.len()
455 );
456 return JarScanOutcome::Cached {
457 jar_path: jar_path.to_path_buf(),
458 stubs: cached_stubs,
459 };
460 }
461
462 match scan_jar(jar_path) {
464 Ok(stubs) => {
465 debug!("Scanned {} ({} classes)", jar_path.display(), stubs.len());
466
467 if let Err(e) = stub_cache.put(jar_path, &stubs) {
469 warn!("Failed to cache stubs for {}: {e}", jar_path.display());
470 }
471
472 JarScanOutcome::Scanned {
473 jar_path: jar_path.to_path_buf(),
474 stubs,
475 }
476 }
477 Err(e) => JarScanOutcome::Failed {
478 jar_path: jar_path.to_path_buf(),
479 error: e.to_string(),
480 },
481 }
482}
483
484fn build_provenance(
490 resolved_classpaths: &[ResolvedClasspath],
491 depth: ClasspathDepth,
492) -> Vec<ClasspathProvenance> {
493 let mut by_jar: std::collections::HashMap<PathBuf, ClasspathProvenance> =
494 std::collections::HashMap::new();
495
496 for classpath in resolved_classpaths {
497 for entry in &classpath.entries {
498 if matches!(depth, ClasspathDepth::Shallow) && !entry.is_direct {
499 continue;
500 }
501
502 let provenance =
503 by_jar
504 .entry(entry.jar_path.clone())
505 .or_insert_with(|| ClasspathProvenance {
506 jar_path: entry.jar_path.clone(),
507 coordinates: entry.coordinates.clone(),
508 is_direct: entry.is_direct,
509 scopes: Vec::new(),
510 });
511
512 if provenance.coordinates.is_none() {
513 provenance.coordinates.clone_from(&entry.coordinates);
514 }
515 provenance.is_direct &= entry.is_direct;
516
517 let scope = ClasspathScope {
518 module_name: classpath.module_name.clone(),
519 module_root: classpath.module_root.clone(),
520 is_direct: entry.is_direct,
521 };
522 if !provenance.scopes.iter().any(|existing| existing == &scope) {
523 provenance.scopes.push(scope);
524 }
525 }
526 }
527
528 let mut result: Vec<_> = by_jar.into_values().collect();
529 result.sort_by(|a, b| a.jar_path.cmp(&b.jar_path));
530 result
531}
532
533fn persist_artifacts(
539 classpath_dir: &Path,
540 index: &ClasspathIndex,
541 provenance: &[ClasspathProvenance],
542) -> ClasspathResult<()> {
543 std::fs::create_dir_all(classpath_dir).map_err(|e| {
544 ClasspathError::IndexError(format!(
545 "Cannot create classpath directory {}: {e}",
546 classpath_dir.display()
547 ))
548 })?;
549
550 let index_path = classpath_dir.join("index.sqry");
552 index.save(&index_path)?;
553 info!("Saved classpath index to {}", index_path.display());
554
555 let provenance_path = classpath_dir.join("provenance.json");
557 let provenance_json = serde_json::to_string_pretty(provenance)
558 .map_err(|e| ClasspathError::IndexError(format!("Cannot serialize provenance: {e}")))?;
559 std::fs::write(&provenance_path, provenance_json).map_err(|e| {
560 ClasspathError::IndexError(format!(
561 "Cannot write provenance to {}: {e}",
562 provenance_path.display()
563 ))
564 })?;
565 info!("Saved provenance to {}", provenance_path.display());
566
567 Ok(())
568}
569
570#[cfg(test)]
575mod tests {
576 use super::*;
577 use std::io::Write;
578 use tempfile::TempDir;
579 use zip::write::SimpleFileOptions;
580
581 fn build_minimal_class(class_name: &str) -> Vec<u8> {
583 let mut bytes = Vec::new();
584
585 bytes.extend_from_slice(&0xCAFE_BABEu32.to_be_bytes());
587 bytes.extend_from_slice(&0u16.to_be_bytes());
589 bytes.extend_from_slice(&52u16.to_be_bytes());
591
592 let class_bytes = class_name.as_bytes();
594 let object_bytes = b"java/lang/Object";
595
596 let cp_count: u16 = 5;
597 bytes.extend_from_slice(&cp_count.to_be_bytes());
598
599 bytes.push(1);
601 bytes.extend_from_slice(&(class_bytes.len() as u16).to_be_bytes());
602 bytes.extend_from_slice(class_bytes);
603
604 bytes.push(7);
606 bytes.extend_from_slice(&1u16.to_be_bytes());
607
608 bytes.push(1);
610 bytes.extend_from_slice(&(object_bytes.len() as u16).to_be_bytes());
611 bytes.extend_from_slice(object_bytes);
612
613 bytes.push(7);
615 bytes.extend_from_slice(&3u16.to_be_bytes());
616
617 bytes.extend_from_slice(&0x0021u16.to_be_bytes());
619 bytes.extend_from_slice(&2u16.to_be_bytes());
621 bytes.extend_from_slice(&4u16.to_be_bytes());
623 bytes.extend_from_slice(&0u16.to_be_bytes());
625 bytes.extend_from_slice(&0u16.to_be_bytes());
627 bytes.extend_from_slice(&0u16.to_be_bytes());
629 bytes.extend_from_slice(&0u16.to_be_bytes());
631
632 bytes
633 }
634
635 fn build_test_jar(entries: &[(&str, &[u8])]) -> Vec<u8> {
637 let mut buf = Vec::new();
638 {
639 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
640 let options =
641 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
642 for (name, data) in entries {
643 writer.start_file(*name, options).unwrap();
644 writer.write_all(data).unwrap();
645 }
646 writer.finish().unwrap();
647 }
648 buf
649 }
650
651 fn write_test_jar(dir: &Path, name: &str, classes: &[(&str, &[u8])]) -> PathBuf {
653 let jar_bytes = build_test_jar(classes);
654 let jar_path = dir.join(name);
655 std::fs::write(&jar_path, &jar_bytes).unwrap();
656 jar_path
657 }
658
659 #[test]
662 fn test_default_config() {
663 let config = ClasspathConfig::default();
664 assert!(!config.enabled);
665 assert_eq!(config.depth, ClasspathDepth::Full);
666 assert!(config.build_system_override.is_none());
667 assert!(config.classpath_file.is_none());
668 assert!(!config.force);
669 assert_eq!(config.timeout_secs, 60);
670 assert!(config.allow_build_tool_execution);
672 }
673
674 #[test]
677 fn no_build_tool_skips_resolution_without_running_a_build_tool() {
678 let tmp = TempDir::new().unwrap();
683 std::fs::write(tmp.path().join("build.gradle"), "// empty\n").unwrap();
684
685 let config = ClasspathConfig {
686 enabled: true,
687 allow_build_tool_execution: false,
688 ..ClasspathConfig::default()
689 };
690
691 let resolved = resolve_from_build_system(tmp.path(), &config)
692 .expect("disabled build-tool execution must be a graceful skip, not an error");
693 assert!(
694 resolved.is_empty(),
695 "no cached classpath exists, so the disabled root yields nothing"
696 );
697 }
698
699 #[cfg(unix)]
700 #[test]
701 fn no_build_tool_does_not_execute_a_project_wrapper() {
702 use std::os::unix::fs::PermissionsExt;
703
704 let tmp = TempDir::new().unwrap();
707 std::fs::write(tmp.path().join("build.gradle"), "// empty\n").unwrap();
708 let sentinel = tmp.path().join("EXECUTED");
709 let gradlew = tmp.path().join("gradlew");
710 std::fs::write(
711 &gradlew,
712 format!("#!/bin/sh\ntouch \"{}\"\nexit 0\n", sentinel.display()),
713 )
714 .unwrap();
715 std::fs::set_permissions(&gradlew, std::fs::Permissions::from_mode(0o755)).unwrap();
716
717 let disabled = ClasspathConfig {
719 enabled: true,
720 allow_build_tool_execution: false,
721 ..ClasspathConfig::default()
722 };
723 let _ = resolve_from_build_system(tmp.path(), &disabled);
724 assert!(
725 !sentinel.exists(),
726 "the project build tool must not run under --no-build-tool"
727 );
728
729 let enabled = ClasspathConfig {
732 enabled: true,
733 allow_build_tool_execution: true,
734 ..ClasspathConfig::default()
735 };
736 let _ = resolve_from_build_system(tmp.path(), &enabled);
737 assert!(
738 sentinel.exists(),
739 "sanity: the wrapper executes when build-tool execution is enabled"
740 );
741 }
742
743 #[test]
744 fn read_cached_classpath_returns_none_without_a_cache() {
745 let tmp = TempDir::new().unwrap();
748 let resolve_config = crate::resolve::ResolveConfig {
749 project_root: tmp.path().to_path_buf(),
750 timeout_secs: 30,
751 cache_path: Some(tmp.path().join(".sqry").join("classpath")),
752 };
753 for build_system in [
754 BuildSystem::Gradle,
755 BuildSystem::Maven,
756 BuildSystem::Bazel,
757 BuildSystem::Sbt,
758 ] {
759 assert!(
760 read_cached_classpath(build_system, &resolve_config).is_none(),
761 "{build_system:?} must report no cached classpath when none exists"
762 );
763 }
764 }
765
766 #[test]
769 fn test_resolve_from_manual_file_basic() {
770 let tmp = TempDir::new().unwrap();
771
772 let jar_a = tmp.path().join("a.jar");
774 let jar_b = tmp.path().join("b.jar");
775 std::fs::write(&jar_a, b"fake jar a").unwrap();
776 std::fs::write(&jar_b, b"fake jar b").unwrap();
777
778 let cp_file = tmp.path().join("classpath.txt");
780 std::fs::write(
781 &cp_file,
782 format!("{}\n{}\n", jar_a.display(), jar_b.display()),
783 )
784 .unwrap();
785
786 let result = resolve_from_manual_file(tmp.path(), &cp_file).unwrap();
787 assert_eq!(result.len(), 1);
788 assert_eq!(result[0].module_name, "manual");
789 assert_eq!(result[0].module_root, tmp.path());
790 assert_eq!(result[0].entries.len(), 2);
791 assert!(result[0].entries[0].is_direct);
792 assert!(result[0].entries[1].is_direct);
793 }
794
795 #[test]
796 fn test_resolve_from_manual_file_skips_comments_and_blanks() {
797 let tmp = TempDir::new().unwrap();
798 let jar_a = tmp.path().join("a.jar");
799 std::fs::write(&jar_a, b"fake jar a").unwrap();
800
801 let cp_file = tmp.path().join("classpath.txt");
802 std::fs::write(
803 &cp_file,
804 format!(
805 "# This is a comment\n\n{}\n\n# Another comment\n",
806 jar_a.display()
807 ),
808 )
809 .unwrap();
810
811 let result = resolve_from_manual_file(tmp.path(), &cp_file).unwrap();
812 assert_eq!(result[0].entries.len(), 1);
813 }
814
815 #[test]
816 fn test_resolve_from_manual_file_nonexistent_file() {
817 let result =
818 resolve_from_manual_file(Path::new("/tmp"), Path::new("/nonexistent/classpath.txt"));
819 assert!(result.is_err());
820 let err = result.unwrap_err().to_string();
821 assert!(err.contains("Cannot open classpath file"));
822 }
823
824 #[test]
825 fn test_resolve_from_manual_file_nonexistent_jars_included() {
826 let tmp = TempDir::new().unwrap();
827 let cp_file = tmp.path().join("classpath.txt");
828 std::fs::write(&cp_file, "/nonexistent/jar.jar\n").unwrap();
829
830 let result = resolve_from_manual_file(tmp.path(), &cp_file).unwrap();
831 assert_eq!(result[0].entries.len(), 1);
832 assert_eq!(
833 result[0].entries[0].jar_path,
834 PathBuf::from("/nonexistent/jar.jar")
835 );
836 }
837
838 #[test]
841 fn test_deduplicate_jar_paths() {
842 let entries = [
843 ClasspathEntry {
844 jar_path: PathBuf::from("/a.jar"),
845 coordinates: None,
846 is_direct: true,
847 source_jar: None,
848 },
849 ClasspathEntry {
850 jar_path: PathBuf::from("/b.jar"),
851 coordinates: None,
852 is_direct: true,
853 source_jar: None,
854 },
855 ClasspathEntry {
856 jar_path: PathBuf::from("/a.jar"),
857 coordinates: None,
858 is_direct: false,
859 source_jar: None,
860 },
861 ];
862 let refs: Vec<&ClasspathEntry> = entries.iter().collect();
863 let unique = deduplicate_jar_paths(&refs);
864 assert_eq!(unique.len(), 2);
865 assert_eq!(unique[0], PathBuf::from("/a.jar"));
866 assert_eq!(unique[1], PathBuf::from("/b.jar"));
867 }
868
869 #[test]
872 fn test_build_provenance() {
873 let classpaths = vec![ResolvedClasspath {
874 module_name: "app".to_string(),
875 module_root: PathBuf::from("/repo/app"),
876 entries: vec![
877 ClasspathEntry {
878 jar_path: PathBuf::from("/guava.jar"),
879 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
880 is_direct: true,
881 source_jar: None,
882 },
883 ClasspathEntry {
884 jar_path: PathBuf::from("/commons.jar"),
885 coordinates: None,
886 is_direct: false,
887 source_jar: None,
888 },
889 ],
890 }];
891 let prov = build_provenance(&classpaths, ClasspathDepth::Full);
892
893 assert_eq!(prov.len(), 2);
894 assert_eq!(prov[0].jar_path, PathBuf::from("/commons.jar"));
895 assert_eq!(
896 prov[1].coordinates,
897 Some("com.google.guava:guava:33.0.0".to_string())
898 );
899 assert!(!prov[0].is_direct);
900 assert!(prov[1].is_direct);
901 assert!(prov[0].coordinates.is_none());
902 assert_eq!(prov[1].scopes[0].module_root, PathBuf::from("/repo/app"));
903 }
904
905 #[test]
906 fn test_build_provenance_mixed_directness_same_jar_is_conservative() {
907 let shared_jar = PathBuf::from("/shared.jar");
908 let classpaths = vec![
909 ResolvedClasspath {
910 module_name: "app".to_string(),
911 module_root: PathBuf::from("/repo/app"),
912 entries: vec![ClasspathEntry {
913 jar_path: shared_jar.clone(),
914 coordinates: Some("com.example:shared:1.0.0".to_string()),
915 is_direct: true,
916 source_jar: None,
917 }],
918 },
919 ResolvedClasspath {
920 module_name: "worker".to_string(),
921 module_root: PathBuf::from("/repo/worker"),
922 entries: vec![ClasspathEntry {
923 jar_path: shared_jar.clone(),
924 coordinates: Some("com.example:shared:1.0.0".to_string()),
925 is_direct: false,
926 source_jar: None,
927 }],
928 },
929 ];
930 let prov = build_provenance(&classpaths, ClasspathDepth::Full);
931
932 assert_eq!(prov.len(), 1);
933 assert_eq!(prov[0].jar_path, shared_jar);
934 assert!(
935 !prov[0].is_direct,
936 "aggregate directness should be conservative when scopes disagree"
937 );
938 assert!(
939 prov[0].has_direct_scope(),
940 "per-scope metadata should retain the direct scope"
941 );
942 assert_eq!(prov[0].scopes.len(), 2);
943 }
944
945 #[test]
948 fn test_scan_single_jar_fresh() {
949 let tmp = TempDir::new().unwrap();
950 let class_a = build_minimal_class("com/example/Foo");
951 let jar_path = write_test_jar(
952 tmp.path(),
953 "test.jar",
954 &[("com/example/Foo.class", &class_a)],
955 );
956
957 let cache = StubCache::new(tmp.path());
958 let outcome = scan_single_jar(&jar_path, &cache, false);
959
960 match outcome {
961 JarScanOutcome::Scanned { stubs, .. } => {
962 assert_eq!(stubs.len(), 1);
963 assert_eq!(stubs[0].fqn, "com.example.Foo");
964 }
965 other => panic!("Expected Scanned, got {:?}", outcome_name(&other)),
966 }
967 }
968
969 #[test]
970 fn test_scan_single_jar_cached() {
971 let tmp = TempDir::new().unwrap();
972 let class_a = build_minimal_class("com/example/Bar");
973 let jar_path = write_test_jar(
974 tmp.path(),
975 "test.jar",
976 &[("com/example/Bar.class", &class_a)],
977 );
978
979 let cache = StubCache::new(tmp.path());
980
981 let outcome = scan_single_jar(&jar_path, &cache, false);
983 assert!(matches!(outcome, JarScanOutcome::Scanned { .. }));
984
985 let outcome = scan_single_jar(&jar_path, &cache, false);
987 match outcome {
988 JarScanOutcome::Cached { stubs, .. } => {
989 assert_eq!(stubs.len(), 1);
990 assert_eq!(stubs[0].fqn, "com.example.Bar");
991 }
992 other => panic!("Expected Cached, got {:?}", outcome_name(&other)),
993 }
994 }
995
996 #[test]
997 fn test_scan_single_jar_force_bypasses_cache() {
998 let tmp = TempDir::new().unwrap();
999 let class_a = build_minimal_class("com/example/Baz");
1000 let jar_path = write_test_jar(
1001 tmp.path(),
1002 "test.jar",
1003 &[("com/example/Baz.class", &class_a)],
1004 );
1005
1006 let cache = StubCache::new(tmp.path());
1007
1008 let _ = scan_single_jar(&jar_path, &cache, false);
1010
1011 let outcome = scan_single_jar(&jar_path, &cache, true);
1013 assert!(
1014 matches!(outcome, JarScanOutcome::Scanned { .. }),
1015 "force=true should bypass cache"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_scan_single_jar_nonexistent() {
1021 let tmp = TempDir::new().unwrap();
1022 let cache = StubCache::new(tmp.path());
1023 let outcome = scan_single_jar(Path::new("/nonexistent.jar"), &cache, false);
1024 assert!(
1025 matches!(outcome, JarScanOutcome::Failed { .. }),
1026 "Should fail for nonexistent JAR"
1027 );
1028 }
1029
1030 #[test]
1033 #[allow(clippy::match_same_arms)] #[allow(clippy::match_wildcard_for_single_variants)] fn test_scan_jars_parallel_multiple() {
1036 let tmp = TempDir::new().unwrap();
1037 let class_a = build_minimal_class("com/example/A");
1038 let class_b = build_minimal_class("com/example/B");
1039
1040 let jar_a = write_test_jar(tmp.path(), "a.jar", &[("com/example/A.class", &class_a)]);
1041 let jar_b = write_test_jar(tmp.path(), "b.jar", &[("com/example/B.class", &class_b)]);
1042
1043 let cache = StubCache::new(tmp.path());
1044 let results = scan_jars_parallel(&[jar_a, jar_b], &cache, false);
1045
1046 assert_eq!(results.len(), 2);
1047 let total_stubs: usize = results
1048 .iter()
1049 .filter_map(|r| match r {
1050 #[allow(clippy::match_same_arms)] JarScanOutcome::Scanned { stubs, .. } | JarScanOutcome::Cached { stubs, .. } => {
1052 Some(stubs.len())
1053 }
1054 _ => None,
1055 })
1056 .sum();
1057 assert_eq!(total_stubs, 2);
1058 }
1059
1060 #[test]
1063 fn test_persist_artifacts_roundtrip() {
1064 let tmp = TempDir::new().unwrap();
1065 let classpath_dir = tmp.path().join("classpath");
1066
1067 let index = ClasspathIndex::build(vec![]);
1068 let provenance = vec![ClasspathProvenance {
1069 jar_path: PathBuf::from("/test.jar"),
1070 coordinates: Some("test:test:1.0".to_string()),
1071 is_direct: true,
1072 scopes: vec![ClasspathScope {
1073 module_name: "manual".to_string(),
1074 module_root: tmp.path().to_path_buf(),
1075 is_direct: true,
1076 }],
1077 }];
1078
1079 persist_artifacts(&classpath_dir, &index, &provenance).unwrap();
1080
1081 let index_path = classpath_dir.join("index.sqry");
1083 assert!(index_path.exists());
1084 let loaded_index = ClasspathIndex::load(&index_path).unwrap();
1085 assert_eq!(loaded_index.classes.len(), 0);
1086
1087 let prov_path = classpath_dir.join("provenance.json");
1089 assert!(prov_path.exists());
1090 let prov_json = std::fs::read_to_string(&prov_path).unwrap();
1091 let loaded_prov: Vec<ClasspathProvenance> = serde_json::from_str(&prov_json).unwrap();
1092 assert_eq!(loaded_prov.len(), 1);
1093 assert_eq!(
1094 loaded_prov[0].coordinates,
1095 Some("test:test:1.0".to_string())
1096 );
1097 }
1098
1099 #[test]
1102 fn test_depth_shallow_filters_transitive() {
1103 let tmp = TempDir::new().unwrap();
1104
1105 let class_d = build_minimal_class("com/example/Direct");
1106 let class_t = build_minimal_class("com/example/Transitive");
1107
1108 let jar_d = write_test_jar(
1109 tmp.path(),
1110 "direct.jar",
1111 &[("com/example/Direct.class", &class_d)],
1112 );
1113 let jar_t = write_test_jar(
1114 tmp.path(),
1115 "transitive.jar",
1116 &[("com/example/Transitive.class", &class_t)],
1117 );
1118
1119 let cp_file = tmp.path().join("classpath.txt");
1121 std::fs::write(
1122 &cp_file,
1123 format!("{}\n{}\n", jar_d.display(), jar_t.display()),
1124 )
1125 .unwrap();
1126
1127 let entries = [
1129 ClasspathEntry {
1130 jar_path: jar_d,
1131 coordinates: None,
1132 is_direct: true,
1133 source_jar: None,
1134 },
1135 ClasspathEntry {
1136 jar_path: jar_t,
1137 coordinates: None,
1138 is_direct: false,
1139 source_jar: None,
1140 },
1141 ];
1142 let all_refs: Vec<&ClasspathEntry> = entries.iter().collect();
1143
1144 let full: Vec<&ClasspathEntry> = all_refs.clone();
1146 assert_eq!(full.len(), 2);
1147
1148 let shallow: Vec<&ClasspathEntry> = all_refs.into_iter().filter(|e| e.is_direct).collect();
1150 assert_eq!(shallow.len(), 1);
1151 assert!(shallow[0].is_direct);
1152 }
1153
1154 #[test]
1157 fn test_full_pipeline_with_manual_file() {
1158 let tmp = TempDir::new().unwrap();
1159
1160 let class_a = build_minimal_class("com/example/Alpha");
1161 let class_b = build_minimal_class("com/example/Beta");
1162
1163 let jar_path = write_test_jar(
1164 tmp.path(),
1165 "deps.jar",
1166 &[
1167 ("com/example/Alpha.class", &class_a),
1168 ("com/example/Beta.class", &class_b),
1169 ],
1170 );
1171
1172 let cp_file = tmp.path().join("classpath.txt");
1174 std::fs::write(&cp_file, format!("{}\n", jar_path.display())).unwrap();
1175
1176 let config = ClasspathConfig {
1177 enabled: true,
1178 depth: ClasspathDepth::Full,
1179 build_system_override: None,
1180 classpath_file: Some(cp_file),
1181 force: false,
1182 timeout_secs: 30,
1183 allow_build_tool_execution: true,
1184 };
1185
1186 let result = run_classpath_pipeline(tmp.path(), &config).unwrap();
1187 assert_eq!(result.jars_scanned, 1);
1188 assert_eq!(result.classes_parsed, 2);
1189 assert_eq!(result.index.classes.len(), 2);
1190 assert!(result.index.lookup_fqn("com.example.Alpha").is_some());
1191 assert!(result.index.lookup_fqn("com.example.Beta").is_some());
1192 assert_eq!(result.provenance.len(), 1);
1193
1194 let index_path = tmp.path().join(".sqry/classpath/index.sqry");
1196 assert!(index_path.exists());
1197 let prov_path = tmp.path().join(".sqry/classpath/provenance.json");
1198 assert!(prov_path.exists());
1199 }
1200
1201 #[test]
1202 fn test_pipeline_no_build_system_returns_error() {
1203 let tmp = TempDir::new().unwrap();
1204 let config = ClasspathConfig {
1205 enabled: true,
1206 ..ClasspathConfig::default()
1207 };
1208
1209 let result = run_classpath_pipeline(tmp.path(), &config);
1210 assert!(result.is_err());
1211 let err = result.unwrap_err().to_string();
1212 assert!(
1213 err.contains("No JVM build system detected"),
1214 "Expected detection error, got: {err}"
1215 );
1216 }
1217
1218 fn outcome_name(outcome: &JarScanOutcome) -> &'static str {
1221 match outcome {
1222 JarScanOutcome::Scanned { .. } => "Scanned",
1223 JarScanOutcome::Cached { .. } => "Cached",
1224 JarScanOutcome::Failed { .. } => "Failed",
1225 }
1226 }
1227}