Skip to main content

depguard_types/
receipt.rs

1use crate::RepoPath;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use serde_json::Value as JsonValue;
5use time::OffsetDateTime;
6
7/// Stable schema identifiers for depguard reports.
8pub const SCHEMA_REPORT_V1: &str = "depguard.report.v1";
9pub const SCHEMA_REPORT_V2: &str = "depguard.report.v2";
10pub const SCHEMA_SENSOR_REPORT_V1: &str = "sensor.report.v1";
11
12/// Severity is intentionally small: it maps cleanly to CI signals.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "lowercase")]
15pub enum Severity {
16    Info,
17    Warning,
18    Error,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22pub struct Location {
23    pub path: RepoPath,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub line: Option<u32>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub col: Option<u32>,
28}
29
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
31pub struct Finding {
32    pub severity: Severity,
33    pub check_id: String,
34    pub code: String,
35    pub message: String,
36
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub location: Option<Location>,
39
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub help: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub url: Option<String>,
44
45    /// Stable identifier intended for dedup and trending. Typically a hash of:
46    /// `check_id + code + canonical_path + (line?) + salient fields`.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub fingerprint: Option<String>,
49
50    /// Check-specific structured payload (kept open-ended for forward compatibility).
51    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
52    pub data: JsonValue,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
56#[serde(rename_all = "lowercase")]
57pub enum Verdict {
58    Pass,
59    Warn,
60    Fail,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64pub struct ToolMeta {
65    pub name: String,
66    pub version: String,
67}
68
69// ============================================================================
70// Receipt v2 (cockpit envelope aligned)
71// ============================================================================
72
73/// Severity used in v2 receipts ("warn" instead of "warning").
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75#[serde(rename_all = "lowercase")]
76pub enum SeverityV2 {
77    Info,
78    Warn,
79    Error,
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83#[serde(rename_all = "lowercase")]
84pub enum VerdictStatus {
85    Pass,
86    Warn,
87    Fail,
88    Skip,
89}
90
91/// Helper function for skip_serializing_if on suppressed field.
92fn is_zero(val: &u32) -> bool {
93    *val == 0
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97pub struct VerdictCounts {
98    pub info: u32,
99    pub warn: u32,
100    pub error: u32,
101    /// Count of findings suppressed by baseline filtering.
102    #[serde(default, skip_serializing_if = "is_zero")]
103    pub suppressed: u32,
104}
105
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107pub struct VerdictV2 {
108    pub status: VerdictStatus,
109    pub counts: VerdictCounts,
110    #[serde(default)]
111    pub reasons: Vec<String>,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub struct ToolMetaV2 {
116    pub name: String,
117    pub version: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub commit: Option<String>,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123pub struct RunHost {
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub os: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub arch: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub hostname: Option<String>,
130}
131
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
133pub struct RunCi {
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub provider: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub run_id: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub job: Option<String>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub url: Option<String>,
142}
143
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
145pub struct RunGit {
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub repo: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub base_ref: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub head_ref: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub base_sha: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub head_sha: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub merge_base: Option<String>,
158}
159
160// ============================================================================
161// Capability reporting for No Green By Omission
162// ============================================================================
163
164/// Status of a capability (available, missing, or degraded).
165#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
166#[serde(rename_all = "lowercase")]
167pub enum CapabilityAvailability {
168    Available,
169    Missing,
170    Degraded,
171}
172
173/// Status of a single capability with optional reason.
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175pub struct CapabilityStatus {
176    pub status: CapabilityAvailability,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub reason: Option<String>,
179}
180
181/// Capabilities block for No Green By Omission reporting.
182#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
183pub struct Capabilities {
184    /// Git integration status (for diff scope, blame, etc.).
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub git: Option<CapabilityStatus>,
187    /// Configuration file status.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub config: Option<CapabilityStatus>,
190}
191
192#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
193pub struct RunMeta {
194    #[schemars(with = "String")]
195    #[serde(with = "time::serde::rfc3339")]
196    pub started_at: OffsetDateTime,
197    #[schemars(with = "Option<String>")]
198    #[serde(skip_serializing_if = "Option::is_none")]
199    #[serde(with = "time::serde::rfc3339::option")]
200    pub ended_at: Option<OffsetDateTime>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub duration_ms: Option<u64>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub host: Option<RunHost>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub ci: Option<RunCi>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub git: Option<RunGit>,
209    /// Capability status for No Green By Omission reporting.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub capabilities: Option<Capabilities>,
212}
213
214#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
215pub struct FindingV2 {
216    pub severity: SeverityV2,
217    pub check_id: String,
218    pub code: String,
219    pub message: String,
220
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub location: Option<Location>,
223
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub help: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub url: Option<String>,
228
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub fingerprint: Option<String>,
231
232    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
233    pub data: JsonValue,
234}
235
236// ============================================================================
237// Artifact pointers
238// ============================================================================
239
240/// Type classification for artifact pointers.
241#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
242#[serde(rename_all = "lowercase")]
243pub enum ArtifactType {
244    Comment,
245    Annotation,
246    Extra,
247}
248
249/// Pointer to an additional artifact produced by a sensor run.
250#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
251pub struct ArtifactPointer {
252    /// Type classification for this artifact.
253    #[serde(rename = "type")]
254    pub artifact_type: ArtifactType,
255    /// Path to the artifact file, relative to artifacts directory.
256    pub path: String,
257    /// MIME type or format identifier (e.g., "text/markdown").
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub format: Option<String>,
260}
261
262// ============================================================================
263// Depguard-specific data
264// ============================================================================
265
266/// Depguard-specific summary payload for the report.
267#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
268pub struct DepguardData {
269    pub scope: String,
270    pub profile: String,
271
272    pub manifests_scanned: u32,
273    pub dependencies_scanned: u32,
274
275    pub findings_total: u32,
276    pub findings_emitted: u32,
277
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub truncated_reason: Option<String>,
280}
281
282/// A generic receipt/envelope.
283///
284/// Keeping this generic allows Depguard to embed tool-specific data while still enforcing a stable outer shape.
285#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
286pub struct ReportEnvelope<TData = DepguardData> {
287    /// Versioned schema identifier for the envelope shape.
288    pub schema: String,
289    pub tool: ToolMeta,
290    #[schemars(with = "String")]
291    #[serde(with = "time::serde::rfc3339")]
292    pub started_at: OffsetDateTime,
293    #[schemars(with = "String")]
294    #[serde(with = "time::serde::rfc3339")]
295    pub finished_at: OffsetDateTime,
296    pub verdict: Verdict,
297    pub findings: Vec<Finding>,
298    pub data: TData,
299}
300
301/// V1 report (legacy envelope).
302pub type DepguardReportV1 = ReportEnvelope<DepguardData>;
303
304/// V2 report (cockpit-aligned envelope).
305#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
306pub struct ReportEnvelopeV2<TData = DepguardData> {
307    pub schema: String,
308    pub tool: ToolMetaV2,
309    pub run: RunMeta,
310    pub verdict: VerdictV2,
311    pub findings: Vec<FindingV2>,
312    /// Optional list of additional artifacts produced by this run.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub artifacts: Option<Vec<ArtifactPointer>>,
315    pub data: TData,
316}
317
318pub type DepguardReportV2 = ReportEnvelopeV2<DepguardData>;
319
320// Back-compat alias (v1).
321pub type DepguardReport = DepguardReportV1;
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use time::OffsetDateTime;
327
328    #[test]
329    fn verdict_counts_skip_zero_suppressed() {
330        let counts = VerdictCounts {
331            info: 0,
332            warn: 0,
333            error: 0,
334            suppressed: 0,
335        };
336        let value = serde_json::to_value(&counts).unwrap();
337        assert!(value.get("suppressed").is_none());
338
339        let counts = VerdictCounts {
340            info: 0,
341            warn: 1,
342            error: 0,
343            suppressed: 2,
344        };
345        let value = serde_json::to_value(&counts).unwrap();
346        assert_eq!(value["suppressed"], 2);
347    }
348
349    #[test]
350    fn run_meta_omits_optional_fields() {
351        let run = RunMeta {
352            started_at: OffsetDateTime::UNIX_EPOCH,
353            ended_at: None,
354            duration_ms: None,
355            host: None,
356            ci: None,
357            git: None,
358            capabilities: None,
359        };
360        let value = serde_json::to_value(&run).unwrap();
361        assert!(value.get("ended_at").is_none());
362        assert!(value.get("duration_ms").is_none());
363        assert!(value.get("host").is_none());
364        assert!(value.get("ci").is_none());
365        assert!(value.get("git").is_none());
366        assert!(value.get("capabilities").is_none());
367        assert!(value.get("started_at").is_some());
368    }
369}