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