1use std::fmt;
12
13use axioval_ir::contract::SourceField;
14use axioval_ir::{Discipline, SourceId};
15use regex::Regex;
16use thiserror::Error;
17
18use crate::SourceMetadataIndex;
19
20pub 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(®ex::escape(escaped.encode_utf8(&mut [0; 4])));
42 }
43 other => out.push_str(®ex::escape(other.encode_utf8(&mut [0; 4]))),
44 }
45 }
46 out.push('$');
47 Ok(out)
48}
49
50#[derive(Clone, Debug)]
53pub struct DisciplineRule {
54 field: SourceField,
55 pattern: String,
56 regex: Regex,
57 discipline: Discipline,
58}
59
60impl DisciplineRule {
61 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 #[must_use]
92 pub fn field(&self) -> SourceField {
93 self.field
94 }
95
96 #[must_use]
98 pub fn pattern(&self) -> &str {
99 &self.pattern
100 }
101
102 #[must_use]
104 pub fn discipline(&self) -> &Discipline {
105 &self.discipline
106 }
107}
108
109impl fmt::Display for DisciplineRule {
110 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#[derive(Clone, Debug, Default)]
124pub struct DisciplineMap {
125 rules: Vec<DisciplineRule>,
126}
127
128impl DisciplineMap {
129 #[must_use]
131 pub fn new() -> Self {
132 Self::default()
133 }
134
135 #[must_use]
137 pub fn with(mut self, rule: DisciplineRule) -> Self {
138 self.rules.push(rule);
139 self
140 }
141
142 #[must_use]
144 pub fn rules(&self) -> &[DisciplineRule] {
145 &self.rules
146 }
147
148 #[must_use]
150 pub fn is_empty(&self) -> bool {
151 self.rules.is_empty()
152 }
153
154 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#[derive(Clone, Debug, Eq, PartialEq)]
176pub(crate) enum Mapping {
177 Assigned { rule: usize, value: String },
178 Unread { rule: usize },
179 Unmatched,
180}
181
182#[derive(Clone, Debug, Eq, PartialEq)]
184pub enum DisciplineOrigin {
185 Declared,
187 Mapped {
189 rule: String,
191 field: SourceField,
193 value: String,
195 },
196}
197
198impl DisciplineOrigin {
199 #[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#[derive(Clone, Debug, Eq, PartialEq)]
212pub enum UnmappedReason {
213 NoMatch,
215 Unread(String),
218}
219
220#[derive(Clone, Debug, Error, Eq, PartialEq)]
222pub enum DisciplineMapError {
223 #[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}