Skip to main content

axioval_engine/
discipline_map.rs

1//! Disciplines assigned from source metadata.
2//!
3//! A federated check commonly takes each model's discipline from the
4//! application that wrote it or its file name. A [`DisciplineMap`] is the
5//! host's ordered list of such assignments; applied to a session, it gives a
6//! discipline to each source that declares none, and records which entry
7//! did so and on which value, so a report can say where a discipline came
8//! from. A declared discipline always wins, and a source no entry matches
9//! keeps none, so discipline rules over it stay not evaluated.
10
11use std::fmt;
12
13use axioval_ir::contract::SourceField;
14use axioval_ir::{Discipline, SourceId};
15use regex::Regex;
16use thiserror::Error;
17
18use crate::SourceMetadataIndex;
19
20/// A wildcard pattern as an anchored regular expression.
21///
22/// `*` is any run of characters (none included), `?` exactly one, and a
23/// backslash makes the next character literal (`\*`, `\?`, `\\`). Every
24/// other character is literal. This is the one translation of the `like`
25/// operator's wildcards.
26///
27/// # Errors
28///
29/// A pattern ending in a lone backslash.
30pub fn wildcard_regex(pattern: &str) -> Result<String, String> {
31    let mut out = String::from("(?s)^");
32    let mut chars = pattern.chars();
33    while let Some(c) = chars.next() {
34        match c {
35            '*' => out.push_str(".*"),
36            '?' => out.push('.'),
37            '\\' => {
38                let escaped = chars
39                    .next()
40                    .ok_or("a wildcard pattern ends with a backslash")?;
41                out.push_str(&regex::escape(escaped.encode_utf8(&mut [0; 4])));
42            }
43            other => out.push_str(&regex::escape(other.encode_utf8(&mut [0; 4]))),
44        }
45    }
46    out.push('$');
47    Ok(out)
48}
49
50/// One assignment: sources whose `field` has a value matching `pattern`
51/// play `discipline`.
52#[derive(Clone, Debug)]
53pub struct DisciplineRule {
54    field: SourceField,
55    pattern: String,
56    regex: Regex,
57    discipline: Discipline,
58}
59
60impl DisciplineRule {
61    /// An assignment by a wildcard pattern over the whole value, compared
62    /// case-sensitively, as the `like` operator compares.
63    ///
64    /// # Errors
65    ///
66    /// A pattern that is empty or ends in a lone backslash.
67    pub fn new(
68        field: SourceField,
69        pattern: impl Into<String>,
70        discipline: Discipline,
71    ) -> Result<Self, DisciplineMapError> {
72        let pattern = pattern.into();
73        if pattern.is_empty() {
74            return Err(DisciplineMapError::InvalidPattern(
75                pattern,
76                "the pattern is empty".into(),
77            ));
78        }
79        let regex = wildcard_regex(&pattern)
80            .and_then(|translated| Regex::new(&translated).map_err(|error| error.to_string()))
81            .map_err(|message| DisciplineMapError::InvalidPattern(pattern.clone(), message))?;
82        Ok(Self {
83            field,
84            pattern,
85            regex,
86            discipline,
87        })
88    }
89
90    /// The metadata field the pattern reads.
91    #[must_use]
92    pub fn field(&self) -> SourceField {
93        self.field
94    }
95
96    /// The wildcard pattern as written.
97    #[must_use]
98    pub fn pattern(&self) -> &str {
99        &self.pattern
100    }
101
102    /// The discipline a matching source plays.
103    #[must_use]
104    pub fn discipline(&self) -> &Discipline {
105        &self.discipline
106    }
107}
108
109impl fmt::Display for DisciplineRule {
110    /// `field:pattern=discipline`, as the CLI takes it.
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(
113            f,
114            "{}:{}={}",
115            self.field.as_str(),
116            self.pattern,
117            self.discipline
118        )
119    }
120}
121
122/// A host's ordered disciplines by source metadata.
123#[derive(Clone, Debug, Default)]
124pub struct DisciplineMap {
125    rules: Vec<DisciplineRule>,
126}
127
128impl DisciplineMap {
129    /// A map assigning nothing.
130    #[must_use]
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    /// Appends `rule`; earlier rules are tried first.
136    #[must_use]
137    pub fn with(mut self, rule: DisciplineRule) -> Self {
138        self.rules.push(rule);
139        self
140    }
141
142    /// The rules, in the order they are tried.
143    #[must_use]
144    pub fn rules(&self) -> &[DisciplineRule] {
145        &self.rules
146    }
147
148    /// Whether the map assigns nothing.
149    #[must_use]
150    pub fn is_empty(&self) -> bool {
151        self.rules.is_empty()
152    }
153
154    /// What the map decides for `source`: the first rule one of whose field
155    /// values matches assigns its discipline. A rule whose field was never
156    /// read cannot be passed over, since it might match, so it stops the
157    /// search and the source is left without a discipline.
158    pub(crate) fn decide(&self, source: &SourceId, metadata: &SourceMetadataIndex) -> Mapping {
159        for (index, rule) in self.rules.iter().enumerate() {
160            let Some(values) = metadata.values(source, rule.field) else {
161                return Mapping::Unread { rule: index };
162            };
163            if let Some(value) = values.iter().find(|value| rule.regex.is_match(value)) {
164                return Mapping::Assigned {
165                    rule: index,
166                    value: value.clone(),
167                };
168            }
169        }
170        Mapping::Unmatched
171    }
172}
173
174/// What a [`DisciplineMap`] decided for one source, by rule index.
175#[derive(Clone, Debug, Eq, PartialEq)]
176pub(crate) enum Mapping {
177    Assigned { rule: usize, value: String },
178    Unread { rule: usize },
179    Unmatched,
180}
181
182/// Where a source's discipline came from.
183#[derive(Clone, Debug, Eq, PartialEq)]
184pub enum DisciplineOrigin {
185    /// The host declared it for the source.
186    Declared,
187    /// A discipline map assigned it.
188    Mapped {
189        /// The assigning rule, `field:pattern=discipline`.
190        rule: String,
191        /// The field it read.
192        field: SourceField,
193        /// The value its pattern matched.
194        value: String,
195    },
196}
197
198impl DisciplineOrigin {
199    /// A reviewable locator for evidence citing the assignment:
200    /// `discipline-map:field:pattern=discipline@value`.
201    #[must_use]
202    pub fn locator(&self) -> Option<String> {
203        match self {
204            Self::Declared => None,
205            Self::Mapped { rule, value, .. } => Some(format!("discipline-map:{rule}@{value}")),
206        }
207    }
208}
209
210/// Why a source kept no discipline under a map.
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub enum UnmappedReason {
213    /// No rule matched any value.
214    NoMatch,
215    /// The rule (`field:pattern=discipline`) reads a field the source never
216    /// stated, so neither it nor a later rule could decide.
217    Unread(String),
218}
219
220/// An invalid discipline map.
221#[derive(Clone, Debug, Error, Eq, PartialEq)]
222pub enum DisciplineMapError {
223    /// A pattern cannot be matched.
224    #[error("discipline map pattern `{0}`: {1}")]
225    InvalidPattern(String, String),
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::SourceMetadata;
232
233    fn source(document: &str) -> SourceId {
234        SourceId::new("test", document).unwrap()
235    }
236
237    fn rule(field: SourceField, pattern: &str, discipline: &str) -> DisciplineRule {
238        DisciplineRule::new(field, pattern, Discipline::new(discipline).unwrap()).unwrap()
239    }
240
241    #[test]
242    fn the_first_matching_rule_assigns_and_an_unread_field_stops_the_search() {
243        let map = DisciplineMap::new()
244            .with(rule(
245                SourceField::Application,
246                "*Architecture*",
247                "architecture",
248            ))
249            .with(rule(SourceField::FileName, "*_STR*", "structure"));
250        let index = SourceMetadataIndex::new([
251            (
252                source("a"),
253                SourceMetadata::new()
254                    .with(SourceField::Application, ["Tool Architecture"])
255                    .with(SourceField::FileName, ["a_STR.ifc"]),
256            ),
257            (
258                source("b"),
259                SourceMetadata::new()
260                    .with(SourceField::Application, ["Other"])
261                    .with(SourceField::FileName, ["b_STR.ifc"]),
262            ),
263            (
264                source("c"),
265                SourceMetadata::new().with(SourceField::FileName, ["c_STR.ifc"]),
266            ),
267            (
268                source("d"),
269                SourceMetadata::new()
270                    .with(SourceField::Application, ["Other"])
271                    .with(SourceField::FileName, ["d.ifc"]),
272            ),
273        ]);
274        assert_eq!(
275            map.decide(&source("a"), &index),
276            Mapping::Assigned {
277                rule: 0,
278                value: "Tool Architecture".into()
279            }
280        );
281        assert_eq!(
282            map.decide(&source("b"), &index),
283            Mapping::Assigned {
284                rule: 1,
285                value: "b_STR.ifc".into()
286            }
287        );
288        assert_eq!(
289            map.decide(&source("c"), &index),
290            Mapping::Unread { rule: 0 }
291        );
292        assert_eq!(map.decide(&source("d"), &index), Mapping::Unmatched);
293    }
294
295    #[test]
296    fn patterns_are_wildcards_over_the_whole_value() {
297        assert_eq!(wildcard_regex(r"W?-*.1").unwrap(), r"(?s)^W.\-.*\.1$");
298        assert!(wildcard_regex("a\\").is_err());
299        assert!(matches!(
300            DisciplineRule::new(SourceField::Application, "", Discipline::new("x").unwrap()),
301            Err(DisciplineMapError::InvalidPattern(..))
302        ));
303        assert_eq!(
304            rule(SourceField::FileName, "*.ifc", "mep").to_string(),
305            "fileName:*.ifc=mep"
306        );
307    }
308}