dev_prune/adapters/
dotnet_build.rs1use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
25use anyhow::{Result, anyhow};
26use std::fs;
27use std::path::{Path, PathBuf};
28
29const ASSETS_FILE: &str = "project.assets.json";
31
32const PROJECT_PATH_POINTER: &str = "/project/restore/projectPath";
34
35const PROJECT_EXTENSIONS: &[&str] = &["csproj", "fsproj", "vbproj"];
37
38const CONFIG_DIRS: &[&str] = &["Debug", "Release"];
40
41pub struct DotnetBuild;
43
44fn 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
63fn 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 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
94fn 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 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 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 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 fn restored_obj(dir: &Path, for_project: &str) {
209 let obj = dir.join("obj");
210 fs::create_dir_all(&obj).unwrap();
211 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 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 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 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 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 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 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}