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;
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 for object in inventory.objects {
77 let line = find_uid_line(&content, &object.uid.to_string());
78 let source = match line {
79 Some(n) => SourceLocation::file_line(&canonical, n),
80 None => SourceLocation::file(&canonical),
81 };
82 objects.push(object.with_source(source));
83 }
84
85 Ok(())
86}
87
88fn find_uid_line(content: &str, uid: &str) -> Option<usize> {
91 content
92 .lines()
93 .position(|line| line.contains(uid) && is_uid_key_line(line))
94 .map(|idx| idx + 1)
95}
96
97fn is_uid_key_line(line: &str) -> bool {
99 let rest = line.trim_start();
100 let rest = rest.strip_prefix('-').map_or(rest, str::trim_start);
101 let rest = rest.strip_prefix('"').unwrap_or(rest);
102 rest.strip_prefix("uid")
103 .is_some_and(|after| after.starts_with([':', '"', ' ']))
104}
105
106fn merge_schema(current: &mut Option<Schema>, incoming: Option<Schema>) -> Result<()> {
107 let Some(incoming) = incoming else {
108 return Ok(());
109 };
110 match current {
111 Some(existing) => {
112 for (name, schema) in incoming.types {
113 if existing.types.contains_key(&name) {
114 return Err(anyhow!("duplicate schema type {name}"));
115 }
116 existing.types.insert(name, schema);
117 }
118 }
119 None => {
120 *current = Some(incoming);
121 }
122 }
123 Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::{find_uid_line, merge_schema};
129 use alembic_core::{Schema, TypeSchema};
130 use std::collections::BTreeMap;
131
132 #[test]
133 fn locates_uid_definition_not_an_earlier_reference() {
134 let content = r#"objects:
135 - uid: "dev-1"
136 attrs:
137 site: "site-1"
138 - uid: "site-1"
139"#;
140 assert_eq!(find_uid_line(content, "site-1"), Some(5));
141 }
142
143 #[test]
144 fn locates_uid_key_in_json() {
145 let content = r#"{
146 "objects": [
147 {
148 "uid": "dev-1",
149 "attrs": { "site": "site-1" }
150 },
151 {
152 "uid": "site-1"
153 }
154 ]
155}
156"#;
157 assert_eq!(find_uid_line(content, "site-1"), Some(8));
158 }
159
160 fn schema_with_type(name: &str) -> Schema {
161 let mut schema = Schema::default();
162 schema.types.insert(
163 name.to_string(),
164 TypeSchema {
165 key: BTreeMap::new(),
166 fields: BTreeMap::new(),
167 },
168 );
169 schema
170 }
171
172 #[test]
173 fn merge_schema_combines_disjoint_types() {
174 let mut current = Some(schema_with_type("dcim.site"));
175 merge_schema(&mut current, Some(schema_with_type("dcim.device"))).unwrap();
176 let types = current.unwrap().types;
177 assert!(types.contains_key("dcim.site"));
178 assert!(types.contains_key("dcim.device"));
179 }
180
181 #[test]
182 fn merge_schema_rejects_duplicate_type() {
183 let mut current = Some(schema_with_type("dcim.site"));
184 let err = merge_schema(&mut current, Some(schema_with_type("dcim.site"))).unwrap_err();
185 assert_eq!(err.to_string(), "duplicate schema type dcim.site");
186 }
187}