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 COURSIER_CACHE_REL: &str = ".cache/coursier/v1";
24
25#[allow(clippy::missing_errors_doc)] pub 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
58fn 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
88fn 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#[allow(clippy::manual_let_else)] fn 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 if is_sbt_log_line(trimmed) {
134 continue;
135 }
136
137 if trimmed.starts_with("List(") || trimmed.contains("Attributed(") {
139 jars.extend(parse_attributed_format(trimmed));
140 continue;
141 }
142
143 if trimmed.contains(':') && trimmed.contains(".jar") {
145 jars.extend(parse_colon_separated(trimmed));
146 continue;
147 }
148
149 if is_jar_path(trimmed) {
151 jars.push(PathBuf::from(trimmed));
152 }
153 }
154
155 jars
156}
157
158fn 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
182fn 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
193fn is_jar_path(s: &str) -> bool {
195 !s.is_empty() && s.to_ascii_lowercase().ends_with(".jar")
196}
197
198fn 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
207fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
216 let path_str = jar_path.to_str()?;
217
218 let maven2_idx = path_str.find("/maven2/")?;
220 let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
221
222 let components: Vec<&str> = after_maven2.split('/').collect();
224 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 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
248fn 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, source_jar,
264 }
265 })
266 .collect()
267}
268
269fn 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 let sources_jar = parent.join(format!("{stem}-sources.jar"));
278 if sources_jar.exists() {
279 return Some(sources_jar);
280 }
281
282 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#[allow(clippy::case_sensitive_file_extension_comparisons)] fn 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
304fn 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
339fn 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
353fn 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
392fn 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#[allow(dead_code)]
401fn coursier_cache_dir() -> Option<PathBuf> {
402 dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
403}
404
405fn dirs_path_home() -> Option<PathBuf> {
407 std::env::var_os("HOME").map(PathBuf::from)
408}
409
410#[cfg(test)]
413mod tests {
414 use super::*;
415 use tempfile::TempDir;
416
417 #[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]
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]
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]
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]
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]
564 fn test_missing_sbt_binary_error() {
565 let tmp = TempDir::new().unwrap();
566 let original_path = std::env::var_os("PATH");
567
568 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]
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]
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]
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]
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]
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]
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}