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