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_error(
79 config,
80 &[ModuleInfo::root(&config.project_root)],
81 &e.to_string(),
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 failed_modules = 0usize;
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 failed_modules += 1;
114 }
115 }
116 }
117
118 if failed_modules > 0 && results.is_empty() {
119 return try_cache_or_error(
120 config,
121 &module_infos,
122 "All Maven module subprocess resolutions failed",
123 );
124 }
125
126 if failed_modules > 0 {
127 warn!(
128 "Maven resolution incomplete: {failed_modules}/{} modules failed; using partial classpath result",
129 module_infos.len()
130 );
131 }
132
133 if !results.is_empty() {
134 write_maven_cache(config, &results);
135 }
136 Ok(results)
137}
138
139struct ModuleInfo {
141 name: String,
142 root: PathBuf,
143}
144
145impl ModuleInfo {
146 fn root(project_root: &Path) -> Self {
147 Self {
148 name: String::new(),
149 root: project_root.to_path_buf(),
150 }
151 }
152}
153
154fn resolve_via_subprocess(
156 module_root: &Path,
157 timeout_secs: u64,
158 maven_repo: &Path,
159) -> ClasspathResult<ResolvedClasspath> {
160 let classpath_output = run_maven_build_classpath(module_root, timeout_secs)?;
161 let entries = parse_classpath_string(&classpath_output, maven_repo);
162
163 Ok(ResolvedClasspath {
164 module_name: String::new(),
165 module_root: module_root.to_path_buf(),
166 entries,
167 })
168}
169
170fn resolve_module_via_subprocess(
172 info: &ModuleInfo,
173 timeout_secs: u64,
174 maven_repo: &Path,
175) -> ClasspathResult<ResolvedClasspath> {
176 let classpath_output = run_maven_build_classpath(&info.root, timeout_secs)?;
177 let entries = parse_classpath_string(&classpath_output, maven_repo);
178
179 Ok(ResolvedClasspath {
180 module_name: info.name.clone(),
181 module_root: info.root.clone(),
182 entries,
183 })
184}
185
186fn run_maven_build_classpath(working_dir: &Path, timeout_secs: u64) -> ClasspathResult<String> {
190 let temp_dir = tempfile::tempdir()
191 .map_err(|e| ClasspathError::ResolutionFailed(format!("tempdir: {e}")))?;
192 let output_file = temp_dir.path().join("classpath.txt");
193
194 let mvn_cmd = find_mvn_command(working_dir).ok_or_else(|| {
195 ClasspathError::ResolutionFailed("No Maven wrapper or installed mvn found".to_string())
196 })?;
197
198 let mut command = Command::new(&mvn_cmd);
199 command
200 .arg("dependency:build-classpath")
201 .arg("-DincludeScope=compile")
202 .arg(format!("-Dmdep.outputFile={}", output_file.display()))
203 .arg("-q")
204 .arg("--batch-mode")
205 .current_dir(working_dir);
206
207 debug!(
208 "Running Maven: {} dependency:build-classpath in {}",
209 mvn_cmd.display(),
210 working_dir.display()
211 );
212
213 let output = run_command_with_timeout(&mut command, Duration::from_secs(timeout_secs))?;
214
215 if !output.status.success() {
216 let stderr = String::from_utf8_lossy(&output.stderr);
217 return Err(ClasspathError::ResolutionFailed(format!(
218 "mvn dependency:build-classpath failed (exit {}): {}",
219 output.status,
220 stderr.chars().take(500).collect::<String>()
221 )));
222 }
223
224 let classpath = std::fs::read_to_string(&output_file).map_err(|e| {
226 ClasspathError::ResolutionFailed(format!(
227 "Failed to read Maven classpath output file {}: {e}",
228 output_file.display()
229 ))
230 })?;
231
232 Ok(classpath.trim().to_string())
233}
234
235fn find_mvn_command(working_dir: &Path) -> Option<PathBuf> {
239 #[cfg(unix)]
240 let wrapper = working_dir.join("mvnw");
241 #[cfg(windows)]
242 let wrapper = working_dir.join("mvnw.cmd");
243
244 if wrapper.exists() {
245 Some(wrapper)
246 } else {
247 which_binary(if cfg!(windows) { "mvn.cmd" } else { "mvn" })
248 }
249}
250
251fn run_command_with_timeout(
256 command: &mut Command,
257 timeout: Duration,
258) -> ClasspathResult<std::process::Output> {
259 let mut child = command
260 .stdout(std::process::Stdio::piped())
261 .stderr(std::process::Stdio::piped())
262 .spawn()
263 .map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn mvn: {e}")))?;
264
265 let start = std::time::Instant::now();
266
267 loop {
268 match child.try_wait() {
269 Ok(Some(status)) => {
270 return collect_child_output(child, status);
271 }
272 Ok(None) => {
273 if start.elapsed() > timeout {
274 let _ = child.kill();
275 let _ = child.wait();
276 return Err(ClasspathError::ResolutionFailed(format!(
277 "mvn timed out after {}s",
278 timeout.as_secs()
279 )));
280 }
281 std::thread::sleep(Duration::from_millis(100));
282 }
283 Err(e) => {
284 return Err(ClasspathError::ResolutionFailed(format!(
285 "Failed to check mvn process status: {e}"
286 )));
287 }
288 }
289 }
290}
291
292#[allow(clippy::unnecessary_wraps)] fn collect_child_output(
295 mut child: std::process::Child,
296 status: std::process::ExitStatus,
297) -> ClasspathResult<std::process::Output> {
298 let mut stdout = Vec::new();
299 let mut stderr = Vec::new();
300 if let Some(ref mut out) = child.stdout {
301 let _ = out.read_to_end(&mut stdout);
302 }
303 if let Some(ref mut err) = child.stderr {
304 let _ = err.read_to_end(&mut stderr);
305 }
306 Ok(std::process::Output {
307 status,
308 stdout,
309 stderr,
310 })
311}
312
313#[must_use]
319pub fn parse_classpath_string(classpath: &str, maven_repo: &Path) -> Vec<ClasspathEntry> {
320 if classpath.is_empty() {
321 return Vec::new();
322 }
323
324 classpath
325 .split(CLASSPATH_SEPARATOR)
326 .filter(|p| !p.is_empty())
327 .map(|p| {
328 let jar_path = PathBuf::from(p.trim());
329 let coordinate = extract_coordinates_from_repo_path(&jar_path, maven_repo);
330 let source_jar = find_source_jar(&jar_path);
331 ClasspathEntry {
332 jar_path,
333 coordinates: coordinate,
334 is_direct: true, source_jar,
336 }
337 })
338 .collect()
339}
340
341#[must_use]
351pub fn extract_coordinates_from_repo_path(jar_path: &Path, maven_repo: &Path) -> Option<String> {
352 let jar_path_str = normalize_path(jar_path);
353 let repo_str = normalize_path(maven_repo);
354
355 let relative = jar_path_str
357 .strip_prefix(&repo_str)?
358 .trim_start_matches('/');
359 if relative.is_empty() {
360 return None;
361 }
362
363 let parts: Vec<&str> = relative.split('/').collect();
364 if parts.len() < 4 {
366 return None;
367 }
368
369 let version = parts[parts.len() - 2];
371 let artifact_id = parts[parts.len() - 3];
372 let group_parts = &parts[..parts.len() - 3];
373
374 if group_parts.is_empty() {
375 return None;
376 }
377
378 let group_id = group_parts.join(".");
379 Some(format!("{group_id}:{artifact_id}:{version}"))
380}
381
382fn normalize_path(p: &Path) -> String {
384 p.to_string_lossy().replace('\\', "/")
385}
386
387#[allow(clippy::case_sensitive_file_extension_comparisons)] fn find_source_jar(jar_path: &Path) -> Option<PathBuf> {
393 let file_name = jar_path.file_name()?.to_str()?;
394 if !file_name.ends_with(".jar") {
395 return None;
396 }
397
398 let stem = file_name.strip_suffix(".jar")?;
399 let source_name = format!("{stem}-sources.jar");
400 let source_path = jar_path.with_file_name(source_name);
401
402 if source_path.exists() {
403 Some(source_path)
404 } else {
405 None
406 }
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct PomDependency {
416 pub group_id: String,
418 pub artifact_id: String,
420 pub version: Option<String>,
422 pub scope: Option<String>,
424}
425
426#[must_use]
432pub fn parse_pom_dependencies(pom_content: &str) -> Vec<PomDependency> {
433 let mut deps = Vec::new();
434
435 let mut search_from = 0;
436 loop {
437 let Some(start) = pom_content[search_from..].find("<dependency>") else {
438 break;
439 };
440 let abs_start = search_from + start;
441
442 let Some(end) = pom_content[abs_start..].find("</dependency>") else {
443 break;
444 };
445 let abs_end = abs_start + end + "</dependency>".len();
446
447 let block = &pom_content[abs_start..abs_end];
448 search_from = abs_end;
449
450 let Some(group_id) = extract_xml_element(block, "groupId") else {
451 continue;
452 };
453 let Some(artifact_id) = extract_xml_element(block, "artifactId") else {
454 continue;
455 };
456
457 if group_id.contains("${") || artifact_id.contains("${") {
459 continue;
460 }
461
462 let version = extract_xml_element(block, "version");
463 let scope = extract_xml_element(block, "scope");
464
465 if scope.as_deref() == Some("test") {
467 continue;
468 }
469
470 deps.push(PomDependency {
471 group_id,
472 artifact_id,
473 version,
474 scope,
475 });
476 }
477
478 deps
479}
480
481fn extract_xml_element(xml: &str, tag: &str) -> Option<String> {
486 let open = format!("<{tag}>");
487 let close = format!("</{tag}>");
488 let start = xml.find(&open)?;
489 let content_start = start + open.len();
490 let end = xml[content_start..].find(&close)?;
491 let content = xml[content_start..content_start + end].trim();
492 if content.is_empty() {
493 None
494 } else {
495 Some(content.to_string())
496 }
497}
498
499#[cfg(test)]
508fn resolve_from_pom_fallback(
509 module_root: &Path,
510 module_name: &str,
511 maven_repo: &Path,
512) -> ClasspathResult<ResolvedClasspath> {
513 let pom_path = module_root.join("pom.xml");
514 let pom_content = std::fs::read_to_string(&pom_path).map_err(|e| {
515 ClasspathError::ResolutionFailed(format!(
516 "Failed to read pom.xml at {}: {e}",
517 pom_path.display()
518 ))
519 })?;
520
521 let deps = parse_pom_dependencies(&pom_content);
522 let mut entries = Vec::new();
523
524 let display_name = if module_name.is_empty() {
525 "<root>"
526 } else {
527 module_name
528 };
529
530 for dep in &deps {
531 let Some(version) = &dep.version else {
532 warn!(
533 "Skipping {}:{} in {} — no version (may be from dependencyManagement)",
534 dep.group_id, dep.artifact_id, display_name
535 );
536 continue;
537 };
538
539 if version.contains("${") {
541 warn!(
542 "Skipping {}:{}:{} in {} — version contains property placeholder",
543 dep.group_id, dep.artifact_id, version, display_name
544 );
545 continue;
546 }
547
548 let jar_path =
549 construct_maven_jar_path(maven_repo, &dep.group_id, &dep.artifact_id, version);
550
551 if jar_path.exists() {
552 let source_jar = find_source_jar(&jar_path);
553 let coordinates = format!("{}:{}:{}", dep.group_id, dep.artifact_id, version);
554 entries.push(ClasspathEntry {
555 jar_path,
556 coordinates: Some(coordinates),
557 is_direct: true,
558 source_jar,
559 });
560 } else {
561 warn!(
562 "JAR not found in local repo for {}: {}:{}:{} (expected at {})",
563 display_name,
564 dep.group_id,
565 dep.artifact_id,
566 version,
567 jar_path.display()
568 );
569 }
570 }
571
572 info!(
573 "POM fallback for '{}': {} entries resolved",
574 display_name,
575 entries.len()
576 );
577
578 Ok(ResolvedClasspath {
579 module_name: module_name.to_string(),
580 module_root: module_root.to_path_buf(),
581 entries,
582 })
583}
584
585#[must_use]
589pub fn construct_maven_jar_path(
590 maven_repo: &Path,
591 group_id: &str,
592 artifact_id: &str,
593 version: &str,
594) -> PathBuf {
595 let group_path = group_id.replace('.', "/");
596 maven_repo
597 .join(group_path)
598 .join(artifact_id)
599 .join(version)
600 .join(format!("{artifact_id}-{version}.jar"))
601}
602
603#[must_use]
611pub fn detect_modules(pom_path: &Path) -> Vec<String> {
612 let content = match std::fs::read_to_string(pom_path) {
613 Ok(c) => c,
614 Err(e) => {
615 warn!("Could not read pom.xml at {}: {e}", pom_path.display());
616 return Vec::new();
617 }
618 };
619
620 let Some(modules_start) = content.find("<modules>") else {
622 return Vec::new();
623 };
624 let Some(modules_end) = content[modules_start..].find("</modules>") else {
625 return Vec::new();
626 };
627 let modules_block = &content[modules_start..modules_start + modules_end];
628
629 let mut modules = Vec::new();
631 let mut search_from = 0;
632 loop {
633 let Some(start) = modules_block[search_from..].find("<module>") else {
634 break;
635 };
636 let abs_start = search_from + start + "<module>".len();
637 let Some(end) = modules_block[abs_start..].find("</module>") else {
638 break;
639 };
640 let module_name = modules_block[abs_start..abs_start + end].trim();
641 if !module_name.is_empty() {
642 modules.push(module_name.to_string());
643 }
644 search_from = abs_start + end + "</module>".len();
645 }
646
647 debug!("Detected Maven modules: {modules:?}");
648 modules
649}
650
651fn write_maven_cache(config: &ResolveConfig, entries: &[ResolvedClasspath]) {
657 let dir = cache_dir(config);
658 if let Err(e) = std::fs::create_dir_all(&dir) {
659 warn!("Could not create Maven cache dir: {e}");
660 return;
661 }
662 let cache_path = dir.join(MAVEN_CACHE_FILE);
663 match serde_json::to_string_pretty(entries) {
664 Ok(json) => {
665 if let Err(e) = std::fs::write(&cache_path, &json) {
666 warn!("Could not write Maven cache: {e}");
667 } else {
668 debug!("Wrote Maven cache to {}", cache_path.display());
669 }
670 }
671 Err(e) => warn!("Could not serialize Maven cache: {e}"),
672 }
673}
674
675fn read_maven_cache(config: &ResolveConfig) -> Option<Vec<ResolvedClasspath>> {
677 let cache_path = cache_dir(config).join(MAVEN_CACHE_FILE);
678 let data = std::fs::read_to_string(&cache_path).ok()?;
679 serde_json::from_str(&data).ok()
680}
681
682fn cache_dir(config: &ResolveConfig) -> PathBuf {
684 config
685 .cache_path
686 .clone()
687 .unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
688}
689
690#[allow(clippy::unnecessary_wraps)] fn try_cache_or_error(
693 config: &ResolveConfig,
694 module_infos: &[ModuleInfo],
695 live_error: &str,
696) -> ClasspathResult<Vec<ResolvedClasspath>> {
697 if let Some(cached) = read_maven_cache(config) {
699 info!("Using cached Maven classpath ({} modules)", cached.len());
700 warn_if_cache_stale(config, &cached);
701 return Ok(cached);
702 }
703
704 let module_summary = module_infos
705 .iter()
706 .map(|info| {
707 if info.name.is_empty() {
708 info.root.display().to_string()
709 } else {
710 format!("{} ({})", info.name, info.root.display())
711 }
712 })
713 .collect::<Vec<_>>()
714 .join(", ");
715
716 Err(ClasspathError::ResolutionFailed(format!(
717 "{live_error}. No Maven cache available for [{module_summary}]. Provide mvnw, install mvn, or use --classpath-file."
718 )))
719}
720
721fn default_maven_repo() -> PathBuf {
723 #[cfg(unix)]
724 let home = std::env::var_os("HOME").map(PathBuf::from);
725 #[cfg(windows)]
726 let home = std::env::var_os("USERPROFILE").map(PathBuf::from);
727 #[cfg(not(any(unix, windows)))]
728 let home: Option<PathBuf> = None;
729
730 home.map_or_else(
731 || PathBuf::from(".m2").join("repository"),
732 |h| h.join(".m2").join("repository"),
733 )
734}
735
736fn warn_if_cache_stale(config: &ResolveConfig, classpaths: &[ResolvedClasspath]) {
737 if classpaths.is_empty() {
738 return;
739 }
740 let cache_path = cache_dir(config).join(MAVEN_CACHE_FILE);
741 let Ok(cache_meta) = std::fs::metadata(&cache_path) else {
742 return;
743 };
744 let Ok(cache_mtime) = cache_meta.modified() else {
745 return;
746 };
747
748 for cp in classpaths {
749 let pom_path = cp.module_root.join("pom.xml");
750 let Ok(meta) = std::fs::metadata(&pom_path) else {
751 continue;
752 };
753 let Ok(modified) = meta.modified() else {
754 continue;
755 };
756 if modified > cache_mtime {
757 warn!(
758 "Using cached Maven classpath from {} even though {} is newer; cache may be stale",
759 cache_path.display(),
760 pom_path.display()
761 );
762 return;
763 }
764 }
765}
766
767fn which_binary(name: &str) -> Option<PathBuf> {
768 let path_var = std::env::var_os("PATH")?;
769 for dir in std::env::split_paths(&path_var) {
770 let candidate = dir.join(name);
771 if candidate.is_file() {
772 return Some(candidate);
773 }
774 }
775 None
776}
777
778#[cfg(test)]
783mod tests {
784 use super::*;
785 use tempfile::TempDir;
786
787 #[test]
792 fn test_parse_classpath_string_basic() {
793 let repo = PathBuf::from("/home/user/.m2/repository");
794 let classpath = "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar\
795 :/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar";
796
797 let entries = parse_classpath_string(classpath, &repo);
798 assert_eq!(entries.len(), 2);
799
800 assert_eq!(
801 entries[0].jar_path,
802 PathBuf::from(
803 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
804 )
805 );
806 assert_eq!(
807 entries[0].coordinates.as_deref(),
808 Some("com.google.guava:guava:33.0.0")
809 );
810
811 assert_eq!(
812 entries[1].jar_path,
813 PathBuf::from(
814 "/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
815 )
816 );
817 assert_eq!(
818 entries[1].coordinates.as_deref(),
819 Some("org.slf4j:slf4j-api:2.0.9")
820 );
821 }
822
823 #[test]
824 fn test_parse_classpath_string_empty() {
825 let repo = PathBuf::from("/home/user/.m2/repository");
826 let entries = parse_classpath_string("", &repo);
827 assert!(entries.is_empty());
828 }
829
830 #[test]
831 fn test_parse_classpath_string_single_entry() {
832 let repo = PathBuf::from("/home/user/.m2/repository");
833 let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
834 let entries = parse_classpath_string(classpath, &repo);
835 assert_eq!(entries.len(), 1);
836 assert_eq!(
837 entries[0].coordinates.as_deref(),
838 Some("junit:junit:4.13.2")
839 );
840 }
841
842 #[test]
843 fn test_parse_classpath_non_repo_path() {
844 let repo = PathBuf::from("/home/user/.m2/repository");
845 let classpath = "/opt/custom/lib/some.jar";
846 let entries = parse_classpath_string(classpath, &repo);
847 assert_eq!(entries.len(), 1);
848 assert_eq!(
849 entries[0].jar_path,
850 PathBuf::from("/opt/custom/lib/some.jar")
851 );
852 assert!(entries[0].coordinates.is_none());
853 }
854
855 #[test]
860 fn test_extract_coordinates_guava() {
861 let repo = PathBuf::from("/home/user/.m2/repository");
862 let jar = PathBuf::from(
863 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
864 );
865 let coords = extract_coordinates_from_repo_path(&jar, &repo);
866 assert_eq!(coords.as_deref(), Some("com.google.guava:guava:33.0.0"));
867 }
868
869 #[test]
870 fn test_extract_coordinates_simple_group() {
871 let repo = PathBuf::from("/home/user/.m2/repository");
872 let jar = PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar");
873 let coords = extract_coordinates_from_repo_path(&jar, &repo);
874 assert_eq!(coords.as_deref(), Some("junit:junit:4.13.2"));
875 }
876
877 #[test]
878 fn test_extract_coordinates_outside_repo() {
879 let repo = PathBuf::from("/home/user/.m2/repository");
880 let jar = PathBuf::from("/opt/lib/foo.jar");
881 let coords = extract_coordinates_from_repo_path(&jar, &repo);
882 assert!(coords.is_none());
883 }
884
885 #[test]
886 fn test_extract_coordinates_too_short_path() {
887 let repo = PathBuf::from("/home/user/.m2/repository");
888 let jar = PathBuf::from("/home/user/.m2/repository/foo/bar");
889 let coords = extract_coordinates_from_repo_path(&jar, &repo);
891 assert!(coords.is_none());
892 }
893
894 #[test]
895 fn test_extract_coordinates_deep_group() {
896 let repo = PathBuf::from("/home/user/.m2/repository");
897 let jar = PathBuf::from(
898 "/home/user/.m2/repository/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
899 );
900 let coords = extract_coordinates_from_repo_path(&jar, &repo);
901 assert_eq!(
902 coords.as_deref(),
903 Some("org.apache.commons:commons-lang3:3.14.0")
904 );
905 }
906
907 #[test]
908 fn test_extract_coordinates_repo_root_itself() {
909 let repo = PathBuf::from("/home/user/.m2/repository");
910 let jar = PathBuf::from("/home/user/.m2/repository");
911 let coords = extract_coordinates_from_repo_path(&jar, &repo);
912 assert!(coords.is_none());
913 }
914
915 #[test]
920 fn test_detect_modules_multi() {
921 let tmp = TempDir::new().unwrap();
922 let pom = r#"<?xml version="1.0" encoding="UTF-8"?>
923<project>
924 <modelVersion>4.0.0</modelVersion>
925 <groupId>com.example</groupId>
926 <artifactId>parent</artifactId>
927 <version>1.0.0</version>
928 <packaging>pom</packaging>
929 <modules>
930 <module>core</module>
931 <module>web</module>
932 <module>api</module>
933 </modules>
934</project>"#;
935 let pom_path = tmp.path().join("pom.xml");
936 std::fs::write(&pom_path, pom).unwrap();
937
938 let modules = detect_modules(&pom_path);
939 assert_eq!(modules, vec!["core", "web", "api"]);
940 }
941
942 #[test]
943 fn test_detect_modules_none() {
944 let tmp = TempDir::new().unwrap();
945 let pom = r#"<?xml version="1.0"?>
946<project>
947 <groupId>com.example</groupId>
948 <artifactId>single</artifactId>
949 <version>1.0.0</version>
950</project>"#;
951 let pom_path = tmp.path().join("pom.xml");
952 std::fs::write(&pom_path, pom).unwrap();
953
954 let modules = detect_modules(&pom_path);
955 assert!(modules.is_empty());
956 }
957
958 #[test]
959 fn test_detect_modules_missing_pom() {
960 let modules = detect_modules(Path::new("/nonexistent/pom.xml"));
961 assert!(modules.is_empty());
962 }
963
964 #[test]
965 fn test_detect_modules_whitespace_handling() {
966 let tmp = TempDir::new().unwrap();
967 let pom = r"<project>
968 <modules>
969 <module> core </module>
970 <module>
971 api
972 </module>
973 </modules>
974</project>";
975 let pom_path = tmp.path().join("pom.xml");
976 std::fs::write(&pom_path, pom).unwrap();
977
978 let modules = detect_modules(&pom_path);
979 assert_eq!(modules, vec!["core", "api"]);
980 }
981
982 #[test]
987 fn test_construct_maven_jar_path() {
988 let repo = PathBuf::from("/home/user/.m2/repository");
989 let path = construct_maven_jar_path(&repo, "com.google.guava", "guava", "33.0.0");
990 assert_eq!(
991 path,
992 PathBuf::from(
993 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
994 )
995 );
996 }
997
998 #[test]
999 fn test_construct_maven_jar_path_simple_group() {
1000 let repo = PathBuf::from("/home/user/.m2/repository");
1001 let path = construct_maven_jar_path(&repo, "junit", "junit", "4.13.2");
1002 assert_eq!(
1003 path,
1004 PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar")
1005 );
1006 }
1007
1008 #[test]
1013 fn test_parse_pom_dependencies_basic() {
1014 let pom = r"<project>
1015 <dependencies>
1016 <dependency>
1017 <groupId>com.google.guava</groupId>
1018 <artifactId>guava</artifactId>
1019 <version>33.0.0</version>
1020 </dependency>
1021 <dependency>
1022 <groupId>org.slf4j</groupId>
1023 <artifactId>slf4j-api</artifactId>
1024 <version>2.0.9</version>
1025 </dependency>
1026 </dependencies>
1027</project>";
1028
1029 let deps = parse_pom_dependencies(pom);
1030 assert_eq!(deps.len(), 2);
1031 assert_eq!(deps[0].group_id, "com.google.guava");
1032 assert_eq!(deps[0].artifact_id, "guava");
1033 assert_eq!(deps[0].version.as_deref(), Some("33.0.0"));
1034 assert_eq!(deps[1].group_id, "org.slf4j");
1035 assert_eq!(deps[1].artifact_id, "slf4j-api");
1036 assert_eq!(deps[1].version.as_deref(), Some("2.0.9"));
1037 }
1038
1039 #[test]
1040 fn test_parse_pom_dependencies_skips_test_scope() {
1041 let pom = r"<project>
1042 <dependencies>
1043 <dependency>
1044 <groupId>com.google.guava</groupId>
1045 <artifactId>guava</artifactId>
1046 <version>33.0.0</version>
1047 </dependency>
1048 <dependency>
1049 <groupId>junit</groupId>
1050 <artifactId>junit</artifactId>
1051 <version>4.13.2</version>
1052 <scope>test</scope>
1053 </dependency>
1054 </dependencies>
1055</project>";
1056
1057 let deps = parse_pom_dependencies(pom);
1058 assert_eq!(deps.len(), 1);
1059 assert_eq!(deps[0].artifact_id, "guava");
1060 }
1061
1062 #[test]
1063 fn test_parse_pom_dependencies_skips_property_placeholders() {
1064 let pom = r"<project>
1065 <dependencies>
1066 <dependency>
1067 <groupId>${project.groupId}</groupId>
1068 <artifactId>internal-lib</artifactId>
1069 <version>1.0.0</version>
1070 </dependency>
1071 <dependency>
1072 <groupId>org.example</groupId>
1073 <artifactId>real-dep</artifactId>
1074 <version>2.0.0</version>
1075 </dependency>
1076 </dependencies>
1077</project>";
1078
1079 let deps = parse_pom_dependencies(pom);
1080 assert_eq!(deps.len(), 1);
1081 assert_eq!(deps[0].group_id, "org.example");
1082 }
1083
1084 #[test]
1085 fn test_parse_pom_dependencies_no_version() {
1086 let pom = r"<project>
1087 <dependencies>
1088 <dependency>
1089 <groupId>org.example</groupId>
1090 <artifactId>managed-dep</artifactId>
1091 </dependency>
1092 </dependencies>
1093</project>";
1094
1095 let deps = parse_pom_dependencies(pom);
1096 assert_eq!(deps.len(), 1);
1097 assert!(deps[0].version.is_none());
1098 }
1099
1100 #[test]
1101 fn test_parse_pom_dependencies_with_compile_scope() {
1102 let pom = r"<project>
1103 <dependencies>
1104 <dependency>
1105 <groupId>org.example</groupId>
1106 <artifactId>dep</artifactId>
1107 <version>1.0</version>
1108 <scope>compile</scope>
1109 </dependency>
1110 </dependencies>
1111</project>";
1112
1113 let deps = parse_pom_dependencies(pom);
1114 assert_eq!(deps.len(), 1);
1115 assert_eq!(deps[0].scope.as_deref(), Some("compile"));
1116 }
1117
1118 #[test]
1119 fn test_parse_pom_empty_dependencies() {
1120 let pom = r"<project>
1121 <dependencies>
1122 </dependencies>
1123</project>";
1124
1125 let deps = parse_pom_dependencies(pom);
1126 assert!(deps.is_empty());
1127 }
1128
1129 #[test]
1130 fn test_parse_pom_no_dependencies_element() {
1131 let pom = r"<project>
1132 <groupId>com.example</groupId>
1133</project>";
1134
1135 let deps = parse_pom_dependencies(pom);
1136 assert!(deps.is_empty());
1137 }
1138
1139 #[test]
1144 fn test_parse_classpath_string_trailing_separator() {
1145 let repo = PathBuf::from("/home/user/.m2/repository");
1146 let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar:";
1147 let entries = parse_classpath_string(classpath, &repo);
1148 assert_eq!(entries.len(), 1);
1149 }
1150
1151 #[test]
1152 fn test_parse_classpath_string_leading_separator() {
1153 let repo = PathBuf::from("/home/user/.m2/repository");
1154 let classpath = ":/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
1155 let entries = parse_classpath_string(classpath, &repo);
1156 assert_eq!(entries.len(), 1);
1157 }
1158
1159 #[test]
1160 fn test_parse_classpath_string_double_separator() {
1161 let repo = PathBuf::from("/home/user/.m2/repository");
1162 let classpath = "/a.jar::/b.jar";
1163 let entries = parse_classpath_string(classpath, &repo);
1164 assert_eq!(entries.len(), 2);
1165 }
1166
1167 #[test]
1168 fn test_extract_xml_element_missing() {
1169 let xml = "<dependency><groupId>g</groupId></dependency>";
1170 assert!(extract_xml_element(xml, "artifactId").is_none());
1171 }
1172
1173 #[test]
1174 fn test_extract_xml_element_empty() {
1175 let xml = "<dependency><groupId></groupId></dependency>";
1176 assert!(extract_xml_element(xml, "groupId").is_none());
1177 }
1178
1179 #[test]
1184 fn test_cache_roundtrip() {
1185 let tmp = TempDir::new().unwrap();
1186 let config = ResolveConfig {
1187 project_root: tmp.path().to_path_buf(),
1188 timeout_secs: 60,
1189 cache_path: Some(tmp.path().join("cache")),
1190 };
1191
1192 let entries = vec![ResolvedClasspath {
1193 module_name: "core".to_string(),
1194 module_root: tmp.path().join("core"),
1195 entries: vec![ClasspathEntry {
1196 jar_path: PathBuf::from("/repo/guava/guava/33.0.0/guava-33.0.0.jar"),
1197 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
1198 is_direct: true,
1199 source_jar: None,
1200 }],
1201 }];
1202
1203 write_maven_cache(&config, &entries);
1204 let loaded = read_maven_cache(&config);
1205 assert!(loaded.is_some());
1206 let loaded = loaded.unwrap();
1207 assert_eq!(loaded.len(), 1);
1208 assert_eq!(loaded[0].module_name, "core");
1209 assert_eq!(loaded[0].entries.len(), 1);
1210 assert_eq!(
1211 loaded[0].entries[0].coordinates.as_deref(),
1212 Some("com.google.guava:guava:33.0.0")
1213 );
1214 }
1215
1216 #[test]
1217 fn test_cache_read_missing_returns_none() {
1218 let tmp = TempDir::new().unwrap();
1219 let config = ResolveConfig {
1220 project_root: tmp.path().to_path_buf(),
1221 timeout_secs: 60,
1222 cache_path: Some(tmp.path().join("nonexistent-cache")),
1223 };
1224 assert!(read_maven_cache(&config).is_none());
1225 }
1226
1227 #[test]
1232 fn test_source_jar_found() {
1233 let tmp = TempDir::new().unwrap();
1234 let jar = tmp.path().join("guava-33.0.0.jar");
1235 let source = tmp.path().join("guava-33.0.0-sources.jar");
1236 std::fs::write(&jar, b"").unwrap();
1237 std::fs::write(&source, b"").unwrap();
1238
1239 let result = find_source_jar(&jar);
1240 assert_eq!(result, Some(source));
1241 }
1242
1243 #[test]
1244 fn test_source_jar_not_present() {
1245 let tmp = TempDir::new().unwrap();
1246 let jar = tmp.path().join("guava-33.0.0.jar");
1247 std::fs::write(&jar, b"").unwrap();
1248
1249 let result = find_source_jar(&jar);
1250 assert!(result.is_none());
1251 }
1252
1253 #[test]
1254 fn test_source_jar_non_jar_file() {
1255 let result = find_source_jar(Path::new("/some/file.txt"));
1256 assert!(result.is_none());
1257 }
1258
1259 #[test]
1264 fn test_resolve_from_pom_fallback_with_local_jars() {
1265 let tmp = TempDir::new().unwrap();
1266
1267 let repo = tmp.path().join("repo");
1269 let jar_dir = repo.join("com/example/mylib/1.0.0");
1270 std::fs::create_dir_all(&jar_dir).unwrap();
1271 std::fs::write(jar_dir.join("mylib-1.0.0.jar"), b"fake jar").unwrap();
1272
1273 let pom = r"<project>
1275 <dependencies>
1276 <dependency>
1277 <groupId>com.example</groupId>
1278 <artifactId>mylib</artifactId>
1279 <version>1.0.0</version>
1280 </dependency>
1281 <dependency>
1282 <groupId>com.missing</groupId>
1283 <artifactId>nolib</artifactId>
1284 <version>2.0.0</version>
1285 </dependency>
1286 </dependencies>
1287</project>";
1288 std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
1289
1290 let result = resolve_from_pom_fallback(tmp.path(), "", &repo).unwrap();
1291 assert_eq!(result.entries.len(), 1);
1293 assert_eq!(
1294 result.entries[0].coordinates.as_deref(),
1295 Some("com.example:mylib:1.0.0")
1296 );
1297 }
1298
1299 #[test]
1304 fn test_resolve_maven_classpath_no_pom() {
1305 let tmp = TempDir::new().unwrap();
1306 let config = ResolveConfig {
1307 project_root: tmp.path().to_path_buf(),
1308 timeout_secs: 10,
1309 cache_path: None,
1310 };
1311
1312 let result = resolve_maven_classpath(&config);
1313 assert!(result.is_err());
1314 }
1315
1316 #[test]
1317 fn test_resolve_maven_classpath_errors_when_mvn_missing_and_no_cache() {
1318 let tmp = TempDir::new().unwrap();
1319
1320 let pom = r"<project>
1322 <dependencies>
1323 <dependency>
1324 <groupId>org.example</groupId>
1325 <artifactId>dep</artifactId>
1326 <version>1.0.0</version>
1327 </dependency>
1328 </dependencies>
1329</project>";
1330 std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
1331
1332 let config = ResolveConfig {
1333 project_root: tmp.path().to_path_buf(),
1334 timeout_secs: 5,
1335 cache_path: None,
1336 };
1337
1338 let result = resolve_maven_classpath(&config);
1339 assert!(result.is_err());
1340 }
1341
1342 #[test]
1343 fn test_resolve_maven_classpath_multimodule_errors_without_tooling() {
1344 let tmp = TempDir::new().unwrap();
1345
1346 let root_pom = r"<project>
1348 <modules>
1349 <module>core</module>
1350 <module>web</module>
1351 </modules>
1352</project>";
1353 std::fs::write(tmp.path().join("pom.xml"), root_pom).unwrap();
1354
1355 let core_dir = tmp.path().join("core");
1357 std::fs::create_dir_all(&core_dir).unwrap();
1358 std::fs::write(
1359 core_dir.join("pom.xml"),
1360 r"<project>
1361 <dependencies>
1362 <dependency>
1363 <groupId>org.example</groupId>
1364 <artifactId>core-dep</artifactId>
1365 <version>1.0.0</version>
1366 </dependency>
1367 </dependencies>
1368</project>",
1369 )
1370 .unwrap();
1371
1372 let web_dir = tmp.path().join("web");
1373 std::fs::create_dir_all(&web_dir).unwrap();
1374 std::fs::write(
1375 web_dir.join("pom.xml"),
1376 r"<project>
1377 <dependencies>
1378 <dependency>
1379 <groupId>org.example</groupId>
1380 <artifactId>web-dep</artifactId>
1381 <version>2.0.0</version>
1382 </dependency>
1383 </dependencies>
1384</project>",
1385 )
1386 .unwrap();
1387
1388 let config = ResolveConfig {
1389 project_root: tmp.path().to_path_buf(),
1390 timeout_secs: 5,
1391 cache_path: None,
1392 };
1393
1394 let result = resolve_maven_classpath(&config);
1395 assert!(result.is_err());
1396 }
1397
1398 #[test]
1403 fn test_find_mvn_command_no_wrapper() {
1404 let tmp = TempDir::new().unwrap();
1405 let cmd = find_mvn_command(tmp.path());
1406 assert!(
1407 cmd.is_none() || cmd.as_ref().is_some_and(|path| path.ends_with("mvn")),
1408 "expected no wrapper or mvn path, got: {cmd:?}"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_find_mvn_command_with_wrapper() {
1414 let tmp = TempDir::new().unwrap();
1415 #[cfg(unix)]
1416 let wrapper_name = "mvnw";
1417 #[cfg(windows)]
1418 let wrapper_name = "mvnw.cmd";
1419 std::fs::write(tmp.path().join(wrapper_name), b"#!/bin/sh\nexec mvn \"$@\"").unwrap();
1420
1421 let cmd = find_mvn_command(tmp.path());
1422 assert!(
1423 cmd.as_ref()
1424 .is_some_and(|path| path.ends_with(wrapper_name)),
1425 "Expected wrapper path, got: {cmd:?}"
1426 );
1427 }
1428
1429 #[test]
1434 fn test_parse_pom_dependencies_version_with_property() {
1435 let pom = r"<project>
1436 <dependencies>
1437 <dependency>
1438 <groupId>org.example</groupId>
1439 <artifactId>lib</artifactId>
1440 <version>${lib.version}</version>
1441 </dependency>
1442 </dependencies>
1443</project>";
1444
1445 let deps = parse_pom_dependencies(pom);
1446 assert_eq!(deps.len(), 1);
1449 assert_eq!(deps[0].version.as_deref(), Some("${lib.version}"));
1450 }
1451
1452 #[test]
1453 fn test_parse_pom_dependencies_multiple_scopes() {
1454 let pom = r"<project>
1455 <dependencies>
1456 <dependency>
1457 <groupId>a</groupId>
1458 <artifactId>compile-dep</artifactId>
1459 <version>1.0</version>
1460 <scope>compile</scope>
1461 </dependency>
1462 <dependency>
1463 <groupId>b</groupId>
1464 <artifactId>runtime-dep</artifactId>
1465 <version>1.0</version>
1466 <scope>runtime</scope>
1467 </dependency>
1468 <dependency>
1469 <groupId>c</groupId>
1470 <artifactId>provided-dep</artifactId>
1471 <version>1.0</version>
1472 <scope>provided</scope>
1473 </dependency>
1474 <dependency>
1475 <groupId>d</groupId>
1476 <artifactId>test-dep</artifactId>
1477 <version>1.0</version>
1478 <scope>test</scope>
1479 </dependency>
1480 </dependencies>
1481</project>";
1482
1483 let deps = parse_pom_dependencies(pom);
1484 assert_eq!(deps.len(), 3);
1486 assert_eq!(deps[0].artifact_id, "compile-dep");
1487 assert_eq!(deps[1].artifact_id, "runtime-dep");
1488 assert_eq!(deps[2].artifact_id, "provided-dep");
1489 }
1490}