1#[cfg(feature = "git")]
4use super::ports::GitPort;
5use super::ports::ReceiptSource;
6#[cfg(feature = "fs")]
7use super::ports::WritePort;
8use anyhow::Context;
9use buildfix_receipts::LoadedReceipt;
10use camino::{Utf8Path, Utf8PathBuf};
11#[cfg(feature = "memory")]
12use tracing::debug;
13
14#[cfg(feature = "fs")]
16#[derive(Debug, Clone)]
17pub struct FsReceiptSource {
18 pub artifacts_dir: Utf8PathBuf,
19}
20
21#[cfg(feature = "fs")]
22impl FsReceiptSource {
23 pub fn new(artifacts_dir: Utf8PathBuf) -> Self {
24 Self { artifacts_dir }
25 }
26}
27
28#[cfg(feature = "fs")]
29impl ReceiptSource for FsReceiptSource {
30 fn load_receipts(&self) -> anyhow::Result<Vec<LoadedReceipt>> {
31 buildfix_receipts::load_receipts(&self.artifacts_dir)
32 .with_context(|| format!("load receipts from {}", self.artifacts_dir))
33 }
34}
35
36#[cfg(feature = "git")]
38#[derive(Debug, Clone, Default)]
39pub struct ShellGitPort;
40
41#[cfg(feature = "git")]
42impl GitPort for ShellGitPort {
43 fn head_sha(&self, repo_root: &Utf8Path) -> anyhow::Result<Option<String>> {
44 match buildfix_edit::get_head_sha(repo_root) {
45 Ok(sha) => Ok(Some(sha)),
46 Err(_) => Ok(None),
47 }
48 }
49
50 fn is_dirty(&self, repo_root: &Utf8Path) -> anyhow::Result<Option<bool>> {
51 match buildfix_edit::is_working_tree_dirty(repo_root) {
52 Ok(dirty) => Ok(Some(dirty)),
53 Err(_) => Ok(None),
54 }
55 }
56
57 fn commit_all(&self, repo_root: &Utf8Path, message: &str) -> anyhow::Result<Option<String>> {
58 use std::process::Command;
59
60 let add = Command::new("git")
61 .args(["add", "-A"])
62 .current_dir(repo_root)
63 .output()
64 .with_context(|| format!("git add -A in {}", repo_root))?;
65 if !add.status.success() {
66 anyhow::bail!(
67 "git add -A failed: {}",
68 String::from_utf8_lossy(&add.stderr).trim()
69 );
70 }
71
72 let commit = Command::new("git")
73 .args(["commit", "-m", message])
74 .current_dir(repo_root)
75 .output()
76 .with_context(|| format!("git commit in {}", repo_root))?;
77
78 if !commit.status.success() {
79 let stdout = String::from_utf8_lossy(&commit.stdout);
80 let stderr = String::from_utf8_lossy(&commit.stderr);
81 let combined = format!("{}\n{}", stdout, stderr).to_ascii_lowercase();
82 if combined.contains("nothing to commit") || combined.contains("no changes added") {
83 return Ok(None);
84 }
85 anyhow::bail!(
86 "git commit failed: {}",
87 format!("{}\n{}", stdout.trim(), stderr.trim()).trim()
88 );
89 }
90
91 match self.head_sha(repo_root)? {
92 Some(sha) => Ok(Some(sha)),
93 None => anyhow::bail!("git commit succeeded but head sha is unavailable"),
94 }
95 }
96}
97
98#[cfg(feature = "memory")]
105#[derive(Debug, Clone)]
106pub struct InMemoryReceiptSource {
107 receipts: Vec<LoadedReceipt>,
108}
109
110#[cfg(feature = "memory")]
111impl InMemoryReceiptSource {
112 pub fn new(mut receipts: Vec<LoadedReceipt>) -> Self {
113 receipts.retain(|r| {
114 let sid = r.sensor_id.as_str().to_ascii_lowercase();
115 let p = r.path.as_str().replace('\\', "/");
116 let p = p.to_ascii_lowercase();
117
118 let is_buildfix = sid == "buildfix"
119 || p.starts_with("artifacts/buildfix/")
120 || p.contains("/artifacts/buildfix/");
121 let is_cockpit = sid == "cockpit"
122 || p.starts_with("artifacts/cockpit/")
123 || p.contains("/artifacts/cockpit/");
124
125 if is_buildfix || is_cockpit {
126 debug!(
127 path = r.path.as_str(),
128 sensor_id = r.sensor_id.as_str(),
129 "skipping non-sensor receipt"
130 );
131 return false;
132 }
133 true
134 });
135 receipts.sort_by(|a, b| a.path.cmp(&b.path));
136 Self { receipts }
137 }
138}
139
140#[cfg(feature = "memory")]
141impl ReceiptSource for InMemoryReceiptSource {
142 fn load_receipts(&self) -> anyhow::Result<Vec<LoadedReceipt>> {
143 Ok(self.receipts.clone())
144 }
145}
146
147#[cfg(feature = "fs")]
149#[derive(Debug, Clone, Default)]
150pub struct FsWritePort;
151
152#[cfg(feature = "fs")]
153impl WritePort for FsWritePort {
154 fn write_file(&self, path: &Utf8Path, contents: &[u8]) -> anyhow::Result<()> {
155 if let Some(parent) = path.parent() {
156 std::fs::create_dir_all(parent)
157 .with_context(|| format!("create parent dir for {}", path))?;
158 }
159 std::fs::write(path, contents).with_context(|| format!("write {}", path))
160 }
161
162 fn create_dir_all(&self, path: &Utf8Path) -> anyhow::Result<()> {
163 std::fs::create_dir_all(path).with_context(|| format!("create_dir_all {}", path))
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use buildfix_receipts::ReceiptLoadError;
171 use camino::Utf8Path;
172 use std::process::Command;
173 use tempfile::TempDir;
174
175 fn make_receipt(path: &str) -> LoadedReceipt {
176 LoadedReceipt {
177 path: Utf8PathBuf::from(path),
178 sensor_id: "test".to_string(),
179 receipt: Err(ReceiptLoadError::Io {
180 message: "stub".to_string(),
181 }),
182 }
183 }
184
185 fn make_receipt_with_sensor(path: &str, sensor_id: &str) -> LoadedReceipt {
186 LoadedReceipt {
187 path: Utf8PathBuf::from(path),
188 sensor_id: sensor_id.to_string(),
189 receipt: Err(ReceiptLoadError::Io {
190 message: "stub".to_string(),
191 }),
192 }
193 }
194
195 #[cfg(feature = "memory")]
196 #[test]
197 fn in_memory_sorts_by_path() {
198 let source = InMemoryReceiptSource::new(vec![
199 make_receipt("artifacts/z-sensor/report.json"),
200 make_receipt("artifacts/a-sensor/report.json"),
201 make_receipt("artifacts/m-sensor/report.json"),
202 ]);
203 let loaded = source.load_receipts().unwrap();
204 let paths: Vec<&str> = loaded.iter().map(|r| r.path.as_str()).collect();
205 assert_eq!(
206 paths,
207 vec![
208 "artifacts/a-sensor/report.json",
209 "artifacts/m-sensor/report.json",
210 "artifacts/z-sensor/report.json",
211 ]
212 );
213 }
214
215 #[cfg(feature = "memory")]
216 #[test]
217 fn in_memory_preserves_errors() {
218 let source = InMemoryReceiptSource::new(vec![make_receipt("artifacts/bad/report.json")]);
219 let loaded = source.load_receipts().unwrap();
220 assert_eq!(loaded.len(), 1);
221 assert!(loaded[0].receipt.is_err());
222 }
223
224 #[cfg(feature = "memory")]
225 #[test]
226 fn in_memory_empty_source() {
227 let source = InMemoryReceiptSource::new(vec![]);
228 let loaded = source.load_receipts().unwrap();
229 assert!(loaded.is_empty());
230 }
231
232 #[cfg(feature = "memory")]
233 #[test]
234 fn in_memory_filters_buildfix_by_sensor_id() {
235 let source = InMemoryReceiptSource::new(vec![make_receipt_with_sensor(
236 "some/arbitrary/path.json",
237 "buildfix",
238 )]);
239 let loaded = source.load_receipts().unwrap();
240 assert!(loaded.is_empty());
241 }
242
243 #[cfg(feature = "memory")]
244 #[test]
245 fn in_memory_filters_buildfix_by_path() {
246 let source = InMemoryReceiptSource::new(vec![make_receipt_with_sensor(
247 "artifacts/buildfix/report.json",
248 "unknown",
249 )]);
250 let loaded = source.load_receipts().unwrap();
251 assert!(loaded.is_empty());
252 }
253
254 #[cfg(feature = "memory")]
255 #[test]
256 fn in_memory_filters_cockpit_by_sensor_id() {
257 let source = InMemoryReceiptSource::new(vec![make_receipt_with_sensor(
258 "some/arbitrary/path.json",
259 "cockpit",
260 )]);
261 let loaded = source.load_receipts().unwrap();
262 assert!(loaded.is_empty());
263 }
264
265 #[cfg(feature = "memory")]
266 #[test]
267 fn in_memory_filters_cockpit_by_path() {
268 let source = InMemoryReceiptSource::new(vec![make_receipt_with_sensor(
269 "artifacts/cockpit/report.json",
270 "unknown",
271 )]);
272 let loaded = source.load_receipts().unwrap();
273 assert!(loaded.is_empty());
274 }
275
276 #[cfg(feature = "memory")]
277 #[test]
278 fn in_memory_filters_buildfix_by_backslash_path() {
279 let source = InMemoryReceiptSource::new(vec![make_receipt_with_sensor(
280 r"artifacts\buildfix\report.json",
281 "unknown",
282 )]);
283 let loaded = source.load_receipts().unwrap();
284 assert!(loaded.is_empty());
285 }
286
287 #[cfg(feature = "memory")]
288 #[test]
289 fn in_memory_filters_cockpit_by_absolute_path() {
290 let source = InMemoryReceiptSource::new(vec![make_receipt_with_sensor(
291 r"C:\repo\artifacts\cockpit\report.json",
292 "unknown",
293 )]);
294 let loaded = source.load_receipts().unwrap();
295 assert!(loaded.is_empty());
296 }
297
298 #[cfg(feature = "memory")]
299 #[test]
300 fn in_memory_filters_case_insensitive_path() {
301 let source = InMemoryReceiptSource::new(vec![make_receipt_with_sensor(
302 r"C:\repo\Artifacts\cockpit\report.json",
303 "unknown",
304 )]);
305 let loaded = source.load_receipts().unwrap();
306 assert!(loaded.is_empty());
307 }
308
309 #[cfg(feature = "memory")]
310 #[test]
311 fn in_memory_filters_reserved_among_others() {
312 let source = InMemoryReceiptSource::new(vec![
313 make_receipt_with_sensor("artifacts/z-sensor/report.json", "z-sensor"),
314 make_receipt_with_sensor("artifacts/buildfix/report.json", "buildfix"),
315 make_receipt_with_sensor("artifacts/cockpit/report.json", "unknown"),
316 make_receipt_with_sensor("artifacts/a-sensor/report.json", "a-sensor"),
317 ]);
318 let loaded = source.load_receipts().unwrap();
319 let paths: Vec<&str> = loaded.iter().map(|r| r.path.as_str()).collect();
320 assert_eq!(
321 paths,
322 vec![
323 "artifacts/a-sensor/report.json",
324 "artifacts/z-sensor/report.json"
325 ]
326 );
327 }
328
329 #[cfg(feature = "memory")]
330 #[test]
331 fn in_memory_filters_case_sensitive_only_for_reservations() {
332 let source = InMemoryReceiptSource::new(vec![
333 make_receipt_with_sensor("ARTIFACTS/BUILDFIX/REPORT.JSON", "unknown"),
334 make_receipt_with_sensor("artifacts/buildfix/report.json", "unknown"),
335 ]);
336 let loaded = source.load_receipts().unwrap();
337 assert_eq!(loaded.len(), 0);
338 }
339
340 #[cfg(feature = "fs")]
341 #[test]
342 fn fs_receipt_source_loads_from_artifacts() {
343 let temp = TempDir::new().expect("temp dir");
344 let artifacts = Utf8PathBuf::from_path_buf(temp.path().join("artifacts")).expect("utf8");
345 std::fs::create_dir_all(artifacts.join("builddiag")).expect("mkdir");
346 std::fs::write(
347 artifacts.join("builddiag").join("report.json"),
348 valid_receipt_json(),
349 )
350 .expect("write receipt");
351
352 let source = FsReceiptSource::new(artifacts.clone());
353 let receipts = source.load_receipts().expect("load receipts");
354 assert_eq!(receipts.len(), 1);
355 assert_eq!(receipts[0].sensor_id, "builddiag");
356 assert!(receipts[0].receipt.is_ok());
357 }
358
359 #[cfg(feature = "fs")]
360 fn valid_receipt_json() -> &'static str {
361 r#"{
362 "schema": "sensor.report.v1",
363 "tool": { "name": "builddiag", "version": "1.0.0" },
364 "verdict": { "status": "pass", "counts": { "findings": 0, "errors": 0, "warnings": 0 } },
365 "findings": []
366 }"#
367 }
368
369 #[cfg(feature = "fs")]
370 #[test]
371 fn fs_write_port_writes_and_creates_dirs() {
372 let temp = TempDir::new().expect("temp dir");
373 let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
374 let target = root.join("nested").join("file.txt");
375
376 let port = FsWritePort;
377 port.write_file(&target, b"hello").expect("write");
378
379 let contents = std::fs::read_to_string(&target).expect("read");
380 assert_eq!(contents, "hello");
381
382 let extra_dir = root.join("extra");
383 port.create_dir_all(&extra_dir).expect("mkdir");
384 assert!(extra_dir.exists());
385 }
386
387 #[cfg(feature = "git")]
388 fn run_git(root: &Utf8Path, args: &[&str]) {
389 let status = Command::new("git")
390 .args(args)
391 .current_dir(root)
392 .status()
393 .expect("run git");
394 assert!(status.success(), "git {:?} failed", args);
395 }
396
397 #[cfg(feature = "git")]
398 #[test]
399 fn shell_git_port_returns_none_outside_repo() {
400 let temp = TempDir::new().expect("temp dir");
401 let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
402 let port = ShellGitPort;
403 assert!(port.head_sha(&root).expect("head").is_none());
404 assert!(port.is_dirty(&root).expect("dirty").is_none());
405 }
406
407 #[cfg(feature = "git")]
408 #[test]
409 fn shell_git_port_reads_head_and_dirty() {
410 let temp = TempDir::new().expect("temp dir");
411 let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
412 std::fs::write(root.join("Cargo.toml"), "[workspace]\n").expect("write");
413
414 run_git(&root, &["init"]);
415 run_git(&root, &["config", "user.email", "test@example.com"]);
416 run_git(&root, &["config", "user.name", "Test User"]);
417 run_git(&root, &["add", "."]);
418 run_git(&root, &["commit", "-m", "init"]);
419
420 let port = ShellGitPort;
421 let head_before = port.head_sha(&root).expect("head before");
422 assert!(head_before.is_some());
423 assert_eq!(port.is_dirty(&root).expect("dirty"), Some(false));
424
425 std::fs::write(root.join("Cargo.toml"), "[workspace]\n# dirty\n").expect("write");
426 assert_eq!(port.is_dirty(&root).expect("dirty"), Some(true));
427
428 let committed = port
429 .commit_all(&root, "buildfix: test auto-commit")
430 .expect("commit");
431 assert!(committed.is_some());
432 assert_eq!(port.is_dirty(&root).expect("dirty after"), Some(false));
433 assert_ne!(committed, head_before);
434 }
435}