Skip to main content

cumulus_nimbus/
lib.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{json, Map, Value};
3use sha2::{Digest, Sha256};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum NimbusError {
8    #[error("schema declaration is required")]
9    MissingSchema,
10    #[error("schema name must be quoted")]
11    InvalidSchemaName,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct Diagnostic {
16    pub code: String,
17    pub message: String,
18    pub line: usize,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct CompileOutput {
23    pub ir: Value,
24    pub hash: String,
25    pub diagnostics: Vec<Diagnostic>,
26}
27
28pub fn compile(source: &str) -> Result<CompileOutput, NimbusError> {
29    let schema_name = parse_schema_name(source)?;
30    let mut resources = Vec::new();
31    let mut diagnostics = Vec::new();
32
33    for (index, raw_line) in source.lines().enumerate() {
34        let line = raw_line.trim();
35        if line.starts_with("collection ") {
36            let name = line
37                .trim_start_matches("collection ")
38                .split(|c: char| c.is_whitespace() || c == '{')
39                .next()
40                .unwrap_or("")
41                .trim();
42            if name.is_empty() {
43                diagnostics.push(Diagnostic {
44                    code: "NIMBUS_COLLECTION_NAME".to_string(),
45                    message: "collection name is required".to_string(),
46                    line: index + 1,
47                });
48            } else {
49                resources.push(json!({
50                    "kind": "collection",
51                    "name": name,
52                    "fields": []
53                }));
54            }
55        }
56    }
57
58    let ir = json!({
59        "$schema": "https://schemas.cumulus.sh/nimbus/v1/schema",
60        "apiVersion": "nimbus.cumulus/v1alpha1",
61        "kind": "SchemaBundle",
62        "metadata": {
63            "name": schema_name
64        },
65        "imports": [],
66        "spec": {
67            "resources": resources,
68            "policies": [],
69            "bindings": {
70                "env": [],
71                "secrets": []
72            }
73        }
74    });
75    let canonical = canonical_json(&ir);
76    let hash = sha256_hex(canonical.as_bytes());
77    Ok(CompileOutput {
78        ir,
79        hash,
80        diagnostics,
81    })
82}
83
84pub fn canonical_json(value: &Value) -> String {
85    let sorted = sort_value(value);
86    serde_json::to_string(&sorted).expect("canonical JSON serialization failed")
87}
88
89pub fn sha256_hex(bytes: &[u8]) -> String {
90    let digest = Sha256::digest(bytes);
91    let mut out = String::with_capacity(digest.len() * 2);
92    for byte in digest {
93        out.push_str(&format!("{byte:02x}"));
94    }
95    out
96}
97
98fn parse_schema_name(source: &str) -> Result<String, NimbusError> {
99    for line in source.lines() {
100        let trimmed = line.trim();
101        if !trimmed.starts_with("schema ") {
102            continue;
103        }
104        let after = trimmed.trim_start_matches("schema ").trim_start();
105        if !after.starts_with('"') {
106            return Err(NimbusError::InvalidSchemaName);
107        }
108        let rest = &after[1..];
109        let Some(end) = rest.find('"') else {
110            return Err(NimbusError::InvalidSchemaName);
111        };
112        return Ok(rest[..end].to_string());
113    }
114    Err(NimbusError::MissingSchema)
115}
116
117fn sort_value(value: &Value) -> Value {
118    match value {
119        Value::Array(items) => Value::Array(items.iter().map(sort_value).collect()),
120        Value::Object(map) => {
121            let mut sorted = Map::new();
122            let mut keys: Vec<_> = map.keys().collect();
123            keys.sort();
124            for key in keys {
125                sorted.insert(key.clone(), sort_value(&map[key]));
126            }
127            Value::Object(sorted)
128        }
129        _ => value.clone(),
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn compiles_schema_bundle() {
139        let out = compile(
140            r#"
141            schema "crm" {
142              collection contacts {
143              }
144            }
145            "#,
146        )
147        .unwrap();
148        assert_eq!(out.ir["metadata"]["name"], "crm");
149        assert_eq!(out.ir["spec"]["resources"][0]["name"], "contacts");
150        assert_eq!(out.hash.len(), 64);
151    }
152
153    #[test]
154    fn canonical_json_sorts_keys() {
155        let value = json!({ "b": 1, "a": { "d": 1, "c": 2 } });
156        assert_eq!(canonical_json(&value), r#"{"a":{"c":2,"d":1},"b":1}"#);
157    }
158}
159