sandogasa_inventory/
lib.rs1pub mod content_resolver;
11pub mod hs_relmon;
12pub mod import_json;
13mod model;
14
15pub use model::{Inventory, InventoryMeta, Package, Priority, WorkloadMeta};
16
17pub fn json_schema() -> String {
19 let schema = schemars::schema_for!(Inventory);
20 serde_json::to_string_pretty(&schema).expect("schema serialization failed")
21}
22
23pub fn load_and_merge(paths: &[String]) -> Result<Inventory, String> {
31 let mut iter = paths.iter();
32 let first = iter
33 .next()
34 .ok_or("at least one inventory file is required")?;
35 let mut inventory = load(first)?;
36 for path in iter {
37 let other = load(path)?;
38 for conflict in inventory.merge(&other) {
39 eprintln!("warning: {path}: {conflict}");
40 }
41 }
42 Ok(inventory)
43}
44
45pub fn load(path: &str) -> Result<Inventory, String> {
47 let content =
48 std::fs::read_to_string(path).map_err(|e| format!("failed to read {path}: {e}"))?;
49 parse(&content)
50}
51
52pub fn parse(content: &str) -> Result<Inventory, String> {
54 toml::from_str(content).map_err(|e| format!("failed to parse inventory: {e}"))
55}
56
57pub fn save(inventory: &Inventory, path: &str) -> Result<(), String> {
59 let content =
60 toml::to_string_pretty(inventory).map_err(|e| format!("TOML serialization failed: {e}"))?;
61 std::fs::write(path, content).map_err(|e| format!("failed to write {path}: {e}"))
62}
63
64pub fn to_toml(inventory: &Inventory) -> Result<String, String> {
66 toml::to_string_pretty(inventory).map_err(|e| format!("TOML serialization failed: {e}"))
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn parse_minimal() {
75 let toml = r#"
76[inventory]
77name = "test"
78description = "test inventory"
79maintainer = "tester"
80
81[[package]]
82name = "foo"
83"#;
84 let inv = parse(toml).unwrap();
85 assert_eq!(inv.inventory.name, "test");
86 assert_eq!(inv.package.len(), 1);
87 assert_eq!(inv.package[0].name, "foo");
88 }
89
90 #[test]
91 fn parse_full() {
92 let toml = r#"
93[inventory]
94name = "test"
95description = "test inventory"
96maintainer = "tester"
97labels = ["eln-extras"]
98private_fields = ["poc", "team"]
99
100[[package]]
101name = "systemd"
102poc = "Team <team@example.com>"
103reason = "Core init"
104team = "userspace"
105task = "T123"
106rpms = ["systemd-networkd"]
107track = "upstream"
108repology_name = "systemd"
109distros = "upstream,fedora"
110file_issue = true
111
112[package.arch_rpms]
113x86_64 = ["systemd-boot-unsigned"]
114"#;
115 let inv = parse(toml).unwrap();
116 assert_eq!(inv.inventory.private_fields, vec!["poc", "team"]);
117 let pkg = &inv.package[0];
118 assert_eq!(pkg.name, "systemd");
119 assert_eq!(pkg.poc.as_deref(), Some("Team <team@example.com>"));
120 assert_eq!(
121 pkg.rpms.as_deref(),
122 Some(&["systemd-networkd".to_string()][..])
123 );
124 assert_eq!(pkg.track.as_deref(), Some("upstream"));
125 assert!(pkg.file_issue.unwrap());
126 let arch = pkg.arch_rpms.as_ref().unwrap();
127 assert_eq!(arch["x86_64"], vec!["systemd-boot-unsigned"]);
128 }
129
130 #[test]
131 fn round_trip() {
132 let toml_in = r#"
133[inventory]
134name = "test"
135description = "desc"
136maintainer = "me"
137
138[[package]]
139name = "foo"
140rpms = ["foo", "foo-libs"]
141"#;
142 let inv = parse(toml_in).unwrap();
143 let toml_out = to_toml(&inv).unwrap();
144 let inv2 = parse(&toml_out).unwrap();
145 assert_eq!(inv.inventory.name, inv2.inventory.name);
146 assert_eq!(inv.package.len(), inv2.package.len());
147 assert_eq!(inv.package[0].name, inv2.package[0].name);
148 }
149
150 #[test]
151 fn load_nonexistent_errors() {
152 assert!(load("/tmp/nonexistent-sandogasa-inv-test.toml").is_err());
153 }
154
155 #[test]
156 fn save_and_load() {
157 let dir = tempfile::tempdir().unwrap();
158 let path = dir.path().join("test.toml");
159 let inv = parse(
160 r#"
161[inventory]
162name = "roundtrip"
163description = "d"
164maintainer = "m"
165
166[[package]]
167name = "pkg1"
168"#,
169 )
170 .unwrap();
171 save(&inv, path.to_str().unwrap()).unwrap();
172 let loaded = load(path.to_str().unwrap()).unwrap();
173 assert_eq!(loaded.inventory.name, "roundtrip");
174 assert_eq!(loaded.package.len(), 1);
175 }
176
177 #[test]
178 fn load_and_merge_multiple() {
179 let dir = tempfile::tempdir().unwrap();
180 let p1 = dir.path().join("inv1.toml");
181 let p2 = dir.path().join("inv2.toml");
182
183 std::fs::write(
184 &p1,
185 r#"
186[inventory]
187name = "first"
188description = "d"
189maintainer = "m"
190
191[[package]]
192name = "aaa"
193
194[[package]]
195name = "bbb"
196"#,
197 )
198 .unwrap();
199
200 std::fs::write(
201 &p2,
202 r#"
203[inventory]
204name = "second"
205description = "d2"
206maintainer = "m2"
207
208[[package]]
209name = "ccc"
210
211[[package]]
212name = "bbb"
213reason = "updated"
214"#,
215 )
216 .unwrap();
217
218 let paths = vec![
219 p1.to_str().unwrap().to_string(),
220 p2.to_str().unwrap().to_string(),
221 ];
222 let merged = load_and_merge(&paths).unwrap();
223
224 assert_eq!(merged.inventory.name, "first");
226 assert_eq!(merged.package.len(), 3);
228 let bbb = merged.find_package("bbb").unwrap();
230 assert_eq!(bbb.reason.as_deref(), Some("updated"));
231 }
232
233 #[test]
234 fn parse_invalid_errors() {
235 assert!(parse("this is not valid toml [[[").is_err());
236 }
237
238 #[test]
239 fn parse_with_workloads() {
240 let toml = r#"
241[inventory]
242name = "test"
243description = "d"
244maintainer = "m"
245
246[inventory.workloads.hyperscale]
247name = "hs-packages"
248
249[[package]]
250name = "foo"
251"#;
252 let inv = parse(toml).unwrap();
253 assert!(inv.inventory.workloads.contains_key("hyperscale"));
254 assert_eq!(
255 inv.inventory.workloads["hyperscale"].name.as_deref(),
256 Some("hs-packages")
257 );
258 }
259
260 #[test]
272 fn schema_up_to_date() {
273 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
274 .join("data")
275 .join("inventory.schema.json");
276 let generated = json_schema();
277
278 if std::env::var("UPDATE_SCHEMA").is_ok() {
279 std::fs::write(&schema_path, &generated).expect("failed to write schema");
280 eprintln!("Updated {}", schema_path.display());
281 return;
282 }
283
284 let committed = std::fs::read_to_string(&schema_path).unwrap_or_else(|_| {
285 panic!(
286 "Schema file not found at {}. Run:\n \
287 UPDATE_SCHEMA=1 cargo test -p sandogasa-inventory schema_up_to_date",
288 schema_path.display()
289 )
290 });
291
292 if generated != committed {
293 for (i, (a, b)) in generated.lines().zip(committed.lines()).enumerate() {
295 if a != b {
296 panic!(
297 "Schema is out of date (first difference at line {}). Run:\n \
298 UPDATE_SCHEMA=1 cargo test -p sandogasa-inventory schema_up_to_date\n\n\
299 expected: {a}\n actual: {b}",
300 i + 1
301 );
302 }
303 }
304 panic!(
306 "Schema is out of date (line count differs). Run:\n \
307 UPDATE_SCHEMA=1 cargo test -p sandogasa-inventory schema_up_to_date"
308 );
309 }
310 }
311}