Skip to main content

buildfix_artifacts/
lib.rs

1//! Artifact serialization and persistence for buildfix outputs.
2
3use anyhow::Context;
4use buildfix_render::{render_apply_md, render_comment_md, render_plan_md};
5use buildfix_types::apply::BuildfixApply;
6use buildfix_types::plan::BuildfixPlan;
7use buildfix_types::report::BuildfixReport;
8use buildfix_types::schema::BUILDFIX_REPORT_V1;
9use buildfix_types::wire::{PlanV1, ReportV1};
10use camino::Utf8Path;
11use std::collections::BTreeMap;
12use std::fs;
13
14/// Filesystem-facing abstraction for artifact emission.
15pub trait ArtifactWriter {
16    fn write_file(&self, path: &Utf8Path, contents: &[u8]) -> anyhow::Result<()>;
17    fn create_dir_all(&self, path: &Utf8Path) -> anyhow::Result<()>;
18}
19
20/// Standard filesystem implementation.
21#[derive(Debug, Default, Clone)]
22pub struct FsArtifactWriter;
23
24impl ArtifactWriter for FsArtifactWriter {
25    fn write_file(&self, path: &Utf8Path, contents: &[u8]) -> anyhow::Result<()> {
26        if let Some(parent) = path.parent() {
27            fs::create_dir_all(parent)
28                .with_context(|| format!("create parent dir for {}", path))?;
29        }
30        fs::write(path, contents).with_context(|| format!("write {}", path))
31    }
32
33    fn create_dir_all(&self, path: &Utf8Path) -> anyhow::Result<()> {
34        fs::create_dir_all(path).with_context(|| format!("create dir {}", path))
35    }
36}
37
38fn render_plan_report(
39    plan: &BuildfixPlan,
40    report: &BuildfixReport,
41    patch: &str,
42    out_dir: &Utf8Path,
43) -> anyhow::Result<BTreeMap<String, Vec<u8>>> {
44    let plan_wire = PlanV1::try_from(plan).context("convert plan to wire")?;
45    let plan_json = serde_json::to_string_pretty(&plan_wire).context("serialize plan")?;
46
47    let report_wire = ReportV1::from(report);
48    let report_json = serde_json::to_string_pretty(&report_wire).context("serialize report")?;
49
50    let mut extra_report = report.clone();
51    extra_report.schema = BUILDFIX_REPORT_V1.to_string();
52    let extras_wire = ReportV1::from(&extra_report);
53    let extras_json =
54        serde_json::to_string_pretty(&extras_wire).context("serialize extras report")?;
55
56    let mut files = BTreeMap::new();
57    files.insert(
58        out_dir.join("plan.json").to_string(),
59        plan_json.into_bytes(),
60    );
61    files.insert(
62        out_dir.join("plan.md").to_string(),
63        render_plan_md(plan).into_bytes(),
64    );
65    files.insert(
66        out_dir.join("comment.md").to_string(),
67        render_comment_md(plan).into_bytes(),
68    );
69    files.insert(
70        out_dir.join("patch.diff").to_string(),
71        patch.as_bytes().to_vec(),
72    );
73    files.insert(
74        out_dir.join("report.json").to_string(),
75        report_json.into_bytes(),
76    );
77    files.insert(
78        out_dir
79            .join("extras")
80            .join("buildfix.report.v1.json")
81            .to_string(),
82        extras_json.into_bytes(),
83    );
84    Ok(files)
85}
86
87fn render_apply_report(
88    apply: &BuildfixApply,
89    report: &BuildfixReport,
90    patch: &str,
91    out_dir: &Utf8Path,
92) -> anyhow::Result<BTreeMap<String, Vec<u8>>> {
93    let apply_wire =
94        buildfix_types::wire::ApplyV1::try_from(apply).context("convert apply to wire")?;
95    let apply_json = serde_json::to_string_pretty(&apply_wire).context("serialize apply")?;
96
97    let report_wire = ReportV1::from(report);
98    let report_json = serde_json::to_string_pretty(&report_wire).context("serialize report")?;
99
100    let mut extra_report = report.clone();
101    extra_report.schema = BUILDFIX_REPORT_V1.to_string();
102    let extras_wire = ReportV1::from(&extra_report);
103    let extras_json =
104        serde_json::to_string_pretty(&extras_wire).context("serialize extras report")?;
105
106    let mut files = BTreeMap::new();
107    files.insert(
108        out_dir.join("apply.json").to_string(),
109        apply_json.into_bytes(),
110    );
111    files.insert(
112        out_dir.join("apply.md").to_string(),
113        render_apply_md(apply).into_bytes(),
114    );
115    files.insert(
116        out_dir.join("patch.diff").to_string(),
117        patch.as_bytes().to_vec(),
118    );
119    files.insert(
120        out_dir.join("report.json").to_string(),
121        report_json.into_bytes(),
122    );
123    files.insert(
124        out_dir
125            .join("extras")
126            .join("buildfix.report.v1.json")
127            .to_string(),
128        extras_json.into_bytes(),
129    );
130    Ok(files)
131}
132
133fn write_files<W: ArtifactWriter>(
134    files: BTreeMap<String, Vec<u8>>,
135    writer: &W,
136) -> anyhow::Result<()> {
137    for (path, contents) in files {
138        writer.write_file(Utf8Path::new(&path), &contents)?;
139    }
140    Ok(())
141}
142
143/// Emit all plan artifacts (plan.json, plan.md, comment.md, patch, report, extras).
144pub fn write_plan_artifacts<W: ArtifactWriter>(
145    plan: &BuildfixPlan,
146    report: &BuildfixReport,
147    patch: &str,
148    out_dir: &Utf8Path,
149    writer: &W,
150) -> anyhow::Result<()> {
151    writer.create_dir_all(out_dir)?;
152    writer.create_dir_all(&out_dir.join("extras"))?;
153    let files = render_plan_report(plan, report, patch, out_dir)?;
154    write_files(files, writer)
155}
156
157/// Emit all apply artifacts (apply.json, apply.md, patch, report, extras).
158pub fn write_apply_artifacts<W: ArtifactWriter>(
159    apply: &BuildfixApply,
160    report: &BuildfixReport,
161    patch: &str,
162    out_dir: &Utf8Path,
163    writer: &W,
164) -> anyhow::Result<()> {
165    writer.create_dir_all(out_dir)?;
166    writer.create_dir_all(&out_dir.join("extras"))?;
167    let files = render_apply_report(apply, report, patch, out_dir)?;
168    write_files(files, writer)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::cell::RefCell;
175    use std::collections::HashMap;
176    use std::rc::Rc;
177
178    /// Mock writer for testing.
179    #[derive(Debug, Clone)]
180    struct MockArtifactWriter {
181        files: Rc<RefCell<HashMap<String, Vec<u8>>>>,
182        dirs: Rc<RefCell<Vec<String>>>,
183        fail_write: bool,
184    }
185
186    impl MockArtifactWriter {
187        fn new() -> Self {
188            Self {
189                files: Rc::new(RefCell::new(HashMap::new())),
190                dirs: Rc::new(RefCell::new(Vec::new())),
191                fail_write: false,
192            }
193        }
194
195        fn with_write_failure(self) -> Self {
196            Self {
197                fail_write: true,
198                ..self
199            }
200        }
201    }
202
203    impl ArtifactWriter for MockArtifactWriter {
204        fn write_file(&self, path: &Utf8Path, contents: &[u8]) -> anyhow::Result<()> {
205            if self.fail_write {
206                anyhow::bail!("simulated write failure");
207            }
208            self.files
209                .borrow_mut()
210                .insert(path.to_string(), contents.to_vec());
211            Ok(())
212        }
213
214        fn create_dir_all(&self, path: &Utf8Path) -> anyhow::Result<()> {
215            self.dirs.borrow_mut().push(path.to_string());
216            Ok(())
217        }
218    }
219
220    #[test]
221    fn test_fs_artifact_writer_creates_parent_dirs() {
222        let writer = FsArtifactWriter;
223        let temp = tempfile::tempdir().unwrap();
224        let path = temp.path().join("nested").join("file.txt");
225
226        // This should create parent directories
227        writer
228            .write_file(path.as_path().try_into().unwrap(), b"content")
229            .unwrap();
230
231        assert!(path.exists());
232    }
233
234    #[test]
235    fn test_fs_artifact_writer_write_and_read() {
236        let temp = tempfile::tempdir().unwrap();
237        let path = temp.path().join("test.txt");
238        let writer = FsArtifactWriter;
239
240        writer
241            .write_file(path.as_path().try_into().unwrap(), b"hello world")
242            .unwrap();
243
244        let contents = std::fs::read(&path).unwrap();
245        assert_eq!(contents, b"hello world");
246    }
247
248    #[test]
249    fn test_mock_writer_records_files() {
250        let writer = MockArtifactWriter::new();
251
252        writer
253            .write_file("test/path/file.txt".into(), b"content")
254            .unwrap();
255
256        let files = writer.files.borrow();
257        assert!(files.contains_key("test/path/file.txt"));
258        assert_eq!(files.get("test/path/file.txt").unwrap(), b"content");
259    }
260
261    #[test]
262    fn test_mock_writer_records_dirs() {
263        let writer = MockArtifactWriter::new();
264
265        writer.create_dir_all("a/b/c".into()).unwrap();
266
267        let dirs = writer.dirs.borrow();
268        assert!(dirs.contains(&"a/b/c".to_string()));
269    }
270
271    #[test]
272    fn test_mock_writer_propagates_write_errors() {
273        let writer = MockArtifactWriter::new().with_write_failure();
274
275        let result = writer.write_file("test.txt".into(), b"content");
276
277        assert!(result.is_err());
278        assert_eq!(result.unwrap_err().to_string(), "simulated write failure");
279    }
280}