Skip to main content

alembic_engine/
loader.rs

1//! inventory file loading with include/import support.
2
3use 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/// raw on-disk representation for a inventory file.
12#[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
24/// load a inventory file (yaml or json) and merge any includes.
25pub 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
37/// recursive loader with cycle-safe include handling.
38fn 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    // set source location on each object from this file, with line numbers.
76    // the uid->line index is built once per file (one pass), not rescanned per
77    // object, so loading is linear in file size rather than objects x lines.
78    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
90/// map each object's `uid` value to the 1-indexed line where it is defined (its
91/// own `uid:` key), keeping the first occurrence. built in a single pass so the
92/// caller can resolve every object's source line without rescanning the file.
93fn 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
103/// if `line`'s key is `uid`, the declared value with surrounding quotes and a
104/// trailing json comma stripped; otherwise `None`. handles yaml `uid:` /
105/// `- uid:` and json `"uid":`.
106fn 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        // "dev-1" resolves to its own line, not the earlier "dev-10" line that
180        // contains it as a substring (the pre-index scan matched on `contains`).
181        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}