1use std::path::Path;
7
8use chrono::Utc;
9use covguard_types::{
10 CHECK_ID_RUNTIME, CODE_RUNTIME_ERROR, Capabilities, Finding, InputCapability, InputStatus,
11 Inputs, InputsCapability, Report, ReportData, Run, Severity, Tool, Verdict, VerdictCounts,
12 VerdictStatus, compute_fingerprint,
13};
14use thiserror::Error;
15
16const RAW_ARTIFACTS_DIR: &str = "artifacts/covguard/raw";
17
18#[derive(Debug, Error)]
20pub enum ArtifactWriteError {
21 #[error("Failed to create directory '{path}': {source}")]
23 DirCreate {
24 path: String,
25 #[source]
26 source: std::io::Error,
27 },
28
29 #[error("Failed to write file '{path}': {source}")]
31 FileWrite {
32 path: String,
33 #[source]
34 source: std::io::Error,
35 },
36
37 #[error("Failed to serialize report: {0}")]
39 Serialize(#[from] serde_json::Error),
40}
41
42#[derive(Debug, Default)]
44pub struct FsArtifactWriter;
45
46impl FsArtifactWriter {
47 pub fn new() -> Self {
49 Self
50 }
51}
52
53pub fn write_report(path: &str, report: &Report) -> Result<(), ArtifactWriteError> {
55 let body = serde_json::to_string_pretty(report)?;
56 write_text(path, &body)
57}
58
59pub fn write_fallback_receipt(
61 out_path: &str,
62 error_message: &str,
63 diff_reason: &str,
64 coverage_reason: &str,
65) -> Result<(), ArtifactWriteError> {
66 let report = fallback_report(error_message, diff_reason, coverage_reason);
67 write_report(out_path, &report)
68}
69
70pub fn write_raw_artifacts(
72 diff_content: &str,
73 lcov_texts: &[String],
74) -> Result<(), ArtifactWriteError> {
75 write_raw_artifacts_to(Path::new(RAW_ARTIFACTS_DIR), diff_content, lcov_texts)
76}
77
78pub fn write_raw_artifacts_to(
80 raw_dir: &Path,
81 diff_content: &str,
82 lcov_texts: &[String],
83) -> Result<(), ArtifactWriteError> {
84 if !raw_dir.exists() {
85 ensure_directory(raw_dir)?;
86 }
87
88 let diff_path = raw_dir.join("diff.patch");
89 let lcov_path = raw_dir.join("lcov.info");
90 let combined = lcov_texts.join("\n");
91
92 write_text_path(&diff_path, diff_content)?;
93 write_text_path(&lcov_path, &combined)?;
94 Ok(())
95}
96
97pub fn ensure_parent_dir(path: &str) -> Result<(), ArtifactWriteError> {
99 ensure_parent_dir_path(Path::new(path))
100}
101
102fn ensure_parent_dir_path(path: &Path) -> Result<(), ArtifactWriteError> {
104 let parent = path.parent();
105 if let Some(parent) = parent
106 && !parent.as_os_str().is_empty()
107 && !parent.exists()
108 {
109 ensure_directory(parent)?;
110 }
111 Ok(())
112}
113
114fn write_text_path(path: &Path, body: &str) -> Result<(), ArtifactWriteError> {
115 ensure_parent_dir_path(path)?;
116 std::fs::write(path, body).map_err(|source| ArtifactWriteError::FileWrite {
117 path: path.display().to_string(),
118 source,
119 })
120}
121
122fn ensure_directory(path: &Path) -> Result<(), ArtifactWriteError> {
123 std::fs::create_dir_all(path).map_err(|source| ArtifactWriteError::DirCreate {
124 path: path.display().to_string(),
125 source,
126 })
127}
128
129pub fn write_text(path: &str, body: &str) -> Result<(), ArtifactWriteError> {
131 write_text_path(Path::new(path), body)
132}
133
134impl FsArtifactWriter {
135 pub fn write_report(&self, path: &str, report: &Report) -> Result<(), ArtifactWriteError> {
137 write_report(path, report)
138 }
139
140 pub fn write_fallback_receipt(
142 &self,
143 out_path: &str,
144 error_message: &str,
145 diff_reason: &str,
146 coverage_reason: &str,
147 ) -> Result<(), ArtifactWriteError> {
148 write_fallback_receipt(out_path, error_message, diff_reason, coverage_reason)
149 }
150
151 pub fn write_text(&self, path: &str, body: &str) -> Result<(), ArtifactWriteError> {
153 write_text(path, body)
154 }
155
156 pub fn write_raw_artifacts(
158 &self,
159 diff_content: &str,
160 lcov_texts: &[String],
161 ) -> Result<(), ArtifactWriteError> {
162 write_raw_artifacts(diff_content, lcov_texts)
163 }
164
165 pub fn write_raw_artifacts_to(
167 &self,
168 raw_dir: &Path,
169 diff_content: &str,
170 lcov_texts: &[String],
171 ) -> Result<(), ArtifactWriteError> {
172 write_raw_artifacts_to(raw_dir, diff_content, lcov_texts)
173 }
174
175 pub fn ensure_parent_dir(&self, path: &str) -> Result<(), ArtifactWriteError> {
177 ensure_parent_dir(path)
178 }
179}
180
181fn fallback_report(error_message: &str, diff_reason: &str, coverage_reason: &str) -> Report {
182 let started_at = Utc::now();
183 let runtime_fp = compute_fingerprint(&[CODE_RUNTIME_ERROR, "covguard"]);
184
185 Report {
186 schema: "sensor.report.v1".to_string(),
187 tool: Tool {
188 name: "covguard".to_string(),
189 version: env!("CARGO_PKG_VERSION").to_string(),
190 commit: None,
191 },
192 run: Run {
193 started_at: started_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
194 ended_at: Some(started_at.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
195 duration_ms: Some(0),
196 capabilities: Some(Capabilities {
197 inputs: InputsCapability {
198 diff: InputCapability {
199 status: InputStatus::Unavailable,
200 reason: Some(diff_reason.to_string()),
201 },
202 coverage: InputCapability {
203 status: InputStatus::Unavailable,
204 reason: Some(coverage_reason.to_string()),
205 },
206 },
207 }),
208 },
209 verdict: Verdict {
210 status: VerdictStatus::Fail,
211 counts: VerdictCounts {
212 info: 0,
213 warn: 0,
214 error: 1,
215 },
216 reasons: vec![CODE_RUNTIME_ERROR.to_string()],
217 },
218 findings: vec![Finding {
219 severity: Severity::Error,
220 check_id: CHECK_ID_RUNTIME.to_string(),
221 code: CODE_RUNTIME_ERROR.to_string(),
222 message: format!("covguard failed due to a runtime error: {error_message}"),
223 location: None,
224 data: None,
225 fingerprint: Some(runtime_fp),
226 }],
227 data: ReportData {
228 scope: "added".to_string(),
229 threshold_pct: 0.0,
230 changed_lines_total: 0,
231 covered_lines: 0,
232 uncovered_lines: 0,
233 missing_lines: 0,
234 ignored_lines_count: 0,
235 excluded_files_count: 0,
236 diff_coverage_pct: 0.0,
237 inputs: Inputs {
238 diff_source: "unknown".to_string(),
239 diff_file: None,
240 base: None,
241 head: None,
242 lcov_paths: vec![],
243 },
244 debug: None,
245 truncation: None,
246 },
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use std::fs;
254
255 #[test]
256 fn builds_fallback_receipt() {
257 let report = super::fallback_report("boom", "missing_diff", "missing_lcov");
258
259 assert_eq!(report.schema, "sensor.report.v1");
260 assert_eq!(report.verdict.status, VerdictStatus::Fail);
261 assert_eq!(
262 report.findings.first().expect("finding").message,
263 "covguard failed due to a runtime error: boom"
264 );
265 }
266
267 #[test]
268 fn writes_raw_artifacts_to_explicit_dir() {
269 let dir = std::env::temp_dir().join("covguard-adapters-artifacts-raw");
270 let _ = fs::remove_dir_all(&dir);
271 write_raw_artifacts_to(&dir, "diff", &["lcov".to_string()]).expect("write raw");
272
273 assert_eq!(
274 fs::read_to_string(dir.join("diff.patch")).expect("diff"),
275 "diff"
276 );
277 assert_eq!(
278 fs::read_to_string(dir.join("lcov.info")).expect("lcov"),
279 "lcov"
280 );
281
282 let _ = fs::remove_dir_all(&dir);
283 }
284
285 #[test]
286 fn ensures_parent_directory() {
287 let path = std::env::temp_dir()
288 .join("covguard-adapters-artifacts")
289 .join("a.json");
290 let _ = fs::remove_dir_all(path.parent().expect("parent"));
291 ensure_parent_dir(path.to_str().unwrap()).expect("ensure parent");
292 assert!(path.parent().expect("parent").exists());
293 let _ = fs::remove_dir_all(path.parent().unwrap());
294 }
295
296 #[test]
297 fn writes_fallback_receipt() {
298 let out = std::env::temp_dir().join("covguard-adapters-artifacts-receipt.json");
299 let _ = fs::remove_file(&out);
300 write_fallback_receipt(
301 out.to_str().unwrap(),
302 "boom",
303 "missing_diff",
304 "missing_lcov",
305 )
306 .expect("write receipt");
307 let body = fs::read_to_string(&out).expect("read");
308 assert!(body.contains("\"sensor.report.v1\""));
309 let _ = fs::remove_file(&out);
310 }
311
312 #[test]
313 fn writer_api_is_usable() {
314 let writer = FsArtifactWriter::new();
315 let out = std::env::temp_dir().join("covguard-adapters-artifacts-writer.json");
316 let raw_dir = std::env::temp_dir().join("covguard-adapters-artifacts-writer-raw");
317 let _ = fs::remove_file(&out);
318 let _ = fs::remove_dir_all(&raw_dir);
319
320 writer
321 .write_fallback_receipt(
322 out.to_str().unwrap(),
323 "boom",
324 "missing_diff",
325 "missing_lcov",
326 )
327 .expect("write fallback");
328 writer
329 .write_raw_artifacts_to(&raw_dir, "diff", &["lcov".to_string()])
330 .expect("write raw");
331 assert!(out.exists());
332 assert!(raw_dir.join("diff.patch").exists());
333 assert!(raw_dir.join("lcov.info").exists());
334
335 let _ = fs::remove_file(&out);
336 let _ = fs::remove_dir_all(&raw_dir);
337 }
338}