1use 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
14pub 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#[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
143pub 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
157pub 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 #[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 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}