Skip to main content

animsmith_core/
evaluation.rs

1//! Typed check-evaluation records.
2//!
3//! Selection, configuration, applicability, evaluation coverage, content
4//! findings, and coverage gaps are independent dimensions. The types in this
5//! module are the single execution/result boundary for both CLI and embedded
6//! consumers.
7
8use std::collections::BTreeSet;
9use std::fmt;
10
11use serde::ser::SerializeStruct;
12use serde::{Serialize, Serializer};
13
14use crate::check::{Check, CheckCtx};
15use crate::config::{ConfigValidationError, SeveritySetting};
16use crate::finding::Finding;
17
18/// One authoritative built-in evidence-code definition.
19///
20/// The `builtin_codes!` rows below own each built-in code's serialized
21/// identity, meaning, and allowed emitters. Both runtime validation and the
22/// output-documentation contract consume these definitions.
23#[derive(Debug, Clone, Copy)]
24struct BuiltinEvidenceCode {
25    code: &'static str,
26    #[cfg_attr(not(test), allow(dead_code))]
27    meaning: &'static str,
28    emitted_by: &'static [&'static str],
29}
30
31macro_rules! builtin_codes {
32    (
33        $kind:ident, $registry:ident, $definitions:ident, $error:ident, $registry_doc:literal;
34        $($name:ident => $value:literal,
35            meaning = $meaning:literal,
36            emitted_by = [$($emitter:literal),+ $(,)?]),+ $(,)?
37    ) => {
38        impl $kind {
39            $(#[doc = $meaning] pub const $name: Self = Self($value);)+
40        }
41
42        #[doc = $registry_doc]
43        pub const $registry: &[$kind] = &[$($kind::$name),+];
44
45        const $definitions: &[BuiltinEvidenceCode] = &[
46            $(BuiltinEvidenceCode {
47                code: $value,
48                meaning: $meaning,
49                emitted_by: &[$($emitter),+],
50            }),+
51        ];
52
53        impl $kind {
54            fn builtin_definition(self) -> Option<&'static BuiltinEvidenceCode> {
55                $definitions.iter().find(|definition| definition.code == self.0)
56            }
57
58            fn validate_emitter(self, check_id: &'static str) -> Result<(), EvaluationError> {
59                if self
60                    .builtin_definition()
61                    .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
62                {
63                    return Err(EvaluationError::$error {
64                        check_id,
65                        code: self,
66                    });
67                }
68                Ok(())
69            }
70        }
71    };
72}
73
74/// Whether a check was selected for this invocation.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SelectionState {
78    /// Selected explicitly or through the default full catalog.
79    Selected,
80    /// Omitted by an explicit selection.
81    Unselected,
82}
83
84/// Whether configuration enabled the check.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ConfigurationState {
88    /// The check is enabled.
89    Enabled,
90    /// The check is opt-in without an enabling severity, or
91    /// `severity = "off"` disabled it.
92    Disabled,
93}
94
95/// Whether a check applies to the supplied document and declarations.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
97#[serde(rename_all = "snake_case")]
98pub enum Applicability {
99    /// The check has work for this document/configuration.
100    Applicable,
101    /// The check has no work for this document/configuration.
102    NotApplicable,
103}
104
105/// How much applicable work was evaluated.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "snake_case")]
108pub enum EvaluationState {
109    /// All modelled work completed.
110    Complete,
111    /// Some modelled work completed and some has a typed coverage gap.
112    Partial,
113    /// No applicable work was evaluated.
114    NotEvaluated,
115}
116
117/// Stable machine code for a unit of evaluated or missing work.
118///
119/// Built-in codes are constants. Custom checks may construct namespaced codes
120/// without forcing animsmith's built-in registry to become a closed enum.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
122#[serde(transparent)]
123pub struct EvaluationScopeCode(&'static str);
124
125builtin_codes!(
126    EvaluationScopeCode,
127    BUILTIN_EVALUATION_SCOPE_CODES,
128    BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
129    BuiltinEvaluationScopeEmitterMismatch,
130    "Complete built-in evaluation-scope vocabulary for consumers that need to inspect or allow-list animsmith's catalog codes. Custom checks may use additional namespaced codes.";
131    FIRST_FRAME_REST_DELTA => "first_frame_rest_delta",
132        meaning = "The named clip's first-frame/rest-pose rotation evidence was evaluated.",
133        emitted_by = ["bind-pose"],
134    LOOP_CLOSURE => "loop_closure",
135        meaning = "One named clip's per-bone model-space pose closure was measured.",
136        emitted_by = ["loop-closure"],
137    DUPLICATE_LOOP_ENDPOINT => "duplicate_loop_endpoint",
138        meaning = "One named clip's authored tracks were analyzed for redundant closing endpoint keys.",
139        emitted_by = ["duplicate-loop-endpoint"],
140    LOOP_SEAM => "loop_seam",
141        meaning = "One named clip's positional loop seam was measured.",
142        emitted_by = ["loop-seam"],
143    LOOP_SEAM_VELOCITY => "loop_seam_velocity",
144        meaning = "One named clip's per-bone model-space seam velocity continuity was measured.",
145        emitted_by = ["loop-seam-vel"],
146    LOOP_SEAM_ROTATION => "loop_seam_rotation",
147        meaning = "One named clip's per-bone model-space angular seam velocity continuity was measured.",
148        emitted_by = ["loop-seam-rot"],
149    FOOT_STANCE => "foot_stance",
150        meaning = "Whole-clip prerequisites for stance analysis were evaluated.",
151        emitted_by = ["foot-slide"],
152    LEFT_FOOT_STANCE => "left_foot_stance",
153        meaning = "The named clip's left foot/toe stance was evaluated.",
154        emitted_by = ["foot-slide"],
155    RIGHT_FOOT_STANCE => "right_foot_stance",
156        meaning = "The named clip's right foot/toe stance was evaluated.",
157        emitted_by = ["foot-slide"],
158    ROOT_MOTION_SPEED => "root_motion_speed",
159        meaning = "One named clip's root-motion speed was measured.",
160        emitted_by = ["root-motion-speed"],
161    MEMBER_EXISTENCE => "member_existence",
162        meaning = "Configured group members were checked for existence.",
163        emitted_by = ["gait-group", "sync-group", "time-complement"],
164    PHASE_MEASUREMENT => "phase_measurement",
165        meaning = "One named clip's gait phase was measured or lacked usable evidence.",
166        emitted_by = ["gait-group", "time-complement"],
167    PHASE_COHERENCE => "phase_coherence",
168        meaning = "One named group's measurable gait phases were compared.",
169        emitted_by = ["gait-group", "time-complement"],
170    SYNC_MEMBER_MEASUREMENT => "sync_member_measurement",
171        meaning = "One named same-time sync-group member's timing evidence was measured.",
172        emitted_by = ["sync-group"],
173    SYNC_COMPATIBILITY => "sync_compatibility",
174        meaning = "One named same-time sync group had compatible member timing evidence compared.",
175        emitted_by = ["sync-group"],
176    TRAVEL_MODE => "travel_mode",
177        meaning = "One named clip's in-place/root-motion declaration was judged.",
178        emitted_by = ["in-place"],
179    FRAME_GRID => "frame_grid",
180        meaning = "The named clip's declared frame grid was evaluated.",
181        emitted_by = ["fps"],
182    REQUIRED_BONE_PRESENCE => "required_bone_presence",
183        meaning = "Configured structural skeleton-bone presence requirements were evaluated.",
184        emitted_by = ["required-bones"],
185    SELECTED_NODE_REST_SCALE => "selected_node_rest_scale",
186        meaning = "One configured source-node selector resolved and its effective rest-world linear scale was evaluated.",
187        emitted_by = ["rest-world-scale"],
188);
189
190impl EvaluationScopeCode {
191    /// Construct a stable custom scope code.
192    ///
193    /// Custom checks should use a namespaced value such as `acme:reference`.
194    pub const fn custom(code: &'static str) -> Self {
195        Self(code)
196    }
197
198    /// Return the serialized snake-case or namespaced code.
199    pub const fn as_str(self) -> &'static str {
200        self.0
201    }
202}
203
204impl fmt::Display for EvaluationScopeCode {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str(self.0)
207    }
208}
209
210/// A stable identifier for work that completed or could not be evaluated.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212pub struct EvaluationScope {
213    /// Consumer-neutral work-unit code such as `member_existence`.
214    pub code: EvaluationScopeCode,
215    /// Optional subject within the check, such as a group or clip name.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub subject: Option<String>,
218}
219
220impl EvaluationScope {
221    /// Construct a whole-check scope.
222    pub fn new(code: EvaluationScopeCode) -> Self {
223        Self {
224            code,
225            subject: None,
226        }
227    }
228
229    /// Attach a subject identifier.
230    pub fn subject(mut self, subject: impl Into<String>) -> Self {
231        self.subject = Some(subject.into());
232        self
233    }
234}
235
236/// Stable machine code for an evaluation-coverage gap.
237///
238/// Built-in codes are constants. Custom checks may construct their own code;
239/// embedders should namespace custom values so they cannot collide with future
240/// built-ins.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
242#[serde(transparent)]
243pub struct CoverageGapCode(&'static str);
244
245builtin_codes!(
246    CoverageGapCode,
247    BUILTIN_COVERAGE_GAP_CODES,
248    BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
249    BuiltinCoverageGapEmitterMismatch,
250    "Complete built-in coverage-gap vocabulary for consumers that need to inspect or allow-list animsmith's catalog codes. Custom checks may use additional namespaced codes.";
251    ROLES_UNRESOLVED => "roles_unresolved",
252        meaning = "Required semantic rig roles were not resolved.",
253        emitted_by = ["loop-seam", "root-motion-speed", "in-place", "foot-slide", "gait-group", "time-complement"],
254    MEASUREMENT_UNAVAILABLE => "measurement_unavailable",
255        meaning = "A required numeric measurement could not be produced or did not meet its evidence floor.",
256        emitted_by = ["loop-closure", "duplicate-loop-endpoint", "loop-seam", "loop-seam-vel", "loop-seam-rot", "root-motion-speed", "in-place", "foot-slide", "gait-group", "sync-group", "time-complement", "rest-world-scale"],
257    SKELETON_UNAVAILABLE => "skeleton_unavailable",
258        meaning = "Required skeleton presence work could not run because the file has no usable skeleton.",
259        emitted_by = ["required-bones"],
260    NODE_SELECTOR_NO_MATCH => "node_selector_no_match",
261        meaning = "A configured source-node selector matched no named source node.",
262        emitted_by = ["rest-world-scale"],
263    NODE_SELECTOR_AMBIGUOUS => "node_selector_ambiguous",
264        meaning = "A configured source-node selector matched more than one named source node.",
265        emitted_by = ["rest-world-scale"],
266    INSUFFICIENT_MEASURABLE_MEMBERS => "insufficient_measurable_members",
267        meaning = "Fewer than two configured group members produced usable comparison evidence.",
268        emitted_by = ["gait-group", "sync-group", "time-complement"],
269    MEMBERS_NOT_EVALUATED => "members_not_evaluated",
270        meaning = "Some configured group members did not produce usable comparison evidence.",
271        emitted_by = ["gait-group", "sync-group", "time-complement"],
272    INVALID_DECLARED_FPS => "invalid_declared_fps",
273        meaning = "A declared frame rate was zero, negative, or non-finite.",
274        emitted_by = ["fps"],
275    SYNC_FRAME_GRID_UNAVAILABLE => "sync_frame_grid_unavailable",
276        meaning = "A same-time sync-group member lacks usable declared frame-grid evidence.",
277        emitted_by = ["sync-group"],
278    INSUFFICIENT_ROTATION_EVIDENCE => "insufficient_rotation_evidence",
279        meaning = "Too few usable rotation tracks existed for a bind-pose comparison.",
280        emitted_by = ["bind-pose"],
281);
282
283impl CoverageGapCode {
284    /// Construct a stable custom code.
285    ///
286    /// Custom checks should use a namespaced value such as
287    /// `acme:reference_unavailable`.
288    pub const fn custom(code: &'static str) -> Self {
289        Self(code)
290    }
291
292    /// Return the serialized snake-case or namespaced code.
293    pub const fn as_str(self) -> &'static str {
294        self.0
295    }
296}
297
298impl fmt::Display for CoverageGapCode {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        f.write_str(self.0)
301    }
302}
303
304/// A typed reason applicable work could not be evaluated.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct CoverageGap {
307    /// Stable machine code. Automation must not parse [`CoverageGap::message`].
308    pub code: CoverageGapCode,
309    /// Human-readable display text.
310    pub message: String,
311    /// Optional work scope affected by the gap.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub scope: Option<EvaluationScope>,
314}
315
316impl CoverageGap {
317    /// Construct a whole-check coverage gap.
318    pub fn new(code: CoverageGapCode, message: impl Into<String>) -> Self {
319        Self {
320            code,
321            message: message.into(),
322            scope: None,
323        }
324    }
325
326    /// Attach the affected work scope.
327    pub fn scope(mut self, scope: EvaluationScope) -> Self {
328        self.scope = Some(scope);
329        self
330    }
331}
332
333/// Output evidence from one selected, enabled, applicable check.
334///
335/// The shared evaluation boundary derives coverage from completed scopes and
336/// gaps and reports malformed evidence through [`EvaluationError`].
337#[derive(Debug, Clone)]
338pub struct CheckOutput {
339    findings: Vec<Finding>,
340    evaluated_scopes: Vec<EvaluationScope>,
341    gaps: Vec<CoverageGap>,
342}
343
344impl CheckOutput {
345    /// Collect findings, completed scopes, and coverage gaps through the one
346    /// check-output construction path.
347    ///
348    /// Classification and validation happen when [`evaluate_checks`] projects
349    /// this evidence into a [`CheckEvaluation`].
350    pub fn from_coverage(
351        findings: Vec<Finding>,
352        evaluated_scopes: Vec<EvaluationScope>,
353        gaps: Vec<CoverageGap>,
354    ) -> Self {
355        Self {
356            findings,
357            evaluated_scopes,
358            gaps,
359        }
360    }
361
362    /// Content findings emitted by evaluated work.
363    pub fn findings(&self) -> &[Finding] {
364        &self.findings
365    }
366
367    /// Stable identifiers for work that completed.
368    pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
369        &self.evaluated_scopes
370    }
371
372    /// Typed reasons work did not complete.
373    pub fn gaps(&self) -> &[CoverageGap] {
374        &self.gaps
375    }
376}
377
378/// Final output-v7 record for one catalog check.
379#[derive(Debug, Clone)]
380pub struct CheckEvaluation {
381    check_id: &'static str,
382    selection: SelectionState,
383    configuration: ConfigurationState,
384    applicability: Applicability,
385    output: CheckOutput,
386}
387
388impl CheckEvaluation {
389    /// Construct a selected, enabled, applicable evaluation from validated
390    /// check output.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error for an empty check id, malformed coverage codes,
395    /// built-in evidence emitted by an undeclared check, or when a nested
396    /// finding names a different check.
397    pub fn evaluated(check_id: &'static str, output: CheckOutput) -> Result<Self, EvaluationError> {
398        if check_id.is_empty() {
399            return Err(EvaluationError::InvalidCheckId(check_id));
400        }
401        if !output.gaps.is_empty()
402            && output.evaluated_scopes.is_empty()
403            && !output.findings.is_empty()
404        {
405            return Err(EvaluationError::InvalidCheckOutput {
406                check_id,
407                reason: "not-evaluated output cannot carry content findings",
408            });
409        }
410        if let Some(finding) = output
411            .findings
412            .iter()
413            .find(|finding| finding.check_id != check_id)
414        {
415            return Err(EvaluationError::FindingCheckIdMismatch {
416                check_id,
417                finding_check_id: finding.check_id,
418            });
419        }
420        for scope in &output.evaluated_scopes {
421            if scope.code.as_str().is_empty() {
422                return Err(EvaluationError::InvalidCheckOutput {
423                    check_id,
424                    reason: "evaluated scope code cannot be empty",
425                });
426            }
427            scope.code.validate_emitter(check_id)?;
428        }
429        for gap in &output.gaps {
430            if gap.code.as_str().is_empty() {
431                return Err(EvaluationError::InvalidCheckOutput {
432                    check_id,
433                    reason: "coverage gap code cannot be empty",
434                });
435            }
436            gap.code.validate_emitter(check_id)?;
437            if let Some(scope) = &gap.scope {
438                if scope.code.as_str().is_empty() {
439                    return Err(EvaluationError::InvalidCheckOutput {
440                        check_id,
441                        reason: "coverage gap scope code cannot be empty",
442                    });
443                }
444                scope.code.validate_emitter(check_id)?;
445            }
446        }
447        Ok(Self {
448            check_id,
449            selection: SelectionState::Selected,
450            configuration: ConfigurationState::Enabled,
451            applicability: Applicability::Applicable,
452            output,
453        })
454    }
455
456    /// Stable check id.
457    pub fn check_id(&self) -> &'static str {
458        self.check_id
459    }
460
461    /// Invocation selection state.
462    pub fn selection(&self) -> SelectionState {
463        self.selection
464    }
465
466    /// Configuration activation state.
467    pub fn configuration(&self) -> ConfigurationState {
468        self.configuration
469    }
470
471    /// Applicability to this document/configuration.
472    pub fn applicability(&self) -> Applicability {
473        self.applicability
474    }
475
476    /// Derive evaluation coverage from activation, completed scopes, and gaps.
477    pub fn evaluation(&self) -> EvaluationState {
478        if self.selection == SelectionState::Unselected
479            || self.configuration == ConfigurationState::Disabled
480            || self.applicability == Applicability::NotApplicable
481        {
482            EvaluationState::NotEvaluated
483        } else if self.output.gaps.is_empty() {
484            EvaluationState::Complete
485        } else if self.output.evaluated_scopes.is_empty() {
486            EvaluationState::NotEvaluated
487        } else {
488            EvaluationState::Partial
489        }
490    }
491
492    /// Content findings emitted by evaluated work.
493    pub fn findings(&self) -> &[Finding] {
494        self.output.findings()
495    }
496
497    /// Stable identifiers for work that completed.
498    pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
499        self.output.evaluated_scopes()
500    }
501
502    /// Typed reasons work was not evaluated.
503    pub fn gaps(&self) -> &[CoverageGap] {
504        self.output.gaps()
505    }
506
507    fn inactive(
508        check_id: &'static str,
509        selection: SelectionState,
510        configuration: ConfigurationState,
511        applicability: Applicability,
512    ) -> Self {
513        debug_assert!(
514            selection == SelectionState::Unselected
515                || configuration == ConfigurationState::Disabled
516                || applicability == Applicability::NotApplicable
517        );
518        Self {
519            check_id,
520            selection,
521            configuration,
522            applicability,
523            output: CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
524        }
525    }
526
527    fn override_severity(&mut self, severity: SeveritySetting) {
528        if let Some(severity) = severity.as_severity() {
529            for finding in &mut self.output.findings {
530                finding.severity = severity;
531            }
532        }
533    }
534}
535
536impl Serialize for CheckEvaluation {
537    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
538    where
539        S: Serializer,
540    {
541        let mut fields = 6;
542        fields += usize::from(!self.output.evaluated_scopes.is_empty());
543        fields += usize::from(!self.output.gaps.is_empty());
544        let mut state = serializer.serialize_struct("CheckEvaluation", fields)?;
545        state.serialize_field("check_id", &self.check_id)?;
546        state.serialize_field("selection", &self.selection)?;
547        state.serialize_field("configuration", &self.configuration)?;
548        state.serialize_field("applicability", &self.applicability)?;
549        state.serialize_field("evaluation", &self.evaluation())?;
550        state.serialize_field("findings", &self.output.findings)?;
551        if !self.output.evaluated_scopes.is_empty() {
552            state.serialize_field("evaluated_scopes", &self.output.evaluated_scopes)?;
553        }
554        if !self.output.gaps.is_empty() {
555            state.serialize_field("gaps", &self.output.gaps)?;
556        }
557        state.end()
558    }
559}
560
561/// Catalog-selection policy for [`evaluate_checks`].
562#[derive(Debug, Clone, Copy)]
563pub enum CheckSelection<'a> {
564    /// Select the whole supplied catalog.
565    All,
566    /// Select only the named ids.
567    Only(&'a BTreeSet<String>),
568}
569
570impl CheckSelection<'_> {
571    fn contains(self, id: &str) -> bool {
572        match self {
573            Self::All => true,
574            Self::Only(ids) => ids.contains(id),
575        }
576    }
577}
578
579/// Invalid catalog or check output supplied to [`evaluate_checks`].
580#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
581#[non_exhaustive]
582pub enum EvaluationError {
583    /// The supplied programmatic configuration contains an invalid value.
584    #[error("invalid configuration: {0}")]
585    InvalidConfiguration(#[from] ConfigValidationError),
586    /// A catalog or directly constructed check record used an empty id.
587    #[error("check id cannot be empty")]
588    InvalidCheckId(&'static str),
589    /// Two catalog entries used the same stable check id.
590    #[error("duplicate check id {0:?}")]
591    DuplicateCheckId(&'static str),
592    /// Explicit selection named an id absent from the supplied catalog.
593    #[error("unknown selected check id {0:?}")]
594    UnknownSelection(String),
595    /// Check evidence violates the derived coverage-state invariants.
596    #[error("check {check_id:?} emitted invalid output: {reason}")]
597    InvalidCheckOutput {
598        /// Stable id of the check that emitted malformed evidence.
599        check_id: &'static str,
600        /// Contract rule violated by the output.
601        reason: &'static str,
602    },
603    /// A nested finding claimed a different check id than its parent record.
604    #[error("check {check_id:?} emitted a finding for {finding_check_id:?}")]
605    FindingCheckIdMismatch {
606        /// Parent check id.
607        check_id: &'static str,
608        /// Mismatched nested finding id.
609        finding_check_id: &'static str,
610    },
611    /// A built-in completed or missing-work scope came from an undeclared check.
612    #[error("check {check_id:?} cannot emit built-in evaluation scope {code}")]
613    BuiltinEvaluationScopeEmitterMismatch {
614        /// Stable id of the check that emitted the scope.
615        check_id: &'static str,
616        /// Built-in scope code that does not declare this emitter.
617        code: EvaluationScopeCode,
618    },
619    /// A built-in coverage-gap code came from an undeclared check.
620    #[error("check {check_id:?} cannot emit built-in coverage gap {code}")]
621    BuiltinCoverageGapEmitterMismatch {
622        /// Stable id of the check that emitted the gap.
623        check_id: &'static str,
624        /// Built-in gap code that does not declare this emitter.
625        code: CoverageGapCode,
626    },
627}
628
629/// Evaluate a full catalog into one record per check.
630///
631/// Selection and configuration are recorded independently. A check can be
632/// disabled by `severity = "off"` or by its opt-in default; an explicit
633/// `note`, `warn`, or `error` severity enables an opt-in check. Inactive checks
634/// never evaluate, but their cheap applicability predicate still establishes
635/// whether declared work exists. Coverage gaps are nonblocking evidence;
636/// callers own any stricter policy.
637///
638/// # Errors
639///
640/// Returns an error for invalid directly constructed configuration, empty or
641/// duplicate catalog ids, unknown explicitly selected ids, malformed coverage
642/// evidence, built-in evidence emitted by an undeclared check, or a nested
643/// finding whose id disagrees with its parent check. Configuration validation
644/// runs before catalog inspection or check evaluation.
645pub fn evaluate_checks(
646    ctx: &CheckCtx<'_>,
647    checks: &[Box<dyn Check>],
648    selection: CheckSelection<'_>,
649) -> Result<Vec<CheckEvaluation>, EvaluationError> {
650    ctx.config.validate()?;
651
652    let mut catalog_ids = BTreeSet::new();
653    for check in checks {
654        if check.id().is_empty() {
655            return Err(EvaluationError::InvalidCheckId(check.id()));
656        }
657        if !catalog_ids.insert(check.id()) {
658            return Err(EvaluationError::DuplicateCheckId(check.id()));
659        }
660    }
661    if let CheckSelection::Only(selected) = selection
662        && let Some(unknown) = selected
663            .iter()
664            .find(|id| !catalog_ids.contains(id.as_str()))
665    {
666        return Err(EvaluationError::UnknownSelection(unknown.clone()));
667    }
668
669    let mut records = Vec::with_capacity(checks.len());
670    for check in checks {
671        let selection_state = if selection.contains(check.id()) {
672            SelectionState::Selected
673        } else {
674            SelectionState::Unselected
675        };
676        let setting = ctx.config.check_settings(check.id()).severity;
677        let configuration = match setting {
678            Some(SeveritySetting::Off) => ConfigurationState::Disabled,
679            Some(_) => ConfigurationState::Enabled,
680            None if check.enabled_by_default() => ConfigurationState::Enabled,
681            None => ConfigurationState::Disabled,
682        };
683        let applicability = check.applicability(ctx);
684
685        if selection_state == SelectionState::Unselected
686            || configuration == ConfigurationState::Disabled
687            || applicability == Applicability::NotApplicable
688        {
689            records.push(CheckEvaluation::inactive(
690                check.id(),
691                selection_state,
692                configuration,
693                applicability,
694            ));
695            continue;
696        }
697
698        let mut evaluation = CheckEvaluation::evaluated(check.id(), check.evaluate(ctx))?;
699        if let Some(setting) = setting {
700            evaluation.override_severity(setting);
701        }
702        records.push(evaluation);
703    }
704    Ok(records)
705}
706
707#[cfg(test)]
708mod authority_contract {
709    use std::collections::BTreeSet;
710    use std::path::{Path, PathBuf};
711
712    use super::{
713        BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS, BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
714        BuiltinEvidenceCode, CheckEvaluation, CheckOutput, CoverageGap, CoverageGapCode,
715        EvaluationError, EvaluationScope, EvaluationScopeCode,
716    };
717
718    fn assert_reference_table(docs: &str, heading: &str, entries: &[BuiltinEvidenceCode]) {
719        let section = docs
720            .split_once(heading)
721            .unwrap_or_else(|| panic!("missing reference heading {heading:?}"))
722            .1;
723        let documented = section
724            .lines()
725            .skip_while(|line| line.trim().is_empty())
726            .take_while(|line| !line.trim().is_empty())
727            .filter(|line| line.starts_with("| `"))
728            .collect::<Vec<_>>();
729        let expected = entries
730            .iter()
731            .map(|definition| {
732                let BuiltinEvidenceCode {
733                    code,
734                    meaning,
735                    emitted_by,
736                } = definition;
737                assert!(
738                    !meaning.trim().is_empty() && !meaning.contains(['\r', '\n']),
739                    "{code} must have a one-line meaning"
740                );
741                let emitters = emitted_by
742                    .iter()
743                    .map(|check_id| format!("`{check_id}`"))
744                    .collect::<Vec<_>>()
745                    .join(", ");
746                format!("| `{code}` | {meaning} | {emitters} |")
747            })
748            .collect::<Vec<_>>();
749
750        assert_eq!(documented.len(), expected.len(), "row count for {heading}");
751        let documented = documented.into_iter().collect::<BTreeSet<_>>();
752        assert_eq!(
753            documented.len(),
754            expected.len(),
755            "duplicate rows for {heading}"
756        );
757        let expected = expected.iter().map(String::as_str).collect::<BTreeSet<_>>();
758        assert_eq!(documented, expected, "exact rows for {heading}");
759    }
760
761    #[test]
762    fn output_docs_match_registered_builtin_evidence_codes_exactly() {
763        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
764        let Some(workspace_root) = source_workspace_root(manifest_dir) else {
765            // Published crates intentionally exclude repository-level docs.
766            return;
767        };
768        let docs_path = workspace_root.join("docs/output.md");
769        let docs = std::fs::read_to_string(&docs_path)
770            .unwrap_or_else(|error| panic!("cannot read {}: {error}", docs_path.display()));
771        let crlf = docs.lines().collect::<Vec<_>>().join("\r\n");
772        for line_endings in [docs.as_str(), crlf.as_str()] {
773            assert_reference_table(
774                line_endings,
775                "Built-in gap codes are:",
776                BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
777            );
778            assert_reference_table(
779                line_endings,
780                "Built-in completed/gap scope codes are:",
781                BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
782            );
783        }
784    }
785
786    #[test]
787    fn builtin_evidence_authority_has_unique_codes_and_known_emitters() {
788        let catalog_ids = crate::all_checks()
789            .into_iter()
790            .map(|check| check.id())
791            .collect::<BTreeSet<_>>();
792        let authorities = [
793            ("coverage-gap", BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS),
794            (
795                "evaluation-scope",
796                BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
797            ),
798        ];
799
800        for (kind, definitions) in authorities {
801            assert!(
802                definitions
803                    .iter()
804                    .all(|definition| !definition.code.is_empty()),
805                "{kind} authority codes must be nonempty"
806            );
807            let defined_codes = definitions
808                .iter()
809                .map(|definition| definition.code)
810                .collect::<BTreeSet<_>>();
811            assert_eq!(
812                defined_codes.len(),
813                definitions.len(),
814                "duplicate {kind} authority code"
815            );
816            for definition in definitions {
817                let emitters = definition
818                    .emitted_by
819                    .iter()
820                    .copied()
821                    .collect::<BTreeSet<_>>();
822                assert_eq!(
823                    emitters.len(),
824                    definition.emitted_by.len(),
825                    "{} has duplicate emitters",
826                    definition.code
827                );
828                for emitter in emitters {
829                    assert!(
830                        catalog_ids.contains(emitter),
831                        "{} declares unknown emitter {emitter:?}",
832                        definition.code
833                    );
834                }
835            }
836        }
837    }
838
839    #[test]
840    fn every_builtin_code_enforces_its_emitter_matrix() {
841        let catalog_ids = crate::all_checks()
842            .into_iter()
843            .map(|check| check.id())
844            .collect::<Vec<_>>();
845
846        for definition in BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS {
847            for &check_id in &catalog_ids {
848                let code = EvaluationScopeCode::custom(definition.code);
849                let completed = CheckEvaluation::evaluated(
850                    check_id,
851                    CheckOutput::from_coverage(
852                        Vec::new(),
853                        vec![EvaluationScope::new(code)],
854                        Vec::new(),
855                    ),
856                );
857                let gap_scope = CheckEvaluation::evaluated(
858                    check_id,
859                    CheckOutput::from_coverage(
860                        Vec::new(),
861                        Vec::new(),
862                        vec![
863                            CoverageGap::new(CoverageGapCode::custom("test:gap"), "gap")
864                                .scope(EvaluationScope::new(code)),
865                        ],
866                    ),
867                );
868                if definition.emitted_by.contains(&check_id) {
869                    assert!(
870                        completed.is_ok(),
871                        "{} must allow {check_id:?}",
872                        definition.code
873                    );
874                    assert!(
875                        gap_scope.is_ok(),
876                        "{} must allow {check_id:?}",
877                        definition.code
878                    );
879                } else {
880                    let expected =
881                        EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code };
882                    assert_eq!(completed.unwrap_err(), expected);
883                    assert_eq!(gap_scope.unwrap_err(), expected);
884                }
885            }
886        }
887
888        for definition in BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS {
889            for &check_id in &catalog_ids {
890                let code = CoverageGapCode::custom(definition.code);
891                let gap = CheckEvaluation::evaluated(
892                    check_id,
893                    CheckOutput::from_coverage(
894                        Vec::new(),
895                        Vec::new(),
896                        vec![CoverageGap::new(code, "test gap")],
897                    ),
898                );
899                if definition.emitted_by.contains(&check_id) {
900                    assert!(gap.is_ok(), "{} must allow {check_id:?}", definition.code);
901                } else {
902                    assert_eq!(
903                        gap.unwrap_err(),
904                        EvaluationError::BuiltinCoverageGapEmitterMismatch { check_id, code }
905                    );
906                }
907            }
908        }
909    }
910
911    #[test]
912    fn source_workspace_detection_has_a_positive_checkout_control() {
913        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
914        let detected = source_workspace_root(manifest_dir);
915        if manifest_dir.join(".cargo_vcs_info.json").is_file() {
916            assert!(detected.is_none(), "published packages must skip repo docs");
917            return;
918        }
919
920        let expected = manifest_dir.join("../..");
921        if expected.join("docs/output.md").is_file() {
922            assert_eq!(
923                detected.as_deref(),
924                Some(expected.as_path()),
925                "the exact source checkout must enforce its output docs"
926            );
927        }
928    }
929
930    fn source_workspace_root(manifest_dir: &Path) -> Option<PathBuf> {
931        if manifest_dir.join(".cargo_vcs_info.json").is_file() {
932            return None;
933        }
934        let workspace_root = manifest_dir.join("../..");
935        let current_manifest = manifest_dir.join("Cargo.toml").canonicalize().ok()?;
936        let workspace_manifest = workspace_root
937            .join("crates/animsmith-core/Cargo.toml")
938            .canonicalize()
939            .ok()?;
940        (current_manifest == workspace_manifest).then_some(workspace_root)
941    }
942}