1use 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
24const COURSIER_CACHE_REL: &str = ".cache/coursier/v1";
26
27#[allow(clippy::missing_errors_doc)] pub 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
60fn 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
90fn 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#[allow(clippy::manual_let_else)] fn 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 if is_sbt_log_line(trimmed) {
136 continue;
137 }
138
139 if trimmed.starts_with("List(") || trimmed.contains("Attributed(") {
141 jars.extend(parse_attributed_format(trimmed));
142 continue;
143 }
144
145 if trimmed.contains(':') && trimmed.contains(".jar") {
147 jars.extend(parse_colon_separated(trimmed));
148 continue;
149 }
150
151 if is_jar_path(trimmed) {
153 jars.push(PathBuf::from(trimmed));
154 }
155 }
156
157 jars
158}
159
160fn 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
184fn 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
195fn is_jar_path(s: &str) -> bool {
197 !s.is_empty() && s.to_ascii_lowercase().ends_with(".jar")
198}
199
200fn 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
209fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
218 let path_str = jar_path.to_str()?;
219
220 let maven2_idx = path_str.find("/maven2/")?;
222 let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
223
224 let components: Vec<&str> = after_maven2.split('/').collect();
226 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 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
250fn 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, source_jar,
266 }
267 })
268 .collect()
269}
270
271fn 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 let sources_jar = parent.join(format!("{stem}-sources.jar"));
280 if sources_jar.exists() {
281 return Some(sources_jar);
282 }
283
284 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#[allow(clippy::case_sensitive_file_extension_comparisons)] fn 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
306fn 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
346fn 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
360fn 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
399fn 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#[allow(dead_code)]
408fn coursier_cache_dir() -> Option<PathBuf> {
409 dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
410}
411
412fn dirs_path_home() -> Option<PathBuf> {
414 std::env::var_os("HOME").map(PathBuf::from)
415}
416
417#[cfg(test)]
420mod tests {
421 use super::*;
422 use tempfile::TempDir;
423
424 #[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]
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]
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]
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]
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]
571 fn test_missing_sbt_binary_error() {
572 let tmp = TempDir::new().unwrap();
573 let original_path = std::env::var_os("PATH");
574
575 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]
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]
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]
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]
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]
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]
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}