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.
676/// Read a previously cached Maven classpath without invoking Maven. Returns
677/// `None` when no usable cache is present. Used when build-tool execution is
678/// disabled (`--no-build-tool`).
679pub(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
689/// Compute the cache directory path.
690fn 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/// Try cache first, then POM fallback.
698#[allow(clippy::unnecessary_wraps)] // Result for API consistency
699fn try_cache_or_error(
700    config: &ResolveConfig,
701    module_infos: &[ModuleInfo],
702    live_error: &str,
703) -> ClasspathResult<Vec<ResolvedClasspath>> {
704    // Try cache.
705    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
728/// Get the default Maven local repository path.
729fn 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// ---------------------------------------------------------------------------
786// Tests
787// ---------------------------------------------------------------------------
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use tempfile::TempDir;
793
794    // -----------------------------------------------------------------------
795    // 1. Parse colon-separated classpath output
796    // -----------------------------------------------------------------------
797
798    #[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    // -----------------------------------------------------------------------
863    // 2. Extract coordinates from Maven repo path
864    // -----------------------------------------------------------------------
865
866    #[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        // Only 2 parts (foo/bar), need at least 4: group/artifact/version/file
897        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    // -----------------------------------------------------------------------
923    // 3. Multi-module POM detection
924    // -----------------------------------------------------------------------
925
926    #[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    // -----------------------------------------------------------------------
990    // 4. Offline fallback — construct paths in local repo
991    // -----------------------------------------------------------------------
992
993    #[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    // -----------------------------------------------------------------------
1016    // 5. POM.xml dependency parsing
1017    // -----------------------------------------------------------------------
1018
1019    #[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    // -----------------------------------------------------------------------
1147    // 6. Malformed output handled gracefully
1148    // -----------------------------------------------------------------------
1149
1150    #[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    // -----------------------------------------------------------------------
1187    // 7. Cache roundtrip
1188    // -----------------------------------------------------------------------
1189
1190    #[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    // -----------------------------------------------------------------------
1235    // Source JAR discovery
1236    // -----------------------------------------------------------------------
1237
1238    #[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    // -----------------------------------------------------------------------
1267    // POM fallback integration
1268    // -----------------------------------------------------------------------
1269
1270    #[test]
1271    fn test_resolve_from_pom_fallback_with_local_jars() {
1272        let tmp = TempDir::new().unwrap();
1273
1274        // Set up a fake Maven repo with one JAR.
1275        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        // Create a pom.xml referencing the dependency.
1281        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        // Should find mylib but not nolib.
1299        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    // -----------------------------------------------------------------------
1307    // Integration: resolve_maven_classpath (no real mvn)
1308    // -----------------------------------------------------------------------
1309
1310    #[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        // Create a pom.xml with a dependency.
1328        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        // Create root pom with modules.
1354        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        // Create module directories with their own poms.
1363        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    // -----------------------------------------------------------------------
1406    // Maven wrapper detection
1407    // -----------------------------------------------------------------------
1408
1409    #[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    // -----------------------------------------------------------------------
1437    // POM dependency edge cases
1438    // -----------------------------------------------------------------------
1439
1440    #[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        // version contains ${...} but groupId/artifactId are clean,
1454        // so the dependency should be included.
1455        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        // test scope is skipped, others are included.
1492        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}