Skip to main content

sqry_classpath/detect/
mod.rs

1//! Build system detection for JVM projects.
2//!
3//! Scans for marker files (build.gradle, pom.xml, BUILD, build.sbt) to identify
4//! the build system in use. Priority order: Bazel > Gradle > Maven > sbt.
5
6use std::path::{Path, PathBuf};
7
8use log::warn;
9use serde::{Deserialize, Serialize};
10use walkdir::WalkDir;
11
12/// JVM build system type.
13#[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    /// Parse a build system name from a string (case-insensitive).
23    ///
24    /// Returns `None` for unrecognized values.
25    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    /// Detection priority (higher wins when multiple build systems are present).
36    ///
37    /// Bazel projects often also contain `pom.xml` or `build.gradle` for IDE
38    /// compatibility, so Bazel markers should take precedence.
39    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    /// Marker files that indicate this build system is in use.
49    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    /// All build system variants for iteration.
71    const ALL: [Self; 4] = [Self::Gradle, Self::Maven, Self::Bazel, Self::Sbt];
72}
73
74/// Directories skipped during recursive JVM build-root discovery.
75const IGNORED_DIR_NAMES: &[&str] = &[".git", ".sqry", "target", "build", "node_modules", "vendor"];
76
77/// Result of build system detection.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct DetectionResult {
80    /// Detected build system, if any.
81    pub build_system: Option<BuildSystem>,
82    /// Project root directory where the build system was detected.
83    pub project_root: PathBuf,
84    /// Marker files that were found.
85    pub markers_found: Vec<String>,
86    /// Override source (if `--build-system` CLI flag was used).
87    pub override_source: Option<String>,
88}
89
90/// Detect the build system for a JVM project.
91///
92/// Scans for marker files starting at `project_root`. If `override_build_system`
93/// is provided, it takes precedence over auto-detection.
94///
95/// # Priority (when multiple build systems detected)
96/// Bazel > Gradle > Maven > sbt
97///
98/// This priority reflects that Bazel projects often also contain `pom.xml` or
99/// `build.gradle` for IDE compatibility, so the Bazel marker should win.
100#[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    // Handle override case first.
115    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/// Discover JVM build roots recursively under a repository root.
170///
171/// Child roots that are already modeled by an ancestor Gradle settings file or
172/// Maven reactor are pruned to avoid duplicate resolution.
173#[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
320/// Write the detection result as JSON to `.sqry/classpath/build-system.json`
321/// for debugging. Silently skips if the directory does not exist or the write fails.
322fn write_diagnostics(project_root: &Path, result: &DetectionResult) {
323    let sqry_dir = project_root.join(".sqry").join("classpath");
324
325    // Create the directory if it doesn't exist. If creation fails, skip silently.
326    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    /// Helper: create marker files in the temp directory.
358    fn create_markers(dir: &Path, markers: &[&str]) {
359        for marker in markers {
360            let path = dir.join(marker);
361            // Create parent directories if needed (e.g., project/build.properties).
362            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        // Even though pom.xml is present, override should force Gradle.
424        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        // Override skips marker scanning.
430        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        // All markers should be recorded.
453        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}