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};
10
11/// JVM build system type.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum BuildSystem {
14    Gradle,
15    Maven,
16    Bazel,
17    Sbt,
18}
19
20impl BuildSystem {
21    /// Parse a build system name from a string (case-insensitive).
22    ///
23    /// Returns `None` for unrecognized values.
24    fn from_str_loose(s: &str) -> Option<Self> {
25        match s.to_lowercase().as_str() {
26            "gradle" => Some(Self::Gradle),
27            "maven" => Some(Self::Maven),
28            "bazel" => Some(Self::Bazel),
29            "sbt" => Some(Self::Sbt),
30            _ => None,
31        }
32    }
33
34    /// Detection priority (higher wins when multiple build systems are present).
35    ///
36    /// Bazel projects often also contain `pom.xml` or `build.gradle` for IDE
37    /// compatibility, so Bazel markers should take precedence.
38    fn priority(self) -> u8 {
39        match self {
40            Self::Bazel => 4,
41            Self::Gradle => 3,
42            Self::Maven => 2,
43            Self::Sbt => 1,
44        }
45    }
46
47    /// Marker files that indicate this build system is in use.
48    fn markers(self) -> &'static [&'static str] {
49        match self {
50            Self::Gradle => &[
51                "build.gradle",
52                "build.gradle.kts",
53                "settings.gradle",
54                "settings.gradle.kts",
55                "gradlew",
56            ],
57            Self::Maven => &["pom.xml"],
58            Self::Bazel => &[
59                "BUILD",
60                "BUILD.bazel",
61                "WORKSPACE",
62                "WORKSPACE.bazel",
63                "MODULE.bazel",
64            ],
65            Self::Sbt => &["build.sbt", "project/build.properties"],
66        }
67    }
68
69    /// All build system variants for iteration.
70    const ALL: [Self; 4] = [Self::Gradle, Self::Maven, Self::Bazel, Self::Sbt];
71}
72
73/// Result of build system detection.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DetectionResult {
76    /// Detected build system, if any.
77    pub build_system: Option<BuildSystem>,
78    /// Project root directory where the build system was detected.
79    pub project_root: PathBuf,
80    /// Marker files that were found.
81    pub markers_found: Vec<String>,
82    /// Override source (if `--build-system` CLI flag was used).
83    pub override_source: Option<String>,
84}
85
86/// Detect the build system for a JVM project.
87///
88/// Scans for marker files starting at `project_root`. If `override_build_system`
89/// is provided, it takes precedence over auto-detection.
90///
91/// # Priority (when multiple build systems detected)
92/// Bazel > Gradle > Maven > sbt
93///
94/// This priority reflects that Bazel projects often also contain `pom.xml` or
95/// `build.gradle` for IDE compatibility, so the Bazel marker should win.
96#[must_use]
97pub fn detect_build_system(
98    project_root: &Path,
99    override_build_system: Option<&str>,
100) -> DetectionResult {
101    // Handle override case first.
102    if let Some(override_value) = override_build_system {
103        let result = if let Some(bs) = BuildSystem::from_str_loose(override_value) {
104            DetectionResult {
105                build_system: Some(bs),
106                project_root: project_root.to_path_buf(),
107                markers_found: Vec::new(),
108                override_source: Some(override_value.to_string()),
109            }
110        } else {
111            warn!(
112                "Invalid build system override '{override_value}'. Valid values: gradle, maven, bazel, sbt"
113            );
114            DetectionResult {
115                build_system: None,
116                project_root: project_root.to_path_buf(),
117                markers_found: Vec::new(),
118                override_source: Some(override_value.to_string()),
119            }
120        };
121        write_diagnostics(project_root, &result);
122        return result;
123    }
124
125    // Auto-detect by scanning for marker files.
126    let mut markers_found = Vec::new();
127    let mut best_system: Option<BuildSystem> = None;
128
129    for build_system in BuildSystem::ALL {
130        for marker in build_system.markers() {
131            let marker_path = project_root.join(marker);
132            if marker_path.exists() {
133                markers_found.push((*marker).to_string());
134
135                match best_system {
136                    Some(current) if current.priority() >= build_system.priority() => {
137                        // Current winner has equal or higher priority; keep it.
138                    }
139                    _ => {
140                        best_system = Some(build_system);
141                    }
142                }
143            }
144        }
145    }
146
147    let result = DetectionResult {
148        build_system: best_system,
149        project_root: project_root.to_path_buf(),
150        markers_found,
151        override_source: None,
152    };
153
154    write_diagnostics(project_root, &result);
155    result
156}
157
158/// Write the detection result as JSON to `.sqry/classpath/build-system.json`
159/// for debugging. Silently skips if the directory does not exist or the write fails.
160fn write_diagnostics(project_root: &Path, result: &DetectionResult) {
161    let sqry_dir = project_root.join(".sqry").join("classpath");
162
163    // Create the directory if it doesn't exist. If creation fails, skip silently.
164    if let Err(e) = std::fs::create_dir_all(&sqry_dir) {
165        warn!(
166            "Could not create diagnostics directory {}: {}",
167            sqry_dir.display(),
168            e
169        );
170        return;
171    }
172
173    let diagnostics_path = sqry_dir.join("build-system.json");
174    match serde_json::to_string_pretty(result) {
175        Ok(json) => {
176            if let Err(e) = std::fs::write(&diagnostics_path, json) {
177                warn!(
178                    "Could not write build system diagnostics to {}: {}",
179                    diagnostics_path.display(),
180                    e
181                );
182            }
183        }
184        Err(e) => {
185            warn!("Could not serialize detection result: {e}");
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use tempfile::TempDir;
194
195    /// Helper: create marker files in the temp directory.
196    fn create_markers(dir: &Path, markers: &[&str]) {
197        for marker in markers {
198            let path = dir.join(marker);
199            // Create parent directories if needed (e.g., project/build.properties).
200            if let Some(parent) = path.parent() {
201                std::fs::create_dir_all(parent).unwrap();
202            }
203            std::fs::write(&path, "").unwrap();
204        }
205    }
206
207    #[test]
208    fn test_build_gradle_detected() {
209        let tmp = TempDir::new().unwrap();
210        create_markers(tmp.path(), &["build.gradle"]);
211
212        let result = detect_build_system(tmp.path(), None);
213        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
214        assert!(result.markers_found.contains(&"build.gradle".to_string()));
215        assert!(result.override_source.is_none());
216    }
217
218    #[test]
219    fn test_pom_xml_only_maven() {
220        let tmp = TempDir::new().unwrap();
221        create_markers(tmp.path(), &["pom.xml"]);
222
223        let result = detect_build_system(tmp.path(), None);
224        assert_eq!(result.build_system, Some(BuildSystem::Maven));
225        assert!(result.markers_found.contains(&"pom.xml".to_string()));
226    }
227
228    #[test]
229    fn test_build_and_pom_bazel_wins() {
230        let tmp = TempDir::new().unwrap();
231        create_markers(tmp.path(), &["BUILD", "pom.xml"]);
232
233        let result = detect_build_system(tmp.path(), None);
234        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
235        assert!(result.markers_found.contains(&"BUILD".to_string()));
236        assert!(result.markers_found.contains(&"pom.xml".to_string()));
237    }
238
239    #[test]
240    fn test_build_sbt_only() {
241        let tmp = TempDir::new().unwrap();
242        create_markers(tmp.path(), &["build.sbt"]);
243
244        let result = detect_build_system(tmp.path(), None);
245        assert_eq!(result.build_system, Some(BuildSystem::Sbt));
246        assert!(result.markers_found.contains(&"build.sbt".to_string()));
247    }
248
249    #[test]
250    fn test_no_markers_none() {
251        let tmp = TempDir::new().unwrap();
252
253        let result = detect_build_system(tmp.path(), None);
254        assert_eq!(result.build_system, None);
255        assert!(result.markers_found.is_empty());
256    }
257
258    #[test]
259    fn test_override_works() {
260        let tmp = TempDir::new().unwrap();
261        // Even though pom.xml is present, override should force Gradle.
262        create_markers(tmp.path(), &["pom.xml"]);
263
264        let result = detect_build_system(tmp.path(), Some("gradle"));
265        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
266        assert_eq!(result.override_source, Some("gradle".to_string()));
267        // Override skips marker scanning.
268        assert!(result.markers_found.is_empty());
269    }
270
271    #[test]
272    fn test_invalid_override_returns_none() {
273        let tmp = TempDir::new().unwrap();
274
275        let result = detect_build_system(tmp.path(), Some("ninja"));
276        assert_eq!(result.build_system, None);
277        assert_eq!(result.override_source, Some("ninja".to_string()));
278    }
279
280    #[test]
281    fn test_all_markers_bazel_wins() {
282        let tmp = TempDir::new().unwrap();
283        create_markers(
284            tmp.path(),
285            &["build.gradle", "pom.xml", "BUILD", "build.sbt", "WORKSPACE"],
286        );
287
288        let result = detect_build_system(tmp.path(), None);
289        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
290        // All markers should be recorded.
291        assert!(result.markers_found.len() >= 4);
292    }
293
294    #[test]
295    fn test_build_gradle_kts_detected() {
296        let tmp = TempDir::new().unwrap();
297        create_markers(tmp.path(), &["build.gradle.kts"]);
298
299        let result = detect_build_system(tmp.path(), None);
300        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
301        assert!(
302            result
303                .markers_found
304                .contains(&"build.gradle.kts".to_string())
305        );
306    }
307
308    #[test]
309    fn test_workspace_bazel_detected() {
310        let tmp = TempDir::new().unwrap();
311        create_markers(tmp.path(), &["WORKSPACE.bazel"]);
312
313        let result = detect_build_system(tmp.path(), None);
314        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
315        assert!(
316            result
317                .markers_found
318                .contains(&"WORKSPACE.bazel".to_string())
319        );
320    }
321
322    #[test]
323    fn test_diagnostics_file_written() {
324        let tmp = TempDir::new().unwrap();
325        create_markers(tmp.path(), &["pom.xml"]);
326
327        let _result = detect_build_system(tmp.path(), None);
328
329        let diagnostics_path = tmp.path().join(".sqry/classpath/build-system.json");
330        assert!(diagnostics_path.exists(), "diagnostics file should exist");
331
332        let contents = std::fs::read_to_string(&diagnostics_path).unwrap();
333        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
334        assert_eq!(parsed["build_system"], "Maven");
335    }
336
337    #[test]
338    fn test_project_root_recorded() {
339        let tmp = TempDir::new().unwrap();
340        let result = detect_build_system(tmp.path(), None);
341        assert_eq!(result.project_root, tmp.path());
342    }
343
344    #[test]
345    fn test_override_case_insensitive() {
346        let tmp = TempDir::new().unwrap();
347
348        let result = detect_build_system(tmp.path(), Some("MAVEN"));
349        assert_eq!(result.build_system, Some(BuildSystem::Maven));
350
351        let result = detect_build_system(tmp.path(), Some("Gradle"));
352        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
353
354        let result = detect_build_system(tmp.path(), Some("SBT"));
355        assert_eq!(result.build_system, Some(BuildSystem::Sbt));
356
357        let result = detect_build_system(tmp.path(), Some("BAZEL"));
358        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
359    }
360
361    #[test]
362    fn test_settings_gradle_detected() {
363        let tmp = TempDir::new().unwrap();
364        create_markers(tmp.path(), &["settings.gradle"]);
365
366        let result = detect_build_system(tmp.path(), None);
367        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
368    }
369
370    #[test]
371    fn test_settings_gradle_kts_detected() {
372        let tmp = TempDir::new().unwrap();
373        create_markers(tmp.path(), &["settings.gradle.kts"]);
374
375        let result = detect_build_system(tmp.path(), None);
376        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
377    }
378
379    #[test]
380    fn test_gradlew_detected() {
381        let tmp = TempDir::new().unwrap();
382        create_markers(tmp.path(), &["gradlew"]);
383
384        let result = detect_build_system(tmp.path(), None);
385        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
386    }
387
388    #[test]
389    fn test_module_bazel_detected() {
390        let tmp = TempDir::new().unwrap();
391        create_markers(tmp.path(), &["MODULE.bazel"]);
392
393        let result = detect_build_system(tmp.path(), None);
394        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
395    }
396
397    #[test]
398    fn test_sbt_project_build_properties() {
399        let tmp = TempDir::new().unwrap();
400        create_markers(tmp.path(), &["project/build.properties"]);
401
402        let result = detect_build_system(tmp.path(), None);
403        assert_eq!(result.build_system, Some(BuildSystem::Sbt));
404        assert!(
405            result
406                .markers_found
407                .contains(&"project/build.properties".to_string())
408        );
409    }
410
411    #[test]
412    fn test_gradle_vs_maven_gradle_wins() {
413        let tmp = TempDir::new().unwrap();
414        create_markers(tmp.path(), &["build.gradle", "pom.xml"]);
415
416        let result = detect_build_system(tmp.path(), None);
417        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
418    }
419
420    #[test]
421    fn test_gradle_vs_sbt_gradle_wins() {
422        let tmp = TempDir::new().unwrap();
423        create_markers(tmp.path(), &["build.gradle", "build.sbt"]);
424
425        let result = detect_build_system(tmp.path(), None);
426        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
427    }
428
429    #[test]
430    fn test_maven_vs_sbt_maven_wins() {
431        let tmp = TempDir::new().unwrap();
432        create_markers(tmp.path(), &["pom.xml", "build.sbt"]);
433
434        let result = detect_build_system(tmp.path(), None);
435        assert_eq!(result.build_system, Some(BuildSystem::Maven));
436    }
437
438    #[test]
439    fn test_multiple_gradle_markers() {
440        let tmp = TempDir::new().unwrap();
441        create_markers(tmp.path(), &["build.gradle", "settings.gradle", "gradlew"]);
442
443        let result = detect_build_system(tmp.path(), None);
444        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
445        assert_eq!(result.markers_found.len(), 3);
446    }
447
448    #[test]
449    fn test_multiple_bazel_markers() {
450        let tmp = TempDir::new().unwrap();
451        create_markers(
452            tmp.path(),
453            &["BUILD", "BUILD.bazel", "WORKSPACE", "MODULE.bazel"],
454        );
455
456        let result = detect_build_system(tmp.path(), None);
457        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
458        assert_eq!(result.markers_found.len(), 4);
459    }
460}