1use std::io::Read;
18use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::time::Duration;
21
22use log::{debug, info, warn};
23use serde::{Deserialize, Serialize};
24
25use super::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
26use crate::{ClasspathError, ClasspathResult};
27
28const MAVEN_CACHE_FILE: &str = "maven-resolved-classpath.json";
30
31#[cfg(unix)]
33const CLASSPATH_SEPARATOR: char = ':';
34
35#[cfg(windows)]
37const CLASSPATH_SEPARATOR: char = ';';
38
39pub fn resolve_maven_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
48 let pom_path = config.project_root.join("pom.xml");
49 if !pom_path.exists() {
50 return Err(ClasspathError::ResolutionFailed(
51 "pom.xml not found in project root".to_string(),
52 ));
53 }
54
55 let modules = detect_modules(&pom_path);
56 let maven_repo = default_maven_repo();
57
58 if modules.is_empty() {
59 resolve_single_project(config, &maven_repo)
60 } else {
61 resolve_multi_module(config, &modules, &maven_repo)
62 }
63}
64
65fn resolve_single_project(
67 config: &ResolveConfig,
68 maven_repo: &Path,
69) -> ClasspathResult<Vec<ResolvedClasspath>> {
70 match resolve_via_subprocess(&config.project_root, config.timeout_secs, maven_repo) {
71 Ok(resolved) => {
72 write_maven_cache(config, std::slice::from_ref(&resolved));
73 Ok(vec![resolved])
74 }
75 Err(e) => {
76 warn!("Maven subprocess resolution failed: {e}");
77 try_cache_or_fallback(
78 config,
79 &[ModuleInfo::root(&config.project_root)],
80 maven_repo,
81 )
82 }
83 }
84}
85
86fn resolve_multi_module(
88 config: &ResolveConfig,
89 modules: &[String],
90 maven_repo: &Path,
91) -> ClasspathResult<Vec<ResolvedClasspath>> {
92 let module_infos: Vec<ModuleInfo> = modules
93 .iter()
94 .map(|m| ModuleInfo {
95 name: m.clone(),
96 root: config.project_root.join(m),
97 })
98 .collect();
99
100 let mut results = Vec::new();
101 let mut any_failed = false;
102
103 for info in &module_infos {
104 if !info.root.join("pom.xml").exists() {
105 warn!("Module '{}' has no pom.xml, skipping", info.name);
106 continue;
107 }
108 match resolve_module_via_subprocess(info, config.timeout_secs, maven_repo) {
109 Ok(resolved) => results.push(resolved),
110 Err(e) => {
111 warn!("Maven resolution failed for module '{}': {e}", info.name);
112 any_failed = true;
113 }
114 }
115 }
116
117 if any_failed && results.is_empty() {
118 return try_cache_or_fallback(config, &module_infos, maven_repo);
119 }
120
121 if !results.is_empty() {
122 write_maven_cache(config, &results);
123 }
124 Ok(results)
125}
126
127struct ModuleInfo {
129 name: String,
130 root: PathBuf,
131}
132
133impl ModuleInfo {
134 fn root(project_root: &Path) -> Self {
135 Self {
136 name: String::new(),
137 root: project_root.to_path_buf(),
138 }
139 }
140}
141
142fn resolve_via_subprocess(
144 module_root: &Path,
145 timeout_secs: u64,
146 maven_repo: &Path,
147) -> ClasspathResult<ResolvedClasspath> {
148 let classpath_output = run_maven_build_classpath(module_root, timeout_secs)?;
149 let entries = parse_classpath_string(&classpath_output, maven_repo);
150
151 Ok(ResolvedClasspath {
152 module_name: String::new(),
153 entries,
154 })
155}
156
157fn resolve_module_via_subprocess(
159 info: &ModuleInfo,
160 timeout_secs: u64,
161 maven_repo: &Path,
162) -> ClasspathResult<ResolvedClasspath> {
163 let classpath_output = run_maven_build_classpath(&info.root, timeout_secs)?;
164 let entries = parse_classpath_string(&classpath_output, maven_repo);
165
166 Ok(ResolvedClasspath {
167 module_name: info.name.clone(),
168 entries,
169 })
170}
171
172fn run_maven_build_classpath(working_dir: &Path, timeout_secs: u64) -> ClasspathResult<String> {
176 let temp_dir = tempfile::tempdir()
177 .map_err(|e| ClasspathError::ResolutionFailed(format!("tempdir: {e}")))?;
178 let output_file = temp_dir.path().join("classpath.txt");
179
180 let mvn_cmd = find_mvn_command(working_dir);
181
182 let mut command = Command::new(&mvn_cmd);
183 command
184 .arg("dependency:build-classpath")
185 .arg("-DincludeScope=compile")
186 .arg(format!("-Dmdep.outputFile={}", output_file.display()))
187 .arg("-q")
188 .arg("--batch-mode")
189 .current_dir(working_dir);
190
191 debug!(
192 "Running Maven: {} dependency:build-classpath in {}",
193 mvn_cmd,
194 working_dir.display()
195 );
196
197 let output = run_command_with_timeout(&mut command, Duration::from_secs(timeout_secs))?;
198
199 if !output.status.success() {
200 let stderr = String::from_utf8_lossy(&output.stderr);
201 return Err(ClasspathError::ResolutionFailed(format!(
202 "mvn dependency:build-classpath failed (exit {}): {}",
203 output.status,
204 stderr.chars().take(500).collect::<String>()
205 )));
206 }
207
208 let classpath = std::fs::read_to_string(&output_file).map_err(|e| {
210 ClasspathError::ResolutionFailed(format!(
211 "Failed to read Maven classpath output file {}: {e}",
212 output_file.display()
213 ))
214 })?;
215
216 Ok(classpath.trim().to_string())
217}
218
219fn find_mvn_command(working_dir: &Path) -> String {
223 #[cfg(unix)]
224 let wrapper = working_dir.join("mvnw");
225 #[cfg(windows)]
226 let wrapper = working_dir.join("mvnw.cmd");
227
228 if wrapper.exists() {
229 wrapper.display().to_string()
230 } else {
231 "mvn".to_string()
232 }
233}
234
235fn run_command_with_timeout(
240 command: &mut Command,
241 timeout: Duration,
242) -> ClasspathResult<std::process::Output> {
243 let mut child = command
244 .stdout(std::process::Stdio::piped())
245 .stderr(std::process::Stdio::piped())
246 .spawn()
247 .map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn mvn: {e}")))?;
248
249 let start = std::time::Instant::now();
250
251 loop {
252 match child.try_wait() {
253 Ok(Some(status)) => {
254 return collect_child_output(child, status);
255 }
256 Ok(None) => {
257 if start.elapsed() > timeout {
258 let _ = child.kill();
259 let _ = child.wait();
260 return Err(ClasspathError::ResolutionFailed(format!(
261 "mvn timed out after {}s",
262 timeout.as_secs()
263 )));
264 }
265 std::thread::sleep(Duration::from_millis(100));
266 }
267 Err(e) => {
268 return Err(ClasspathError::ResolutionFailed(format!(
269 "Failed to check mvn process status: {e}"
270 )));
271 }
272 }
273 }
274}
275
276fn collect_child_output(
278 mut child: std::process::Child,
279 status: std::process::ExitStatus,
280) -> ClasspathResult<std::process::Output> {
281 let mut stdout = Vec::new();
282 let mut stderr = Vec::new();
283 if let Some(ref mut out) = child.stdout {
284 let _ = out.read_to_end(&mut stdout);
285 }
286 if let Some(ref mut err) = child.stderr {
287 let _ = err.read_to_end(&mut stderr);
288 }
289 Ok(std::process::Output {
290 status,
291 stdout,
292 stderr,
293 })
294}
295
296pub fn parse_classpath_string(classpath: &str, maven_repo: &Path) -> Vec<ClasspathEntry> {
302 if classpath.is_empty() {
303 return Vec::new();
304 }
305
306 classpath
307 .split(CLASSPATH_SEPARATOR)
308 .filter(|p| !p.is_empty())
309 .map(|p| {
310 let jar_path = PathBuf::from(p.trim());
311 let coordinate = extract_coordinates_from_repo_path(&jar_path, maven_repo);
312 let source_jar = find_source_jar(&jar_path);
313 ClasspathEntry {
314 jar_path,
315 coordinates: coordinate,
316 is_direct: true, source_jar,
318 }
319 })
320 .collect()
321}
322
323pub fn extract_coordinates_from_repo_path(jar_path: &Path, maven_repo: &Path) -> Option<String> {
333 let jar_path_str = normalize_path(jar_path);
334 let repo_str = normalize_path(maven_repo);
335
336 let relative = jar_path_str
338 .strip_prefix(&repo_str)?
339 .trim_start_matches('/');
340 if relative.is_empty() {
341 return None;
342 }
343
344 let parts: Vec<&str> = relative.split('/').collect();
345 if parts.len() < 4 {
347 return None;
348 }
349
350 let version = parts[parts.len() - 2];
352 let artifact_id = parts[parts.len() - 3];
353 let group_parts = &parts[..parts.len() - 3];
354
355 if group_parts.is_empty() {
356 return None;
357 }
358
359 let group_id = group_parts.join(".");
360 Some(format!("{group_id}:{artifact_id}:{version}"))
361}
362
363fn normalize_path(p: &Path) -> String {
365 p.to_string_lossy().replace('\\', "/")
366}
367
368fn find_source_jar(jar_path: &Path) -> Option<PathBuf> {
373 let file_name = jar_path.file_name()?.to_str()?;
374 if !file_name.ends_with(".jar") {
375 return None;
376 }
377
378 let stem = file_name.strip_suffix(".jar")?;
379 let source_name = format!("{stem}-sources.jar");
380 let source_path = jar_path.with_file_name(source_name);
381
382 if source_path.exists() {
383 Some(source_path)
384 } else {
385 None
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct PomDependency {
396 pub group_id: String,
398 pub artifact_id: String,
400 pub version: Option<String>,
402 pub scope: Option<String>,
404}
405
406pub fn parse_pom_dependencies(pom_content: &str) -> Vec<PomDependency> {
412 let mut deps = Vec::new();
413
414 let mut search_from = 0;
415 loop {
416 let Some(start) = pom_content[search_from..].find("<dependency>") else {
417 break;
418 };
419 let abs_start = search_from + start;
420
421 let Some(end) = pom_content[abs_start..].find("</dependency>") else {
422 break;
423 };
424 let abs_end = abs_start + end + "</dependency>".len();
425
426 let block = &pom_content[abs_start..abs_end];
427 search_from = abs_end;
428
429 let Some(group_id) = extract_xml_element(block, "groupId") else {
430 continue;
431 };
432 let Some(artifact_id) = extract_xml_element(block, "artifactId") else {
433 continue;
434 };
435
436 if group_id.contains("${") || artifact_id.contains("${") {
438 continue;
439 }
440
441 let version = extract_xml_element(block, "version");
442 let scope = extract_xml_element(block, "scope");
443
444 if scope.as_deref() == Some("test") {
446 continue;
447 }
448
449 deps.push(PomDependency {
450 group_id,
451 artifact_id,
452 version,
453 scope,
454 });
455 }
456
457 deps
458}
459
460fn extract_xml_element(xml: &str, tag: &str) -> Option<String> {
465 let open = format!("<{tag}>");
466 let close = format!("</{tag}>");
467 let start = xml.find(&open)?;
468 let content_start = start + open.len();
469 let end = xml[content_start..].find(&close)?;
470 let content = xml[content_start..content_start + end].trim();
471 if content.is_empty() {
472 None
473 } else {
474 Some(content.to_string())
475 }
476}
477
478fn resolve_from_pom_fallback(
487 module_root: &Path,
488 module_name: &str,
489 maven_repo: &Path,
490) -> ClasspathResult<ResolvedClasspath> {
491 let pom_path = module_root.join("pom.xml");
492 let pom_content = std::fs::read_to_string(&pom_path).map_err(|e| {
493 ClasspathError::ResolutionFailed(format!(
494 "Failed to read pom.xml at {}: {e}",
495 pom_path.display()
496 ))
497 })?;
498
499 let deps = parse_pom_dependencies(&pom_content);
500 let mut entries = Vec::new();
501
502 let display_name = if module_name.is_empty() {
503 "<root>"
504 } else {
505 module_name
506 };
507
508 for dep in &deps {
509 let Some(version) = &dep.version else {
510 warn!(
511 "Skipping {}:{} in {} — no version (may be from dependencyManagement)",
512 dep.group_id, dep.artifact_id, display_name
513 );
514 continue;
515 };
516
517 if version.contains("${") {
519 warn!(
520 "Skipping {}:{}:{} in {} — version contains property placeholder",
521 dep.group_id, dep.artifact_id, version, display_name
522 );
523 continue;
524 }
525
526 let jar_path =
527 construct_maven_jar_path(maven_repo, &dep.group_id, &dep.artifact_id, version);
528
529 if jar_path.exists() {
530 let source_jar = find_source_jar(&jar_path);
531 let coordinates = format!("{}:{}:{}", dep.group_id, dep.artifact_id, version);
532 entries.push(ClasspathEntry {
533 jar_path,
534 coordinates: Some(coordinates),
535 is_direct: true,
536 source_jar,
537 });
538 } else {
539 warn!(
540 "JAR not found in local repo for {}: {}:{}:{} (expected at {})",
541 display_name,
542 dep.group_id,
543 dep.artifact_id,
544 version,
545 jar_path.display()
546 );
547 }
548 }
549
550 info!(
551 "POM fallback for '{}': {} entries resolved",
552 display_name,
553 entries.len()
554 );
555
556 Ok(ResolvedClasspath {
557 module_name: module_name.to_string(),
558 entries,
559 })
560}
561
562pub fn construct_maven_jar_path(
566 maven_repo: &Path,
567 group_id: &str,
568 artifact_id: &str,
569 version: &str,
570) -> PathBuf {
571 let group_path = group_id.replace('.', "/");
572 maven_repo
573 .join(group_path)
574 .join(artifact_id)
575 .join(version)
576 .join(format!("{artifact_id}-{version}.jar"))
577}
578
579pub fn detect_modules(pom_path: &Path) -> Vec<String> {
587 let content = match std::fs::read_to_string(pom_path) {
588 Ok(c) => c,
589 Err(e) => {
590 warn!("Could not read pom.xml at {}: {e}", pom_path.display());
591 return Vec::new();
592 }
593 };
594
595 let Some(modules_start) = content.find("<modules>") else {
597 return Vec::new();
598 };
599 let Some(modules_end) = content[modules_start..].find("</modules>") else {
600 return Vec::new();
601 };
602 let modules_block = &content[modules_start..modules_start + modules_end];
603
604 let mut modules = Vec::new();
606 let mut search_from = 0;
607 loop {
608 let Some(start) = modules_block[search_from..].find("<module>") else {
609 break;
610 };
611 let abs_start = search_from + start + "<module>".len();
612 let Some(end) = modules_block[abs_start..].find("</module>") else {
613 break;
614 };
615 let module_name = modules_block[abs_start..abs_start + end].trim();
616 if !module_name.is_empty() {
617 modules.push(module_name.to_string());
618 }
619 search_from = abs_start + end + "</module>".len();
620 }
621
622 debug!("Detected Maven modules: {:?}", modules);
623 modules
624}
625
626fn write_maven_cache(config: &ResolveConfig, entries: &[ResolvedClasspath]) {
632 let dir = cache_dir(config);
633 if let Err(e) = std::fs::create_dir_all(&dir) {
634 warn!("Could not create Maven cache dir: {e}");
635 return;
636 }
637 let cache_path = dir.join(MAVEN_CACHE_FILE);
638 match serde_json::to_string_pretty(entries) {
639 Ok(json) => {
640 if let Err(e) = std::fs::write(&cache_path, &json) {
641 warn!("Could not write Maven cache: {e}");
642 } else {
643 debug!("Wrote Maven cache to {}", cache_path.display());
644 }
645 }
646 Err(e) => warn!("Could not serialize Maven cache: {e}"),
647 }
648}
649
650fn read_maven_cache(config: &ResolveConfig) -> Option<Vec<ResolvedClasspath>> {
652 let cache_path = cache_dir(config).join(MAVEN_CACHE_FILE);
653 let data = std::fs::read_to_string(&cache_path).ok()?;
654 serde_json::from_str(&data).ok()
655}
656
657fn cache_dir(config: &ResolveConfig) -> PathBuf {
659 config
660 .cache_path
661 .clone()
662 .unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
663}
664
665fn try_cache_or_fallback(
667 config: &ResolveConfig,
668 module_infos: &[ModuleInfo],
669 maven_repo: &Path,
670) -> ClasspathResult<Vec<ResolvedClasspath>> {
671 if let Some(cached) = read_maven_cache(config) {
673 info!("Using cached Maven classpath ({} modules)", cached.len());
674 return Ok(cached);
675 }
676
677 info!("Falling back to pom.xml parsing (lossy, no transitive deps)");
679 let mut results = Vec::new();
680 for info in module_infos {
681 let pom_path = info.root.join("pom.xml");
682 if !pom_path.exists() {
683 continue;
684 }
685 match resolve_from_pom_fallback(&info.root, &info.name, maven_repo) {
686 Ok(resolved) => results.push(resolved),
687 Err(e) => {
688 warn!("POM fallback failed for '{}': {e}", info.name);
689 }
690 }
691 }
692
693 if results.is_empty() {
694 warn!("All Maven resolution strategies exhausted; returning empty classpath");
695 }
696
697 Ok(results)
698}
699
700fn default_maven_repo() -> PathBuf {
702 #[cfg(unix)]
703 let home = std::env::var_os("HOME").map(PathBuf::from);
704 #[cfg(windows)]
705 let home = std::env::var_os("USERPROFILE").map(PathBuf::from);
706 #[cfg(not(any(unix, windows)))]
707 let home: Option<PathBuf> = None;
708
709 home.map_or_else(
710 || PathBuf::from(".m2").join("repository"),
711 |h| h.join(".m2").join("repository"),
712 )
713}
714
715#[cfg(test)]
720mod tests {
721 use super::*;
722 use tempfile::TempDir;
723
724 #[test]
729 fn test_parse_classpath_string_basic() {
730 let repo = PathBuf::from("/home/user/.m2/repository");
731 let classpath = "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar\
732 :/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar";
733
734 let entries = parse_classpath_string(classpath, &repo);
735 assert_eq!(entries.len(), 2);
736
737 assert_eq!(
738 entries[0].jar_path,
739 PathBuf::from(
740 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
741 )
742 );
743 assert_eq!(
744 entries[0].coordinates.as_deref(),
745 Some("com.google.guava:guava:33.0.0")
746 );
747
748 assert_eq!(
749 entries[1].jar_path,
750 PathBuf::from(
751 "/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
752 )
753 );
754 assert_eq!(
755 entries[1].coordinates.as_deref(),
756 Some("org.slf4j:slf4j-api:2.0.9")
757 );
758 }
759
760 #[test]
761 fn test_parse_classpath_string_empty() {
762 let repo = PathBuf::from("/home/user/.m2/repository");
763 let entries = parse_classpath_string("", &repo);
764 assert!(entries.is_empty());
765 }
766
767 #[test]
768 fn test_parse_classpath_string_single_entry() {
769 let repo = PathBuf::from("/home/user/.m2/repository");
770 let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
771 let entries = parse_classpath_string(classpath, &repo);
772 assert_eq!(entries.len(), 1);
773 assert_eq!(
774 entries[0].coordinates.as_deref(),
775 Some("junit:junit:4.13.2")
776 );
777 }
778
779 #[test]
780 fn test_parse_classpath_non_repo_path() {
781 let repo = PathBuf::from("/home/user/.m2/repository");
782 let classpath = "/opt/custom/lib/some.jar";
783 let entries = parse_classpath_string(classpath, &repo);
784 assert_eq!(entries.len(), 1);
785 assert_eq!(
786 entries[0].jar_path,
787 PathBuf::from("/opt/custom/lib/some.jar")
788 );
789 assert!(entries[0].coordinates.is_none());
790 }
791
792 #[test]
797 fn test_extract_coordinates_guava() {
798 let repo = PathBuf::from("/home/user/.m2/repository");
799 let jar = PathBuf::from(
800 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
801 );
802 let coords = extract_coordinates_from_repo_path(&jar, &repo);
803 assert_eq!(coords.as_deref(), Some("com.google.guava:guava:33.0.0"));
804 }
805
806 #[test]
807 fn test_extract_coordinates_simple_group() {
808 let repo = PathBuf::from("/home/user/.m2/repository");
809 let jar = PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar");
810 let coords = extract_coordinates_from_repo_path(&jar, &repo);
811 assert_eq!(coords.as_deref(), Some("junit:junit:4.13.2"));
812 }
813
814 #[test]
815 fn test_extract_coordinates_outside_repo() {
816 let repo = PathBuf::from("/home/user/.m2/repository");
817 let jar = PathBuf::from("/opt/lib/foo.jar");
818 let coords = extract_coordinates_from_repo_path(&jar, &repo);
819 assert!(coords.is_none());
820 }
821
822 #[test]
823 fn test_extract_coordinates_too_short_path() {
824 let repo = PathBuf::from("/home/user/.m2/repository");
825 let jar = PathBuf::from("/home/user/.m2/repository/foo/bar");
826 let coords = extract_coordinates_from_repo_path(&jar, &repo);
828 assert!(coords.is_none());
829 }
830
831 #[test]
832 fn test_extract_coordinates_deep_group() {
833 let repo = PathBuf::from("/home/user/.m2/repository");
834 let jar = PathBuf::from(
835 "/home/user/.m2/repository/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
836 );
837 let coords = extract_coordinates_from_repo_path(&jar, &repo);
838 assert_eq!(
839 coords.as_deref(),
840 Some("org.apache.commons:commons-lang3:3.14.0")
841 );
842 }
843
844 #[test]
845 fn test_extract_coordinates_repo_root_itself() {
846 let repo = PathBuf::from("/home/user/.m2/repository");
847 let jar = PathBuf::from("/home/user/.m2/repository");
848 let coords = extract_coordinates_from_repo_path(&jar, &repo);
849 assert!(coords.is_none());
850 }
851
852 #[test]
857 fn test_detect_modules_multi() {
858 let tmp = TempDir::new().unwrap();
859 let pom = r#"<?xml version="1.0" encoding="UTF-8"?>
860<project>
861 <modelVersion>4.0.0</modelVersion>
862 <groupId>com.example</groupId>
863 <artifactId>parent</artifactId>
864 <version>1.0.0</version>
865 <packaging>pom</packaging>
866 <modules>
867 <module>core</module>
868 <module>web</module>
869 <module>api</module>
870 </modules>
871</project>"#;
872 let pom_path = tmp.path().join("pom.xml");
873 std::fs::write(&pom_path, pom).unwrap();
874
875 let modules = detect_modules(&pom_path);
876 assert_eq!(modules, vec!["core", "web", "api"]);
877 }
878
879 #[test]
880 fn test_detect_modules_none() {
881 let tmp = TempDir::new().unwrap();
882 let pom = r#"<?xml version="1.0"?>
883<project>
884 <groupId>com.example</groupId>
885 <artifactId>single</artifactId>
886 <version>1.0.0</version>
887</project>"#;
888 let pom_path = tmp.path().join("pom.xml");
889 std::fs::write(&pom_path, pom).unwrap();
890
891 let modules = detect_modules(&pom_path);
892 assert!(modules.is_empty());
893 }
894
895 #[test]
896 fn test_detect_modules_missing_pom() {
897 let modules = detect_modules(Path::new("/nonexistent/pom.xml"));
898 assert!(modules.is_empty());
899 }
900
901 #[test]
902 fn test_detect_modules_whitespace_handling() {
903 let tmp = TempDir::new().unwrap();
904 let pom = r#"<project>
905 <modules>
906 <module> core </module>
907 <module>
908 api
909 </module>
910 </modules>
911</project>"#;
912 let pom_path = tmp.path().join("pom.xml");
913 std::fs::write(&pom_path, pom).unwrap();
914
915 let modules = detect_modules(&pom_path);
916 assert_eq!(modules, vec!["core", "api"]);
917 }
918
919 #[test]
924 fn test_construct_maven_jar_path() {
925 let repo = PathBuf::from("/home/user/.m2/repository");
926 let path = construct_maven_jar_path(&repo, "com.google.guava", "guava", "33.0.0");
927 assert_eq!(
928 path,
929 PathBuf::from(
930 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
931 )
932 );
933 }
934
935 #[test]
936 fn test_construct_maven_jar_path_simple_group() {
937 let repo = PathBuf::from("/home/user/.m2/repository");
938 let path = construct_maven_jar_path(&repo, "junit", "junit", "4.13.2");
939 assert_eq!(
940 path,
941 PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar")
942 );
943 }
944
945 #[test]
950 fn test_parse_pom_dependencies_basic() {
951 let pom = r#"<project>
952 <dependencies>
953 <dependency>
954 <groupId>com.google.guava</groupId>
955 <artifactId>guava</artifactId>
956 <version>33.0.0</version>
957 </dependency>
958 <dependency>
959 <groupId>org.slf4j</groupId>
960 <artifactId>slf4j-api</artifactId>
961 <version>2.0.9</version>
962 </dependency>
963 </dependencies>
964</project>"#;
965
966 let deps = parse_pom_dependencies(pom);
967 assert_eq!(deps.len(), 2);
968 assert_eq!(deps[0].group_id, "com.google.guava");
969 assert_eq!(deps[0].artifact_id, "guava");
970 assert_eq!(deps[0].version.as_deref(), Some("33.0.0"));
971 assert_eq!(deps[1].group_id, "org.slf4j");
972 assert_eq!(deps[1].artifact_id, "slf4j-api");
973 assert_eq!(deps[1].version.as_deref(), Some("2.0.9"));
974 }
975
976 #[test]
977 fn test_parse_pom_dependencies_skips_test_scope() {
978 let pom = r#"<project>
979 <dependencies>
980 <dependency>
981 <groupId>com.google.guava</groupId>
982 <artifactId>guava</artifactId>
983 <version>33.0.0</version>
984 </dependency>
985 <dependency>
986 <groupId>junit</groupId>
987 <artifactId>junit</artifactId>
988 <version>4.13.2</version>
989 <scope>test</scope>
990 </dependency>
991 </dependencies>
992</project>"#;
993
994 let deps = parse_pom_dependencies(pom);
995 assert_eq!(deps.len(), 1);
996 assert_eq!(deps[0].artifact_id, "guava");
997 }
998
999 #[test]
1000 fn test_parse_pom_dependencies_skips_property_placeholders() {
1001 let pom = r#"<project>
1002 <dependencies>
1003 <dependency>
1004 <groupId>${project.groupId}</groupId>
1005 <artifactId>internal-lib</artifactId>
1006 <version>1.0.0</version>
1007 </dependency>
1008 <dependency>
1009 <groupId>org.example</groupId>
1010 <artifactId>real-dep</artifactId>
1011 <version>2.0.0</version>
1012 </dependency>
1013 </dependencies>
1014</project>"#;
1015
1016 let deps = parse_pom_dependencies(pom);
1017 assert_eq!(deps.len(), 1);
1018 assert_eq!(deps[0].group_id, "org.example");
1019 }
1020
1021 #[test]
1022 fn test_parse_pom_dependencies_no_version() {
1023 let pom = r#"<project>
1024 <dependencies>
1025 <dependency>
1026 <groupId>org.example</groupId>
1027 <artifactId>managed-dep</artifactId>
1028 </dependency>
1029 </dependencies>
1030</project>"#;
1031
1032 let deps = parse_pom_dependencies(pom);
1033 assert_eq!(deps.len(), 1);
1034 assert!(deps[0].version.is_none());
1035 }
1036
1037 #[test]
1038 fn test_parse_pom_dependencies_with_compile_scope() {
1039 let pom = r#"<project>
1040 <dependencies>
1041 <dependency>
1042 <groupId>org.example</groupId>
1043 <artifactId>dep</artifactId>
1044 <version>1.0</version>
1045 <scope>compile</scope>
1046 </dependency>
1047 </dependencies>
1048</project>"#;
1049
1050 let deps = parse_pom_dependencies(pom);
1051 assert_eq!(deps.len(), 1);
1052 assert_eq!(deps[0].scope.as_deref(), Some("compile"));
1053 }
1054
1055 #[test]
1056 fn test_parse_pom_empty_dependencies() {
1057 let pom = r#"<project>
1058 <dependencies>
1059 </dependencies>
1060</project>"#;
1061
1062 let deps = parse_pom_dependencies(pom);
1063 assert!(deps.is_empty());
1064 }
1065
1066 #[test]
1067 fn test_parse_pom_no_dependencies_element() {
1068 let pom = r#"<project>
1069 <groupId>com.example</groupId>
1070</project>"#;
1071
1072 let deps = parse_pom_dependencies(pom);
1073 assert!(deps.is_empty());
1074 }
1075
1076 #[test]
1081 fn test_parse_classpath_string_trailing_separator() {
1082 let repo = PathBuf::from("/home/user/.m2/repository");
1083 let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar:";
1084 let entries = parse_classpath_string(classpath, &repo);
1085 assert_eq!(entries.len(), 1);
1086 }
1087
1088 #[test]
1089 fn test_parse_classpath_string_leading_separator() {
1090 let repo = PathBuf::from("/home/user/.m2/repository");
1091 let classpath = ":/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
1092 let entries = parse_classpath_string(classpath, &repo);
1093 assert_eq!(entries.len(), 1);
1094 }
1095
1096 #[test]
1097 fn test_parse_classpath_string_double_separator() {
1098 let repo = PathBuf::from("/home/user/.m2/repository");
1099 let classpath = "/a.jar::/b.jar";
1100 let entries = parse_classpath_string(classpath, &repo);
1101 assert_eq!(entries.len(), 2);
1102 }
1103
1104 #[test]
1105 fn test_extract_xml_element_missing() {
1106 let xml = "<dependency><groupId>g</groupId></dependency>";
1107 assert!(extract_xml_element(xml, "artifactId").is_none());
1108 }
1109
1110 #[test]
1111 fn test_extract_xml_element_empty() {
1112 let xml = "<dependency><groupId></groupId></dependency>";
1113 assert!(extract_xml_element(xml, "groupId").is_none());
1114 }
1115
1116 #[test]
1121 fn test_cache_roundtrip() {
1122 let tmp = TempDir::new().unwrap();
1123 let config = ResolveConfig {
1124 project_root: tmp.path().to_path_buf(),
1125 timeout_secs: 60,
1126 cache_path: Some(tmp.path().join("cache")),
1127 };
1128
1129 let entries = vec![ResolvedClasspath {
1130 module_name: "core".to_string(),
1131 entries: vec![ClasspathEntry {
1132 jar_path: PathBuf::from("/repo/guava/guava/33.0.0/guava-33.0.0.jar"),
1133 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
1134 is_direct: true,
1135 source_jar: None,
1136 }],
1137 }];
1138
1139 write_maven_cache(&config, &entries);
1140 let loaded = read_maven_cache(&config);
1141 assert!(loaded.is_some());
1142 let loaded = loaded.unwrap();
1143 assert_eq!(loaded.len(), 1);
1144 assert_eq!(loaded[0].module_name, "core");
1145 assert_eq!(loaded[0].entries.len(), 1);
1146 assert_eq!(
1147 loaded[0].entries[0].coordinates.as_deref(),
1148 Some("com.google.guava:guava:33.0.0")
1149 );
1150 }
1151
1152 #[test]
1153 fn test_cache_read_missing_returns_none() {
1154 let tmp = TempDir::new().unwrap();
1155 let config = ResolveConfig {
1156 project_root: tmp.path().to_path_buf(),
1157 timeout_secs: 60,
1158 cache_path: Some(tmp.path().join("nonexistent-cache")),
1159 };
1160 assert!(read_maven_cache(&config).is_none());
1161 }
1162
1163 #[test]
1168 fn test_source_jar_found() {
1169 let tmp = TempDir::new().unwrap();
1170 let jar = tmp.path().join("guava-33.0.0.jar");
1171 let source = tmp.path().join("guava-33.0.0-sources.jar");
1172 std::fs::write(&jar, b"").unwrap();
1173 std::fs::write(&source, b"").unwrap();
1174
1175 let result = find_source_jar(&jar);
1176 assert_eq!(result, Some(source));
1177 }
1178
1179 #[test]
1180 fn test_source_jar_not_present() {
1181 let tmp = TempDir::new().unwrap();
1182 let jar = tmp.path().join("guava-33.0.0.jar");
1183 std::fs::write(&jar, b"").unwrap();
1184
1185 let result = find_source_jar(&jar);
1186 assert!(result.is_none());
1187 }
1188
1189 #[test]
1190 fn test_source_jar_non_jar_file() {
1191 let result = find_source_jar(Path::new("/some/file.txt"));
1192 assert!(result.is_none());
1193 }
1194
1195 #[test]
1200 fn test_resolve_from_pom_fallback_with_local_jars() {
1201 let tmp = TempDir::new().unwrap();
1202
1203 let repo = tmp.path().join("repo");
1205 let jar_dir = repo.join("com/example/mylib/1.0.0");
1206 std::fs::create_dir_all(&jar_dir).unwrap();
1207 std::fs::write(jar_dir.join("mylib-1.0.0.jar"), b"fake jar").unwrap();
1208
1209 let pom = r#"<project>
1211 <dependencies>
1212 <dependency>
1213 <groupId>com.example</groupId>
1214 <artifactId>mylib</artifactId>
1215 <version>1.0.0</version>
1216 </dependency>
1217 <dependency>
1218 <groupId>com.missing</groupId>
1219 <artifactId>nolib</artifactId>
1220 <version>2.0.0</version>
1221 </dependency>
1222 </dependencies>
1223</project>"#;
1224 std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
1225
1226 let result = resolve_from_pom_fallback(tmp.path(), "", &repo).unwrap();
1227 assert_eq!(result.entries.len(), 1);
1229 assert_eq!(
1230 result.entries[0].coordinates.as_deref(),
1231 Some("com.example:mylib:1.0.0")
1232 );
1233 }
1234
1235 #[test]
1240 fn test_resolve_maven_classpath_no_pom() {
1241 let tmp = TempDir::new().unwrap();
1242 let config = ResolveConfig {
1243 project_root: tmp.path().to_path_buf(),
1244 timeout_secs: 10,
1245 cache_path: None,
1246 };
1247
1248 let result = resolve_maven_classpath(&config);
1249 assert!(result.is_err());
1250 }
1251
1252 #[test]
1253 fn test_resolve_maven_classpath_falls_back_to_pom_when_mvn_missing() {
1254 let tmp = TempDir::new().unwrap();
1255
1256 let pom = r#"<project>
1258 <dependencies>
1259 <dependency>
1260 <groupId>org.example</groupId>
1261 <artifactId>dep</artifactId>
1262 <version>1.0.0</version>
1263 </dependency>
1264 </dependencies>
1265</project>"#;
1266 std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
1267
1268 let config = ResolveConfig {
1269 project_root: tmp.path().to_path_buf(),
1270 timeout_secs: 5,
1271 cache_path: None,
1272 };
1273
1274 let result = resolve_maven_classpath(&config);
1278 assert!(result.is_ok());
1279 }
1280
1281 #[test]
1282 fn test_resolve_maven_classpath_multimodule_falls_back() {
1283 let tmp = TempDir::new().unwrap();
1284
1285 let root_pom = r#"<project>
1287 <modules>
1288 <module>core</module>
1289 <module>web</module>
1290 </modules>
1291</project>"#;
1292 std::fs::write(tmp.path().join("pom.xml"), root_pom).unwrap();
1293
1294 let core_dir = tmp.path().join("core");
1296 std::fs::create_dir_all(&core_dir).unwrap();
1297 std::fs::write(
1298 core_dir.join("pom.xml"),
1299 r#"<project>
1300 <dependencies>
1301 <dependency>
1302 <groupId>org.example</groupId>
1303 <artifactId>core-dep</artifactId>
1304 <version>1.0.0</version>
1305 </dependency>
1306 </dependencies>
1307</project>"#,
1308 )
1309 .unwrap();
1310
1311 let web_dir = tmp.path().join("web");
1312 std::fs::create_dir_all(&web_dir).unwrap();
1313 std::fs::write(
1314 web_dir.join("pom.xml"),
1315 r#"<project>
1316 <dependencies>
1317 <dependency>
1318 <groupId>org.example</groupId>
1319 <artifactId>web-dep</artifactId>
1320 <version>2.0.0</version>
1321 </dependency>
1322 </dependencies>
1323</project>"#,
1324 )
1325 .unwrap();
1326
1327 let config = ResolveConfig {
1328 project_root: tmp.path().to_path_buf(),
1329 timeout_secs: 5,
1330 cache_path: None,
1331 };
1332
1333 let result = resolve_maven_classpath(&config);
1335 assert!(result.is_ok());
1336 let resolved = result.unwrap();
1337 assert_eq!(resolved.len(), 2);
1340 }
1341
1342 #[test]
1347 fn test_find_mvn_command_no_wrapper() {
1348 let tmp = TempDir::new().unwrap();
1349 let cmd = find_mvn_command(tmp.path());
1350 assert_eq!(cmd, "mvn");
1351 }
1352
1353 #[test]
1354 fn test_find_mvn_command_with_wrapper() {
1355 let tmp = TempDir::new().unwrap();
1356 #[cfg(unix)]
1357 let wrapper_name = "mvnw";
1358 #[cfg(windows)]
1359 let wrapper_name = "mvnw.cmd";
1360 std::fs::write(tmp.path().join(wrapper_name), b"#!/bin/sh\nexec mvn \"$@\"").unwrap();
1361
1362 let cmd = find_mvn_command(tmp.path());
1363 assert!(cmd.contains("mvnw"), "Expected wrapper path, got: {cmd}");
1364 }
1365
1366 #[test]
1371 fn test_parse_pom_dependencies_version_with_property() {
1372 let pom = r#"<project>
1373 <dependencies>
1374 <dependency>
1375 <groupId>org.example</groupId>
1376 <artifactId>lib</artifactId>
1377 <version>${lib.version}</version>
1378 </dependency>
1379 </dependencies>
1380</project>"#;
1381
1382 let deps = parse_pom_dependencies(pom);
1383 assert_eq!(deps.len(), 1);
1386 assert_eq!(deps[0].version.as_deref(), Some("${lib.version}"));
1387 }
1388
1389 #[test]
1390 fn test_parse_pom_dependencies_multiple_scopes() {
1391 let pom = r#"<project>
1392 <dependencies>
1393 <dependency>
1394 <groupId>a</groupId>
1395 <artifactId>compile-dep</artifactId>
1396 <version>1.0</version>
1397 <scope>compile</scope>
1398 </dependency>
1399 <dependency>
1400 <groupId>b</groupId>
1401 <artifactId>runtime-dep</artifactId>
1402 <version>1.0</version>
1403 <scope>runtime</scope>
1404 </dependency>
1405 <dependency>
1406 <groupId>c</groupId>
1407 <artifactId>provided-dep</artifactId>
1408 <version>1.0</version>
1409 <scope>provided</scope>
1410 </dependency>
1411 <dependency>
1412 <groupId>d</groupId>
1413 <artifactId>test-dep</artifactId>
1414 <version>1.0</version>
1415 <scope>test</scope>
1416 </dependency>
1417 </dependencies>
1418</project>"#;
1419
1420 let deps = parse_pom_dependencies(pom);
1421 assert_eq!(deps.len(), 3);
1423 assert_eq!(deps[0].artifact_id, "compile-dep");
1424 assert_eq!(deps[1].artifact_id, "runtime-dep");
1425 assert_eq!(deps[2].artifact_id, "provided-dep");
1426 }
1427}