Skip to main content

animsmith_core/
finding.rs

1//! Structured lint findings. The structured fields (not just a message
2//! string) are what make `diff`, the JSON schema, and the HTML report
3//! cheap downstream.
4
5use serde::Serialize;
6use std::collections::BTreeMap;
7use std::fmt;
8
9/// Severity of a lint finding.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Severity {
13    /// Informational diagnostic that does not fail a gate.
14    Note,
15    /// Warning-level finding; the CLI treats warnings as a clean exit
16    /// unless configured to deny warnings.
17    Warning,
18    /// Error-level finding; the CLI exits with a content-failure status
19    /// when any error is present.
20    Error,
21}
22
23impl fmt::Display for Severity {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        f.write_str(match self {
26            Severity::Note => "note",
27            Severity::Warning => "warning",
28            Severity::Error => "error",
29        })
30    }
31}
32
33/// A measured or expected quantity attached to a finding.
34#[derive(Debug, Clone, Serialize)]
35#[serde(untagged)]
36#[non_exhaustive]
37pub enum Value {
38    /// Numeric measured or expected value.
39    Number(f64),
40    /// Textual measured or expected value.
41    Text(String),
42}
43
44/// One configured member's machine-readable evidence attached to a group
45/// finding.
46///
47/// The member order is the configuration order, while the measurement map is
48/// key-sorted for stable JSON. Values are deliberately scalar so consumers do
49/// not need to parse presentation text to recover a group comparison table.
50#[derive(Debug, Clone, Serialize)]
51#[non_exhaustive]
52pub struct MemberMeasurement {
53    /// Configured member name, including a name absent from the input file.
54    pub member: String,
55    /// Named scalar measurements for this member. Non-finite numeric values
56    /// are excluded by [`Self::measurement`] and [`Finding::members`].
57    pub measurements: BTreeMap<String, Value>,
58}
59
60impl MemberMeasurement {
61    /// Construct an empty evidence row for `member`.
62    pub fn new(member: impl Into<String>) -> Self {
63        Self {
64            member: member.into(),
65            measurements: BTreeMap::new(),
66        }
67    }
68
69    /// Attach a finite numeric or textual measurement.
70    pub fn measurement(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
71        let value = value.into();
72        if value.is_finite() {
73            self.measurements.insert(name.into(), value);
74        }
75        self
76    }
77}
78
79impl fmt::Display for Value {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Value::Number(n) => write!(f, "{n:.4}"),
83            Value::Text(s) => f.write_str(s),
84        }
85    }
86}
87
88/// A structured lint result emitted by a [`crate::Check`].
89///
90/// The JSON shape is part of animsmith's automation contract. The Rust
91/// struct is marked `non_exhaustive` so new optional context fields can
92/// be added before 1.0 without forcing downstream construction through
93/// struct literals.
94#[derive(Debug, Clone, Serialize)]
95#[non_exhaustive]
96pub struct Finding {
97    /// Stable check id such as `"loop-seam"`.
98    pub check_id: &'static str,
99    /// Effective severity after any per-check override.
100    pub severity: Severity,
101    /// Clip associated with the finding, when the finding is clip-local.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub clip: Option<String>,
104    /// Bone associated with the finding, when applicable.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub bone: Option<String>,
107    /// Stable source-node path associated with the finding, when applicable.
108    /// Path components carry source indices so repeated display names remain
109    /// distinguishable.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub node: Option<String>,
112    /// Finite time in seconds associated with the finding, when applicable.
113    /// Non-finite values are omitted from serialized output.
114    #[serde(skip_serializing_if = "non_finite_time_or_none")]
115    pub time_s: Option<f32>,
116    /// Measured value that triggered the finding. Non-finite numeric values
117    /// are omitted from serialized output.
118    #[serde(skip_serializing_if = "non_finite_value_or_none")]
119    pub measured: Option<Value>,
120    /// Expected value or threshold for the finding. Non-finite numeric values
121    /// are omitted from serialized output.
122    #[serde(skip_serializing_if = "non_finite_value_or_none")]
123    pub expected: Option<Value>,
124    /// Per-member evidence for a group-level finding, in configured order.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub members: Option<Vec<MemberMeasurement>>,
127    /// Human-readable explanation.
128    pub message: String,
129}
130
131impl Finding {
132    /// Construct a finding with no optional context fields set.
133    pub fn new(check_id: &'static str, severity: Severity, message: impl Into<String>) -> Self {
134        Self {
135            check_id,
136            severity,
137            clip: None,
138            bone: None,
139            node: None,
140            time_s: None,
141            measured: None,
142            expected: None,
143            members: None,
144            message: message.into(),
145        }
146    }
147
148    /// Attach a clip name.
149    pub fn clip(mut self, clip: impl Into<String>) -> Self {
150        self.clip = Some(clip.into());
151        self
152    }
153
154    /// Attach a bone name.
155    pub fn bone(mut self, bone: impl Into<String>) -> Self {
156        self.bone = Some(bone.into());
157        self
158    }
159
160    /// Attach a stable source-node path.
161    pub fn node(mut self, node: impl Into<String>) -> Self {
162        self.node = Some(node.into());
163        self
164    }
165
166    /// Attach a clip time in seconds.
167    pub fn time(mut self, t: f32) -> Self {
168        self.time_s = t.is_finite().then_some(t);
169        self
170    }
171
172    /// Attach a measured value.
173    pub fn measured(mut self, v: impl Into<Value>) -> Self {
174        let value = v.into();
175        self.measured = value.is_finite().then_some(value);
176        self
177    }
178
179    /// Attach an expected value or threshold.
180    pub fn expected(mut self, v: impl Into<Value>) -> Self {
181        let value = v.into();
182        self.expected = value.is_finite().then_some(value);
183        self
184    }
185
186    /// Attach a configured-order group member table.
187    ///
188    /// Any non-finite numeric values supplied through a directly constructed
189    /// row are removed before serialization, matching scalar finding values.
190    pub fn members(mut self, mut members: Vec<MemberMeasurement>) -> Self {
191        for member in &mut members {
192            member.measurements.retain(|_, value| value.is_finite());
193        }
194        self.members = Some(members);
195        self
196    }
197}
198
199impl Value {
200    fn is_finite(&self) -> bool {
201        match self {
202            Self::Number(number) => number.is_finite(),
203            Self::Text(_) => true,
204        }
205    }
206}
207
208fn non_finite_time_or_none(value: &Option<f32>) -> bool {
209    value.is_none_or(|time| !time.is_finite())
210}
211
212fn non_finite_value_or_none(value: &Option<Value>) -> bool {
213    value.as_ref().is_none_or(|value| !value.is_finite())
214}
215
216impl From<f64> for Value {
217    fn from(n: f64) -> Self {
218        Value::Number(n)
219    }
220}
221
222impl From<f32> for Value {
223    fn from(n: f32) -> Self {
224        Value::Number(n as f64)
225    }
226}
227
228impl From<&str> for Value {
229    fn from(s: &str) -> Self {
230        Value::Text(s.to_owned())
231    }
232}
233
234impl From<String> for Value {
235    fn from(s: String) -> Self {
236        Value::Text(s)
237    }
238}