1use std::collections::BTreeMap;
36
37use super::component_problem;
38use super::layout::{RelPath, TAG_TYPES};
39use super::manifest::Manifest;
40use super::target::Target;
41use crate::error::DeployFinding;
42
43pub const DEFAULT_EXCLUDED_TAG_PROVIDERS: [&str; 1] = ["System"];
46
47pub fn entry_problem(name: &str) -> Option<&'static str> {
55 if !name.is_empty() && name.trim().is_empty() {
56 Some("must not be blank")
57 } else if name.trim() != name {
58 Some("must not have leading or trailing whitespace")
59 } else if name.chars().any(char::is_control) {
60 Some("must not contain a control character")
61 } else {
62 component_problem(name)
63 }
64}
65
66pub(crate) fn list_problems(key: &str, list: &[String]) -> Vec<String> {
71 let mut problems = Vec::new();
72 for (i, name) in list.iter().enumerate() {
73 if let Some(why) = entry_problem(name) {
74 problems.push(format!("{key}[{i}] {name:?}: {why}"));
75 }
76 if list[..i]
77 .iter()
78 .any(|earlier| earlier.eq_ignore_ascii_case(name))
79 {
80 problems.push(format!("{key}[{i}] {name:?}: duplicate tag provider"));
81 }
82 }
83 problems
84}
85
86pub fn effective(target: &Target, manifest: Option<&Manifest>) -> Vec<String> {
91 let mut by_fold: BTreeMap<String, String> = BTreeMap::new();
92 let listed = DEFAULT_EXCLUDED_TAG_PROVIDERS
93 .iter()
94 .map(|p| (*p).to_string())
95 .chain(target.exclude_tag_providers.iter().cloned())
96 .chain(target.read_only_tag_providers.iter().cloned())
97 .chain(manifest.into_iter().flat_map(|m| {
98 m.exclude_tag_providers
99 .iter()
100 .chain(m.read_only_tag_providers.iter())
101 .cloned()
102 }));
103 for name in listed {
104 let fold = name.to_ascii_lowercase();
105 match by_fold.get_mut(&fold) {
106 Some(kept) if name.as_bytes() < kept.as_bytes() => *kept = name,
107 Some(_) => {}
108 None => {
109 by_fold.insert(fold, name);
110 }
111 }
112 }
113 let mut names: Vec<String> = by_fold.into_values().collect();
114 names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
115 names
116}
117
118pub fn is_excluded(provider: &str, excluded: &[String]) -> bool {
120 excluded.iter().any(|e| e.eq_ignore_ascii_case(provider))
121}
122
123pub fn excluded_provider<'a>(path: &'a RelPath, excluded: &[String]) -> Option<&'a str> {
128 let mut parts = path.components();
129 let (module, ty, provider) = (parts.next()?, parts.next()?, parts.next()?);
130 (module == "ignition" && TAG_TYPES.contains(&ty) && is_excluded(provider, excluded))
131 .then_some(provider)
132}
133
134pub fn contradictions(
137 excluded: &[String],
138 manifest: &Manifest,
139 manifest_file: &str,
140) -> Vec<DeployFinding> {
141 manifest
142 .tag_providers
143 .iter()
144 .enumerate()
145 .filter(|(_, provider)| is_excluded(provider, excluded))
146 .map(|(i, provider)| DeployFinding {
147 file: manifest_file.to_string(),
148 line: None,
149 column: None,
150 message: format!(
151 "tag_providers[{i}] {provider:?}: the tag provider is excluded (by exclude_tag_providers, read_only_tag_providers or the built-in System default); an excluded provider cannot be declared"
152 ),
153 })
154 .collect()
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn target(extra: &str) -> Target {
162 let yaml = format!(
163 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n{extra}"
164 );
165 Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("target")
166 }
167
168 fn manifest(extra: &str) -> Manifest {
169 let yaml = format!("schema_version: 1\ntypes: []\n{extra}");
170 Manifest::parse("gateways/g/manifest.yaml", yaml.as_bytes()).expect("manifest")
171 }
172
173 #[test]
174 fn exclude_default_is_system_alone() {
175 assert_eq!(DEFAULT_EXCLUDED_TAG_PROVIDERS, ["System"]);
176 assert_eq!(effective(&target(""), None), ["System"]);
177 assert_eq!(effective(&target(""), Some(&manifest(""))), ["System"]);
178 }
179
180 #[test]
181 fn exclude_effective_unions_default_target_and_manifest() {
182 let t = target("exclude_tag_providers: [\"MQTT Engine\"]\n");
183 let m = manifest("exclude_tag_providers: [\"Sparkplug B\", system]\n");
184 assert_eq!(
186 effective(&t, Some(&m)),
187 ["MQTT Engine", "Sparkplug B", "System"]
188 );
189 let both = manifest("exclude_tag_providers: [\"MQTT Engine\"]\n");
191 assert_eq!(effective(&t, Some(&both)), ["MQTT Engine", "System"]);
192 }
193
194 #[test]
198 fn exclude_effective_unions_read_only_lists() {
199 let t = target(
200 "exclude_tag_providers: [\"MQTT Engine\"]\nread_only_tag_providers: [\"Broker Transmission\"]\n",
201 );
202 let m = manifest("read_only_tag_providers: [EdgeGateway01, \"broker transmission\"]\n");
203 assert_eq!(
204 effective(&t, Some(&m)),
205 [
206 "Broker Transmission",
207 "EdgeGateway01",
208 "MQTT Engine",
209 "System"
210 ]
211 );
212 assert_eq!(
213 effective(&t, None),
214 ["Broker Transmission", "MQTT Engine", "System"]
215 );
216 let excluded = effective(&t, Some(&m));
217 assert!(is_excluded("edgegateway01", &excluded));
218 let path = RelPath::new("ignition/tag-definition/EdgeGateway01/A/tags.json").expect("path");
219 assert_eq!(excluded_provider(&path, &excluded), Some("EdgeGateway01"));
220 let declared = manifest("tag_providers: [default, EdgeGateway01]\n");
221 let found = contradictions(&excluded, &declared, "gateways/g/manifest.yaml");
222 assert_eq!(found.len(), 1, "{found:?}");
223 assert!(
224 found[0].message.contains("read_only_tag_providers"),
225 "{}",
226 found[0].message
227 );
228 }
229
230 #[test]
231 fn exclude_list_problems_name_the_key() {
232 let list = vec![" x".to_string(), "X".to_string(), "x".to_string()];
233 let problems = list_problems("read_only_tag_providers", &list);
234 assert!(
235 problems[0].starts_with("read_only_tag_providers[0] "),
236 "{problems:?}"
237 );
238 assert!(
239 problems
240 .iter()
241 .any(|p| p.starts_with("read_only_tag_providers[2] ") && p.contains("duplicate")),
242 "{problems:?}"
243 );
244 }
245
246 #[test]
247 fn exclude_is_excluded_folds_ascii_case() {
248 let excluded = vec!["MQTT Engine".to_string(), "System".to_string()];
249 assert!(is_excluded("system", &excluded));
250 assert!(is_excluded("mqtt ENGINE", &excluded));
251 assert!(!is_excluded("default", &excluded));
252 assert!(!is_excluded("MQTT", &excluded));
253 }
254
255 #[test]
256 fn exclude_contradictions_flag_declared_excluded_providers() {
257 let m = manifest("tag_providers: [default, system]\n");
258 let found = contradictions(
259 &effective(&target(""), Some(&m)),
260 &m,
261 "gateways/g/manifest.yaml",
262 );
263 assert_eq!(found.len(), 1, "{found:?}");
264 assert_eq!(found[0].file, "gateways/g/manifest.yaml");
265 assert_eq!((found[0].line, found[0].column), (None, None));
266 assert!(
267 found[0].message.starts_with("tag_providers[1] \"system\"")
268 && found[0].message.contains("excluded"),
269 "{}",
270 found[0].message
271 );
272
273 let t = target("exclude_tag_providers: [\"MQTT Engine\"]\n");
274 let m = manifest("tag_providers: [\"MQTT Engine\", default]\n");
275 let found = contradictions(&effective(&t, Some(&m)), &m, "gateways/g/manifest.yaml");
276 assert_eq!(found.len(), 1, "{found:?}");
277 assert!(
278 found[0]
279 .message
280 .starts_with("tag_providers[0] \"MQTT Engine\""),
281 "{}",
282 found[0].message
283 );
284
285 let m = manifest("tag_providers: [default]\n");
286 assert!(contradictions(&effective(&t, Some(&m)), &m, "x").is_empty());
287 }
288
289 #[test]
290 fn exclude_excluded_provider_matches_the_four_tag_types_only() {
291 let excluded = vec!["MQTT Engine".to_string(), "System".to_string()];
292 let of = |p: &str| {
293 let path = RelPath::new(p).expect("path");
294 excluded_provider(&path, &excluded).map(str::to_string)
295 };
296 for (path, want) in [
297 (
298 "ignition/tag-provider/MQTT Engine/config.json",
299 Some("MQTT Engine"),
300 ),
301 (
302 "ignition/tag-group/System/Default/config.json",
303 Some("System"),
304 ),
305 (
306 "ignition/tag-type-definition/system/T/tags.json",
307 Some("system"),
308 ),
309 (
310 "ignition/tag-definition/mqtt engine/Edge Nodes/tags.json",
311 Some("mqtt engine"),
312 ),
313 ("ignition/tag-provider/default/config.json", None),
314 ("ignition/database-translator/MQTT Engine/config.json", None),
315 ("com.example/tag-provider/MQTT Engine/config.json", None),
316 ] {
317 assert_eq!(of(path).as_deref(), want, "{path}");
318 }
319 }
320
321 #[test]
322 fn exclude_entry_problem_refuses_blank_padded_and_unsafe_names() {
323 assert_eq!(entry_problem("MQTT Engine"), None);
324 assert_eq!(entry_problem("System"), None);
325 for bad in [
326 "",
327 " ",
328 " MQTT Engine",
329 "MQTT Engine\t",
330 "a/b",
331 ".x",
332 "..",
333 ] {
334 assert!(entry_problem(bad).is_some(), "{bad:?}");
335 }
336 }
337
338 #[test]
342 fn exclude_entry_problem_refuses_control_characters() {
343 for bad in [
344 "MQTT\nEngine",
345 "MQTT\rEngine",
346 "MQTT\u{1b}[31mEngine",
347 "a\u{7f}b",
348 "a\u{85}b",
349 "a\u{0}b",
350 ] {
351 assert_eq!(
352 entry_problem(bad),
353 Some("must not contain a control character"),
354 "{bad:?}"
355 );
356 }
357 assert_eq!(entry_problem("\n"), Some("must not be blank"));
358 assert_eq!(
359 entry_problem("MQTT Engine\n"),
360 Some("must not have leading or trailing whitespace")
361 );
362 assert_eq!(entry_problem("MQTT Engine (caf\u{e9})"), None);
363 }
364}