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 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
57fn 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
87fn 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#[allow(clippy::manual_let_else)] fn 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 if is_sbt_log_line(trimmed) {
133 continue;
134 }
135
136 if trimmed.starts_with("List(") || trimmed.contains("Attributed(") {
138 jars.extend(parse_attributed_format(trimmed));
139 continue;
140 }
141
142 if trimmed.contains(':') && trimmed.contains(".jar") {
144 jars.extend(parse_colon_separated(trimmed));
145 continue;
146 }
147
148 if is_jar_path(trimmed) {
150 jars.push(PathBuf::from(trimmed));
151 }
152 }
153
154 jars
155}
156
157fn 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
181fn 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
192fn is_jar_path(s: &str) -> bool {
194 !s.is_empty() && s.to_ascii_lowercase().ends_with(".jar")
195}
196
197fn 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
206fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
215 let path_str = jar_path.to_str()?;
216
217 let maven2_idx = path_str.find("/maven2/")?;
219 let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
220
221 let components: Vec<&str> = after_maven2.split('/').collect();
223 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 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
247fn 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, source_jar,
263 }
264 })
265 .collect()
266}
267
268fn 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 let sources_jar = parent.join(format!("{stem}-sources.jar"));
277 if sources_jar.exists() {
278 return Some(sources_jar);
279 }
280
281 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#[allow(clippy::case_sensitive_file_extension_comparisons)] fn 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
303fn 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
338fn 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
352fn 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
391fn 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#[allow(dead_code)]
400fn coursier_cache_dir() -> Option<PathBuf> {
401 dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
402}
403
404fn dirs_path_home() -> Option<PathBuf> {
406 std::env::var_os("HOME").map(PathBuf::from)
407}
408
409#[cfg(test)]
412mod tests {
413 use super::*;
414 use tempfile::TempDir;
415
416 #[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]
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]
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]
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]
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]
563 fn test_missing_sbt_binary_error() {
564 let tmp = TempDir::new().unwrap();
565 let original_path = std::env::var_os("PATH");
566
567 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]
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]
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]
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]
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]
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]
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}