Skip to main content

observer_core/
inventory.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use 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}