Skip to main content

dev_prune/adapters/
sbt.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// sbt build tree adapter.
5//
6// sbt compiles a Scala project into `target/` beside `build.sbt`, and compiles the
7// build definition itself into `project/target/`. Both are written only by sbt and
8// regenerate in full from `sbt compile`, which is why sbt's own `.gitignore` guidance
9// excludes them. The claim is exactly those two paths: sub-project `target/`
10// directories deeper in a multi-module build are left to a later pass to learn about,
11// because walking for them means guessing at what is a module and what is not.
12//
13// A repository that is both an sbt and a Maven or Gradle project deduplicates by path
14// in the engine: whichever enabled adapter claims `target/` first owns it, and it is
15// deleted once either way.
16//
17// Opt-in, and held to `build_idle_days`: getting the directories back means
18// recompiling the project and the build definition.
19
20use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
21use anyhow::{Result, anyhow};
22use std::fs;
23use std::path::Path;
24
25/// The build definition at every sbt project's root.
26const MANIFEST: &str = "build.sbt";
27
28/// sbt build tree adapter. Opt-in; see the module comment.
29pub struct Sbt;
30
31impl PackageManager for Sbt {
32    fn name(&self) -> &'static str {
33        "sbt"
34    }
35
36    fn detect(&self, path: &Path) -> bool {
37        path.join(MANIFEST).is_file()
38    }
39
40    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
41        let mut dirs = Vec::new();
42        let target = path.join("target");
43        if target.is_dir() {
44            dirs.push(BloatDir {
45                name: "target".to_string(),
46                size_bytes: dir_size(&target),
47                path: target,
48                shared_bytes: 0,
49            });
50        }
51        let project_target = path.join("project").join("target");
52        if project_target.is_dir() {
53            dirs.push(BloatDir {
54                // Named by its path from the root: a second bare "target" row would
55                // read as the same directory twice.
56                name: "project/target".to_string(),
57                size_bytes: dir_size(&project_target),
58                path: project_target,
59                shared_bytes: 0,
60            });
61        }
62        dirs
63    }
64
65    /// What is checked is that the build definition still reads as one, because it is
66    /// what `sbt compile` is about to execute. Nearly every `build.sbt` assigns a
67    /// setting with `:=`; the rare one that does not (a bare aggregation root) is
68    /// accepted when `project/build.properties` pins an `sbt.version`, which is sbt's
69    /// own marker for the project root. Running sbt here to find out would start a
70    /// compile, and JVM spin-up besides, in the middle of a delete pass.
71    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
72        let manifest = path.join(MANIFEST);
73        let content = fs::read_to_string(&manifest).map_err(|e| {
74            anyhow!("`{MANIFEST}` could not be read ({e}): nothing to rebuild the targets from.")
75        })?;
76        if content.contains(":=") {
77            return Ok(());
78        }
79        let properties = path.join("project").join("build.properties");
80        if fs::read_to_string(&properties)
81            .map(|p| p.contains("sbt.version"))
82            .unwrap_or(false)
83        {
84            return Ok(());
85        }
86        Err(anyhow!(
87            "`{MANIFEST}` assigns no settings and `project/build.properties` pins no \
88             `sbt.version`: refusing to treat the build trees as regenerable from it."
89        ))
90    }
91
92    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
93        println!("sbt's build trees regenerate on the next `sbt compile`");
94        Ok(())
95    }
96
97    fn lockfiles(&self) -> &'static [&'static str] {
98        &[MANIFEST]
99    }
100
101    fn opt_in(&self) -> bool {
102        true
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::path::PathBuf;
110    use tempfile::tempdir;
111
112    /// A project root holding a plausible `build.sbt`.
113    fn project(dir: &Path) -> PathBuf {
114        fs::write(
115            dir.join(MANIFEST),
116            "name := \"demo\"\n\nscalaVersion := \"3.4.2\"\n",
117        )
118        .unwrap();
119        dir.to_path_buf()
120    }
121
122    fn claimed(project: &Path) -> Vec<String> {
123        Sbt.bloat_dirs(project)
124            .into_iter()
125            .map(|b| b.name)
126            .collect()
127    }
128
129    #[test]
130    fn detects_on_the_build_definition() {
131        let dir = tempdir().unwrap();
132        assert!(!Sbt.detect(dir.path()));
133        project(dir.path());
134        assert!(Sbt.detect(dir.path()));
135    }
136
137    #[test]
138    fn both_target_trees_are_claimed_and_nothing_else() {
139        let dir = tempdir().unwrap();
140        let root = project(dir.path());
141        fs::create_dir(root.join("target")).unwrap();
142        fs::create_dir_all(root.join("project").join("target")).unwrap();
143        fs::create_dir(root.join("src")).unwrap();
144
145        assert_eq!(claimed(&root), vec!["target", "project/target"]);
146    }
147
148    #[test]
149    fn a_settingless_root_passes_when_build_properties_pins_sbt() {
150        let dir = tempdir().unwrap();
151        fs::write(dir.path().join(MANIFEST), "// aggregation root\n").unwrap();
152        fs::create_dir(dir.path().join("project")).unwrap();
153        fs::write(
154            dir.path().join("project").join("build.properties"),
155            "sbt.version=1.10.1\n",
156        )
157        .unwrap();
158        assert!(
159            Sbt.enforce_lockfile(dir.path(), EnforcePolicy::default())
160                .is_ok()
161        );
162    }
163
164    #[test]
165    fn a_missing_or_bogus_build_definition_is_refused() {
166        let dir = tempdir().unwrap();
167        let policy = EnforcePolicy::default();
168        assert!(Sbt.enforce_lockfile(dir.path(), policy).is_err());
169        fs::write(dir.path().join(MANIFEST), "// nothing assigned\n").unwrap();
170        assert!(Sbt.enforce_lockfile(dir.path(), policy).is_err());
171        project(dir.path());
172        assert!(Sbt.enforce_lockfile(dir.path(), policy).is_ok());
173    }
174
175    #[test]
176    fn sbt_is_opt_in() {
177        assert!(Sbt.opt_in());
178    }
179}