Skip to main content

sqry_classpath/resolve/
maven.rs

1//! Maven classpath resolution.
2//!
3//! Resolves classpath JARs from Maven projects via `mvn dependency:build-classpath`
4//! with fallback to pom.xml parsing when Maven is unavailable.
5//!
6//! # Strategy
7//!
8//! 1. Execute `mvn dependency:build-classpath -DincludeScope=compile -Dmdep.outputFile=<temp>`
9//! 2. Parse the output file for JAR paths (colon-separated on Unix, semicolon on Windows)
10//! 3. On failure/timeout, fall back to pom.xml direct parsing (lossy)
11//!
12//! # Multi-module
13//!
14//! Detects child POMs via the `<modules>` element in the root pom.xml and
15//! resolves each module independently.
16
17use 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
28/// Cache file name for Maven-specific resolved classpath.
29const MAVEN_CACHE_FILE: &str = "maven-resolved-classpath.json";
30
31/// Platform-specific path separator for classpath strings.
32#[cfg(unix)]
33const CLASSPATH_SEPARATOR: char = ':';
34
35/// Platform-specific path separator for classpath strings.
36#[cfg(windows)]
37const CLASSPATH_SEPARATOR: char = ';';
38
39/// Resolve classpath for a Maven project.
40///
41/// Strategy:
42/// 1. Execute `mvn dependency:build-classpath -DincludeScope=compile -Dmdep.outputFile=<temp>`
43/// 2. Parse the output file for JAR paths (colon-separated on Unix, semicolon on Windows)
44/// 3. On failure/timeout, fall back to pom.xml direct parsing (lossy)
45///
46/// Multi-module: detects child POMs and resolves per-module.
47#[allow(clippy::missing_errors_doc)] // Internal helper
48pub 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
66/// Resolve a single-module Maven project.
67fn 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
87/// Resolve a multi-module Maven project.
88fn 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
139/// Information about a Maven module.
140struct 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
154/// Resolve a single module by invoking `mvn dependency:build-classpath`.
155fn 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
170/// Resolve a named module by invoking `mvn dependency:build-classpath`.
171fn 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
186/// Execute `mvn dependency:build-classpath` and return the classpath string.
187///
188/// Writes output to a temporary file, reads it back, and cleans up.
189fn 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    // Read the output file.
225    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
235/// Find the Maven command to use.
236///
237/// Prefers `./mvnw` (Maven wrapper) if present, otherwise falls back to `mvn`.
238fn 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
251/// Run a command with a timeout.
252///
253/// Returns the process output or an error if the timeout is exceeded or
254/// the process cannot be spawned (e.g., `mvn` not found).
255fn 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/// Collect stdout and stderr from a finished child process.
293#[allow(clippy::unnecessary_wraps)] // Result for API consistency
294fn 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/// Parse a classpath string (colon-separated on Unix, semicolon on Windows)
314/// into `ClasspathEntry` instances.
315///
316/// Extracts Maven coordinates from the local repository path structure and
317/// checks for corresponding source JARs.
318#[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, // Maven build-classpath does not distinguish; mark all as direct.
335                source_jar,
336            }
337        })
338        .collect()
339}
340
341/// Extract Maven coordinates (`groupId:artifactId:version`) from a path
342/// within the Maven local repository.
343///
344/// Maven stores JARs at:
345/// `~/.m2/repository/<group-path>/<artifact>/<version>/<artifact>-<version>.jar`
346///
347/// Example:
348/// `~/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar`
349/// yields `com.google.guava:guava:33.0.0`.
350#[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    // Check that the JAR is actually within the Maven repository.
356    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    // Minimum: group(1+) / artifact / version / filename = 4 parts
365    if parts.len() < 4 {
366        return None;
367    }
368
369    // Last part is the filename, second-to-last is version, third-to-last is artifact.
370    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
382/// Normalize a path to a forward-slash string for comparison.
383fn normalize_path(p: &Path) -> String {
384    p.to_string_lossy().replace('\\', "/")
385}
386
387/// Look for a source JAR alongside the binary JAR.
388///
389/// Maven source JARs use the pattern: `<artifact>-<version>-sources.jar`
390/// in the same directory as the binary JAR.
391#[allow(clippy::case_sensitive_file_extension_comparisons)] // Known file extensions in domain
392fn 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// ---------------------------------------------------------------------------
410// POM.xml Fallback (Lossy)
411// ---------------------------------------------------------------------------
412
413/// A dependency extracted from pom.xml via simple parsing.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct PomDependency {
416    /// Maven group ID.
417    pub group_id: String,
418    /// Maven artifact ID.
419    pub artifact_id: String,
420    /// Version string, if specified (may be absent when managed by parent).
421    pub version: Option<String>,
422    /// Dependency scope (compile, test, provided, runtime, system).
423    pub scope: Option<String>,
424}
425
426/// Parse dependencies from a pom.xml file using simple string matching.
427///
428/// This is intentionally lossy: it does not resolve properties (`${...}`),
429/// parent POMs, dependency management, or transitive dependencies. It exists
430/// only as a fallback when `mvn` is not available.
431#[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        // Skip property references we cannot resolve.
458        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        // Skip test-scoped dependencies.
466        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
481/// Extract the text content of a simple XML element.
482///
483/// Looks for `<tag>content</tag>` and returns `content` (trimmed).
484/// Does not handle attributes, CDATA, namespaces, or nested elements.
485fn 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/// Resolve classpath from pom.xml fallback (lossy).
500///
501/// Parses pom.xml directly and attempts to locate JARs in the Maven local
502/// repository. This is lossy because it does not resolve:
503/// - Transitive dependencies
504/// - Property placeholders (`${...}`)
505/// - Parent POM inheritance
506/// - Dependency management version overrides
507#[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        // Skip property references we cannot resolve.
540        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/// Construct the expected JAR path in the Maven local repository.
586///
587/// Format: `<repo>/<group-path>/<artifact>/<version>/<artifact>-<version>.jar`
588#[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// ---------------------------------------------------------------------------
604// Multi-module detection
605// ---------------------------------------------------------------------------
606
607/// Detect child modules from a pom.xml's `<modules>` element.
608///
609/// Returns a list of module directory names (relative to the POM's directory).
610#[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    // Find <modules>...</modules> block.
621    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    // Extract each <module>name</module>.
630    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
651// ---------------------------------------------------------------------------
652// Cache helpers
653// ---------------------------------------------------------------------------
654
655/// Write Maven-specific cache.
656fn 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
675/// Read Maven-specific cache.
676fn 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
682/// Compute the cache directory path.
683fn 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/// Try cache first, then POM fallback.
691#[allow(clippy::unnecessary_wraps)] // Result for API consistency
692fn try_cache_or_error(
693    config: &ResolveConfig,
694    module_infos: &[ModuleInfo],
695    live_error: &str,
696) -> ClasspathResult<Vec<ResolvedClasspath>> {
697    // Try cache.
698    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
721/// Get the default Maven local repository path.
722fn 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// ---------------------------------------------------------------------------
779// Tests
780// ---------------------------------------------------------------------------
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use tempfile::TempDir;
786
787    // -----------------------------------------------------------------------
788    // 1. Parse colon-separated classpath output
789    // -----------------------------------------------------------------------
790
791    #[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    // -----------------------------------------------------------------------
856    // 2. Extract coordinates from Maven repo path
857    // -----------------------------------------------------------------------
858
859    #[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        // Only 2 parts (foo/bar), need at least 4: group/artifact/version/file
890        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    // -----------------------------------------------------------------------
916    // 3. Multi-module POM detection
917    // -----------------------------------------------------------------------
918
919    #[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    // -----------------------------------------------------------------------
983    // 4. Offline fallback — construct paths in local repo
984    // -----------------------------------------------------------------------
985
986    #[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    // -----------------------------------------------------------------------
1009    // 5. POM.xml dependency parsing
1010    // -----------------------------------------------------------------------
1011
1012    #[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    // -----------------------------------------------------------------------
1140    // 6. Malformed output handled gracefully
1141    // -----------------------------------------------------------------------
1142
1143    #[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    // -----------------------------------------------------------------------
1180    // 7. Cache roundtrip
1181    // -----------------------------------------------------------------------
1182
1183    #[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    // -----------------------------------------------------------------------
1228    // Source JAR discovery
1229    // -----------------------------------------------------------------------
1230
1231    #[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    // -----------------------------------------------------------------------
1260    // POM fallback integration
1261    // -----------------------------------------------------------------------
1262
1263    #[test]
1264    fn test_resolve_from_pom_fallback_with_local_jars() {
1265        let tmp = TempDir::new().unwrap();
1266
1267        // Set up a fake Maven repo with one JAR.
1268        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        // Create a pom.xml referencing the dependency.
1274        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        // Should find mylib but not nolib.
1292        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    // -----------------------------------------------------------------------
1300    // Integration: resolve_maven_classpath (no real mvn)
1301    // -----------------------------------------------------------------------
1302
1303    #[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        // Create a pom.xml with a dependency.
1321        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        // Create root pom with modules.
1347        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        // Create module directories with their own poms.
1356        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    // -----------------------------------------------------------------------
1399    // Maven wrapper detection
1400    // -----------------------------------------------------------------------
1401
1402    #[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    // -----------------------------------------------------------------------
1430    // POM dependency edge cases
1431    // -----------------------------------------------------------------------
1432
1433    #[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        // version contains ${...} but groupId/artifactId are clean,
1447        // so the dependency should be included.
1448        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        // test scope is skipped, others are included.
1485        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}