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