Skip to main content

sandogasa_inventory/
lib.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3//! Package-of-interest inventory data model and I/O.
4//!
5//! Provides a TOML-based inventory format for tracking packages
6//! across Fedora, EPEL, and CentOS SIGs. Supports exporting to
7//! content-resolver YAML (feedback-pipeline-workload) and
8//! hs-relmon manifest formats.
9
10pub mod content_resolver;
11pub mod hs_relmon;
12pub mod import_json;
13mod model;
14
15pub use model::{Inventory, InventoryMeta, Package, Priority, WorkloadMeta};
16
17/// Generate a JSON Schema for the inventory format.
18pub fn json_schema() -> String {
19    let schema = schemars::schema_for!(Inventory);
20    serde_json::to_string_pretty(&schema).expect("schema serialization failed")
21}
22
23/// Load multiple inventories and merge them into one.
24///
25/// The first inventory provides the metadata (name, description,
26/// etc.). Packages from subsequent inventories are merged in
27/// field by field (see [`Inventory::merge`]); genuine conflicts —
28/// the same package with different values for the same field —
29/// are reported on stderr, with the later file winning.
30pub 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
45/// Load an inventory from a TOML file.
46pub 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
52/// Parse an inventory from a TOML string.
53pub fn parse(content: &str) -> Result<Inventory, String> {
54    toml::from_str(content).map_err(|e| format!("failed to parse inventory: {e}"))
55}
56
57/// Save an inventory to a TOML file.
58pub 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
64/// Serialize an inventory to a TOML string.
65pub 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        // Metadata from first file.
225        assert_eq!(merged.inventory.name, "first");
226        // 3 packages: aaa, bbb (replaced), ccc.
227        assert_eq!(merged.package.len(), 3);
228        // bbb should have the updated reason from second file.
229        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    /// Verify the checked-in JSON Schema matches the current model.
261    ///
262    /// If the schema has changed (e.g. fields added/removed), this
263    /// test fails. To update the checked-in file:
264    ///
265    /// ```sh
266    /// UPDATE_SCHEMA=1 cargo test -p sandogasa-inventory schema_up_to_date
267    /// ```
268    ///
269    /// Review the diff before committing — new required fields are a
270    /// breaking change, new optional fields are a minor change.
271    #[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            // Show first differing line for quick diagnosis.
294            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            // Length difference.
305            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}