Skip to main content

tokmd_envelope/
finding.rs

1//! Portable finding DTOs embedded in sensor reports.
2
3use serde::{Deserialize, Serialize};
4
5/// A finding reported by the sensor.
6///
7/// Findings use a `(check_id, code)` tuple for identity. Combined with
8/// `tool.name` this forms the triple `(tool, check_id, code)` used for
9/// buildfix routing and cockpit policy (e.g., `("tokmd", "risk", "hotspot")`).
10///
11/// # Examples
12///
13/// ```
14/// use tokmd_envelope::{Finding, FindingSeverity, FindingLocation};
15///
16/// let finding = Finding::new(
17///     "risk", "hotspot",
18///     FindingSeverity::Warn,
19///     "High-churn file",
20///     "src/lib.rs modified 42 times in 30 days",
21/// ).with_location(FindingLocation::path_line("src/lib.rs", 1));
22///
23/// assert_eq!(finding.check_id, "risk");
24/// assert_eq!(finding.code, "hotspot");
25/// assert!(finding.location.is_some());
26/// ```
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Finding {
29    /// Check category (e.g., "risk", "contract", "gate").
30    pub check_id: String,
31    /// Finding code within the category (e.g., "hotspot", "coupling").
32    pub code: String,
33    /// Severity level.
34    pub severity: FindingSeverity,
35    /// Short title for the finding.
36    pub title: String,
37    /// Detailed message describing the finding.
38    pub message: String,
39    /// Source location (if applicable).
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub location: Option<FindingLocation>,
42    /// Additional evidence data.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub evidence: Option<serde_json::Value>,
45    /// Documentation URL for this finding type.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub docs_url: Option<String>,
48    /// Stable identity fingerprint for deduplication and buildfix routing.
49    /// BLAKE3 hash of (tool_name, check_id, code, location.path).
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub fingerprint: Option<String>,
52}
53
54/// Severity level for findings.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum FindingSeverity {
58    /// Blocks merge (hard gate failure).
59    Error,
60    /// Review recommended.
61    Warn,
62    /// Informational, no action required.
63    Info,
64}
65
66/// Source location for a finding.
67///
68/// # Examples
69///
70/// ```
71/// use tokmd_envelope::FindingLocation;
72///
73/// // Path only
74/// let loc = FindingLocation::path("src/main.rs");
75/// assert_eq!(loc.path, "src/main.rs");
76/// assert!(loc.line.is_none());
77///
78/// // Path + line
79/// let loc = FindingLocation::path_line("src/lib.rs", 42);
80/// assert_eq!(loc.line, Some(42));
81///
82/// // Path + line + column
83/// let loc = FindingLocation::path_line_column("src/lib.rs", 42, 10);
84/// assert_eq!(loc.column, Some(10));
85/// ```
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct FindingLocation {
88    /// File path (normalized to forward slashes).
89    pub path: String,
90    /// Line number (1-indexed).
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub line: Option<u32>,
93    /// Column number (1-indexed).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub column: Option<u32>,
96}
97
98impl Finding {
99    /// Create a new finding with required fields.
100    pub fn new(
101        check_id: impl Into<String>,
102        code: impl Into<String>,
103        severity: FindingSeverity,
104        title: impl Into<String>,
105        message: impl Into<String>,
106    ) -> Self {
107        Self {
108            check_id: check_id.into(),
109            code: code.into(),
110            severity,
111            title: title.into(),
112            message: message.into(),
113            location: None,
114            evidence: None,
115            docs_url: None,
116            fingerprint: None,
117        }
118    }
119
120    /// Add a location to the finding.
121    pub fn with_location(mut self, location: FindingLocation) -> Self {
122        self.location = Some(location);
123        self
124    }
125
126    /// Add evidence to the finding.
127    pub fn with_evidence(mut self, evidence: serde_json::Value) -> Self {
128        self.evidence = Some(evidence);
129        self
130    }
131
132    /// Add a documentation URL to the finding.
133    pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
134        self.docs_url = Some(url.into());
135        self
136    }
137
138    /// Compute a stable fingerprint from `(tool_name, check_id, code, path)`.
139    ///
140    /// Returns first 16 bytes (32 hex chars) of a BLAKE3 hash for compactness.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use tokmd_envelope::{Finding, FindingSeverity, FindingLocation};
146    ///
147    /// let f = Finding::new("risk", "hotspot", FindingSeverity::Warn, "Churn", "high")
148    ///     .with_location(FindingLocation::path("src/lib.rs"));
149    /// let fp = f.compute_fingerprint("tokmd");
150    /// assert_eq!(fp.len(), 32);
151    ///
152    /// // Same inputs produce same fingerprint
153    /// let f2 = Finding::new("risk", "hotspot", FindingSeverity::Warn, "Churn", "high")
154    ///     .with_location(FindingLocation::path("src/lib.rs"));
155    /// assert_eq!(f2.compute_fingerprint("tokmd"), fp);
156    /// ```
157    pub fn compute_fingerprint(&self, tool_name: &str) -> String {
158        let path = self
159            .location
160            .as_ref()
161            .map(|l| l.path.as_str())
162            .unwrap_or("");
163        let identity = format!("{}\0{}\0{}\0{}", tool_name, self.check_id, self.code, path);
164        let hash = blake3::hash(identity.as_bytes());
165        let hex = hash.to_hex();
166        hex[..32].to_string()
167    }
168
169    /// Auto-compute and set fingerprint. Builder pattern.
170    pub fn with_fingerprint(mut self, tool_name: &str) -> Self {
171        self.fingerprint = Some(self.compute_fingerprint(tool_name));
172        self
173    }
174}
175
176impl FindingLocation {
177    /// Create a new location with just a path.
178    pub fn path(path: impl Into<String>) -> Self {
179        Self {
180            path: path.into(),
181            line: None,
182            column: None,
183        }
184    }
185
186    /// Create a new location with path and line.
187    pub fn path_line(path: impl Into<String>, line: u32) -> Self {
188        Self {
189            path: path.into(),
190            line: Some(line),
191            column: None,
192        }
193    }
194
195    /// Create a new location with path, line, and column.
196    pub fn path_line_column(path: impl Into<String>, line: u32, column: u32) -> Self {
197        Self {
198            path: path.into(),
199            line: Some(line),
200            column: Some(column),
201        }
202    }
203}
204
205impl std::fmt::Display for FindingSeverity {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            FindingSeverity::Error => write!(f, "error"),
209            FindingSeverity::Warn => write!(f, "warn"),
210            FindingSeverity::Info => write!(f, "info"),
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::{Finding, FindingLocation, FindingSeverity};
218    use crate::findings;
219
220    #[test]
221    fn finding_severity_display_matches_serde() {
222        for (variant, expected) in [
223            (FindingSeverity::Error, "error"),
224            (FindingSeverity::Warn, "warn"),
225            (FindingSeverity::Info, "info"),
226        ] {
227            assert_eq!(variant.to_string(), expected);
228            let json = serde_json::to_value(variant).unwrap();
229            assert_eq!(json.as_str().unwrap(), expected);
230        }
231    }
232
233    #[test]
234    fn finding_builders_and_fingerprint() {
235        let location = FindingLocation::path_line_column("src/lib.rs", 10, 2);
236        let finding = Finding::new(
237            findings::risk::CHECK_ID,
238            findings::risk::COUPLING,
239            FindingSeverity::Info,
240            "Coupled module",
241            "Modules share excessive dependencies",
242        )
243        .with_location(location.clone())
244        .with_evidence(serde_json::json!({ "coupling": 0.87 }))
245        .with_docs_url("https://example.com/docs/coupling");
246
247        let expected_identity = format!(
248            "{}\0{}\0{}\0{}",
249            "tokmd",
250            findings::risk::CHECK_ID,
251            findings::risk::COUPLING,
252            location.path
253        );
254        let expected_hash = blake3::hash(expected_identity.as_bytes()).to_hex();
255        let expected_fingerprint = expected_hash[..32].to_string();
256
257        assert_eq!(finding.compute_fingerprint("tokmd"), expected_fingerprint);
258
259        let with_fp = finding.clone().with_fingerprint("tokmd");
260        assert_eq!(
261            with_fp.fingerprint.as_deref(),
262            Some(expected_fingerprint.as_str())
263        );
264
265        let no_location = Finding::new(
266            findings::risk::CHECK_ID,
267            findings::risk::HOTSPOT,
268            FindingSeverity::Warn,
269            "Hotspot",
270            "Churn is elevated",
271        );
272        assert_ne!(
273            no_location.compute_fingerprint("tokmd"),
274            finding.compute_fingerprint("tokmd")
275        );
276    }
277
278    #[test]
279    fn finding_location_constructors() {
280        let path_only = FindingLocation::path("src/main.rs");
281        assert_eq!(path_only.path, "src/main.rs");
282        assert_eq!(path_only.line, None);
283        assert_eq!(path_only.column, None);
284
285        let path_line = FindingLocation::path_line("src/main.rs", 42);
286        assert_eq!(path_line.path, "src/main.rs");
287        assert_eq!(path_line.line, Some(42));
288        assert_eq!(path_line.column, None);
289
290        let path_line_column = FindingLocation::path_line_column("src/main.rs", 7, 3);
291        assert_eq!(path_line_column.path, "src/main.rs");
292        assert_eq!(path_line_column.line, Some(7));
293        assert_eq!(path_line_column.column, Some(3));
294    }
295
296    #[test]
297    fn finding_omits_optional_fields_when_none() {
298        let finding = Finding::new(
299            findings::risk::CHECK_ID,
300            findings::risk::HOTSPOT,
301            FindingSeverity::Warn,
302            "Hotspot",
303            "Churn is elevated",
304        );
305        let json = serde_json::to_value(&finding).unwrap();
306
307        assert_eq!(json["check_id"], findings::risk::CHECK_ID);
308        assert_eq!(json["code"], findings::risk::HOTSPOT);
309        assert_eq!(json["severity"], "warn");
310        assert!(json.get("location").is_none());
311        assert!(json.get("evidence").is_none());
312        assert!(json.get("docs_url").is_none());
313        assert!(json.get("fingerprint").is_none());
314    }
315}