1use std::path::{Path, PathBuf};
7
8use log::warn;
9use serde::{Deserialize, Serialize};
10use walkdir::WalkDir;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum BuildSystem {
15 Gradle,
16 Maven,
17 Bazel,
18 Sbt,
19}
20
21impl BuildSystem {
22 fn from_str_loose(s: &str) -> Option<Self> {
26 match s.to_lowercase().as_str() {
27 "gradle" => Some(Self::Gradle),
28 "maven" => Some(Self::Maven),
29 "bazel" => Some(Self::Bazel),
30 "sbt" => Some(Self::Sbt),
31 _ => None,
32 }
33 }
34
35 fn priority(self) -> u8 {
40 match self {
41 Self::Bazel => 4,
42 Self::Gradle => 3,
43 Self::Maven => 2,
44 Self::Sbt => 1,
45 }
46 }
47
48 fn markers(self) -> &'static [&'static str] {
50 match self {
51 Self::Gradle => &[
52 "build.gradle",
53 "build.gradle.kts",
54 "settings.gradle",
55 "settings.gradle.kts",
56 "gradlew",
57 ],
58 Self::Maven => &["pom.xml"],
59 Self::Bazel => &[
60 "BUILD",
61 "BUILD.bazel",
62 "WORKSPACE",
63 "WORKSPACE.bazel",
64 "MODULE.bazel",
65 ],
66 Self::Sbt => &["build.sbt", "project/build.properties"],
67 }
68 }
69
70 const ALL: [Self; 4] = [Self::Gradle, Self::Maven, Self::Bazel, Self::Sbt];
72}
73
74const IGNORED_DIR_NAMES: &[&str] = &[".git", ".sqry", "target", "build", "node_modules", "vendor"];
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct DetectionResult {
80 pub build_system: Option<BuildSystem>,
82 pub project_root: PathBuf,
84 pub markers_found: Vec<String>,
86 pub override_source: Option<String>,
88}
89
90#[must_use]
101pub fn detect_build_system(
102 project_root: &Path,
103 override_build_system: Option<&str>,
104) -> DetectionResult {
105 let result = detect_build_system_inner(project_root, override_build_system);
106 write_diagnostics(project_root, &result);
107 result
108}
109
110fn detect_build_system_inner(
111 project_root: &Path,
112 override_build_system: Option<&str>,
113) -> DetectionResult {
114 if let Some(override_value) = override_build_system {
116 return if let Some(bs) = BuildSystem::from_str_loose(override_value) {
117 DetectionResult {
118 build_system: Some(bs),
119 project_root: project_root.to_path_buf(),
120 markers_found: Vec::new(),
121 override_source: Some(override_value.to_string()),
122 }
123 } else {
124 warn!(
125 "Invalid build system override '{override_value}'. Valid values: gradle, maven, bazel, sbt"
126 );
127 DetectionResult {
128 build_system: None,
129 project_root: project_root.to_path_buf(),
130 markers_found: Vec::new(),
131 override_source: Some(override_value.to_string()),
132 }
133 };
134 }
135
136 let (markers_found, best_system) = scan_markers(project_root);
137
138 DetectionResult {
139 build_system: best_system,
140 project_root: project_root.to_path_buf(),
141 markers_found,
142 override_source: None,
143 }
144}
145
146fn scan_markers(project_root: &Path) -> (Vec<String>, Option<BuildSystem>) {
147 let mut markers_found = Vec::new();
148 let mut best_system: Option<BuildSystem> = None;
149
150 for build_system in BuildSystem::ALL {
151 for marker in build_system.markers() {
152 let marker_path = project_root.join(marker);
153 if marker_path.exists() {
154 markers_found.push((*marker).to_string());
155
156 match best_system {
157 Some(current) if current.priority() >= build_system.priority() => {}
158 _ => {
159 best_system = Some(build_system);
160 }
161 }
162 }
163 }
164 }
165
166 (markers_found, best_system)
167}
168
169#[must_use]
174pub fn discover_build_roots(
175 project_root: &Path,
176 override_build_system: Option<&str>,
177) -> Vec<DetectionResult> {
178 if let Some(override_value) = override_build_system {
179 let Some(build_system) = BuildSystem::from_str_loose(override_value) else {
180 return vec![detect_build_system(project_root, Some(override_value))];
181 };
182 let mut roots = discover_build_roots_for_system(project_root, build_system);
183 if roots.is_empty() {
184 roots.push(DetectionResult {
185 build_system: Some(build_system),
186 project_root: project_root.to_path_buf(),
187 markers_found: Vec::new(),
188 override_source: Some(override_value.to_string()),
189 });
190 } else {
191 for root in &mut roots {
192 root.override_source = Some(override_value.to_string());
193 }
194 }
195 return roots;
196 }
197
198 let mut candidates = Vec::new();
199 for entry in WalkDir::new(project_root)
200 .follow_links(false)
201 .into_iter()
202 .filter_entry(|entry| should_descend(entry.path()))
203 .filter_map(Result::ok)
204 {
205 if !entry.file_type().is_dir() {
206 continue;
207 }
208
209 let detection = detect_build_system_inner(entry.path(), None);
210 if detection.build_system.is_some() {
211 candidates.push(detection);
212 }
213 }
214
215 prune_discovered_roots(candidates)
216}
217
218fn discover_build_roots_for_system(
219 project_root: &Path,
220 build_system: BuildSystem,
221) -> Vec<DetectionResult> {
222 let mut candidates = Vec::new();
223 for entry in WalkDir::new(project_root)
224 .follow_links(false)
225 .into_iter()
226 .filter_entry(|entry| should_descend(entry.path()))
227 .filter_map(Result::ok)
228 {
229 if !entry.file_type().is_dir() {
230 continue;
231 }
232
233 let (markers_found, detected) = scan_markers(entry.path());
234 if detected == Some(build_system) {
235 candidates.push(DetectionResult {
236 build_system: Some(build_system),
237 project_root: entry.path().to_path_buf(),
238 markers_found,
239 override_source: None,
240 });
241 }
242 }
243
244 prune_discovered_roots(candidates)
245}
246
247fn should_descend(path: &Path) -> bool {
248 path.file_name()
249 .and_then(|name| name.to_str())
250 .is_none_or(|name| !IGNORED_DIR_NAMES.contains(&name))
251}
252
253fn prune_discovered_roots(mut candidates: Vec<DetectionResult>) -> Vec<DetectionResult> {
254 candidates.sort_by(|a, b| {
255 let depth_a = a.project_root.components().count();
256 let depth_b = b.project_root.components().count();
257 depth_a
258 .cmp(&depth_b)
259 .then_with(|| a.project_root.cmp(&b.project_root))
260 });
261
262 let mut accepted = Vec::new();
263 'candidate: for candidate in candidates {
264 for ancestor in &accepted {
265 if should_prune_under_ancestor(&candidate, ancestor) {
266 continue 'candidate;
267 }
268 }
269 accepted.push(candidate);
270 }
271
272 accepted
273}
274
275fn should_prune_under_ancestor(candidate: &DetectionResult, ancestor: &DetectionResult) -> bool {
276 let Some(candidate_system) = candidate.build_system else {
277 return false;
278 };
279 let Some(ancestor_system) = ancestor.build_system else {
280 return false;
281 };
282 if candidate_system != ancestor_system || candidate.project_root == ancestor.project_root {
283 return false;
284 }
285
286 match candidate_system {
287 BuildSystem::Gradle => {
288 candidate.project_root.starts_with(&ancestor.project_root)
289 && ancestor
290 .markers_found
291 .iter()
292 .any(|marker| marker == "settings.gradle" || marker == "settings.gradle.kts")
293 }
294 BuildSystem::Maven => {
295 maven_reactor_contains(&ancestor.project_root, &candidate.project_root)
296 }
297 BuildSystem::Bazel | BuildSystem::Sbt => {
298 candidate.project_root.starts_with(&ancestor.project_root)
299 }
300 }
301}
302
303fn maven_reactor_contains(ancestor_root: &Path, candidate_root: &Path) -> bool {
304 let pom_path = ancestor_root.join("pom.xml");
305 if !pom_path.exists() {
306 return false;
307 }
308
309 let candidate_root = canonicalish(candidate_root);
310 crate::resolve::maven::detect_modules(&pom_path)
311 .into_iter()
312 .map(|module| canonicalish(&ancestor_root.join(module)))
313 .any(|module_root| module_root == candidate_root)
314}
315
316fn canonicalish(path: &Path) -> PathBuf {
317 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
318}
319
320fn write_diagnostics(project_root: &Path, result: &DetectionResult) {
323 let sqry_dir = project_root.join(".sqry").join("classpath");
324
325 if let Err(e) = std::fs::create_dir_all(&sqry_dir) {
327 warn!(
328 "Could not create diagnostics directory {}: {}",
329 sqry_dir.display(),
330 e
331 );
332 return;
333 }
334
335 let diagnostics_path = sqry_dir.join("build-system.json");
336 match serde_json::to_string_pretty(result) {
337 Ok(json) => {
338 if let Err(e) = std::fs::write(&diagnostics_path, json) {
339 warn!(
340 "Could not write build system diagnostics to {}: {}",
341 diagnostics_path.display(),
342 e
343 );
344 }
345 }
346 Err(e) => {
347 warn!("Could not serialize detection result: {e}");
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use tempfile::TempDir;
356
357 fn create_markers(dir: &Path, markers: &[&str]) {
359 for marker in markers {
360 let path = dir.join(marker);
361 if let Some(parent) = path.parent() {
363 std::fs::create_dir_all(parent).unwrap();
364 }
365 std::fs::write(&path, "").unwrap();
366 }
367 }
368
369 #[test]
370 fn test_build_gradle_detected() {
371 let tmp = TempDir::new().unwrap();
372 create_markers(tmp.path(), &["build.gradle"]);
373
374 let result = detect_build_system(tmp.path(), None);
375 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
376 assert!(result.markers_found.contains(&"build.gradle".to_string()));
377 assert!(result.override_source.is_none());
378 }
379
380 #[test]
381 fn test_pom_xml_only_maven() {
382 let tmp = TempDir::new().unwrap();
383 create_markers(tmp.path(), &["pom.xml"]);
384
385 let result = detect_build_system(tmp.path(), None);
386 assert_eq!(result.build_system, Some(BuildSystem::Maven));
387 assert!(result.markers_found.contains(&"pom.xml".to_string()));
388 }
389
390 #[test]
391 fn test_build_and_pom_bazel_wins() {
392 let tmp = TempDir::new().unwrap();
393 create_markers(tmp.path(), &["BUILD", "pom.xml"]);
394
395 let result = detect_build_system(tmp.path(), None);
396 assert_eq!(result.build_system, Some(BuildSystem::Bazel));
397 assert!(result.markers_found.contains(&"BUILD".to_string()));
398 assert!(result.markers_found.contains(&"pom.xml".to_string()));
399 }
400
401 #[test]
402 fn test_build_sbt_only() {
403 let tmp = TempDir::new().unwrap();
404 create_markers(tmp.path(), &["build.sbt"]);
405
406 let result = detect_build_system(tmp.path(), None);
407 assert_eq!(result.build_system, Some(BuildSystem::Sbt));
408 assert!(result.markers_found.contains(&"build.sbt".to_string()));
409 }
410
411 #[test]
412 fn test_no_markers_none() {
413 let tmp = TempDir::new().unwrap();
414
415 let result = detect_build_system(tmp.path(), None);
416 assert_eq!(result.build_system, None);
417 assert!(result.markers_found.is_empty());
418 }
419
420 #[test]
421 fn test_override_works() {
422 let tmp = TempDir::new().unwrap();
423 create_markers(tmp.path(), &["pom.xml"]);
425
426 let result = detect_build_system(tmp.path(), Some("gradle"));
427 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
428 assert_eq!(result.override_source, Some("gradle".to_string()));
429 assert!(result.markers_found.is_empty());
431 }
432
433 #[test]
434 fn test_invalid_override_returns_none() {
435 let tmp = TempDir::new().unwrap();
436
437 let result = detect_build_system(tmp.path(), Some("ninja"));
438 assert_eq!(result.build_system, None);
439 assert_eq!(result.override_source, Some("ninja".to_string()));
440 }
441
442 #[test]
443 fn test_all_markers_bazel_wins() {
444 let tmp = TempDir::new().unwrap();
445 create_markers(
446 tmp.path(),
447 &["build.gradle", "pom.xml", "BUILD", "build.sbt", "WORKSPACE"],
448 );
449
450 let result = detect_build_system(tmp.path(), None);
451 assert_eq!(result.build_system, Some(BuildSystem::Bazel));
452 assert!(result.markers_found.len() >= 4);
454 }
455
456 #[test]
457 fn test_build_gradle_kts_detected() {
458 let tmp = TempDir::new().unwrap();
459 create_markers(tmp.path(), &["build.gradle.kts"]);
460
461 let result = detect_build_system(tmp.path(), None);
462 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
463 assert!(
464 result
465 .markers_found
466 .contains(&"build.gradle.kts".to_string())
467 );
468 }
469
470 #[test]
471 fn test_workspace_bazel_detected() {
472 let tmp = TempDir::new().unwrap();
473 create_markers(tmp.path(), &["WORKSPACE.bazel"]);
474
475 let result = detect_build_system(tmp.path(), None);
476 assert_eq!(result.build_system, Some(BuildSystem::Bazel));
477 assert!(
478 result
479 .markers_found
480 .contains(&"WORKSPACE.bazel".to_string())
481 );
482 }
483
484 #[test]
485 fn test_diagnostics_file_written() {
486 let tmp = TempDir::new().unwrap();
487 create_markers(tmp.path(), &["pom.xml"]);
488
489 let _result = detect_build_system(tmp.path(), None);
490
491 let diagnostics_path = tmp.path().join(".sqry/classpath/build-system.json");
492 assert!(diagnostics_path.exists(), "diagnostics file should exist");
493
494 let contents = std::fs::read_to_string(&diagnostics_path).unwrap();
495 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
496 assert_eq!(parsed["build_system"], "Maven");
497 }
498
499 #[test]
500 fn test_project_root_recorded() {
501 let tmp = TempDir::new().unwrap();
502 let result = detect_build_system(tmp.path(), None);
503 assert_eq!(result.project_root, tmp.path());
504 }
505
506 #[test]
507 fn test_override_case_insensitive() {
508 let tmp = TempDir::new().unwrap();
509
510 let result = detect_build_system(tmp.path(), Some("MAVEN"));
511 assert_eq!(result.build_system, Some(BuildSystem::Maven));
512
513 let result = detect_build_system(tmp.path(), Some("Gradle"));
514 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
515
516 let result = detect_build_system(tmp.path(), Some("SBT"));
517 assert_eq!(result.build_system, Some(BuildSystem::Sbt));
518
519 let result = detect_build_system(tmp.path(), Some("BAZEL"));
520 assert_eq!(result.build_system, Some(BuildSystem::Bazel));
521 }
522
523 #[test]
524 fn test_settings_gradle_detected() {
525 let tmp = TempDir::new().unwrap();
526 create_markers(tmp.path(), &["settings.gradle"]);
527
528 let result = detect_build_system(tmp.path(), None);
529 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
530 }
531
532 #[test]
533 fn test_settings_gradle_kts_detected() {
534 let tmp = TempDir::new().unwrap();
535 create_markers(tmp.path(), &["settings.gradle.kts"]);
536
537 let result = detect_build_system(tmp.path(), None);
538 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
539 }
540
541 #[test]
542 fn test_gradlew_detected() {
543 let tmp = TempDir::new().unwrap();
544 create_markers(tmp.path(), &["gradlew"]);
545
546 let result = detect_build_system(tmp.path(), None);
547 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
548 }
549
550 #[test]
551 fn test_module_bazel_detected() {
552 let tmp = TempDir::new().unwrap();
553 create_markers(tmp.path(), &["MODULE.bazel"]);
554
555 let result = detect_build_system(tmp.path(), None);
556 assert_eq!(result.build_system, Some(BuildSystem::Bazel));
557 }
558
559 #[test]
560 fn test_sbt_project_build_properties() {
561 let tmp = TempDir::new().unwrap();
562 create_markers(tmp.path(), &["project/build.properties"]);
563
564 let result = detect_build_system(tmp.path(), None);
565 assert_eq!(result.build_system, Some(BuildSystem::Sbt));
566 assert!(
567 result
568 .markers_found
569 .contains(&"project/build.properties".to_string())
570 );
571 }
572
573 #[test]
574 fn test_gradle_vs_maven_gradle_wins() {
575 let tmp = TempDir::new().unwrap();
576 create_markers(tmp.path(), &["build.gradle", "pom.xml"]);
577
578 let result = detect_build_system(tmp.path(), None);
579 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
580 }
581
582 #[test]
583 fn test_gradle_vs_sbt_gradle_wins() {
584 let tmp = TempDir::new().unwrap();
585 create_markers(tmp.path(), &["build.gradle", "build.sbt"]);
586
587 let result = detect_build_system(tmp.path(), None);
588 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
589 }
590
591 #[test]
592 fn test_maven_vs_sbt_maven_wins() {
593 let tmp = TempDir::new().unwrap();
594 create_markers(tmp.path(), &["pom.xml", "build.sbt"]);
595
596 let result = detect_build_system(tmp.path(), None);
597 assert_eq!(result.build_system, Some(BuildSystem::Maven));
598 }
599
600 #[test]
601 fn test_multiple_gradle_markers() {
602 let tmp = TempDir::new().unwrap();
603 create_markers(tmp.path(), &["build.gradle", "settings.gradle", "gradlew"]);
604
605 let result = detect_build_system(tmp.path(), None);
606 assert_eq!(result.build_system, Some(BuildSystem::Gradle));
607 assert_eq!(result.markers_found.len(), 3);
608 }
609
610 #[test]
611 fn test_multiple_bazel_markers() {
612 let tmp = TempDir::new().unwrap();
613 create_markers(
614 tmp.path(),
615 &["BUILD", "BUILD.bazel", "WORKSPACE", "MODULE.bazel"],
616 );
617
618 let result = detect_build_system(tmp.path(), None);
619 assert_eq!(result.build_system, Some(BuildSystem::Bazel));
620 assert_eq!(result.markers_found.len(), 4);
621 }
622
623 #[test]
624 fn test_discover_build_roots_mixed_nested_repo() {
625 let tmp = TempDir::new().unwrap();
626 create_markers(tmp.path().join("services/app").as_path(), &["build.gradle"]);
627 create_markers(tmp.path().join("libs/shared").as_path(), &["pom.xml"]);
628 create_markers(tmp.path().join("target/generated").as_path(), &["pom.xml"]);
629
630 let roots = discover_build_roots(tmp.path(), None);
631 let root_paths: Vec<_> = roots.iter().map(|root| root.project_root.clone()).collect();
632
633 assert!(root_paths.contains(&tmp.path().join("services/app")));
634 assert!(root_paths.contains(&tmp.path().join("libs/shared")));
635 assert!(!root_paths.contains(&tmp.path().join("target/generated")));
636 }
637
638 #[test]
639 fn test_discover_build_roots_prunes_gradle_children_under_settings_root() {
640 let tmp = TempDir::new().unwrap();
641 create_markers(tmp.path(), &["settings.gradle", "build.gradle"]);
642 create_markers(tmp.path().join("app").as_path(), &["build.gradle"]);
643
644 let roots = discover_build_roots(tmp.path(), None);
645 assert_eq!(roots.len(), 1);
646 assert_eq!(roots[0].project_root, tmp.path());
647 assert_eq!(roots[0].build_system, Some(BuildSystem::Gradle));
648 }
649}