Skip to main content

sqry_classpath/resolve/
sbt.rs

1//! sbt classpath resolver.
2//!
3//! Resolves JVM classpath entries from sbt projects by:
4//! 1. Executing `sbt -no-colors "print dependencyClasspath"` to get runtime classpath
5//! 2. Parsing the output for JAR file paths (supports both `Attributed(...)` and
6//!    colon-separated formats)
7//! 3. Extracting Maven coordinates from Coursier cache paths
8//! 4. Looking up source JARs in the Coursier cache
9//! 5. Falling back to cached classpath on failure
10
11use std::io::BufRead;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::time::Duration;
15
16use log::{debug, info, warn};
17
18use crate::{ClasspathError, ClasspathResult};
19
20use super::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
21
22/// Default Coursier cache directory (relative to user home).
23const COURSIER_CACHE_REL: &str = ".cache/coursier/v1";
24
25// ── Public API ──────────────────────────────────────────────────────────────
26
27/// Resolve classpath for an sbt project.
28///
29/// Strategy:
30/// 1. Execute `sbt -no-colors "print dependencyClasspath"`
31/// 2. Parse output for JAR paths
32/// 3. On failure, fall back to Coursier cache scanning
33#[allow(clippy::missing_errors_doc)] // Internal helper
34pub fn resolve_sbt_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
35    info!(
36        "Resolving sbt classpath in {}",
37        config.project_root.display()
38    );
39
40    match run_sbt_dependency_classpath(config) {
41        Ok(jar_paths) => {
42            info!("sbt returned {} JAR paths", jar_paths.len());
43            let entries = build_entries(&jar_paths);
44            let resolved = ResolvedClasspath {
45                module_name: infer_module_name(&config.project_root),
46                module_root: config.project_root.clone(),
47                entries,
48            };
49            Ok(vec![resolved])
50        }
51        Err(e) => {
52            warn!("sbt resolution failed: {e}. Attempting cache fallback.");
53            try_cache_fallback(config, &e)
54        }
55    }
56}
57
58// ── sbt execution ───────────────────────────────────────────────────────────
59
60/// Run `sbt -no-colors "print dependencyClasspath"` and parse JAR paths from output.
61fn run_sbt_dependency_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<PathBuf>> {
62    let sbt_bin = find_sbt_binary()?;
63
64    let mut cmd = Command::new(&sbt_bin);
65    cmd.arg("-no-colors")
66        .arg("print dependencyClasspath")
67        .current_dir(&config.project_root)
68        .stderr(std::process::Stdio::null());
69
70    debug!(
71        "Running: {} -no-colors \"print dependencyClasspath\"",
72        sbt_bin.display()
73    );
74
75    let output = run_command_with_timeout(&mut cmd, config.timeout_secs)?;
76
77    if !output.status.success() {
78        return Err(ClasspathError::ResolutionFailed(format!(
79            "sbt exited with status {}",
80            output.status
81        )));
82    }
83
84    let jars = parse_sbt_output(&output.stdout);
85    Ok(jars)
86}
87
88/// Locate the `sbt` binary on `$PATH`.
89fn find_sbt_binary() -> ClasspathResult<PathBuf> {
90    which_binary("sbt").ok_or_else(|| {
91        ClasspathError::ResolutionFailed(
92            "sbt binary not found on PATH. Install sbt to resolve classpath.".to_string(),
93        )
94    })
95}
96
97/// Parse sbt `print dependencyClasspath` output.
98///
99/// sbt outputs classpath entries in one of these formats:
100///
101/// **Attributed format** (older sbt versions):
102/// ```text
103/// List(Attributed(/path/to/jar1.jar), Attributed(/path/to/jar2.jar))
104/// ```
105///
106/// **Colon-separated format** (newer sbt versions):
107/// ```text
108/// /path/to/jar1.jar:/path/to/jar2.jar
109/// ```
110///
111/// **One-per-line format** (some sbt plugins):
112/// ```text
113/// /path/to/jar1.jar
114/// /path/to/jar2.jar
115/// ```
116///
117/// We handle all three formats, filtering to `.jar` files only.
118#[allow(clippy::manual_let_else)] // Match for error handling clarity
119fn parse_sbt_output(stdout: &[u8]) -> Vec<PathBuf> {
120    let mut jars = Vec::new();
121
122    for line in stdout.lines() {
123        let line = match line {
124            Ok(l) => l,
125            Err(_) => continue,
126        };
127        let trimmed = line.trim();
128        if trimmed.is_empty() {
129            continue;
130        }
131
132        // Skip sbt log/info lines (e.g., "[info] ...", "[success] ...").
133        if is_sbt_log_line(trimmed) {
134            continue;
135        }
136
137        // Try Attributed format first.
138        if trimmed.starts_with("List(") || trimmed.contains("Attributed(") {
139            jars.extend(parse_attributed_format(trimmed));
140            continue;
141        }
142
143        // Try colon-separated format (only if line contains ':' and paths).
144        if trimmed.contains(':') && trimmed.contains(".jar") {
145            jars.extend(parse_colon_separated(trimmed));
146            continue;
147        }
148
149        // One-per-line format: single path per line.
150        if is_jar_path(trimmed) {
151            jars.push(PathBuf::from(trimmed));
152        }
153    }
154
155    jars
156}
157
158/// Parse `Attributed(...)` entries from a line.
159///
160/// Input: `List(Attributed(/path/to/a.jar), Attributed(/path/to/b.jar))`
161/// Output: `["/path/to/a.jar", "/path/to/b.jar"]`
162fn parse_attributed_format(line: &str) -> Vec<PathBuf> {
163    let mut results = Vec::new();
164    let mut search_from = 0;
165
166    while let Some(start) = line[search_from..].find("Attributed(") {
167        let abs_start = search_from + start + "Attributed(".len();
168        if let Some(end) = line[abs_start..].find(')') {
169            let path_str = line[abs_start..abs_start + end].trim();
170            if is_jar_path(path_str) {
171                results.push(PathBuf::from(path_str));
172            }
173            search_from = abs_start + end + 1;
174        } else {
175            break;
176        }
177    }
178
179    results
180}
181
182/// Parse colon-separated classpath entries.
183///
184/// Input: `/path/to/a.jar:/path/to/b.jar:/path/to/c.jar`
185fn parse_colon_separated(line: &str) -> Vec<PathBuf> {
186    line.split(':')
187        .map(str::trim)
188        .filter(|s| is_jar_path(s))
189        .map(PathBuf::from)
190        .collect()
191}
192
193/// Check whether a string looks like a JAR file path.
194fn is_jar_path(s: &str) -> bool {
195    !s.is_empty() && s.to_ascii_lowercase().ends_with(".jar")
196}
197
198/// Check whether a line is an sbt log/info/warning line that should be skipped.
199fn is_sbt_log_line(line: &str) -> bool {
200    line.starts_with("[info]")
201        || line.starts_with("[warn]")
202        || line.starts_with("[error]")
203        || line.starts_with("[success]")
204        || line.starts_with("[debug]")
205}
206
207// ── Coordinate extraction ───────────────────────────────────────────────────
208
209/// Parse Maven coordinates from a Coursier cache path.
210///
211/// Coursier cache paths follow the pattern:
212/// `~/.cache/coursier/v1/https/repo1.maven.org/maven2/<group-path>/<artifact>/<version>/<artifact>-<version>.jar`
213///
214/// We extract `group:artifact:version` from this structure.
215fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
216    let path_str = jar_path.to_str()?;
217
218    // Look for the `/maven2/` segment that precedes the Maven layout.
219    let maven2_idx = path_str.find("/maven2/")?;
220    let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
221
222    // Split into path components.
223    let components: Vec<&str> = after_maven2.split('/').collect();
224    // Need at least: group-parts... / artifact / version / filename
225    if components.len() < 3 {
226        return None;
227    }
228
229    let filename = *components.last()?;
230    let version = components[components.len() - 2];
231    let artifact = components[components.len() - 3];
232    let group_parts = &components[..components.len() - 3];
233
234    if group_parts.is_empty() {
235        return None;
236    }
237
238    // Verify filename matches expected pattern.
239    let expected_prefix = format!("{artifact}-{version}");
240    if !filename.starts_with(&expected_prefix) {
241        return None;
242    }
243
244    let group = group_parts.join(".");
245    Some(format!("{group}:{artifact}:{version}"))
246}
247
248// ── Entry construction ──────────────────────────────────────────────────────
249
250/// Build `ClasspathEntry` records from JAR paths, enriching with coordinates
251/// and source JAR locations where possible.
252fn build_entries(jar_paths: &[PathBuf]) -> Vec<ClasspathEntry> {
253    jar_paths
254        .iter()
255        .map(|jar_path| {
256            let coordinates = parse_coursier_coordinates(jar_path);
257            let source_jar = find_source_jar(jar_path);
258
259            ClasspathEntry {
260                jar_path: jar_path.clone(),
261                coordinates,
262                is_direct: false, // sbt dependencyClasspath returns the full transitive closure.
263                source_jar,
264            }
265        })
266        .collect()
267}
268
269/// Find a source JAR alongside a main JAR.
270///
271/// Looks for `<stem>-sources.jar` in the same directory.
272fn find_source_jar(jar_path: &Path) -> Option<PathBuf> {
273    let stem = jar_path.file_stem()?.to_string_lossy();
274    let parent = jar_path.parent()?;
275
276    // Try `<stem>-sources.jar` in the same directory.
277    let sources_jar = parent.join(format!("{stem}-sources.jar"));
278    if sources_jar.exists() {
279        return Some(sources_jar);
280    }
281
282    // Try Coursier-style: derive `-sources.jar` path.
283    if let Some(coursier_sources) = derive_coursier_source_jar(jar_path)
284        && coursier_sources.exists()
285    {
286        return Some(coursier_sources);
287    }
288
289    None
290}
291
292/// Derive the Coursier cache path for a source JAR given the main JAR path.
293#[allow(clippy::case_sensitive_file_extension_comparisons)] // Known file extensions
294fn derive_coursier_source_jar(jar_path: &Path) -> Option<PathBuf> {
295    let path_str = jar_path.to_str()?;
296    if path_str.ends_with(".jar") && !path_str.ends_with("-sources.jar") {
297        let sources_path = format!("{}-sources.jar", &path_str[..path_str.len() - 4]);
298        Some(PathBuf::from(sources_path))
299    } else {
300        None
301    }
302}
303
304// ── Cache fallback ──────────────────────────────────────────────────────────
305
306/// Attempt to load a previously cached classpath when live resolution fails.
307fn try_cache_fallback(
308    config: &ResolveConfig,
309    original_error: &ClasspathError,
310) -> ClasspathResult<Vec<ResolvedClasspath>> {
311    if let Some(ref cache_path) = config.cache_path {
312        if cache_path.exists() {
313            info!("Loading cached classpath from {}", cache_path.display());
314            let content = std::fs::read_to_string(cache_path).map_err(|e| {
315                ClasspathError::CacheError(format!(
316                    "Failed to read cache file {}: {e}",
317                    cache_path.display()
318                ))
319            })?;
320            let cached: Vec<ResolvedClasspath> = serde_json::from_str(&content).map_err(|e| {
321                ClasspathError::CacheError(format!(
322                    "Failed to parse cache file {}: {e}",
323                    cache_path.display()
324                ))
325            })?;
326            return Ok(cached);
327        }
328        warn!(
329            "Cache file {} does not exist; cannot fall back",
330            cache_path.display()
331        );
332    }
333
334    Err(ClasspathError::ResolutionFailed(format!(
335        "sbt resolution failed and no cache available. Original error: {original_error}"
336    )))
337}
338
339// ── Utility functions ───────────────────────────────────────────────────────
340
341/// Find a binary on `$PATH` using `which`-style lookup.
342fn which_binary(name: &str) -> Option<PathBuf> {
343    let path_var = std::env::var_os("PATH")?;
344    for dir in std::env::split_paths(&path_var) {
345        let candidate = dir.join(name);
346        if candidate.is_file() {
347            return Some(candidate);
348        }
349    }
350    None
351}
352
353/// Run a command with a timeout, returning its output.
354fn run_command_with_timeout(
355    cmd: &mut Command,
356    timeout_secs: u64,
357) -> ClasspathResult<std::process::Output> {
358    let mut child = cmd
359        .stdout(std::process::Stdio::piped())
360        .spawn()
361        .map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn command: {e}")))?;
362
363    let timeout = Duration::from_secs(timeout_secs);
364
365    let start = std::time::Instant::now();
366    loop {
367        match child.try_wait() {
368            Ok(Some(_status)) => {
369                return child.wait_with_output().map_err(|e| {
370                    ClasspathError::ResolutionFailed(format!("Failed to collect output: {e}"))
371                });
372            }
373            Ok(None) => {
374                if start.elapsed() >= timeout {
375                    let _ = child.kill();
376                    let _ = child.wait();
377                    return Err(ClasspathError::ResolutionFailed(format!(
378                        "Command timed out after {timeout_secs}s"
379                    )));
380                }
381                std::thread::sleep(Duration::from_millis(100));
382            }
383            Err(e) => {
384                return Err(ClasspathError::ResolutionFailed(format!(
385                    "Failed to check process status: {e}"
386                )));
387            }
388        }
389    }
390}
391
392/// Infer a module name from the project root directory name.
393fn infer_module_name(project_root: &Path) -> String {
394    project_root
395        .file_name()
396        .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string())
397}
398
399/// Return the default Coursier cache directory.
400#[allow(dead_code)]
401fn coursier_cache_dir() -> Option<PathBuf> {
402    dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
403}
404
405/// Get the user's home directory.
406fn dirs_path_home() -> Option<PathBuf> {
407    std::env::var_os("HOME").map(PathBuf::from)
408}
409
410// ── Tests ───────────────────────────────────────────────────────────────────
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use tempfile::TempDir;
416
417    // ── Test: parse sbt Attributed output ───────────────────────────────
418
419    #[test]
420    fn test_parse_attributed_format() {
421        let line =
422            "List(Attributed(/path/to/guava-33.0.0.jar), Attributed(/path/to/slf4j-api-2.0.9.jar))";
423        let result = parse_attributed_format(line);
424        assert_eq!(result.len(), 2);
425        assert_eq!(result[0], PathBuf::from("/path/to/guava-33.0.0.jar"));
426        assert_eq!(result[1], PathBuf::from("/path/to/slf4j-api-2.0.9.jar"));
427    }
428
429    #[test]
430    fn test_parse_attributed_format_single() {
431        let line = "List(Attributed(/only/one.jar))";
432        let result = parse_attributed_format(line);
433        assert_eq!(result.len(), 1);
434        assert_eq!(result[0], PathBuf::from("/only/one.jar"));
435    }
436
437    #[test]
438    fn test_parse_attributed_format_filters_non_jar() {
439        let line = "List(Attributed(/path/to/classes), Attributed(/path/to/real.jar))";
440        let result = parse_attributed_format(line);
441        assert_eq!(result.len(), 1);
442        assert_eq!(result[0], PathBuf::from("/path/to/real.jar"));
443    }
444
445    // ── Test: parse sbt colon-separated output ──────────────────────────
446
447    #[test]
448    fn test_parse_colon_separated() {
449        let line = "/path/to/a.jar:/path/to/b.jar:/path/to/c.jar";
450        let result = parse_colon_separated(line);
451        assert_eq!(result.len(), 3);
452        assert_eq!(result[0], PathBuf::from("/path/to/a.jar"));
453        assert_eq!(result[1], PathBuf::from("/path/to/b.jar"));
454        assert_eq!(result[2], PathBuf::from("/path/to/c.jar"));
455    }
456
457    #[test]
458    fn test_parse_colon_separated_filters_non_jar() {
459        let line = "/path/to/a.jar:/path/to/classes:/path/to/b.jar";
460        let result = parse_colon_separated(line);
461        assert_eq!(result.len(), 2);
462    }
463
464    // ── Test: parse full sbt output ─────────────────────────────────────
465
466    #[test]
467    fn test_parse_sbt_output_attributed() {
468        let output = b"\
469[info] Loading settings for project root from build.sbt ...
470[info] Set current project to myproject
471List(Attributed(/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar), Attributed(/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar))
472[success] Total time: 1 s
473";
474        let result = parse_sbt_output(output);
475        assert_eq!(result.len(), 2);
476        assert!(result[0].to_str().unwrap().contains("guava-33.0.0.jar"));
477        assert!(result[1].to_str().unwrap().contains("slf4j-api-2.0.9.jar"));
478    }
479
480    #[test]
481    fn test_parse_sbt_output_colon_separated() {
482        let output = b"\
483[info] Loading project definition
484/path/to/a.jar:/path/to/b.jar
485[success] Done
486";
487        let result = parse_sbt_output(output);
488        assert_eq!(result.len(), 2);
489    }
490
491    #[test]
492    fn test_parse_sbt_output_one_per_line() {
493        let output = b"\
494/path/to/a.jar
495/path/to/b.jar
496/path/to/c.jar
497";
498        let result = parse_sbt_output(output);
499        assert_eq!(result.len(), 3);
500    }
501
502    #[test]
503    fn test_parse_sbt_output_empty() {
504        let result = parse_sbt_output(b"");
505        assert!(result.is_empty());
506    }
507
508    #[test]
509    fn test_parse_sbt_output_only_log_lines() {
510        let output = b"\
511[info] Loading settings
512[info] Set current project
513[success] Total time: 0 s
514";
515        let result = parse_sbt_output(output);
516        assert!(result.is_empty());
517    }
518
519    // ── Test: sbt log line detection ────────────────────────────────────
520
521    #[test]
522    fn test_is_sbt_log_line() {
523        assert!(is_sbt_log_line("[info] Loading settings"));
524        assert!(is_sbt_log_line("[warn] Deprecated API"));
525        assert!(is_sbt_log_line("[error] Compilation failed"));
526        assert!(is_sbt_log_line("[success] Total time: 1 s"));
527        assert!(is_sbt_log_line("[debug] Resolving dependencies"));
528        assert!(!is_sbt_log_line("/path/to/jar.jar"));
529        assert!(!is_sbt_log_line("List(Attributed(/path.jar))"));
530    }
531
532    // ── Test: Coursier coordinate extraction ────────────────────────────
533
534    #[test]
535    fn test_parse_coursier_coordinates() {
536        let path = PathBuf::from(
537            "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
538        );
539        let coords = parse_coursier_coordinates(&path);
540        assert_eq!(coords, Some("com.google.guava:guava:33.0.0".to_string()));
541    }
542
543    #[test]
544    fn test_parse_coursier_coordinates_scala_library() {
545        let path = PathBuf::from(
546            "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/org/scala-lang/scala-library/2.13.12/scala-library-2.13.12.jar",
547        );
548        let coords = parse_coursier_coordinates(&path);
549        assert_eq!(
550            coords,
551            Some("org.scala-lang:scala-library:2.13.12".to_string())
552        );
553    }
554
555    #[test]
556    fn test_parse_coursier_coordinates_not_coursier() {
557        let path = PathBuf::from("/usr/local/lib/some.jar");
558        assert_eq!(parse_coursier_coordinates(&path), None);
559    }
560
561    // ── Test: missing sbt binary ────────────────────────────────────────
562
563    #[test]
564    fn test_missing_sbt_binary_error() {
565        let tmp = TempDir::new().unwrap();
566        let original_path = std::env::var_os("PATH");
567
568        // SAFETY: This test is not run in parallel with other tests that depend
569        // on PATH. We restore the original value immediately after the check.
570        unsafe { std::env::set_var("PATH", tmp.path()) };
571        let result = find_sbt_binary();
572        if let Some(p) = original_path {
573            unsafe { std::env::set_var("PATH", p) };
574        }
575
576        assert!(result.is_err());
577        let err_msg = result.unwrap_err().to_string();
578        assert!(
579            err_msg.contains("not found"),
580            "Error should mention 'not found': {err_msg}"
581        );
582    }
583
584    // ── Test: resolve with no sbt and no cache ──────────────────────────
585
586    #[test]
587    fn test_resolve_no_sbt_no_cache_returns_error() {
588        let tmp = TempDir::new().unwrap();
589        let config = ResolveConfig {
590            project_root: tmp.path().to_path_buf(),
591            timeout_secs: 5,
592            cache_path: None,
593        };
594
595        let result = resolve_sbt_classpath(&config);
596        assert!(result.is_err());
597    }
598
599    // ── Test: cache fallback ────────────────────────────────────────────
600
601    #[test]
602    fn test_cache_fallback_loads_cached_classpath() {
603        let tmp = TempDir::new().unwrap();
604        let cache_path = tmp.path().join("classpath_cache.json");
605
606        let cached = vec![ResolvedClasspath {
607            module_name: "cached-scala-project".to_string(),
608            module_root: tmp.path().to_path_buf(),
609            entries: vec![ClasspathEntry {
610                jar_path: PathBuf::from("/cached/scala-library.jar"),
611                coordinates: Some("org.scala-lang:scala-library:2.13.12".to_string()),
612                is_direct: false,
613                source_jar: None,
614            }],
615        }];
616        std::fs::write(&cache_path, serde_json::to_string(&cached).unwrap()).unwrap();
617
618        let original_error = ClasspathError::ResolutionFailed("sbt not found".to_string());
619        let config = ResolveConfig {
620            project_root: tmp.path().to_path_buf(),
621            timeout_secs: 5,
622            cache_path: Some(cache_path),
623        };
624
625        let result = try_cache_fallback(&config, &original_error);
626        assert!(result.is_ok());
627        let resolved = result.unwrap();
628        assert_eq!(resolved.len(), 1);
629        assert_eq!(resolved[0].module_name, "cached-scala-project");
630        assert_eq!(resolved[0].entries.len(), 1);
631    }
632
633    #[test]
634    fn test_cache_fallback_missing_cache_file() {
635        let tmp = TempDir::new().unwrap();
636        let cache_path = tmp.path().join("nonexistent.json");
637        let original_error = ClasspathError::ResolutionFailed("sbt not found".to_string());
638        let config = ResolveConfig {
639            project_root: tmp.path().to_path_buf(),
640            timeout_secs: 5,
641            cache_path: Some(cache_path),
642        };
643
644        let result = try_cache_fallback(&config, &original_error);
645        assert!(result.is_err());
646    }
647
648    #[test]
649    fn test_cache_fallback_no_cache_configured() {
650        let original_error = ClasspathError::ResolutionFailed("sbt not found".to_string());
651        let config = ResolveConfig {
652            project_root: PathBuf::from("/tmp"),
653            timeout_secs: 5,
654            cache_path: None,
655        };
656
657        let result = try_cache_fallback(&config, &original_error);
658        assert!(result.is_err());
659        let err_msg = result.unwrap_err().to_string();
660        assert!(err_msg.contains("no cache available"));
661    }
662
663    // ── Test: source JAR discovery ──────────────────────────────────────
664
665    #[test]
666    fn test_find_source_jar_same_directory() {
667        let tmp = TempDir::new().unwrap();
668        let main_jar = tmp.path().join("scala-library-2.13.12.jar");
669        let sources_jar = tmp.path().join("scala-library-2.13.12-sources.jar");
670        std::fs::write(&main_jar, b"").unwrap();
671        std::fs::write(&sources_jar, b"").unwrap();
672
673        let result = find_source_jar(&main_jar);
674        assert_eq!(result, Some(sources_jar));
675    }
676
677    #[test]
678    fn test_find_source_jar_not_present() {
679        let tmp = TempDir::new().unwrap();
680        let main_jar = tmp.path().join("scala-library-2.13.12.jar");
681        std::fs::write(&main_jar, b"").unwrap();
682
683        let result = find_source_jar(&main_jar);
684        assert_eq!(result, None);
685    }
686
687    // ── Test: build_entries enrichment ───────────────────────────────────
688
689    #[test]
690    fn test_build_entries_with_coursier_path() {
691        let jar_paths = vec![
692            PathBuf::from(
693                "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
694            ),
695            PathBuf::from("/some/local/path/unknown.jar"),
696        ];
697
698        let entries = build_entries(&jar_paths);
699        assert_eq!(entries.len(), 2);
700        assert_eq!(
701            entries[0].coordinates,
702            Some("com.google.guava:guava:33.0.0".to_string())
703        );
704        assert_eq!(entries[1].coordinates, None);
705        assert!(!entries[0].is_direct);
706        assert!(!entries[1].is_direct);
707    }
708
709    // ── Test: infer_module_name ─────────────────────────────────────────
710
711    #[test]
712    fn test_infer_module_name() {
713        assert_eq!(
714            infer_module_name(Path::new("/home/user/my-scala-project")),
715            "my-scala-project"
716        );
717        assert_eq!(infer_module_name(Path::new("/")), "root");
718    }
719
720    // ── Test: derive_coursier_source_jar ────────────────────────────────
721
722    #[test]
723    fn test_derive_coursier_source_jar() {
724        let jar = PathBuf::from("/cache/v1/scala-library-2.13.12.jar");
725        let result = derive_coursier_source_jar(&jar);
726        assert_eq!(
727            result,
728            Some(PathBuf::from("/cache/v1/scala-library-2.13.12-sources.jar"))
729        );
730    }
731
732    #[test]
733    fn test_derive_coursier_source_jar_already_sources() {
734        let jar = PathBuf::from("/cache/v1/scala-library-2.13.12-sources.jar");
735        let result = derive_coursier_source_jar(&jar);
736        assert_eq!(result, None);
737    }
738}