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