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            "additionalProperties",
262            "minLength",
263            "maxLength",
264            "enum",
265            "maxItems",
266            "items",
267            "oneOf",
268            "const",
269        ],
270        RenderContext::Properties => return ordered_property_keys(object),
271    };
272    let mut keys = Vec::with_capacity(object.len());
273    for key in preferred {
274        if object.contains_key(*key) {
275            keys.push(*key);
276        }
277    }
278    let mut remaining: Vec<_> = object
279        .keys()
280        .map(String::as_str)
281        .filter(|key| !keys.contains(key))
282        .collect();
283    remaining.sort_unstable();
284    keys.extend(remaining);
285    keys
286}
287
288fn ordered_property_keys(object: &Map<String, Value>) -> Vec<&str> {
289    object.keys().map(String::as_str).collect()
290}
291
292fn render_properties(
293    value: &Value,
294    output: &mut String,
295    indent: usize,
296) -> Result<(), CodegenError> {
297    let Some(properties) = value.as_object() else {
298        return render_value(value, output, indent, RenderContext::Properties);
299    };
300    if properties.is_empty() {
301        output.push_str("{}");
302        return Ok(());
303    }
304    let keys: Vec<_> = properties.keys().map(String::as_str).collect();
305
306    output.push_str("{\n");
307    for (index, key) in keys.iter().enumerate() {
308        push_indent(output, indent + 1);
309        output.push_str(&serialize_scalar(&Value::String((*key).to_owned()))?);
310        output.push_str(": ");
311        render_value(&properties[*key], output, indent + 1, RenderContext::Schema)?;
312        if index + 1 != keys.len() {
313            output.push(',');
314        }
315        output.push('\n');
316    }
317    push_indent(output, indent);
318    output.push('}');
319    Ok(())
320}
321
322fn serialize_scalar(value: &Value) -> Result<String, CodegenError> {
323    serde_json::to_string(value).map_err(|error| CodegenError::InvalidDescriptor {
324        detail: format!("derived snapshot could not serialize: {error}"),
325    })
326}
327
328fn push_indent(output: &mut String, indent: usize) {
329    let _ = write!(output, "{:width$}", "", width = indent * 2);
330}