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