Skip to main content

dev_prune/adapters/
dotnet_build.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// .NET build-output adapter.
5//
6// This is the adapter for the question "is that `bin/` yours or MSBuild's?", and the
7// answer is never the directory's name — plenty of repositories commit a `bin/` full of
8// scripts. The proof is NuGet's: restore writes `obj/project.assets.json` and nobody
9// writes one by hand, and that file records the path of the project it was restored
10// for, precisely so the build can find its dependency graph again. So `obj/` is claimed
11// only when it carries an assets file naming a project file that is still sitting in
12// this directory, and `bin/` is claimed only alongside a proven `obj/` — and only while
13// everything in it is a build-configuration directory (`Debug`, `Release`). One
14// committed script inside `bin/` refuses the whole directory.
15//
16// Deliberately missed, and fine to miss: custom configuration names (`bin/Staging`),
17// the `UseArtifactsOutput` layout (which moves output to `artifacts/`, out of these
18// directories entirely), and packages.config-era projects, which never write an assets
19// file. All of them stay untouched, which is the failure direction this tool prefers.
20//
21// Opt-in, and held to `build_idle_days`: `bin/` and `obj/` are compiled output, and
22// `dotnet build` puts them back by restoring packages and compiling again.
23
24use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
25use anyhow::{Result, anyhow};
26use std::fs;
27use std::path::{Path, PathBuf};
28
29/// The file NuGet's restore writes into `obj/`. Its contents are the whole proof.
30const ASSETS_FILE: &str = "project.assets.json";
31
32/// The JSON pointer to the project the assets file was restored for.
33const PROJECT_PATH_POINTER: &str = "/project/restore/projectPath";
34
35/// The MSBuild project file extensions this adapter detects on.
36const PROJECT_EXTENSIONS: &[&str] = &["csproj", "fsproj", "vbproj"];
37
38/// The only names `bin/` may contain and still be claimed.
39const CONFIG_DIRS: &[&str] = &["Debug", "Release"];
40
41/// .NET build-output adapter. Opt-in; see the module comment.
42pub struct DotnetBuild;
43
44/// The MSBuild project files sitting directly in `dir`, sorted.
45fn project_files(dir: &Path) -> Vec<PathBuf> {
46    let Ok(entries) = fs::read_dir(dir) else {
47        return Vec::new();
48    };
49    let mut found: Vec<PathBuf> = entries
50        .flatten()
51        .map(|e| e.path())
52        .filter(|p| {
53            p.is_file()
54                && p.extension()
55                    .and_then(|e| e.to_str())
56                    .is_some_and(|e| PROJECT_EXTENSIONS.contains(&e))
57        })
58        .collect();
59    found.sort();
60    found
61}
62
63/// Whether `project`'s `obj/` was written by a NuGet restore of a project still here.
64///
65/// The recorded path is compared by file name only: the repository may have been moved
66/// or cloned somewhere else since the restore ran, and the assets file still proves what
67/// wrote the directory even when the absolute path it recorded no longer exists. The
68/// comparison ignores ASCII case because the file name came through a Windows
69/// filesystem at least once.
70fn obj_is_nugets(project: &Path) -> bool {
71    let Ok(raw) = fs::read_to_string(project.join("obj").join(ASSETS_FILE)) else {
72        return false;
73    };
74    let Ok(assets) = serde_json::from_str::<serde_json::Value>(&raw) else {
75        return false;
76    };
77    let Some(recorded) = assets
78        .pointer(PROJECT_PATH_POINTER)
79        .and_then(|v| v.as_str())
80    else {
81        return false;
82    };
83    // The assets file records the path with whatever separators the restoring OS used.
84    let Some(recorded_name) = recorded.rsplit(['/', '\\']).next() else {
85        return false;
86    };
87    project_files(project).iter().any(|p| {
88        p.file_name()
89            .and_then(|n| n.to_str())
90            .is_some_and(|n| n.eq_ignore_ascii_case(recorded_name))
91    })
92}
93
94/// Whether `bin/` holds build configurations and nothing else.
95fn bin_is_only_build_output(bin: &Path) -> bool {
96    let Ok(entries) = fs::read_dir(bin) else {
97        return false;
98    };
99    let mut any = false;
100    for entry in entries.flatten() {
101        let is_config_dir = entry.path().is_dir()
102            && entry
103                .file_name()
104                .to_str()
105                .is_some_and(|n| CONFIG_DIRS.iter().any(|c| n.eq_ignore_ascii_case(c)));
106        if !is_config_dir {
107            return false;
108        }
109        any = true;
110    }
111    any
112}
113
114impl PackageManager for DotnetBuild {
115    fn name(&self) -> &'static str {
116        "dotnet_build"
117    }
118
119    fn detect(&self, path: &Path) -> bool {
120        !project_files(path).is_empty()
121    }
122
123    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
124        // No proven `obj/` means no claim on anything: `bin/` on its own has nothing
125        // machine-written in it to say whose output it is.
126        if !obj_is_nugets(path) {
127            return Vec::new();
128        }
129        let mut dirs = Vec::new();
130        let bin = path.join("bin");
131        if bin.is_dir() && bin_is_only_build_output(&bin) {
132            dirs.push(BloatDir {
133                name: "bin".to_string(),
134                size_bytes: dir_size(&bin),
135                path: bin,
136                shared_bytes: 0,
137            });
138        }
139        let obj = path.join("obj");
140        dirs.push(BloatDir {
141            name: "obj".to_string(),
142            size_bytes: dir_size(&obj),
143            path: obj,
144            shared_bytes: 0,
145        });
146        dirs
147    }
148
149    /// The per-directory proof already ran: [`obj_is_nugets`] claimed the output only
150    /// after the assets file named a project still in this directory. What is left to
151    /// check is that the project files themselves still read as MSBuild XML, because
152    /// they are what `dotnet build` is about to be pointed at. Running `dotnet` here to
153    /// find out would start a restore in the middle of a delete pass, which is the
154    /// opposite of what was asked for.
155    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
156        let projects = project_files(path);
157        if projects.is_empty() {
158            return Err(anyhow!(
159                "no MSBuild project file (*.csproj, *.fsproj, *.vbproj) left in `{}` — \
160                 nothing for `dotnet build` to rebuild from.",
161                path.display()
162            ));
163        }
164        for project in &projects {
165            let name = project.file_name().unwrap_or_default().to_string_lossy();
166            let content = fs::read_to_string(project).map_err(|e| {
167                anyhow!("`{name}` could not be read ({e}) — nothing to rebuild the output from.")
168            })?;
169            if !content.contains("<Project") {
170                return Err(anyhow!(
171                    "`{name}` has no `<Project` root — refusing to treat the build output \
172                     as rebuildable from it."
173                ));
174            }
175        }
176        Ok(())
177    }
178
179    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
180        println!(
181            ".NET build output will come back on the next `dotnet build` — it restores \
182             packages and recompiles in one step"
183        );
184        Ok(())
185    }
186
187    fn opt_in(&self) -> bool {
188        true
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use tempfile::tempdir;
196
197    /// A project directory holding `App.csproj`.
198    fn project(dir: &Path) -> PathBuf {
199        fs::write(
200            dir.join("App.csproj"),
201            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>\n",
202        )
203        .unwrap();
204        dir.to_path_buf()
205    }
206
207    /// An `obj/` written the way NuGet's restore writes one, recording `for_project`.
208    fn restored_obj(dir: &Path, for_project: &str) {
209        let obj = dir.join("obj");
210        fs::create_dir_all(&obj).unwrap();
211        // Escaped the way a JSON writer would have: the assets file is parsed, not
212        // pattern-matched, so the fixture has to be valid JSON.
213        let recorded = for_project.replace('\\', "\\\\");
214        fs::write(
215            obj.join(ASSETS_FILE),
216            format!(
217                "{{\"version\":3,\"project\":{{\"restore\":{{\"projectPath\":\"{recorded}\"}}}}}}"
218            ),
219        )
220        .unwrap();
221        fs::write(obj.join("App.csproj.nuget.g.props"), "<Project />").unwrap();
222    }
223
224    fn build_config(dir: &Path, name: &str) {
225        let config = dir.join("bin").join(name);
226        fs::create_dir_all(&config).unwrap();
227        fs::write(config.join("App.dll"), "compiled").unwrap();
228    }
229
230    fn claimed(project: &Path) -> Vec<String> {
231        DotnetBuild
232            .bloat_dirs(project)
233            .into_iter()
234            .map(|b| b.name)
235            .collect()
236    }
237
238    #[test]
239    fn detects_on_a_project_file_in_the_directory() {
240        let dir = tempdir().unwrap();
241        assert!(!DotnetBuild.detect(dir.path()));
242        project(dir.path());
243        assert!(DotnetBuild.detect(dir.path()));
244    }
245
246    #[test]
247    fn the_assets_file_is_what_separates_nugets_obj_from_yours() {
248        // The whole point of the adapter: `obj/` full of build state is claimed, and an
249        // `obj/` somebody made by hand — no assets file — is never touched.
250        let dir = tempdir().unwrap();
251        let root = project(dir.path());
252        fs::create_dir_all(root.join("obj")).unwrap();
253        fs::write(root.join("obj").join("notes.txt"), "hand made").unwrap();
254        assert!(claimed(&root).is_empty());
255
256        restored_obj(&root, "C:\\src\\App\\App.csproj");
257        assert_eq!(claimed(&root), vec!["obj"]);
258    }
259
260    #[test]
261    fn an_assets_file_for_someone_elses_project_is_refused() {
262        // An `obj/` copied in from another project records that project's file, which is
263        // not here — whatever this directory is, `dotnet build` here did not write it.
264        let dir = tempdir().unwrap();
265        let root = project(dir.path());
266        restored_obj(&root, "/src/other/Other.csproj");
267
268        assert!(claimed(&root).is_empty());
269    }
270
271    #[test]
272    fn a_moved_repository_still_proves_its_own_obj() {
273        // The recorded path is from wherever the restore ran; only the file name has to
274        // still match, so a cloned or moved checkout keeps its claim.
275        let dir = tempdir().unwrap();
276        let root = project(dir.path());
277        restored_obj(&root, "/home/somebody/else/entirely/App.csproj");
278
279        assert_eq!(claimed(&root), vec!["obj"]);
280    }
281
282    #[test]
283    fn bin_is_only_claimed_alongside_a_proven_obj() {
284        // `bin/Debug` with no restored `obj/` has nothing machine-written to say whose
285        // output it is, so nothing at all is claimed.
286        let dir = tempdir().unwrap();
287        let root = project(dir.path());
288        build_config(&root, "Debug");
289
290        assert!(claimed(&root).is_empty());
291    }
292
293    #[test]
294    fn one_committed_file_in_bin_refuses_the_whole_directory() {
295        // The generic name is the hazard: a repository can commit `bin/run.sh` next to
296        // where MSBuild writes `bin/Debug`. The build configurations could be claimed
297        // alone, but a `bin/` that is partly someone's is left entirely alone.
298        let dir = tempdir().unwrap();
299        let root = project(dir.path());
300        restored_obj(&root, "App.csproj");
301        build_config(&root, "Debug");
302        fs::write(root.join("bin").join("run.sh"), "#!/bin/sh").unwrap();
303
304        assert_eq!(claimed(&root), vec!["obj"]);
305    }
306
307    #[test]
308    fn a_directory_of_committed_tools_in_bin_refuses_it_too() {
309        let dir = tempdir().unwrap();
310        let root = project(dir.path());
311        restored_obj(&root, "App.csproj");
312        build_config(&root, "Release");
313        fs::create_dir_all(root.join("bin").join("tools")).unwrap();
314
315        assert_eq!(claimed(&root), vec!["obj"]);
316    }
317
318    #[test]
319    fn configuration_names_match_whatever_their_casing_is() {
320        // `debug`/`RELEASE` come out of case-insensitive filesystems and hand-typed
321        // `-c` flags alike.
322        let dir = tempdir().unwrap();
323        let root = project(dir.path());
324        restored_obj(&root, "app.csproj");
325        build_config(&root, "debug");
326        build_config(&root, "RELEASE");
327
328        assert_eq!(claimed(&root), vec!["bin", "obj"]);
329    }
330
331    #[test]
332    fn an_empty_bin_is_not_claimed() {
333        let dir = tempdir().unwrap();
334        let root = project(dir.path());
335        restored_obj(&root, "App.csproj");
336        fs::create_dir_all(root.join("bin")).unwrap();
337
338        assert_eq!(claimed(&root), vec!["obj"]);
339    }
340
341    #[test]
342    fn a_missing_or_bogus_project_file_is_refused() {
343        let dir = tempdir().unwrap();
344        let policy = EnforcePolicy::default();
345        assert!(DotnetBuild.enforce_lockfile(dir.path(), policy).is_err());
346        fs::write(dir.path().join("App.csproj"), "hello there").unwrap();
347        assert!(DotnetBuild.enforce_lockfile(dir.path(), policy).is_err());
348        project(dir.path());
349        assert!(DotnetBuild.enforce_lockfile(dir.path(), policy).is_ok());
350    }
351
352    #[test]
353    fn dotnet_build_is_opt_in() {
354        assert!(DotnetBuild.opt_in());
355    }
356}