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