Skip to main content

made_core/entities/
validation.rs

1//! Validation outcome of a proposal — generic replacement for the
2//! SWE-specific `CheckSuite` / `PolicyResult` / `LintResult` / `DryRunResult`
3//! of the original service.
4//!
5//! MADE does not know what a validator checks. A validator
6//! is any adapter that, given a proposal, returns a [`ValidatorReport`]
7//! (pass/fail + opaque details). A [`ValidationOutcome`] aggregates all
8//! reports for a single proposal and carries the final [`Score`].
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13use crate::value_objects::{Attributes, Score};
14
15/// A single validator's report on a proposal.
16///
17/// `kind` is a free-form adapter-defined identifier (e.g. `"lint"`,
18/// `"policy"`, `"dry-run"`, `"fact-check"`, `"style"`). `details` is
19/// an opaque bag of structured data carried through to the outcome.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ValidatorReport {
22    kind: String,
23    passed: bool,
24    summary: String,
25    details: Attributes,
26}
27
28impl ValidatorReport {
29    pub fn new(
30        kind: impl Into<String>,
31        passed: bool,
32        summary: impl Into<String>,
33        details: Attributes,
34    ) -> Result<Self, DomainError> {
35        let kind = kind.into();
36        if kind.trim().is_empty() {
37            return Err(DomainError::EmptyField {
38                field: "validator_report.kind",
39            });
40        }
41        Ok(Self {
42            kind,
43            passed,
44            summary: summary.into(),
45            details,
46        })
47    }
48
49    #[must_use]
50    pub fn kind(&self) -> &str {
51        &self.kind
52    }
53    #[must_use]
54    pub fn passed(&self) -> bool {
55        self.passed
56    }
57    #[must_use]
58    pub fn summary(&self) -> &str {
59        &self.summary
60    }
61    #[must_use]
62    pub fn details(&self) -> &Attributes {
63        &self.details
64    }
65}
66
67/// Aggregate validation outcome for one proposal.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ValidationOutcome {
70    score: Score,
71    reports: Vec<ValidatorReport>,
72}
73
74impl ValidationOutcome {
75    #[must_use]
76    pub fn new(score: Score, reports: Vec<ValidatorReport>) -> Self {
77        Self { score, reports }
78    }
79
80    #[must_use]
81    pub fn score(&self) -> Score {
82        self.score
83    }
84    #[must_use]
85    pub fn reports(&self) -> &[ValidatorReport] {
86        &self.reports
87    }
88
89    /// Convenience: true iff all reports passed. Independent of score.
90    #[must_use]
91    pub fn all_passed(&self) -> bool {
92        self.reports.iter().all(ValidatorReport::passed)
93    }
94
95    /// Convenience: number of failing reports.
96    #[must_use]
97    pub fn failures(&self) -> usize {
98        self.reports.iter().filter(|r| !r.passed()).count()
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn report(kind: &str, passed: bool) -> ValidatorReport {
107        ValidatorReport::new(kind, passed, "summary", Attributes::empty()).unwrap()
108    }
109
110    #[test]
111    fn empty_kind_is_rejected() {
112        let err = ValidatorReport::new("  ", true, "", Attributes::empty()).unwrap_err();
113        assert!(matches!(
114            err,
115            DomainError::EmptyField {
116                field: "validator_report.kind"
117            }
118        ));
119    }
120
121    #[test]
122    fn arbitrary_kind_is_accepted() {
123        // No enum of SWE-specific checks (lint/dryrun/policy).
124        for kind in [
125            "lint",
126            "policy",
127            "dry-run",
128            "clinical-safety",
129            "sourcing-feasibility",
130            "fact-check",
131        ] {
132            assert_eq!(report(kind, true).kind(), kind);
133        }
134    }
135
136    #[test]
137    fn outcome_aggregates_reports() {
138        let o = ValidationOutcome::new(
139            Score::new(0.75).unwrap(),
140            vec![report("lint", true), report("policy", false)],
141        );
142        assert_eq!(o.reports().len(), 2);
143        assert!(!o.all_passed());
144        assert_eq!(o.failures(), 1);
145        assert_eq!(o.score().get(), 0.75);
146    }
147
148    #[test]
149    fn outcome_without_failures_reports_all_passed() {
150        let o = ValidationOutcome::new(Score::MAX, vec![report("a", true), report("b", true)]);
151        assert!(o.all_passed());
152        assert_eq!(o.failures(), 0);
153    }
154
155    #[test]
156    fn outcome_with_no_reports_trivially_passes() {
157        let o = ValidationOutcome::new(Score::MIN, vec![]);
158        assert!(o.all_passed());
159        assert_eq!(o.failures(), 0);
160    }
161}