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::fmt;
7
8/// Severity of a lint finding.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Severity {
12    /// Informational diagnostic that does not fail a gate.
13    Note,
14    /// Warning-level finding; the CLI treats warnings as a clean exit
15    /// unless configured to deny warnings.
16    Warning,
17    /// Error-level finding; the CLI exits with a content-failure status
18    /// when any error is present.
19    Error,
20}
21
22impl fmt::Display for Severity {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.write_str(match self {
25            Severity::Note => "note",
26            Severity::Warning => "warning",
27            Severity::Error => "error",
28        })
29    }
30}
31
32/// A measured or expected quantity attached to a finding.
33#[derive(Debug, Clone, Serialize)]
34#[serde(untagged)]
35#[non_exhaustive]
36pub enum Value {
37    /// Numeric measured or expected value.
38    Number(f64),
39    /// Textual measured or expected value.
40    Text(String),
41}
42
43impl fmt::Display for Value {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Value::Number(n) => write!(f, "{n:.4}"),
47            Value::Text(s) => f.write_str(s),
48        }
49    }
50}
51
52/// A structured lint result emitted by a [`crate::Check`].
53///
54/// The JSON shape is part of animsmith's automation contract. The Rust
55/// struct is marked `non_exhaustive` so new optional context fields can
56/// be added before 1.0 without forcing downstream construction through
57/// struct literals.
58#[derive(Debug, Clone, Serialize)]
59#[non_exhaustive]
60pub struct Finding {
61    /// Stable check id such as `"loop-seam"`.
62    pub check_id: &'static str,
63    /// Effective severity after any non-diagnostic override.
64    pub severity: Severity,
65    /// Clip associated with the finding, when the finding is clip-local.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub clip: Option<String>,
68    /// Bone associated with the finding, when applicable.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub bone: Option<String>,
71    /// Time in seconds associated with the finding, when applicable.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub time_s: Option<f32>,
74    /// Measured value that triggered the finding.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub measured: Option<Value>,
77    /// Expected value or threshold for the finding.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub expected: Option<Value>,
80    /// Human-readable explanation.
81    pub message: String,
82    /// A diagnostic (a "skipped: …" note about an unmet prerequisite),
83    /// not a judgement of the content. Diagnostics are exempt from
84    /// per-check severity overrides — a check declared `severity =
85    /// "error"` must never turn a "roles unresolved" note into a false
86    /// failure. Not serialized: the JSON output shape is unchanged.
87    #[serde(skip)]
88    pub diagnostic: bool,
89}
90
91impl Finding {
92    /// Construct a finding with no optional context fields set.
93    pub fn new(check_id: &'static str, severity: Severity, message: impl Into<String>) -> Self {
94        Self {
95            check_id,
96            severity,
97            clip: None,
98            bone: None,
99            time_s: None,
100            measured: None,
101            expected: None,
102            message: message.into(),
103            diagnostic: false,
104        }
105    }
106
107    /// Mark this finding a diagnostic (see [`Finding::diagnostic`]):
108    /// emitted at `Note`, exempt from severity overrides.
109    pub fn as_diagnostic(mut self) -> Self {
110        self.diagnostic = true;
111        self
112    }
113
114    /// Attach a clip name.
115    pub fn clip(mut self, clip: impl Into<String>) -> Self {
116        self.clip = Some(clip.into());
117        self
118    }
119
120    /// Attach a bone name.
121    pub fn bone(mut self, bone: impl Into<String>) -> Self {
122        self.bone = Some(bone.into());
123        self
124    }
125
126    /// Attach a clip time in seconds.
127    pub fn time(mut self, t: f32) -> Self {
128        self.time_s = Some(t);
129        self
130    }
131
132    /// Attach a measured value.
133    pub fn measured(mut self, v: impl Into<Value>) -> Self {
134        self.measured = Some(v.into());
135        self
136    }
137
138    /// Attach an expected value or threshold.
139    pub fn expected(mut self, v: impl Into<Value>) -> Self {
140        self.expected = Some(v.into());
141        self
142    }
143}
144
145impl From<f64> for Value {
146    fn from(n: f64) -> Self {
147        Value::Number(n)
148    }
149}
150
151impl From<f32> for Value {
152    fn from(n: f32) -> Self {
153        Value::Number(n as f64)
154    }
155}
156
157impl From<&str> for Value {
158    fn from(s: &str) -> Self {
159        Value::Text(s.to_owned())
160    }
161}
162
163impl From<String> for Value {
164    fn from(s: String) -> Self {
165        Value::Text(s)
166    }
167}