1use std::collections::BTreeMap;
30
31use super::component_problem;
32use super::layout::{RelPath, TAG_TYPES};
33use super::manifest::Manifest;
34use super::target::Target;
35use crate::error::DeployFinding;
36
37pub const DEFAULT_EXCLUDED_TAG_PROVIDERS: [&str; 1] = ["System"];
40
41pub fn entry_problem(name: &str) -> Option<&'static str> {
48 if !name.is_empty() && name.trim().is_empty() {
49 Some("must not be blank")
50 } else if name.trim() != name {
51 Some("must not have leading or trailing whitespace")
52 } else if name.chars().any(char::is_control) {
53 Some("must not contain a control character")
54 } else {
55 component_problem(name)
56 }
57}
58
59pub(crate) fn list_problems(list: &[String]) -> Vec<String> {
63 let mut problems = Vec::new();
64 for (i, name) in list.iter().enumerate() {
65 if let Some(why) = entry_problem(name) {
66 problems.push(format!("exclude_tag_providers[{i}] {name:?}: {why}"));
67 }
68 if list[..i]
69 .iter()
70 .any(|earlier| earlier.eq_ignore_ascii_case(name))
71 {
72 problems.push(format!(
73 "exclude_tag_providers[{i}] {name:?}: duplicate tag provider"
74 ));
75 }
76 }
77 problems
78}
79
80pub fn effective(target: &Target, manifest: Option<&Manifest>) -> Vec<String> {
85 let mut by_fold: BTreeMap<String, String> = BTreeMap::new();
86 let listed = DEFAULT_EXCLUDED_TAG_PROVIDERS
87 .iter()
88 .map(|p| (*p).to_string())
89 .chain(target.exclude_tag_providers.iter().cloned())
90 .chain(
91 manifest
92 .into_iter()
93 .flat_map(|m| m.exclude_tag_providers.iter().cloned()),
94 );
95 for name in listed {
96 let fold = name.to_ascii_lowercase();
97 match by_fold.get_mut(&fold) {
98 Some(kept) if name.as_bytes() < kept.as_bytes() => *kept = name,
99 Some(_) => {}
100 None => {
101 by_fold.insert(fold, name);
102 }
103 }
104 }
105 let mut names: Vec<String> = by_fold.into_values().collect();
106 names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
107 names
108}
109
110pub fn is_excluded(provider: &str, excluded: &[String]) -> bool {
112 excluded.iter().any(|e| e.eq_ignore_ascii_case(provider))
113}
114
115pub fn excluded_provider<'a>(path: &'a RelPath, excluded: &[String]) -> Option<&'a str> {
120 let mut parts = path.components();
121 let (module, ty, provider) = (parts.next()?, parts.next()?, parts.next()?);
122 (module == "ignition" && TAG_TYPES.contains(&ty) && is_excluded(provider, excluded))
123 .then_some(provider)
124}
125
126pub fn contradictions(
129 excluded: &[String],
130 manifest: &Manifest,
131 manifest_file: &str,
132) -> Vec<DeployFinding> {
133 manifest
134 .tag_providers
135 .iter()
136 .enumerate()
137 .filter(|(_, provider)| is_excluded(provider, excluded))
138 .map(|(i, provider)| DeployFinding {
139 file: manifest_file.to_string(),
140 line: None,
141 column: None,
142 message: format!(
143 "tag_providers[{i}] {provider:?}: the tag provider is excluded (by exclude_tag_providers or the built-in System default); an excluded provider cannot be declared"
144 ),
145 })
146 .collect()
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn target(extra: &str) -> Target {
154 let yaml = format!(
155 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n{extra}"
156 );
157 Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("target")
158 }
159
160 fn manifest(extra: &str) -> Manifest {
161 let yaml = format!("schema_version: 1\ntypes: []\n{extra}");
162 Manifest::parse("gateways/g/manifest.yaml", yaml.as_bytes()).expect("manifest")
163 }
164
165 #[test]
166 fn exclude_default_is_system_alone() {
167 assert_eq!(DEFAULT_EXCLUDED_TAG_PROVIDERS, ["System"]);
168 assert_eq!(effective(&target(""), None), ["System"]);
169 assert_eq!(effective(&target(""), Some(&manifest(""))), ["System"]);
170 }
171
172 #[test]
173 fn exclude_effective_unions_default_target_and_manifest() {
174 let t = target("exclude_tag_providers: [\"MQTT Engine\"]\n");
175 let m = manifest("exclude_tag_providers: [\"Sparkplug B\", system]\n");
176 assert_eq!(
178 effective(&t, Some(&m)),
179 ["MQTT Engine", "Sparkplug B", "System"]
180 );
181 let both = manifest("exclude_tag_providers: [\"MQTT Engine\"]\n");
183 assert_eq!(effective(&t, Some(&both)), ["MQTT Engine", "System"]);
184 }
185
186 #[test]
187 fn exclude_is_excluded_folds_ascii_case() {
188 let excluded = vec!["MQTT Engine".to_string(), "System".to_string()];
189 assert!(is_excluded("system", &excluded));
190 assert!(is_excluded("mqtt ENGINE", &excluded));
191 assert!(!is_excluded("default", &excluded));
192 assert!(!is_excluded("MQTT", &excluded));
193 }
194
195 #[test]
196 fn exclude_contradictions_flag_declared_excluded_providers() {
197 let m = manifest("tag_providers: [default, system]\n");
198 let found = contradictions(
199 &effective(&target(""), Some(&m)),
200 &m,
201 "gateways/g/manifest.yaml",
202 );
203 assert_eq!(found.len(), 1, "{found:?}");
204 assert_eq!(found[0].file, "gateways/g/manifest.yaml");
205 assert_eq!((found[0].line, found[0].column), (None, None));
206 assert!(
207 found[0].message.starts_with("tag_providers[1] \"system\"")
208 && found[0].message.contains("excluded"),
209 "{}",
210 found[0].message
211 );
212
213 let t = target("exclude_tag_providers: [\"MQTT Engine\"]\n");
214 let m = manifest("tag_providers: [\"MQTT Engine\", default]\n");
215 let found = contradictions(&effective(&t, Some(&m)), &m, "gateways/g/manifest.yaml");
216 assert_eq!(found.len(), 1, "{found:?}");
217 assert!(
218 found[0]
219 .message
220 .starts_with("tag_providers[0] \"MQTT Engine\""),
221 "{}",
222 found[0].message
223 );
224
225 let m = manifest("tag_providers: [default]\n");
226 assert!(contradictions(&effective(&t, Some(&m)), &m, "x").is_empty());
227 }
228
229 #[test]
230 fn exclude_excluded_provider_matches_the_four_tag_types_only() {
231 let excluded = vec!["MQTT Engine".to_string(), "System".to_string()];
232 let of = |p: &str| {
233 let path = RelPath::new(p).expect("path");
234 excluded_provider(&path, &excluded).map(str::to_string)
235 };
236 for (path, want) in [
237 (
238 "ignition/tag-provider/MQTT Engine/config.json",
239 Some("MQTT Engine"),
240 ),
241 (
242 "ignition/tag-group/System/Default/config.json",
243 Some("System"),
244 ),
245 (
246 "ignition/tag-type-definition/system/T/tags.json",
247 Some("system"),
248 ),
249 (
250 "ignition/tag-definition/mqtt engine/Edge Nodes/tags.json",
251 Some("mqtt engine"),
252 ),
253 ("ignition/tag-provider/default/config.json", None),
254 ("ignition/database-translator/MQTT Engine/config.json", None),
255 ("com.example/tag-provider/MQTT Engine/config.json", None),
256 ] {
257 assert_eq!(of(path).as_deref(), want, "{path}");
258 }
259 }
260
261 #[test]
262 fn exclude_entry_problem_refuses_blank_padded_and_unsafe_names() {
263 assert_eq!(entry_problem("MQTT Engine"), None);
264 assert_eq!(entry_problem("System"), None);
265 for bad in [
266 "",
267 " ",
268 " MQTT Engine",
269 "MQTT Engine\t",
270 "a/b",
271 ".x",
272 "..",
273 ] {
274 assert!(entry_problem(bad).is_some(), "{bad:?}");
275 }
276 }
277
278 #[test]
282 fn exclude_entry_problem_refuses_control_characters() {
283 for bad in [
284 "MQTT\nEngine",
285 "MQTT\rEngine",
286 "MQTT\u{1b}[31mEngine",
287 "a\u{7f}b",
288 "a\u{85}b",
289 "a\u{0}b",
290 ] {
291 assert_eq!(
292 entry_problem(bad),
293 Some("must not contain a control character"),
294 "{bad:?}"
295 );
296 }
297 assert_eq!(entry_problem("\n"), Some("must not be blank"));
298 assert_eq!(
299 entry_problem("MQTT Engine\n"),
300 Some("must not have leading or trailing whitespace")
301 );
302 assert_eq!(entry_problem("MQTT Engine (caf\u{e9})"), None);
303 }
304}