1use crate::error::{ObserverError, ObserverResult};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Inventory {
10 pub entries: Vec<InventoryEntry>,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct InventoryEntry {
15 pub name: String,
16 pub runner: InventoryRunner,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(tag = "kind", rename_all = "snake_case")]
21pub enum InventoryRunner {
22 Provider { provider: String, target: String },
23 Exec { path: String, args: Vec<String> },
24 Sh { command: String },
25}
26
27impl Inventory {
28 pub fn parse(source: &str) -> ObserverResult<Self> {
29 let mut entries = Vec::new();
30 let mut seen_names = BTreeSet::new();
31
32 for (line_ix, raw_line) in source.lines().enumerate() {
33 let stripped = strip_comment(raw_line);
34 let line = stripped.trim();
35 if line.is_empty() {
36 continue;
37 }
38
39 let entry = parse_entry(line).map_err(|message| {
40 ObserverError::InventoryParse(format!("line {}: {message}", line_ix + 1))
41 })?;
42
43 if !seen_names.insert(entry.name.clone()) {
44 return Err(ObserverError::InventoryParse(format!(
45 "line {}: duplicate test name `{}`",
46 line_ix + 1,
47 entry.name
48 )));
49 }
50
51 entries.push(entry);
52 }
53
54 Ok(Self { entries })
55 }
56
57 pub fn to_canonical_text(&self) -> String {
58 let mut entries = self.entries.clone();
59 entries.sort_by(|left, right| left.name.as_bytes().cmp(right.name.as_bytes()));
60
61 let mut normalized = String::new();
62 for entry in entries {
63 match entry.runner {
64 InventoryRunner::Provider { provider, target } => {
65 normalized.push_str(&format!(
66 "#{} provider: {} target: {}\n",
67 entry.name,
68 json_string(&provider),
69 json_string(&target)
70 ));
71 }
72 InventoryRunner::Exec { path, args } => {
73 let args = args
74 .into_iter()
75 .map(|arg| json_string(&arg))
76 .collect::<Vec<_>>()
77 .join(",");
78 normalized.push_str(&format!(
79 "#{} exec: {} args: [{}]\n",
80 entry.name,
81 json_string(&path),
82 args
83 ));
84 }
85 InventoryRunner::Sh { command } => {
86 normalized.push_str(&format!(
87 "#{} sh: {}\n",
88 entry.name,
89 json_string(&command)
90 ));
91 }
92 }
93 }
94
95 normalized
96 }
97}
98
99fn json_string(value: &str) -> String {
100 serde_json::to_string(value).expect("string serialization must succeed")
101}
102
103fn parse_entry(line: &str) -> Result<InventoryEntry, String> {
104 let rest = line
105 .strip_prefix('#')
106 .ok_or_else(|| "inventory entry must begin with `#`".to_owned())?;
107
108 let name_end = rest
109 .find(char::is_whitespace)
110 .ok_or_else(|| "inventory entry missing runner specification".to_owned())?;
111 let name = &rest[..name_end];
112 if name.is_empty() {
113 return Err("inventory test name must not be empty".to_owned());
114 }
115
116 let runner = rest[name_end..].trim_start();
117 if let Some(spec) = runner.strip_prefix("provider:") {
118 parse_provider_entry(name, spec)
119 } else if let Some(spec) = runner.strip_prefix("exec:") {
120 parse_exec_entry(name, spec)
121 } else if let Some(spec) = runner.strip_prefix("sh:") {
122 parse_sh_entry(name, spec)
123 } else {
124 Err("unknown runner kind".to_owned())
125 }
126}
127
128fn parse_provider_entry(name: &str, spec: &str) -> Result<InventoryEntry, String> {
129 let (provider, rest) = parse_json_string_prefix(spec.trim_start())?;
130 let rest = rest.trim_start();
131 let target_spec = rest
132 .strip_prefix("target:")
133 .ok_or_else(|| "provider entry missing `target:`".to_owned())?;
134 let (target, rest) = parse_json_string_prefix(target_spec.trim_start())?;
135 if !rest.trim().is_empty() {
136 return Err("unexpected trailing content after provider entry".to_owned());
137 }
138
139 Ok(InventoryEntry {
140 name: name.to_owned(),
141 runner: InventoryRunner::Provider { provider, target },
142 })
143}
144
145fn parse_exec_entry(name: &str, spec: &str) -> Result<InventoryEntry, String> {
146 let (path, rest) = parse_json_string_prefix(spec.trim_start())?;
147 let rest = rest.trim_start();
148 let args = if rest.is_empty() {
149 Vec::new()
150 } else {
151 let args_spec = rest
152 .strip_prefix("args:")
153 .ok_or_else(|| "exec entry has unexpected trailing content".to_owned())?;
154 let (args, tail) = parse_json_string_array_prefix(args_spec.trim_start())?;
155 if !tail.trim().is_empty() {
156 return Err("unexpected trailing content after exec entry".to_owned());
157 }
158 args
159 };
160
161 Ok(InventoryEntry {
162 name: name.to_owned(),
163 runner: InventoryRunner::Exec { path, args },
164 })
165}
166
167fn parse_sh_entry(name: &str, spec: &str) -> Result<InventoryEntry, String> {
168 let (command, rest) = parse_json_string_prefix(spec.trim_start())?;
169 if !rest.trim().is_empty() {
170 return Err("unexpected trailing content after sh entry".to_owned());
171 }
172
173 Ok(InventoryEntry {
174 name: name.to_owned(),
175 runner: InventoryRunner::Sh { command },
176 })
177}
178
179fn strip_comment(line: &str) -> String {
180 let mut in_string = false;
181 let mut escaped = false;
182 let chars = line.char_indices().collect::<Vec<_>>();
183
184 for window in chars.windows(2) {
185 let (index, current) = window[0];
186 let (_, next) = window[1];
187
188 if in_string {
189 if escaped {
190 escaped = false;
191 continue;
192 }
193 match current {
194 '\\' => escaped = true,
195 '"' => in_string = false,
196 _ => {}
197 }
198 continue;
199 }
200
201 match current {
202 '"' => in_string = true,
203 ';' if next == ';' => return line[..index].to_owned(),
204 _ => {}
205 }
206 }
207
208 line.to_owned()
209}
210
211fn parse_json_string_prefix(input: &str) -> Result<(String, &str), String> {
212 let end = find_string_end(input)?;
213 let (json, rest) = input.split_at(end);
214 let value = serde_json::from_str::<String>(json)
215 .map_err(|error| format!("invalid string literal: {error}"))?;
216 Ok((value, rest))
217}
218
219fn parse_json_string_array_prefix(input: &str) -> Result<(Vec<String>, &str), String> {
220 let end = find_array_end(input)?;
221 let (json, rest) = input.split_at(end);
222 let values = serde_json::from_str::<Vec<String>>(json)
223 .map_err(|error| format!("invalid args array: {error}"))?;
224 Ok((values, rest))
225}
226
227fn find_string_end(input: &str) -> Result<usize, String> {
228 let mut chars = input.char_indices();
229 match chars.next() {
230 Some((_, '"')) => {}
231 _ => return Err("expected string literal".to_owned()),
232 }
233
234 let mut escaped = false;
235 for (index, ch) in chars {
236 if escaped {
237 escaped = false;
238 continue;
239 }
240 match ch {
241 '\\' => escaped = true,
242 '"' => return Ok(index + ch.len_utf8()),
243 _ => {}
244 }
245 }
246
247 Err("unterminated string literal".to_owned())
248}
249
250fn find_array_end(input: &str) -> Result<usize, String> {
251 let mut chars = input.char_indices();
252 match chars.next() {
253 Some((_, '[')) => {}
254 _ => return Err("expected string array".to_owned()),
255 }
256
257 let mut depth = 1usize;
258 let mut in_string = false;
259 let mut escaped = false;
260 for (index, ch) in chars {
261 if in_string {
262 if escaped {
263 escaped = false;
264 continue;
265 }
266 match ch {
267 '\\' => escaped = true,
268 '"' => in_string = false,
269 _ => {}
270 }
271 continue;
272 }
273
274 match ch {
275 '"' => in_string = true,
276 '[' => depth += 1,
277 ']' => {
278 depth -= 1;
279 if depth == 0 {
280 return Ok(index + ch.len_utf8());
281 }
282 }
283 _ => {}
284 }
285 }
286
287 Err("unterminated string array".to_owned())
288}