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