1use crate::{report_to_result_with_sources, validate};
4use alembic_core::{Inventory, Schema, SourceLocation};
5use anyhow::{anyhow, Context, Result};
6use serde::Deserialize;
7use std::collections::{BTreeSet, HashMap};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Deserialize)]
13struct InventoryFile {
14 #[serde(default)]
15 include: Vec<String>,
16 #[serde(default)]
17 imports: Vec<String>,
18 #[serde(default)]
19 schema: Option<Schema>,
20 #[serde(default)]
21 objects: Vec<alembic_core::Object>,
22}
23
24pub fn load_inventory(path: impl AsRef<Path>) -> Result<Inventory> {
26 let mut visited = BTreeSet::new();
27 let mut objects = Vec::new();
28 let mut schema: Option<Schema> = None;
29 let path = path.as_ref();
30 load_recursive(path, &mut visited, &mut objects, &mut schema)?;
31 let schema = schema.ok_or_else(|| anyhow!("inventory is missing a schema block"))?;
32 let inventory = Inventory { schema, objects };
33 report_to_result_with_sources(validate(&inventory), &inventory.objects)?;
34 Ok(inventory)
35}
36
37fn load_recursive(
39 path: &Path,
40 visited: &mut BTreeSet<PathBuf>,
41 objects: &mut Vec<alembic_core::Object>,
42 schema: &mut Option<Schema>,
43) -> Result<()> {
44 let canonical =
45 fs::canonicalize(path).with_context(|| format!("load inventory: {}", path.display()))?;
46 if !visited.insert(canonical.clone()) {
47 return Ok(());
48 }
49
50 let content = fs::read_to_string(&canonical)
51 .with_context(|| format!("read inventory: {}", canonical.display()))?;
52 let inventory: InventoryFile = if canonical.extension().and_then(|s| s.to_str()) == Some("json")
53 {
54 serde_json::from_str(&content)
55 .with_context(|| format!("parse json: {}", canonical.display()))?
56 } else {
57 serde_yaml::from_str(&content)
58 .with_context(|| format!("parse yaml: {}", canonical.display()))?
59 };
60
61 let base = canonical
62 .parent()
63 .ok_or_else(|| anyhow!("missing parent dir for {}", canonical.display()))?;
64
65 let mut includes = inventory.include;
66 includes.extend(inventory.imports);
67
68 for entry in includes {
69 let include_path = base.join(entry);
70 load_recursive(&include_path, visited, objects, schema)?;
71 }
72
73 merge_schema(schema, inventory.schema)?;
74
75 let uid_lines = index_uid_lines(&content);
79 for object in inventory.objects {
80 let source = match uid_lines.get(object.uid.to_string().as_str()) {
81 Some(&n) => SourceLocation::file_line(&canonical, n),
82 None => SourceLocation::file(&canonical),
83 };
84 objects.push(object.with_source(source));
85 }
86
87 Ok(())
88}
89
90fn index_uid_lines(content: &str) -> HashMap<&str, usize> {
94 let mut index = HashMap::new();
95 for (idx, line) in content.lines().enumerate() {
96 if let Some(uid) = uid_key_value(line) {
97 index.entry(uid).or_insert(idx + 1);
98 }
99 }
100 index
101}
102
103fn uid_key_value(line: &str) -> Option<&str> {
107 let rest = line.trim_start();
108 let rest = rest.strip_prefix('-').map_or(rest, str::trim_start);
109 let rest = rest.strip_prefix('"').unwrap_or(rest);
110 let after = rest.strip_prefix("uid")?.trim_start();
111 let after = after.strip_prefix('"').unwrap_or(after).trim_start();
112 let value = after.strip_prefix(':')?.trim().trim_end_matches(',').trim();
113 let value = value
114 .strip_prefix('"')
115 .and_then(|v| v.strip_suffix('"'))
116 .unwrap_or(value);
117 Some(value)
118}
119
120fn merge_schema(current: &mut Option<Schema>, incoming: Option<Schema>) -> Result<()> {
121 let Some(incoming) = incoming else {
122 return Ok(());
123 };
124 match current {
125 Some(existing) => {
126 for (name, schema) in incoming.types {
127 if existing.types.contains_key(&name) {
128 return Err(anyhow!("duplicate schema type {name}"));
129 }
130 existing.types.insert(name, schema);
131 }
132 }
133 None => {
134 *current = Some(incoming);
135 }
136 }
137 Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142 use super::{index_uid_lines, merge_schema};
143 use alembic_core::{Schema, TypeSchema};
144 use std::collections::BTreeMap;
145
146 #[test]
147 fn locates_uid_definition_not_an_earlier_reference() {
148 let content = r#"objects:
149 - uid: "dev-1"
150 attrs:
151 site: "site-1"
152 - uid: "site-1"
153"#;
154 let index = index_uid_lines(content);
155 assert_eq!(index.get("site-1").copied(), Some(5));
156 assert_eq!(index.get("dev-1").copied(), Some(2));
157 }
158
159 #[test]
160 fn locates_uid_key_in_json() {
161 let content = r#"{
162 "objects": [
163 {
164 "uid": "dev-1",
165 "attrs": { "site": "site-1" }
166 },
167 {
168 "uid": "site-1"
169 }
170 ]
171}
172"#;
173 let index = index_uid_lines(content);
174 assert_eq!(index.get("site-1").copied(), Some(8));
175 }
176
177 #[test]
178 fn distinguishes_a_uid_that_is_a_prefix_of_another() {
179 let content = r#"objects:
182 - uid: "dev-10"
183 - uid: "dev-1"
184"#;
185 let index = index_uid_lines(content);
186 assert_eq!(index.get("dev-1").copied(), Some(3));
187 assert_eq!(index.get("dev-10").copied(), Some(2));
188 }
189
190 fn schema_with_type(name: &str) -> Schema {
191 let mut schema = Schema::default();
192 schema.types.insert(
193 name.to_string(),
194 TypeSchema {
195 key: BTreeMap::new(),
196 fields: BTreeMap::new(),
197 },
198 );
199 schema
200 }
201
202 #[test]
203 fn merge_schema_combines_disjoint_types() {
204 let mut current = Some(schema_with_type("dcim.site"));
205 merge_schema(&mut current, Some(schema_with_type("dcim.device"))).unwrap();
206 let types = current.unwrap().types;
207 assert!(types.contains_key("dcim.site"));
208 assert!(types.contains_key("dcim.device"));
209 }
210
211 #[test]
212 fn merge_schema_rejects_duplicate_type() {
213 let mut current = Some(schema_with_type("dcim.site"));
214 let err = merge_schema(&mut current, Some(schema_with_type("dcim.site"))).unwrap_err();
215 assert_eq!(err.to_string(), "duplicate schema type dcim.site");
216 }
217}