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;
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    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
88/// the 1-indexed line where `uid` is defined (its own `uid:` key), not a line
89/// that merely references it as an attr value.
90fn 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
97/// whether `line`'s key is `uid` (yaml `uid:` / `- uid:`, json `"uid":`).
98fn 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}