Skip to main content

covguard_adapters_artifacts/
lib.rs

1//! Artifact persistence adapters for covguard outputs.
2//!
3//! This crate centralizes filesystem output behavior for reports and related
4//! artifacts so CLI and other adapters can share the same contract.
5
6use 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/// Errors encountered while writing artifacts.
19#[derive(Debug, Error)]
20pub enum ArtifactWriteError {
21    /// Failed to create a parent directory for a path.
22    #[error("Failed to create directory '{path}': {source}")]
23    DirCreate {
24        path: String,
25        #[source]
26        source: std::io::Error,
27    },
28
29    /// Failed writing an output file.
30    #[error("Failed to write file '{path}': {source}")]
31    FileWrite {
32        path: String,
33        #[source]
34        source: std::io::Error,
35    },
36
37    /// Failed to serialize report JSON.
38    #[error("Failed to serialize report: {0}")]
39    Serialize(#[from] serde_json::Error),
40}
41
42/// Concrete artifact writer used by the CLI and other adapters.
43#[derive(Debug, Default)]
44pub struct FsArtifactWriter;
45
46impl FsArtifactWriter {
47    /// Create a new artifact writer.
48    pub fn new() -> Self {
49        Self
50    }
51}
52
53/// Write a domain report JSON payload to disk.
54pub 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
59/// Write fallback/runtime output as a JSON report.
60pub 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
70/// Write raw lint/repro inputs for debugging.
71pub 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
78/// Write raw lint/repro inputs to a supplied directory.
79pub 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
97/// Ensure the parent directory for a path exists.
98pub fn ensure_parent_dir(path: &str) -> Result<(), ArtifactWriteError> {
99    ensure_parent_dir_path(Path::new(path))
100}
101
102/// Ensure the parent directory for a path exists.
103fn 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
129/// Write markdown or SARIF output text to disk.
130pub fn write_text(path: &str, body: &str) -> Result<(), ArtifactWriteError> {
131    write_text_path(Path::new(path), body)
132}
133
134impl FsArtifactWriter {
135    /// Write a domain report JSON payload to disk.
136    pub fn write_report(&self, path: &str, report: &Report) -> Result<(), ArtifactWriteError> {
137        write_report(path, report)
138    }
139
140    /// Write fallback/runtime output as a JSON report.
141    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    /// Write markdown or SARIF output text to disk.
152    pub fn write_text(&self, path: &str, body: &str) -> Result<(), ArtifactWriteError> {
153        write_text(path, body)
154    }
155
156    /// Write raw lint/repro inputs for debugging.
157    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    /// Write raw lint/repro inputs to a supplied directory.
166    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    /// Ensure the parent directory for a path exists.
176    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}