Skip to main content

majordomus_cli/proto/
project.rs

1//! The projections of a `.proto` document schema.
2//!
3//! The `.proto` is canonical and nothing else is written by hand. Two artifacts come out
4//! of it here:
5//!
6//!   the JSON Schema beside it, which the Rust indexer validates front matter against and
7//!   from which `share/allow/<name>.txt` is derived, so the shell tool and the executable
8//!   read one contract rather than two;
9//!
10//!   the section list `share/sections/<name>.txt`, which is the body half of the contract
11//!   in the plainest form a shell can read.
12//!
13//! Both are tracked, because `bin/majordomus doctor` is pure shell and must validate a
14//! fresh checkout without a Rust build having ever run. `scripts/derive-check` regenerates
15//! them and fails on a difference, which is what keeps "canonical" true.
16
17use serde_json::{json, Map, Value};
18
19use super::{Enum, Field, Message, ProtoFile, MSG_BODY, MSG_DOCUMENT, MSG_HEADER};
20
21/// The extension carrying the schema identity on a derived JSON Schema.
22pub const SCHEMA_EXTENSION: &str = "x-majordomus-schema";
23
24/// The extension naming the `.proto` a JSON Schema was derived from. A schema that carries
25/// it is not to be edited; the file it names is.
26pub const DERIVED_EXTENSION: &str = "x-majordomus-derived-from";
27
28/// The extension carrying the body contract, for a reader of the JSON Schema alone.
29pub const SECTIONS_EXTENSION: &str = "x-majordomus-sections";
30
31/// The file name a schema identity derives for its JSON Schema projection, relative to the
32/// schema root: `majordomus.adr/v1` is `majordomus/adr/adr.v1.schema.json`.
33pub fn schema_path(schema_id: &str) -> crate::error::Result<String> {
34    let proto = ProtoFile::expected_path(schema_id)?;
35    Ok(proto.replace(super::PROTO_SUFFIX, crate::share::SCHEMA_SUFFIX))
36}
37
38/// The whole document as one JSON Schema: `header` against the `Header` message, and for a
39/// Markdown kind `body` against the sections the `Body` message names.
40pub fn to_json_schema(file: &ProtoFile) -> crate::error::Result<Value> {
41    let header = file.header()?;
42    let body = file.body();
43
44    let mut defs = Map::new();
45    defs.insert(MSG_HEADER.into(), message_schema(file, header));
46    // every other message the header reaches, and Section when there is a body
47    for message in &file.messages {
48        if message.name == MSG_HEADER || message.name == MSG_DOCUMENT || message.body {
49            continue;
50        }
51        if message.name == "Section" && body.is_none() {
52            continue;
53        }
54        defs.insert(message.name.clone(), message_schema(file, message));
55    }
56
57    let mut properties = Map::new();
58    properties.insert(
59        MSG_HEADER.to_lowercase(),
60        json!({ "$ref": "#/$defs/Header" }),
61    );
62    let mut required = vec![json!("header")];
63
64    if let Some(body) = body {
65        defs.insert(MSG_BODY.into(), body_schema(body));
66        properties.insert(MSG_BODY.to_lowercase(), json!({ "$ref": "#/$defs/Body" }));
67        required.push(json!("body"));
68    }
69
70    let name = file
71        .schema_id
72        .split_once('/')
73        .and_then(|(q, _)| q.rsplit_once('.').map(|(_, n)| n.to_string()))
74        .unwrap_or_else(|| file.schema_id.clone());
75
76    let mut root = Map::new();
77    root.insert(
78        "$schema".into(),
79        json!("https://json-schema.org/draft/2020-12/schema"),
80    );
81    root.insert(
82        "$id".into(),
83        json!(format!(
84            "https://majordomus.dev/schemas/{}",
85            schema_path(&file.schema_id)?
86        )),
87    );
88    root.insert(SCHEMA_EXTENSION.into(), json!(file.schema_id));
89    root.insert(
90        DERIVED_EXTENSION.into(),
91        json!(ProtoFile::expected_path(&file.schema_id)?),
92    );
93    if let Some(allow) = &file.allow_list {
94        root.insert(crate::generate::ALLOW_EXTENSION.into(), json!(allow));
95    }
96    if let Some(message) = file.message(MSG_DOCUMENT) {
97        if !message.doc.is_empty() {
98            root.insert("description".into(), json!(message.doc));
99        }
100    }
101    root.insert("title".into(), json!(name));
102    root.insert("type".into(), json!("object"));
103    root.insert("additionalProperties".into(), json!(false));
104    root.insert("required".into(), Value::Array(required));
105    root.insert("properties".into(), Value::Object(properties));
106    root.insert("$defs".into(), Value::Object(defs));
107    Ok(Value::Object(root))
108}
109
110/// One message as a JSON Schema object.
111fn message_schema(file: &ProtoFile, message: &Message) -> Value {
112    let mut out = Map::new();
113    if !message.doc.is_empty() {
114        out.insert("description".into(), json!(message.doc));
115    }
116    out.insert("type".into(), json!("object"));
117    out.insert("additionalProperties".into(), json!(!message.closed));
118    let required: Vec<Value> = message
119        .fields
120        .iter()
121        .filter(|f| f.options.required)
122        .map(|f| json!(f.key()))
123        .collect();
124    if !required.is_empty() {
125        out.insert("required".into(), Value::Array(required));
126    }
127    let mut properties = Map::new();
128    for field in &message.fields {
129        properties.insert(field.key().to_string(), field_schema(file, field));
130    }
131    out.insert("properties".into(), Value::Object(properties));
132    Value::Object(out)
133}
134
135/// One field as a JSON Schema value; a repeated field is the array around it.
136fn field_schema(file: &ProtoFile, field: &Field) -> Value {
137    let mut inner = scalar_schema(file, field);
138    if let Value::Object(map) = &mut inner {
139        if !field.doc.is_empty() && !field.repeated {
140            map.insert("description".into(), json!(field.doc));
141            // description first reads better; serde_json preserves insertion order, so
142            // rebuild with it in front
143            let mut ordered = Map::new();
144            ordered.insert("description".into(), json!(field.doc));
145            for (k, v) in map.iter() {
146                if k != "description" {
147                    ordered.insert(k.clone(), v.clone());
148                }
149            }
150            *map = ordered;
151        }
152    }
153    if !field.repeated {
154        return inner;
155    }
156    let mut out = Map::new();
157    if !field.doc.is_empty() {
158        out.insert("description".into(), json!(field.doc));
159    }
160    out.insert("type".into(), json!("array"));
161    if let Some(n) = field.options.min_items {
162        out.insert("minItems".into(), json!(n));
163    }
164    out.insert("items".into(), inner);
165    Value::Object(out)
166}
167
168/// The type half of a field: a scalar with its constraints, an enum's closed set, or a
169/// reference to another message.
170fn scalar_schema(file: &ProtoFile, field: &Field) -> Value {
171    if let Some(constant) = &field.options.const_value {
172        return json!({ "const": constant });
173    }
174    if let Some(enumeration) = file.enumeration(&field.type_name) {
175        return json!({ "enum": enum_values(enumeration) });
176    }
177    if file.message(&field.type_name).is_some() {
178        return json!({ "$ref": format!("#/$defs/{}", field.type_name) });
179    }
180    let mut out = Map::new();
181    match field.type_name.as_str() {
182        "string" => {
183            out.insert("type".into(), json!("string"));
184            if let Some(n) = field.options.min_length {
185                out.insert("minLength".into(), json!(n));
186            }
187            if let Some(p) = &field.options.pattern {
188                out.insert("pattern".into(), json!(p));
189            }
190        }
191        "bool" => {
192            out.insert("type".into(), json!("boolean"));
193        }
194        "uint32" | "uint64" | "int32" | "int64" => {
195            out.insert("type".into(), json!("integer"));
196            let minimum =
197                field.options.minimum.or_else(|| {
198                    field.type_name.starts_with('u').then_some(0).filter(|_| {
199                        field.options.maximum.is_some() || field.options.minimum.is_some()
200                    })
201                });
202            if let Some(n) = minimum {
203                out.insert("minimum".into(), json!(n));
204            }
205            if let Some(n) = field.options.maximum {
206                out.insert("maximum".into(), json!(n));
207            }
208        }
209        other => {
210            // A type this reader does not model would silently validate nothing; say so in
211            // the artifact rather than emit an empty schema.
212            out.insert(
213                "$comment".into(),
214                json!(format!("unmodelled proto type '{other}'")),
215            );
216        }
217    }
218    Value::Object(out)
219}
220
221/// The written form of every value of an enum, the proto3 zero value aside.
222fn enum_values(enumeration: &Enum) -> Vec<Value> {
223    enumeration
224        .values
225        .iter()
226        .filter_map(|v| v.written.as_ref())
227        .map(|v| json!(v))
228        .collect()
229}
230
231/// The `Body` message as a JSON Schema object, with the section contract as an extension:
232/// JSON Schema has no word for "a level-two heading called Context that carries content",
233/// so the mechanical shape is expressed in JSON Schema and the contract beside it.
234fn body_schema(body: &Message) -> Value {
235    let mut out = Map::new();
236    if !body.doc.is_empty() {
237        out.insert("description".into(), json!(body.doc));
238    }
239    out.insert("type".into(), json!("object"));
240    out.insert("additionalProperties".into(), json!(false));
241    out.insert("required".into(), json!(["sections"]));
242    out.insert(
243        "properties".into(),
244        json!({
245            "sections": {
246                "description": "Every heading of the body, in document order, as the reader flattens it.",
247                "type": "array",
248                "items": { "$ref": "#/$defs/Section" }
249            }
250        }),
251    );
252    out.insert(
253        SECTIONS_EXTENSION.into(),
254        Value::Array(body.fields.iter().map(section_entry).collect()),
255    );
256    Value::Object(out)
257}
258
259fn section_entry(field: &Field) -> Value {
260    let mut out = Map::new();
261    out.insert(
262        "heading".into(),
263        json!(field.options.heading.clone().unwrap_or_default()),
264    );
265    out.insert("level".into(), json!(field.options.level.unwrap_or(1)));
266    out.insert("required".into(), json!(field.options.required));
267    out.insert("non_empty".into(), json!(field.options.non_empty));
268    if !field.doc.is_empty() {
269        out.insert("description".into(), json!(field.doc));
270    }
271    Value::Object(out)
272}
273
274/// The section list the shell tool reads: one section per line, tab-separated, in document
275/// order — level, heading, `required`/`optional`, `non-empty`/`any`. A kind with no body
276/// contract projects an empty file, which is an answer rather than a missing artifact.
277///
278/// ```
279/// use majordomus_cli::proto;
280/// let text = r#"
281/// syntax = "proto3";
282/// package majordomus.adr.v1;
283/// option (majordomus.schema_id) = "majordomus.adr/v1";
284/// option (majordomus.document_format) = "markdown";
285/// message Header { string id = 1; }
286/// message Body {
287///   option (majordomus.body) = true;
288///   Section context = 1 [(majordomus.heading) = "Context", (majordomus.level) = 2,
289///                        (majordomus.required) = true, (majordomus.non_empty) = true];
290///   Section notes = 2 [(majordomus.heading) = "Notes", (majordomus.level) = 2];
291/// }
292/// message Section { string heading = 1; }
293/// "#;
294/// let file = proto::parse("adr.v1.proto", text).unwrap();
295/// assert_eq!(proto::project::section_lines(&file), [
296///     "2\tContext\trequired\tnon-empty",
297///     "2\tNotes\toptional\tany",
298/// ]);
299/// ```
300pub fn section_lines(file: &ProtoFile) -> Vec<String> {
301    let Some(body) = file.body() else {
302        return Vec::new();
303    };
304    body.fields
305        .iter()
306        .map(|f| {
307            format!(
308                "{}\t{}\t{}\t{}",
309                f.options.level.unwrap_or(1),
310                f.options.heading.clone().unwrap_or_default(),
311                if f.options.required {
312                    "required"
313                } else {
314                    "optional"
315                },
316                if f.options.non_empty {
317                    "non-empty"
318                } else {
319                    "any"
320                },
321            )
322        })
323        .collect()
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    const SRC: &str = r#"
331syntax = "proto3";
332package majordomus.adr.v1;
333import "majordomus/options.proto";
334option (majordomus.schema_id) = "majordomus.adr/v1";
335option (majordomus.allow_list) = "adr";
336option (majordomus.document_format) = "markdown";
337
338// The whole record.
339message Document {
340  Header header = 1;
341  Body body = 2;
342}
343
344message Header {
345  option (majordomus.closed) = true;
346  // The format version.
347  string schema = 1 [(majordomus.required) = true, (majordomus.const) = "adr/v1"];
348  string id = 2 [(majordomus.required) = true, (majordomus.pattern) = "^adr-[0-9]{4}$"];
349  string title = 3 [(majordomus.required) = true, (majordomus.min_length) = 1];
350  Status status = 4 [(majordomus.required) = true];
351  repeated string tags = 5 [(majordomus.pattern) = "^[a-z]+$"];
352  Provenance provenance = 6;
353  uint32 weight = 7 [(majordomus.minimum) = 0, (majordomus.maximum) = 9];
354}
355
356enum Status {
357  STATUS_UNSPECIFIED = 0;
358  STATUS_PROPOSED = 1 [(majordomus.value) = "proposed"];
359  STATUS_ACCEPTED = 2 [(majordomus.value) = "accepted"];
360}
361
362message Provenance {
363  option (majordomus.closed) = true;
364  Origin origin = 1 [(majordomus.required) = true];
365}
366
367enum Origin {
368  ORIGIN_UNSPECIFIED = 0;
369  ORIGIN_AUTHORED = 1 [(majordomus.value) = "authored"];
370}
371
372message Body {
373  option (majordomus.body) = true;
374  Section context = 1 [(majordomus.heading) = "Context", (majordomus.level) = 2,
375                       (majordomus.required) = true, (majordomus.non_empty) = true];
376}
377
378message Section {
379  string heading = 1;
380  uint32 level = 2;
381  bool empty = 3;
382}
383"#;
384
385    fn schema() -> Value {
386        let file = crate::proto::parse("adr.v1.proto", SRC).expect("parses");
387        to_json_schema(&file).expect("projects")
388    }
389
390    #[test]
391    fn the_root_names_the_proto_it_came_from() {
392        let s = schema();
393        assert_eq!(s[SCHEMA_EXTENSION], json!("majordomus.adr/v1"));
394        assert_eq!(s[DERIVED_EXTENSION], json!("majordomus/adr/adr.v1.proto"));
395        assert_eq!(s[crate::generate::ALLOW_EXTENSION], json!("adr"));
396        assert_eq!(s["required"], json!(["header", "body"]));
397        assert_eq!(s["description"], json!("The whole record."));
398    }
399
400    #[test]
401    fn a_const_an_enum_and_a_reference_each_project_their_own_shape() {
402        let h = &schema()["$defs"]["Header"];
403        assert_eq!(h["properties"]["schema"]["const"], json!("adr/v1"));
404        assert_eq!(
405            h["properties"]["status"]["enum"],
406            json!(["proposed", "accepted"])
407        );
408        assert_eq!(
409            h["properties"]["provenance"]["$ref"],
410            json!("#/$defs/Provenance")
411        );
412        assert_eq!(h["additionalProperties"], json!(false));
413        assert_eq!(h["required"], json!(["schema", "id", "title", "status"]));
414    }
415
416    #[test]
417    fn a_repeated_field_becomes_an_array_around_its_item() {
418        let tags = &schema()["$defs"]["Header"]["properties"]["tags"];
419        assert_eq!(tags["type"], json!("array"));
420        assert_eq!(tags["items"]["pattern"], json!("^[a-z]+$"));
421    }
422
423    #[test]
424    fn constraints_and_prose_survive_the_projection() {
425        let h = &schema()["$defs"]["Header"];
426        assert_eq!(h["properties"]["id"]["pattern"], json!("^adr-[0-9]{4}$"));
427        assert_eq!(h["properties"]["title"]["minLength"], json!(1));
428        assert_eq!(h["properties"]["weight"]["maximum"], json!(9));
429        assert_eq!(
430            h["properties"]["schema"]["description"],
431            json!("The format version.")
432        );
433    }
434
435    #[test]
436    fn the_body_carries_the_section_contract() {
437        let body = &schema()["$defs"]["Body"];
438        let sections = body[SECTIONS_EXTENSION].as_array().expect("sections");
439        assert_eq!(sections[0]["heading"], json!("Context"));
440        assert_eq!(sections[0]["level"], json!(2));
441        assert_eq!(sections[0]["non_empty"], json!(true));
442    }
443
444    #[test]
445    fn the_projected_schema_is_a_valid_json_schema_and_accepts_a_real_record() {
446        let s = schema();
447        let validator = jsonschema::validator_for(&s).expect("compiles");
448        assert!(validator.is_valid(&json!({
449            "header": { "schema": "adr/v1", "id": "adr-0001", "title": "t", "status": "proposed" },
450            "body": { "sections": [] }
451        })));
452        assert!(!validator.is_valid(&json!({
453            "header": { "schema": "adr/v1", "id": "nope", "title": "t", "status": "proposed" },
454            "body": { "sections": [] }
455        })));
456    }
457
458    #[test]
459    fn the_allow_list_derives_from_the_header_alone() {
460        let s = schema();
461        assert_eq!(
462            crate::generate::allow_lines(&s),
463            [
464                "^schema$",
465                "^id$",
466                "^title$",
467                "^status$",
468                "^tags(\\.[0-9]+)?$",
469                "^provenance\\.origin$",
470                "^weight$",
471            ]
472        );
473    }
474}