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.
96pub fn detect_build_system(
97    project_root: &Path,
98    override_build_system: Option<&str>,
99) -> DetectionResult {
100    // Handle override case first.
101    if let Some(override_value) = override_build_system {
102        let result = match BuildSystem::from_str_loose(override_value) {
103            Some(bs) => DetectionResult {
104                build_system: Some(bs),
105                project_root: project_root.to_path_buf(),
106                markers_found: Vec::new(),
107                override_source: Some(override_value.to_string()),
108            },
109            None => {
110                warn!(
111                    "Invalid build system override '{}'. Valid values: gradle, maven, bazel, sbt",
112                    override_value
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        };
122        write_diagnostics(project_root, &result);
123        return result;
124    }
125
126    // Auto-detect by scanning for marker files.
127    let mut markers_found = Vec::new();
128    let mut best_system: Option<BuildSystem> = None;
129
130    for build_system in BuildSystem::ALL {
131        for marker in build_system.markers() {
132            let marker_path = project_root.join(marker);
133            if marker_path.exists() {
134                markers_found.push(marker.to_string());
135
136                match best_system {
137                    Some(current) if current.priority() >= build_system.priority() => {
138                        // Current winner has equal or higher priority; keep it.
139                    }
140                    _ => {
141                        best_system = Some(build_system);
142                    }
143                }
144            }
145        }
146    }
147
148    let result = DetectionResult {
149        build_system: best_system,
150        project_root: project_root.to_path_buf(),
151        markers_found,
152        override_source: None,
153    };
154
155    write_diagnostics(project_root, &result);
156    result
157}
158
159/// Write the detection result as JSON to `.sqry/classpath/build-system.json`
160/// for debugging. Silently skips if the directory does not exist or the write fails.
161fn write_diagnostics(project_root: &Path, result: &DetectionResult) {
162    let sqry_dir = project_root.join(".sqry").join("classpath");
163
164    // Create the directory if it doesn't exist. If creation fails, skip silently.
165    if let Err(e) = std::fs::create_dir_all(&sqry_dir) {
166        warn!(
167            "Could not create diagnostics directory {}: {}",
168            sqry_dir.display(),
169            e
170        );
171        return;
172    }
173
174    let diagnostics_path = sqry_dir.join("build-system.json");
175    match serde_json::to_string_pretty(result) {
176        Ok(json) => {
177            if let Err(e) = std::fs::write(&diagnostics_path, json) {
178                warn!(
179                    "Could not write build system diagnostics to {}: {}",
180                    diagnostics_path.display(),
181                    e
182                );
183            }
184        }
185        Err(e) => {
186            warn!("Could not serialize detection result: {}", e);
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use tempfile::TempDir;
195
196    /// Helper: create marker files in the temp directory.
197    fn create_markers(dir: &Path, markers: &[&str]) {
198        for marker in markers {
199            let path = dir.join(marker);
200            // Create parent directories if needed (e.g., project/build.properties).
201            if let Some(parent) = path.parent() {
202                std::fs::create_dir_all(parent).unwrap();
203            }
204            std::fs::write(&path, "").unwrap();
205        }
206    }
207
208    #[test]
209    fn test_build_gradle_detected() {
210        let tmp = TempDir::new().unwrap();
211        create_markers(tmp.path(), &["build.gradle"]);
212
213        let result = detect_build_system(tmp.path(), None);
214        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
215        assert!(result.markers_found.contains(&"build.gradle".to_string()));
216        assert!(result.override_source.is_none());
217    }
218
219    #[test]
220    fn test_pom_xml_only_maven() {
221        let tmp = TempDir::new().unwrap();
222        create_markers(tmp.path(), &["pom.xml"]);
223
224        let result = detect_build_system(tmp.path(), None);
225        assert_eq!(result.build_system, Some(BuildSystem::Maven));
226        assert!(result.markers_found.contains(&"pom.xml".to_string()));
227    }
228
229    #[test]
230    fn test_build_and_pom_bazel_wins() {
231        let tmp = TempDir::new().unwrap();
232        create_markers(tmp.path(), &["BUILD", "pom.xml"]);
233
234        let result = detect_build_system(tmp.path(), None);
235        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
236        assert!(result.markers_found.contains(&"BUILD".to_string()));
237        assert!(result.markers_found.contains(&"pom.xml".to_string()));
238    }
239
240    #[test]
241    fn test_build_sbt_only() {
242        let tmp = TempDir::new().unwrap();
243        create_markers(tmp.path(), &["build.sbt"]);
244
245        let result = detect_build_system(tmp.path(), None);
246        assert_eq!(result.build_system, Some(BuildSystem::Sbt));
247        assert!(result.markers_found.contains(&"build.sbt".to_string()));
248    }
249
250    #[test]
251    fn test_no_markers_none() {
252        let tmp = TempDir::new().unwrap();
253
254        let result = detect_build_system(tmp.path(), None);
255        assert_eq!(result.build_system, None);
256        assert!(result.markers_found.is_empty());
257    }
258
259    #[test]
260    fn test_override_works() {
261        let tmp = TempDir::new().unwrap();
262        // Even though pom.xml is present, override should force Gradle.
263        create_markers(tmp.path(), &["pom.xml"]);
264
265        let result = detect_build_system(tmp.path(), Some("gradle"));
266        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
267        assert_eq!(result.override_source, Some("gradle".to_string()));
268        // Override skips marker scanning.
269        assert!(result.markers_found.is_empty());
270    }
271
272    #[test]
273    fn test_invalid_override_returns_none() {
274        let tmp = TempDir::new().unwrap();
275
276        let result = detect_build_system(tmp.path(), Some("ninja"));
277        assert_eq!(result.build_system, None);
278        assert_eq!(result.override_source, Some("ninja".to_string()));
279    }
280
281    #[test]
282    fn test_all_markers_bazel_wins() {
283        let tmp = TempDir::new().unwrap();
284        create_markers(
285            tmp.path(),
286            &["build.gradle", "pom.xml", "BUILD", "build.sbt", "WORKSPACE"],
287        );
288
289        let result = detect_build_system(tmp.path(), None);
290        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
291        // All markers should be recorded.
292        assert!(result.markers_found.len() >= 4);
293    }
294
295    #[test]
296    fn test_build_gradle_kts_detected() {
297        let tmp = TempDir::new().unwrap();
298        create_markers(tmp.path(), &["build.gradle.kts"]);
299
300        let result = detect_build_system(tmp.path(), None);
301        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
302        assert!(
303            result
304                .markers_found
305                .contains(&"build.gradle.kts".to_string())
306        );
307    }
308
309    #[test]
310    fn test_workspace_bazel_detected() {
311        let tmp = TempDir::new().unwrap();
312        create_markers(tmp.path(), &["WORKSPACE.bazel"]);
313
314        let result = detect_build_system(tmp.path(), None);
315        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
316        assert!(
317            result
318                .markers_found
319                .contains(&"WORKSPACE.bazel".to_string())
320        );
321    }
322
323    #[test]
324    fn test_diagnostics_file_written() {
325        let tmp = TempDir::new().unwrap();
326        create_markers(tmp.path(), &["pom.xml"]);
327
328        let _result = detect_build_system(tmp.path(), None);
329
330        let diagnostics_path = tmp.path().join(".sqry/classpath/build-system.json");
331        assert!(diagnostics_path.exists(), "diagnostics file should exist");
332
333        let contents = std::fs::read_to_string(&diagnostics_path).unwrap();
334        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
335        assert_eq!(parsed["build_system"], "Maven");
336    }
337
338    #[test]
339    fn test_project_root_recorded() {
340        let tmp = TempDir::new().unwrap();
341        let result = detect_build_system(tmp.path(), None);
342        assert_eq!(result.project_root, tmp.path());
343    }
344
345    #[test]
346    fn test_override_case_insensitive() {
347        let tmp = TempDir::new().unwrap();
348
349        let result = detect_build_system(tmp.path(), Some("MAVEN"));
350        assert_eq!(result.build_system, Some(BuildSystem::Maven));
351
352        let result = detect_build_system(tmp.path(), Some("Gradle"));
353        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
354
355        let result = detect_build_system(tmp.path(), Some("SBT"));
356        assert_eq!(result.build_system, Some(BuildSystem::Sbt));
357
358        let result = detect_build_system(tmp.path(), Some("BAZEL"));
359        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
360    }
361
362    #[test]
363    fn test_settings_gradle_detected() {
364        let tmp = TempDir::new().unwrap();
365        create_markers(tmp.path(), &["settings.gradle"]);
366
367        let result = detect_build_system(tmp.path(), None);
368        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
369    }
370
371    #[test]
372    fn test_settings_gradle_kts_detected() {
373        let tmp = TempDir::new().unwrap();
374        create_markers(tmp.path(), &["settings.gradle.kts"]);
375
376        let result = detect_build_system(tmp.path(), None);
377        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
378    }
379
380    #[test]
381    fn test_gradlew_detected() {
382        let tmp = TempDir::new().unwrap();
383        create_markers(tmp.path(), &["gradlew"]);
384
385        let result = detect_build_system(tmp.path(), None);
386        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
387    }
388
389    #[test]
390    fn test_module_bazel_detected() {
391        let tmp = TempDir::new().unwrap();
392        create_markers(tmp.path(), &["MODULE.bazel"]);
393
394        let result = detect_build_system(tmp.path(), None);
395        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
396    }
397
398    #[test]
399    fn test_sbt_project_build_properties() {
400        let tmp = TempDir::new().unwrap();
401        create_markers(tmp.path(), &["project/build.properties"]);
402
403        let result = detect_build_system(tmp.path(), None);
404        assert_eq!(result.build_system, Some(BuildSystem::Sbt));
405        assert!(
406            result
407                .markers_found
408                .contains(&"project/build.properties".to_string())
409        );
410    }
411
412    #[test]
413    fn test_gradle_vs_maven_gradle_wins() {
414        let tmp = TempDir::new().unwrap();
415        create_markers(tmp.path(), &["build.gradle", "pom.xml"]);
416
417        let result = detect_build_system(tmp.path(), None);
418        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
419    }
420
421    #[test]
422    fn test_gradle_vs_sbt_gradle_wins() {
423        let tmp = TempDir::new().unwrap();
424        create_markers(tmp.path(), &["build.gradle", "build.sbt"]);
425
426        let result = detect_build_system(tmp.path(), None);
427        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
428    }
429
430    #[test]
431    fn test_maven_vs_sbt_maven_wins() {
432        let tmp = TempDir::new().unwrap();
433        create_markers(tmp.path(), &["pom.xml", "build.sbt"]);
434
435        let result = detect_build_system(tmp.path(), None);
436        assert_eq!(result.build_system, Some(BuildSystem::Maven));
437    }
438
439    #[test]
440    fn test_multiple_gradle_markers() {
441        let tmp = TempDir::new().unwrap();
442        create_markers(tmp.path(), &["build.gradle", "settings.gradle", "gradlew"]);
443
444        let result = detect_build_system(tmp.path(), None);
445        assert_eq!(result.build_system, Some(BuildSystem::Gradle));
446        assert_eq!(result.markers_found.len(), 3);
447    }
448
449    #[test]
450    fn test_multiple_bazel_markers() {
451        let tmp = TempDir::new().unwrap();
452        create_markers(
453            tmp.path(),
454            &["BUILD", "BUILD.bazel", "WORKSPACE", "MODULE.bazel"],
455        );
456
457        let result = detect_build_system(tmp.path(), None);
458        assert_eq!(result.build_system, Some(BuildSystem::Bazel));
459        assert_eq!(result.markers_found.len(), 4);
460    }
461}