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
675pub(crate) fn read_cached_classpath(config: &ResolveConfig) -> Option<Vec<ResolvedClasspath>> {
680 read_maven_cache(config).filter(|classpaths| !classpaths.is_empty())
681}
682
683fn read_maven_cache(config: &ResolveConfig) -> Option<Vec<ResolvedClasspath>> {
684 let cache_path = cache_dir(config).join(MAVEN_CACHE_FILE);
685 let data = std::fs::read_to_string(&cache_path).ok()?;
686 serde_json::from_str(&data).ok()
687}
688
689fn cache_dir(config: &ResolveConfig) -> PathBuf {
691 config
692 .cache_path
693 .clone()
694 .unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
695}
696
697#[allow(clippy::unnecessary_wraps)] fn try_cache_or_error(
700 config: &ResolveConfig,
701 module_infos: &[ModuleInfo],
702 live_error: &str,
703) -> ClasspathResult<Vec<ResolvedClasspath>> {
704 if let Some(cached) = read_maven_cache(config) {
706 info!("Using cached Maven classpath ({} modules)", cached.len());
707 warn_if_cache_stale(config, &cached);
708 return Ok(cached);
709 }
710
711 let module_summary = module_infos
712 .iter()
713 .map(|info| {
714 if info.name.is_empty() {
715 info.root.display().to_string()
716 } else {
717 format!("{} ({})", info.name, info.root.display())
718 }
719 })
720 .collect::<Vec<_>>()
721 .join(", ");
722
723 Err(ClasspathError::ResolutionFailed(format!(
724 "{live_error}. No Maven cache available for [{module_summary}]. Provide mvnw, install mvn, or use --classpath-file."
725 )))
726}
727
728fn default_maven_repo() -> PathBuf {
730 #[cfg(unix)]
731 let home = std::env::var_os("HOME").map(PathBuf::from);
732 #[cfg(windows)]
733 let home = std::env::var_os("USERPROFILE").map(PathBuf::from);
734 #[cfg(not(any(unix, windows)))]
735 let home: Option<PathBuf> = None;
736
737 home.map_or_else(
738 || PathBuf::from(".m2").join("repository"),
739 |h| h.join(".m2").join("repository"),
740 )
741}
742
743fn warn_if_cache_stale(config: &ResolveConfig, classpaths: &[ResolvedClasspath]) {
744 if classpaths.is_empty() {
745 return;
746 }
747 let cache_path = cache_dir(config).join(MAVEN_CACHE_FILE);
748 let Ok(cache_meta) = std::fs::metadata(&cache_path) else {
749 return;
750 };
751 let Ok(cache_mtime) = cache_meta.modified() else {
752 return;
753 };
754
755 for cp in classpaths {
756 let pom_path = cp.module_root.join("pom.xml");
757 let Ok(meta) = std::fs::metadata(&pom_path) else {
758 continue;
759 };
760 let Ok(modified) = meta.modified() else {
761 continue;
762 };
763 if modified > cache_mtime {
764 warn!(
765 "Using cached Maven classpath from {} even though {} is newer; cache may be stale",
766 cache_path.display(),
767 pom_path.display()
768 );
769 return;
770 }
771 }
772}
773
774fn which_binary(name: &str) -> Option<PathBuf> {
775 let path_var = std::env::var_os("PATH")?;
776 for dir in std::env::split_paths(&path_var) {
777 let candidate = dir.join(name);
778 if candidate.is_file() {
779 return Some(candidate);
780 }
781 }
782 None
783}
784
785#[cfg(test)]
790mod tests {
791 use super::*;
792 use tempfile::TempDir;
793
794 #[test]
799 fn test_parse_classpath_string_basic() {
800 let repo = PathBuf::from("/home/user/.m2/repository");
801 let classpath = "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar\
802 :/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar";
803
804 let entries = parse_classpath_string(classpath, &repo);
805 assert_eq!(entries.len(), 2);
806
807 assert_eq!(
808 entries[0].jar_path,
809 PathBuf::from(
810 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
811 )
812 );
813 assert_eq!(
814 entries[0].coordinates.as_deref(),
815 Some("com.google.guava:guava:33.0.0")
816 );
817
818 assert_eq!(
819 entries[1].jar_path,
820 PathBuf::from(
821 "/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
822 )
823 );
824 assert_eq!(
825 entries[1].coordinates.as_deref(),
826 Some("org.slf4j:slf4j-api:2.0.9")
827 );
828 }
829
830 #[test]
831 fn test_parse_classpath_string_empty() {
832 let repo = PathBuf::from("/home/user/.m2/repository");
833 let entries = parse_classpath_string("", &repo);
834 assert!(entries.is_empty());
835 }
836
837 #[test]
838 fn test_parse_classpath_string_single_entry() {
839 let repo = PathBuf::from("/home/user/.m2/repository");
840 let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
841 let entries = parse_classpath_string(classpath, &repo);
842 assert_eq!(entries.len(), 1);
843 assert_eq!(
844 entries[0].coordinates.as_deref(),
845 Some("junit:junit:4.13.2")
846 );
847 }
848
849 #[test]
850 fn test_parse_classpath_non_repo_path() {
851 let repo = PathBuf::from("/home/user/.m2/repository");
852 let classpath = "/opt/custom/lib/some.jar";
853 let entries = parse_classpath_string(classpath, &repo);
854 assert_eq!(entries.len(), 1);
855 assert_eq!(
856 entries[0].jar_path,
857 PathBuf::from("/opt/custom/lib/some.jar")
858 );
859 assert!(entries[0].coordinates.is_none());
860 }
861
862 #[test]
867 fn test_extract_coordinates_guava() {
868 let repo = PathBuf::from("/home/user/.m2/repository");
869 let jar = PathBuf::from(
870 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
871 );
872 let coords = extract_coordinates_from_repo_path(&jar, &repo);
873 assert_eq!(coords.as_deref(), Some("com.google.guava:guava:33.0.0"));
874 }
875
876 #[test]
877 fn test_extract_coordinates_simple_group() {
878 let repo = PathBuf::from("/home/user/.m2/repository");
879 let jar = PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar");
880 let coords = extract_coordinates_from_repo_path(&jar, &repo);
881 assert_eq!(coords.as_deref(), Some("junit:junit:4.13.2"));
882 }
883
884 #[test]
885 fn test_extract_coordinates_outside_repo() {
886 let repo = PathBuf::from("/home/user/.m2/repository");
887 let jar = PathBuf::from("/opt/lib/foo.jar");
888 let coords = extract_coordinates_from_repo_path(&jar, &repo);
889 assert!(coords.is_none());
890 }
891
892 #[test]
893 fn test_extract_coordinates_too_short_path() {
894 let repo = PathBuf::from("/home/user/.m2/repository");
895 let jar = PathBuf::from("/home/user/.m2/repository/foo/bar");
896 let coords = extract_coordinates_from_repo_path(&jar, &repo);
898 assert!(coords.is_none());
899 }
900
901 #[test]
902 fn test_extract_coordinates_deep_group() {
903 let repo = PathBuf::from("/home/user/.m2/repository");
904 let jar = PathBuf::from(
905 "/home/user/.m2/repository/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
906 );
907 let coords = extract_coordinates_from_repo_path(&jar, &repo);
908 assert_eq!(
909 coords.as_deref(),
910 Some("org.apache.commons:commons-lang3:3.14.0")
911 );
912 }
913
914 #[test]
915 fn test_extract_coordinates_repo_root_itself() {
916 let repo = PathBuf::from("/home/user/.m2/repository");
917 let jar = PathBuf::from("/home/user/.m2/repository");
918 let coords = extract_coordinates_from_repo_path(&jar, &repo);
919 assert!(coords.is_none());
920 }
921
922 #[test]
927 fn test_detect_modules_multi() {
928 let tmp = TempDir::new().unwrap();
929 let pom = r#"<?xml version="1.0" encoding="UTF-8"?>
930<project>
931 <modelVersion>4.0.0</modelVersion>
932 <groupId>com.example</groupId>
933 <artifactId>parent</artifactId>
934 <version>1.0.0</version>
935 <packaging>pom</packaging>
936 <modules>
937 <module>core</module>
938 <module>web</module>
939 <module>api</module>
940 </modules>
941</project>"#;
942 let pom_path = tmp.path().join("pom.xml");
943 std::fs::write(&pom_path, pom).unwrap();
944
945 let modules = detect_modules(&pom_path);
946 assert_eq!(modules, vec!["core", "web", "api"]);
947 }
948
949 #[test]
950 fn test_detect_modules_none() {
951 let tmp = TempDir::new().unwrap();
952 let pom = r#"<?xml version="1.0"?>
953<project>
954 <groupId>com.example</groupId>
955 <artifactId>single</artifactId>
956 <version>1.0.0</version>
957</project>"#;
958 let pom_path = tmp.path().join("pom.xml");
959 std::fs::write(&pom_path, pom).unwrap();
960
961 let modules = detect_modules(&pom_path);
962 assert!(modules.is_empty());
963 }
964
965 #[test]
966 fn test_detect_modules_missing_pom() {
967 let modules = detect_modules(Path::new("/nonexistent/pom.xml"));
968 assert!(modules.is_empty());
969 }
970
971 #[test]
972 fn test_detect_modules_whitespace_handling() {
973 let tmp = TempDir::new().unwrap();
974 let pom = r"<project>
975 <modules>
976 <module> core </module>
977 <module>
978 api
979 </module>
980 </modules>
981</project>";
982 let pom_path = tmp.path().join("pom.xml");
983 std::fs::write(&pom_path, pom).unwrap();
984
985 let modules = detect_modules(&pom_path);
986 assert_eq!(modules, vec!["core", "api"]);
987 }
988
989 #[test]
994 fn test_construct_maven_jar_path() {
995 let repo = PathBuf::from("/home/user/.m2/repository");
996 let path = construct_maven_jar_path(&repo, "com.google.guava", "guava", "33.0.0");
997 assert_eq!(
998 path,
999 PathBuf::from(
1000 "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
1001 )
1002 );
1003 }
1004
1005 #[test]
1006 fn test_construct_maven_jar_path_simple_group() {
1007 let repo = PathBuf::from("/home/user/.m2/repository");
1008 let path = construct_maven_jar_path(&repo, "junit", "junit", "4.13.2");
1009 assert_eq!(
1010 path,
1011 PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar")
1012 );
1013 }
1014
1015 #[test]
1020 fn test_parse_pom_dependencies_basic() {
1021 let pom = r"<project>
1022 <dependencies>
1023 <dependency>
1024 <groupId>com.google.guava</groupId>
1025 <artifactId>guava</artifactId>
1026 <version>33.0.0</version>
1027 </dependency>
1028 <dependency>
1029 <groupId>org.slf4j</groupId>
1030 <artifactId>slf4j-api</artifactId>
1031 <version>2.0.9</version>
1032 </dependency>
1033 </dependencies>
1034</project>";
1035
1036 let deps = parse_pom_dependencies(pom);
1037 assert_eq!(deps.len(), 2);
1038 assert_eq!(deps[0].group_id, "com.google.guava");
1039 assert_eq!(deps[0].artifact_id, "guava");
1040 assert_eq!(deps[0].version.as_deref(), Some("33.0.0"));
1041 assert_eq!(deps[1].group_id, "org.slf4j");
1042 assert_eq!(deps[1].artifact_id, "slf4j-api");
1043 assert_eq!(deps[1].version.as_deref(), Some("2.0.9"));
1044 }
1045
1046 #[test]
1047 fn test_parse_pom_dependencies_skips_test_scope() {
1048 let pom = r"<project>
1049 <dependencies>
1050 <dependency>
1051 <groupId>com.google.guava</groupId>
1052 <artifactId>guava</artifactId>
1053 <version>33.0.0</version>
1054 </dependency>
1055 <dependency>
1056 <groupId>junit</groupId>
1057 <artifactId>junit</artifactId>
1058 <version>4.13.2</version>
1059 <scope>test</scope>
1060 </dependency>
1061 </dependencies>
1062</project>";
1063
1064 let deps = parse_pom_dependencies(pom);
1065 assert_eq!(deps.len(), 1);
1066 assert_eq!(deps[0].artifact_id, "guava");
1067 }
1068
1069 #[test]
1070 fn test_parse_pom_dependencies_skips_property_placeholders() {
1071 let pom = r"<project>
1072 <dependencies>
1073 <dependency>
1074 <groupId>${project.groupId}</groupId>
1075 <artifactId>internal-lib</artifactId>
1076 <version>1.0.0</version>
1077 </dependency>
1078 <dependency>
1079 <groupId>org.example</groupId>
1080 <artifactId>real-dep</artifactId>
1081 <version>2.0.0</version>
1082 </dependency>
1083 </dependencies>
1084</project>";
1085
1086 let deps = parse_pom_dependencies(pom);
1087 assert_eq!(deps.len(), 1);
1088 assert_eq!(deps[0].group_id, "org.example");
1089 }
1090
1091 #[test]
1092 fn test_parse_pom_dependencies_no_version() {
1093 let pom = r"<project>
1094 <dependencies>
1095 <dependency>
1096 <groupId>org.example</groupId>
1097 <artifactId>managed-dep</artifactId>
1098 </dependency>
1099 </dependencies>
1100</project>";
1101
1102 let deps = parse_pom_dependencies(pom);
1103 assert_eq!(deps.len(), 1);
1104 assert!(deps[0].version.is_none());
1105 }
1106
1107 #[test]
1108 fn test_parse_pom_dependencies_with_compile_scope() {
1109 let pom = r"<project>
1110 <dependencies>
1111 <dependency>
1112 <groupId>org.example</groupId>
1113 <artifactId>dep</artifactId>
1114 <version>1.0</version>
1115 <scope>compile</scope>
1116 </dependency>
1117 </dependencies>
1118</project>";
1119
1120 let deps = parse_pom_dependencies(pom);
1121 assert_eq!(deps.len(), 1);
1122 assert_eq!(deps[0].scope.as_deref(), Some("compile"));
1123 }
1124
1125 #[test]
1126 fn test_parse_pom_empty_dependencies() {
1127 let pom = r"<project>
1128 <dependencies>
1129 </dependencies>
1130</project>";
1131
1132 let deps = parse_pom_dependencies(pom);
1133 assert!(deps.is_empty());
1134 }
1135
1136 #[test]
1137 fn test_parse_pom_no_dependencies_element() {
1138 let pom = r"<project>
1139 <groupId>com.example</groupId>
1140</project>";
1141
1142 let deps = parse_pom_dependencies(pom);
1143 assert!(deps.is_empty());
1144 }
1145
1146 #[test]
1151 fn test_parse_classpath_string_trailing_separator() {
1152 let repo = PathBuf::from("/home/user/.m2/repository");
1153 let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar:";
1154 let entries = parse_classpath_string(classpath, &repo);
1155 assert_eq!(entries.len(), 1);
1156 }
1157
1158 #[test]
1159 fn test_parse_classpath_string_leading_separator() {
1160 let repo = PathBuf::from("/home/user/.m2/repository");
1161 let classpath = ":/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
1162 let entries = parse_classpath_string(classpath, &repo);
1163 assert_eq!(entries.len(), 1);
1164 }
1165
1166 #[test]
1167 fn test_parse_classpath_string_double_separator() {
1168 let repo = PathBuf::from("/home/user/.m2/repository");
1169 let classpath = "/a.jar::/b.jar";
1170 let entries = parse_classpath_string(classpath, &repo);
1171 assert_eq!(entries.len(), 2);
1172 }
1173
1174 #[test]
1175 fn test_extract_xml_element_missing() {
1176 let xml = "<dependency><groupId>g</groupId></dependency>";
1177 assert!(extract_xml_element(xml, "artifactId").is_none());
1178 }
1179
1180 #[test]
1181 fn test_extract_xml_element_empty() {
1182 let xml = "<dependency><groupId></groupId></dependency>";
1183 assert!(extract_xml_element(xml, "groupId").is_none());
1184 }
1185
1186 #[test]
1191 fn test_cache_roundtrip() {
1192 let tmp = TempDir::new().unwrap();
1193 let config = ResolveConfig {
1194 project_root: tmp.path().to_path_buf(),
1195 timeout_secs: 60,
1196 cache_path: Some(tmp.path().join("cache")),
1197 };
1198
1199 let entries = vec![ResolvedClasspath {
1200 module_name: "core".to_string(),
1201 module_root: tmp.path().join("core"),
1202 entries: vec![ClasspathEntry {
1203 jar_path: PathBuf::from("/repo/guava/guava/33.0.0/guava-33.0.0.jar"),
1204 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
1205 is_direct: true,
1206 source_jar: None,
1207 }],
1208 }];
1209
1210 write_maven_cache(&config, &entries);
1211 let loaded = read_maven_cache(&config);
1212 assert!(loaded.is_some());
1213 let loaded = loaded.unwrap();
1214 assert_eq!(loaded.len(), 1);
1215 assert_eq!(loaded[0].module_name, "core");
1216 assert_eq!(loaded[0].entries.len(), 1);
1217 assert_eq!(
1218 loaded[0].entries[0].coordinates.as_deref(),
1219 Some("com.google.guava:guava:33.0.0")
1220 );
1221 }
1222
1223 #[test]
1224 fn test_cache_read_missing_returns_none() {
1225 let tmp = TempDir::new().unwrap();
1226 let config = ResolveConfig {
1227 project_root: tmp.path().to_path_buf(),
1228 timeout_secs: 60,
1229 cache_path: Some(tmp.path().join("nonexistent-cache")),
1230 };
1231 assert!(read_maven_cache(&config).is_none());
1232 }
1233
1234 #[test]
1239 fn test_source_jar_found() {
1240 let tmp = TempDir::new().unwrap();
1241 let jar = tmp.path().join("guava-33.0.0.jar");
1242 let source = tmp.path().join("guava-33.0.0-sources.jar");
1243 std::fs::write(&jar, b"").unwrap();
1244 std::fs::write(&source, b"").unwrap();
1245
1246 let result = find_source_jar(&jar);
1247 assert_eq!(result, Some(source));
1248 }
1249
1250 #[test]
1251 fn test_source_jar_not_present() {
1252 let tmp = TempDir::new().unwrap();
1253 let jar = tmp.path().join("guava-33.0.0.jar");
1254 std::fs::write(&jar, b"").unwrap();
1255
1256 let result = find_source_jar(&jar);
1257 assert!(result.is_none());
1258 }
1259
1260 #[test]
1261 fn test_source_jar_non_jar_file() {
1262 let result = find_source_jar(Path::new("/some/file.txt"));
1263 assert!(result.is_none());
1264 }
1265
1266 #[test]
1271 fn test_resolve_from_pom_fallback_with_local_jars() {
1272 let tmp = TempDir::new().unwrap();
1273
1274 let repo = tmp.path().join("repo");
1276 let jar_dir = repo.join("com/example/mylib/1.0.0");
1277 std::fs::create_dir_all(&jar_dir).unwrap();
1278 std::fs::write(jar_dir.join("mylib-1.0.0.jar"), b"fake jar").unwrap();
1279
1280 let pom = r"<project>
1282 <dependencies>
1283 <dependency>
1284 <groupId>com.example</groupId>
1285 <artifactId>mylib</artifactId>
1286 <version>1.0.0</version>
1287 </dependency>
1288 <dependency>
1289 <groupId>com.missing</groupId>
1290 <artifactId>nolib</artifactId>
1291 <version>2.0.0</version>
1292 </dependency>
1293 </dependencies>
1294</project>";
1295 std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
1296
1297 let result = resolve_from_pom_fallback(tmp.path(), "", &repo).unwrap();
1298 assert_eq!(result.entries.len(), 1);
1300 assert_eq!(
1301 result.entries[0].coordinates.as_deref(),
1302 Some("com.example:mylib:1.0.0")
1303 );
1304 }
1305
1306 #[test]
1311 fn test_resolve_maven_classpath_no_pom() {
1312 let tmp = TempDir::new().unwrap();
1313 let config = ResolveConfig {
1314 project_root: tmp.path().to_path_buf(),
1315 timeout_secs: 10,
1316 cache_path: None,
1317 };
1318
1319 let result = resolve_maven_classpath(&config);
1320 assert!(result.is_err());
1321 }
1322
1323 #[test]
1324 fn test_resolve_maven_classpath_errors_when_mvn_missing_and_no_cache() {
1325 let tmp = TempDir::new().unwrap();
1326
1327 let pom = r"<project>
1329 <dependencies>
1330 <dependency>
1331 <groupId>org.example</groupId>
1332 <artifactId>dep</artifactId>
1333 <version>1.0.0</version>
1334 </dependency>
1335 </dependencies>
1336</project>";
1337 std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
1338
1339 let config = ResolveConfig {
1340 project_root: tmp.path().to_path_buf(),
1341 timeout_secs: 5,
1342 cache_path: None,
1343 };
1344
1345 let result = resolve_maven_classpath(&config);
1346 assert!(result.is_err());
1347 }
1348
1349 #[test]
1350 fn test_resolve_maven_classpath_multimodule_errors_without_tooling() {
1351 let tmp = TempDir::new().unwrap();
1352
1353 let root_pom = r"<project>
1355 <modules>
1356 <module>core</module>
1357 <module>web</module>
1358 </modules>
1359</project>";
1360 std::fs::write(tmp.path().join("pom.xml"), root_pom).unwrap();
1361
1362 let core_dir = tmp.path().join("core");
1364 std::fs::create_dir_all(&core_dir).unwrap();
1365 std::fs::write(
1366 core_dir.join("pom.xml"),
1367 r"<project>
1368 <dependencies>
1369 <dependency>
1370 <groupId>org.example</groupId>
1371 <artifactId>core-dep</artifactId>
1372 <version>1.0.0</version>
1373 </dependency>
1374 </dependencies>
1375</project>",
1376 )
1377 .unwrap();
1378
1379 let web_dir = tmp.path().join("web");
1380 std::fs::create_dir_all(&web_dir).unwrap();
1381 std::fs::write(
1382 web_dir.join("pom.xml"),
1383 r"<project>
1384 <dependencies>
1385 <dependency>
1386 <groupId>org.example</groupId>
1387 <artifactId>web-dep</artifactId>
1388 <version>2.0.0</version>
1389 </dependency>
1390 </dependencies>
1391</project>",
1392 )
1393 .unwrap();
1394
1395 let config = ResolveConfig {
1396 project_root: tmp.path().to_path_buf(),
1397 timeout_secs: 5,
1398 cache_path: None,
1399 };
1400
1401 let result = resolve_maven_classpath(&config);
1402 assert!(result.is_err());
1403 }
1404
1405 #[test]
1410 fn test_find_mvn_command_no_wrapper() {
1411 let tmp = TempDir::new().unwrap();
1412 let cmd = find_mvn_command(tmp.path());
1413 assert!(
1414 cmd.is_none() || cmd.as_ref().is_some_and(|path| path.ends_with("mvn")),
1415 "expected no wrapper or mvn path, got: {cmd:?}"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_find_mvn_command_with_wrapper() {
1421 let tmp = TempDir::new().unwrap();
1422 #[cfg(unix)]
1423 let wrapper_name = "mvnw";
1424 #[cfg(windows)]
1425 let wrapper_name = "mvnw.cmd";
1426 std::fs::write(tmp.path().join(wrapper_name), b"#!/bin/sh\nexec mvn \"$@\"").unwrap();
1427
1428 let cmd = find_mvn_command(tmp.path());
1429 assert!(
1430 cmd.as_ref()
1431 .is_some_and(|path| path.ends_with(wrapper_name)),
1432 "Expected wrapper path, got: {cmd:?}"
1433 );
1434 }
1435
1436 #[test]
1441 fn test_parse_pom_dependencies_version_with_property() {
1442 let pom = r"<project>
1443 <dependencies>
1444 <dependency>
1445 <groupId>org.example</groupId>
1446 <artifactId>lib</artifactId>
1447 <version>${lib.version}</version>
1448 </dependency>
1449 </dependencies>
1450</project>";
1451
1452 let deps = parse_pom_dependencies(pom);
1453 assert_eq!(deps.len(), 1);
1456 assert_eq!(deps[0].version.as_deref(), Some("${lib.version}"));
1457 }
1458
1459 #[test]
1460 fn test_parse_pom_dependencies_multiple_scopes() {
1461 let pom = r"<project>
1462 <dependencies>
1463 <dependency>
1464 <groupId>a</groupId>
1465 <artifactId>compile-dep</artifactId>
1466 <version>1.0</version>
1467 <scope>compile</scope>
1468 </dependency>
1469 <dependency>
1470 <groupId>b</groupId>
1471 <artifactId>runtime-dep</artifactId>
1472 <version>1.0</version>
1473 <scope>runtime</scope>
1474 </dependency>
1475 <dependency>
1476 <groupId>c</groupId>
1477 <artifactId>provided-dep</artifactId>
1478 <version>1.0</version>
1479 <scope>provided</scope>
1480 </dependency>
1481 <dependency>
1482 <groupId>d</groupId>
1483 <artifactId>test-dep</artifactId>
1484 <version>1.0</version>
1485 <scope>test</scope>
1486 </dependency>
1487 </dependencies>
1488</project>";
1489
1490 let deps = parse_pom_dependencies(pom);
1491 assert_eq!(deps.len(), 3);
1493 assert_eq!(deps[0].artifact_id, "compile-dep");
1494 assert_eq!(deps[1].artifact_id, "runtime-dep");
1495 assert_eq!(deps[2].artifact_id, "provided-dep");
1496 }
1497}