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