Skip to main content

alembic_engine/
loader.rs

1//! brew 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 brew file.
12#[derive(Debug, Deserialize)]
13struct BrewFile {
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 brew file (yaml or json) and merge any includes.
25pub fn load_brew(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!("brew 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 brew: {}", 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 brew: {}", canonical.display()))?;
52    let brew: BrewFile = if canonical.extension().and_then(|s| s.to_str()) == Some("json") {
53        serde_json::from_str(&content)
54            .with_context(|| format!("parse json: {}", canonical.display()))?
55    } else {
56        serde_yaml::from_str(&content)
57            .with_context(|| format!("parse yaml: {}", canonical.display()))?
58    };
59
60    let base = canonical
61        .parent()
62        .ok_or_else(|| anyhow!("missing parent dir for {}", canonical.display()))?;
63
64    let mut includes = brew.include;
65    includes.extend(brew.imports);
66
67    for entry in includes {
68        let include_path = base.join(entry);
69        load_recursive(&include_path, visited, objects, schema)?;
70    }
71
72    merge_schema(schema, brew.schema)?;
73
74    // Set source location on each object from this file, with line numbers
75    for object in brew.objects {
76        let line = find_uid_line(&content, &object.uid.to_string());
77        let source = match line {
78            Some(n) => SourceLocation::file_line(&canonical, n),
79            None => SourceLocation::file(&canonical),
80        };
81        objects.push(object.with_source(source));
82    }
83
84    Ok(())
85}
86
87/// Find the line number (1-indexed) where a UID appears in the content.
88fn find_uid_line(content: &str, uid: &str) -> Option<usize> {
89    for (idx, line) in content.lines().enumerate() {
90        if line.contains(uid) {
91            return Some(idx + 1); // 1-indexed line numbers
92        }
93    }
94    None
95}
96
97fn merge_schema(current: &mut Option<Schema>, incoming: Option<Schema>) -> Result<()> {
98    let Some(incoming) = incoming else {
99        return Ok(());
100    };
101    match current {
102        Some(existing) => {
103            for (name, schema) in incoming.types {
104                if existing.types.contains_key(&name) {
105                    return Err(anyhow!("duplicate schema type {name}"));
106                }
107                existing.types.insert(name, schema);
108            }
109        }
110        None => {
111            *current = Some(incoming);
112        }
113    }
114    Ok(())
115}