Skip to main content

lenso_contract_codegen/
source.rs

1//! Deterministic locked artifacts derived from compiled Rust Capability source.
2
3use std::{collections::BTreeMap, fmt::Write as _, path::Path};
4
5use lenso_contract_authoring::CapabilitySnapshot;
6use serde_json::{Map, Value, json};
7
8use crate::{CodegenError, check_artifact, load_descriptor, write_artifact};
9
10/// Writes one Descriptor and its package-local Schemas from compiled source types.
11pub fn write_source_snapshot(
12    snapshot: &CapabilitySnapshot,
13    descriptor_path: &Path,
14) -> Result<(), CodegenError> {
15    for (path, source) in snapshot_artifacts(snapshot, descriptor_path)? {
16        write_artifact(&path, &source)?;
17    }
18    load_descriptor(descriptor_path).map(|_| ())
19}
20
21/// Fails unless every committed Descriptor and Schema byte matches compiled source.
22pub fn check_source_snapshot(
23    snapshot: &CapabilitySnapshot,
24    descriptor_path: &Path,
25) -> Result<(), CodegenError> {
26    for (path, source) in snapshot_artifacts(snapshot, descriptor_path)? {
27        check_artifact(&path, &source)?;
28    }
29    load_descriptor(descriptor_path).map(|_| ())
30}
31
32fn snapshot_artifacts(
33    snapshot: &CapabilitySnapshot,
34    descriptor_path: &Path,
35) -> Result<BTreeMap<std::path::PathBuf, String>, CodegenError> {
36    if snapshot.operations.is_empty() {
37        return Err(CodegenError::InvalidDescriptor {
38            detail: "a source Capability must declare at least one Operation".to_owned(),
39        });
40    }
41    let root = descriptor_path.parent().unwrap_or_else(|| Path::new("."));
42    let mut artifacts = BTreeMap::new();
43    let mut operations = Vec::with_capacity(snapshot.operations.len());
44    for operation in &snapshot.operations {
45        let stem = schema_stem(&operation.name)?;
46        let (request_suffix, response_suffix) = match operation.interaction.as_str() {
47            "request" => ("request", "response"),
48            "stream" => ("open", "message"),
49            interaction => {
50                return Err(CodegenError::InvalidDescriptor {
51                    detail: format!(
52                        "source Operation `{}` has unsupported interaction `{interaction}`",
53                        operation.name
54                    ),
55                });
56            }
57        };
58        let request = format!("schemas/{stem}-{request_suffix}.schema.json");
59        let response = format!("schemas/{stem}-{response_suffix}.schema.json");
60        let domain_error = format!("schemas/{stem}-error.schema.json");
61        operations.push(json!({
62            "name": operation.name,
63            "interaction": operation.interaction,
64            "request_schema": request,
65            "response_schema": response,
66            "domain_error_schema": domain_error,
67        }));
68        artifacts.insert(root.join(&request), pretty_json(&operation.request_schema)?);
69        artifacts.insert(
70            root.join(&response),
71            pretty_json(&operation.response_schema)?,
72        );
73        artifacts.insert(
74            root.join(&domain_error),
75            pretty_json(&operation.domain_error_schema)?,
76        );
77    }
78    let descriptor = Value::Object(Map::from_iter([
79        (
80            "id".to_owned(),
81            Value::String(snapshot.capability_id.clone()),
82        ),
83        (
84            "version".to_owned(),
85            Value::String(snapshot.version.clone()),
86        ),
87        ("portable".to_owned(), Value::Bool(snapshot.portable)),
88        (
89            "cross_lane_transfer".to_owned(),
90            Value::Bool(snapshot.cross_lane_transfer),
91        ),
92        ("operations".to_owned(), Value::Array(operations)),
93    ]));
94    artifacts.insert(descriptor_path.to_path_buf(), pretty_json(&descriptor)?);
95    Ok(artifacts)
96}
97
98fn schema_stem(operation: &str) -> Result<String, CodegenError> {
99    if operation.is_empty()
100        || !operation
101            .chars()
102            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
103    {
104        return Err(CodegenError::InvalidDescriptor {
105            detail: format!("source Operation `{operation}` cannot form a Schema filename"),
106        });
107    }
108    Ok(operation.replace('_', "-"))
109}
110
111fn pretty_json(value: &Value) -> Result<String, CodegenError> {
112    let mut source = String::new();
113    render_value(value, &mut source, 0, RenderContext::Root)?;
114    source.push('\n');
115    Ok(source)
116}
117
118#[derive(Clone, Copy)]
119enum RenderContext {
120    Root,
121    Descriptor,
122    Operation,
123    Schema,
124    Properties,
125    RequiredArray,
126}
127
128fn render_value(
129    value: &Value,
130    output: &mut String,
131    indent: usize,
132    context: RenderContext,
133) -> Result<(), CodegenError> {
134    match value {
135        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
136            output.push_str(&serialize_scalar(value)?);
137        }
138        Value::Array(values) => render_array(values, output, indent, context)?,
139        Value::Object(object) => render_object(object, output, indent, context)?,
140    }
141    Ok(())
142}
143
144fn render_array(
145    values: &[Value],
146    output: &mut String,
147    indent: usize,
148    context: RenderContext,
149) -> Result<(), CodegenError> {
150    if values.is_empty() {
151        output.push_str("[]");
152        return Ok(());
153    }
154    if values.iter().all(Value::is_string)
155        && !(matches!(context, RenderContext::RequiredArray) && values.len() > 5)
156    {
157        output.push('[');
158        for (index, value) in values.iter().enumerate() {
159            if index > 0 {
160                output.push_str(", ");
161            }
162            output.push_str(&serialize_scalar(value)?);
163        }
164        output.push(']');
165        return Ok(());
166    }
167    output.push_str("[\n");
168    for (index, value) in values.iter().enumerate() {
169        push_indent(output, indent + 1);
170        let item_context = match context {
171            RenderContext::Descriptor => RenderContext::Operation,
172            _ => RenderContext::Schema,
173        };
174        render_value(value, output, indent + 1, item_context)?;
175        if index + 1 != values.len() {
176            output.push(',');
177        }
178        output.push('\n');
179    }
180    push_indent(output, indent);
181    output.push(']');
182    Ok(())
183}
184
185fn render_object(
186    object: &Map<String, Value>,
187    output: &mut String,
188    indent: usize,
189    context: RenderContext,
190) -> Result<(), CodegenError> {
191    if object.is_empty() {
192        output.push_str("{}");
193        return Ok(());
194    }
195    if object.len() == 1 && object.contains_key("const") {
196        output.push_str("{ \"const\": ");
197        render_value(&object["const"], output, indent, RenderContext::Schema)?;
198        output.push_str(" }");
199        return Ok(());
200    }
201
202    let object_context = match context {
203        RenderContext::Root if object.contains_key("id") && object.contains_key("operations") => {
204            RenderContext::Descriptor
205        }
206        RenderContext::Root => RenderContext::Schema,
207        other => other,
208    };
209    let keys = ordered_keys(object, object_context);
210    output.push_str("{\n");
211    for (index, key) in keys.iter().enumerate() {
212        push_indent(output, indent + 1);
213        output.push_str(&serialize_scalar(&Value::String((*key).to_owned()))?);
214        output.push_str(": ");
215        let child_context = if *key == "operations" {
216            RenderContext::Descriptor
217        } else if *key == "required" {
218            RenderContext::RequiredArray
219        } else {
220            RenderContext::Schema
221        };
222        if *key == "properties" {
223            render_properties(&object[*key], output, indent + 1)?;
224        } else {
225            render_value(&object[*key], output, indent + 1, child_context)?;
226        }
227        if index + 1 != keys.len() {
228            output.push(',');
229        }
230        output.push('\n');
231    }
232    push_indent(output, indent);
233    output.push('}');
234    Ok(())
235}
236
237fn ordered_keys(object: &Map<String, Value>, context: RenderContext) -> Vec<&str> {
238    let preferred: &[&str] = match context {
239        RenderContext::Descriptor => &[
240            "id",
241            "version",
242            "portable",
243            "cross_lane_transfer",
244            "operations",
245        ],
246        RenderContext::Operation => &[
247            "name",
248            "interaction",
249            "request_schema",
250            "response_schema",
251            "domain_error_schema",
252        ],
253        RenderContext::Schema | RenderContext::Root | RenderContext::RequiredArray => &[
254            "$schema",
255            "type",
256            "format",
257            "minimum",
258            "maximum",
259            "required",
260            "properties",
261            "propertyNames",
262            "additionalProperties",
263            "minLength",
264            "maxLength",
265            "enum",
266            "maxItems",
267            "items",
268            "oneOf",
269            "const",
270        ],
271        RenderContext::Properties => return ordered_property_keys(object),
272    };
273    let mut keys = Vec::with_capacity(object.len());
274    for key in preferred {
275        if object.contains_key(*key) {
276            keys.push(*key);
277        }
278    }
279    let mut remaining: Vec<_> = object
280        .keys()
281        .map(String::as_str)
282        .filter(|key| !keys.contains(key))
283        .collect();
284    remaining.sort_unstable();
285    keys.extend(remaining);
286    keys
287}
288
289fn ordered_property_keys(object: &Map<String, Value>) -> Vec<&str> {
290    object.keys().map(String::as_str).collect()
291}
292
293fn render_properties(
294    value: &Value,
295    output: &mut String,
296    indent: usize,
297) -> Result<(), CodegenError> {
298    let Some(properties) = value.as_object() else {
299        return render_value(value, output, indent, RenderContext::Properties);
300    };
301    if properties.is_empty() {
302        output.push_str("{}");
303        return Ok(());
304    }
305    let keys: Vec<_> = properties.keys().map(String::as_str).collect();
306
307    output.push_str("{\n");
308    for (index, key) in keys.iter().enumerate() {
309        push_indent(output, indent + 1);
310        output.push_str(&serialize_scalar(&Value::String((*key).to_owned()))?);
311        output.push_str(": ");
312        render_value(&properties[*key], output, indent + 1, RenderContext::Schema)?;
313        if index + 1 != keys.len() {
314            output.push(',');
315        }
316        output.push('\n');
317    }
318    push_indent(output, indent);
319    output.push('}');
320    Ok(())
321}
322
323fn serialize_scalar(value: &Value) -> Result<String, CodegenError> {
324    serde_json::to_string(value).map_err(|error| CodegenError::InvalidDescriptor {
325        detail: format!("derived snapshot could not serialize: {error}"),
326    })
327}
328
329fn push_indent(output: &mut String, indent: usize) {
330    let _ = write!(output, "{:width$}", "", width = indent * 2);
331}