use crate::{report_to_result_with_sources, validate};
use alembic_core::{Inventory, Schema, SourceLocation};
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct InventoryFile {
#[serde(default)]
include: Vec<String>,
#[serde(default)]
imports: Vec<String>,
#[serde(default)]
schema: Option<Schema>,
#[serde(default)]
objects: Vec<alembic_core::Object>,
}
pub fn load_inventory(path: impl AsRef<Path>) -> Result<Inventory> {
let mut visited = BTreeSet::new();
let mut objects = Vec::new();
let mut schema: Option<Schema> = None;
let path = path.as_ref();
load_recursive(path, &mut visited, &mut objects, &mut schema)?;
let schema = schema.ok_or_else(|| anyhow!("inventory is missing a schema block"))?;
let inventory = Inventory { schema, objects };
report_to_result_with_sources(validate(&inventory), &inventory.objects)?;
Ok(inventory)
}
fn load_recursive(
path: &Path,
visited: &mut BTreeSet<PathBuf>,
objects: &mut Vec<alembic_core::Object>,
schema: &mut Option<Schema>,
) -> Result<()> {
let canonical =
fs::canonicalize(path).with_context(|| format!("load inventory: {}", path.display()))?;
if !visited.insert(canonical.clone()) {
return Ok(());
}
let content = fs::read_to_string(&canonical)
.with_context(|| format!("read inventory: {}", canonical.display()))?;
let inventory: InventoryFile = if canonical.extension().and_then(|s| s.to_str()) == Some("json")
{
serde_json::from_str(&content)
.with_context(|| format!("parse json: {}", canonical.display()))?
} else {
serde_yaml::from_str(&content)
.with_context(|| format!("parse yaml: {}", canonical.display()))?
};
let base = canonical
.parent()
.ok_or_else(|| anyhow!("missing parent dir for {}", canonical.display()))?;
let mut includes = inventory.include;
includes.extend(inventory.imports);
for entry in includes {
let include_path = base.join(entry);
load_recursive(&include_path, visited, objects, schema)?;
}
merge_schema(schema, inventory.schema)?;
for object in inventory.objects {
let line = find_uid_line(&content, &object.uid.to_string());
let source = match line {
Some(n) => SourceLocation::file_line(&canonical, n),
None => SourceLocation::file(&canonical),
};
objects.push(object.with_source(source));
}
Ok(())
}
fn find_uid_line(content: &str, uid: &str) -> Option<usize> {
content
.lines()
.position(|line| line.contains(uid) && is_uid_key_line(line))
.map(|idx| idx + 1)
}
fn is_uid_key_line(line: &str) -> bool {
let rest = line.trim_start();
let rest = rest.strip_prefix('-').map_or(rest, str::trim_start);
let rest = rest.strip_prefix('"').unwrap_or(rest);
rest.strip_prefix("uid")
.is_some_and(|after| after.starts_with([':', '"', ' ']))
}
fn merge_schema(current: &mut Option<Schema>, incoming: Option<Schema>) -> Result<()> {
let Some(incoming) = incoming else {
return Ok(());
};
match current {
Some(existing) => {
for (name, schema) in incoming.types {
if existing.types.contains_key(&name) {
return Err(anyhow!("duplicate schema type {name}"));
}
existing.types.insert(name, schema);
}
}
None => {
*current = Some(incoming);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{find_uid_line, merge_schema};
use alembic_core::{Schema, TypeSchema};
use std::collections::BTreeMap;
#[test]
fn locates_uid_definition_not_an_earlier_reference() {
let content = r#"objects:
- uid: "dev-1"
attrs:
site: "site-1"
- uid: "site-1"
"#;
assert_eq!(find_uid_line(content, "site-1"), Some(5));
}
#[test]
fn locates_uid_key_in_json() {
let content = r#"{
"objects": [
{
"uid": "dev-1",
"attrs": { "site": "site-1" }
},
{
"uid": "site-1"
}
]
}
"#;
assert_eq!(find_uid_line(content, "site-1"), Some(8));
}
fn schema_with_type(name: &str) -> Schema {
let mut schema = Schema::default();
schema.types.insert(
name.to_string(),
TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::new(),
},
);
schema
}
#[test]
fn merge_schema_combines_disjoint_types() {
let mut current = Some(schema_with_type("dcim.site"));
merge_schema(&mut current, Some(schema_with_type("dcim.device"))).unwrap();
let types = current.unwrap().types;
assert!(types.contains_key("dcim.site"));
assert!(types.contains_key("dcim.device"));
}
#[test]
fn merge_schema_rejects_duplicate_type() {
let mut current = Some(schema_with_type("dcim.site"));
let err = merge_schema(&mut current, Some(schema_with_type("dcim.site"))).unwrap_err();
assert_eq!(err.to_string(), "duplicate schema type dcim.site");
}
}