Skip to main content

buildfix_core_runtime/
adapters.rs

1//! Default filesystem and in-memory adapters for pipeline ports.
2
3#[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/// Loads receipts from the filesystem via `buildfix_receipts::load_receipts`.
15#[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/// Git operations via `buildfix_edit` shell helpers.
37#[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/// In-memory receipt source for embedding and testing.
99///
100/// Accepts pre-loaded receipts, filters out reserved non-sensor receipts
101/// (buildfix, cockpit) by `sensor_id` **or** path prefix (belt-and-suspenders),
102/// mirroring the fs loader's self-ingest guard, and sorts by path on
103/// construction to match `FsReceiptSource`'s deterministic ordering.
104#[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/// Filesystem write operations.
148#[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}