Skip to main content

majordomus_cli/proto/
mod.rs

1//! The reader for the layer's Markdown document schemas.
2//!
3//! A Markdown kind of this layer is described by one `.proto` file: `Header` is the YAML
4//! front matter, `Body` names the sections the Markdown body must carry, and `Document` is
5//! the pair. The dialect is proto3 plus the option vocabulary in
6//! `share/schemas/majordomus/options.proto` — what proto3 has no native word for
7//! (presence, a constant, a pattern) is a custom option there.
8//!
9//! Nothing parses a `.proto` at run time. This module is used only by
10//! [`crate::generate`], which projects each file into the artifacts the validators
11//! actually read: the shell tool's allow-list and section list, and a JSON Schema for the
12//! Rust indexer. That keeps the hot path free of a second parser (`hot-path-reads-once`)
13//! and keeps one definition of each contract (ADR 0004): the `.proto` is canonical and
14//! every other form is derived from it.
15//!
16//! The parser is deliberately small: it reads the dialect these schemas are written in and
17//! refuses everything else by name, rather than silently accepting proto it does not model.
18
19mod lex;
20pub mod project;
21
22use std::collections::BTreeMap;
23use std::path::Path;
24
25use crate::error::{Error, Result};
26use lex::{Token, Tokenizer};
27
28// The file-level options `share/schemas/majordomus/options.proto` declares.
29/// The identity a schema declares, which fixes its own path.
30pub const OPT_SCHEMA_ID: &str = "majordomus.schema_id";
31/// The allow-list this schema projects for the shell tool.
32pub const OPT_ALLOW_LIST: &str = "majordomus.allow_list";
33/// How a file of this kind is read; only `markdown` is described in proto.
34pub const OPT_DOCUMENT_FORMAT: &str = "majordomus.document_format";
35
36// The message-level options.
37/// No key beyond the fields declared.
38pub const OPT_CLOSED: &str = "majordomus.closed";
39/// The message names body sections rather than a data shape.
40pub const OPT_BODY: &str = "majordomus.body";
41
42// The field-level options.
43/// The key as written in the document, when the proto field name cannot spell it.
44pub const OPT_KEY: &str = "majordomus.key";
45/// The key must be present.
46pub const OPT_REQUIRED: &str = "majordomus.required";
47/// The value must equal this string exactly.
48pub const OPT_CONST: &str = "majordomus.const";
49/// The value must match this anchored expression.
50pub const OPT_PATTERN: &str = "majordomus.pattern";
51/// Least length of a string.
52pub const OPT_MIN_LENGTH: &str = "majordomus.min_length";
53/// Least number of items of a repeated field.
54pub const OPT_MIN_ITEMS: &str = "majordomus.min_items";
55/// Inclusive lower bound of an integer.
56pub const OPT_MINIMUM: &str = "majordomus.minimum";
57/// Inclusive upper bound of an integer.
58pub const OPT_MAXIMUM: &str = "majordomus.maximum";
59/// The heading text of a body section.
60pub const OPT_HEADING: &str = "majordomus.heading";
61/// The heading level of a body section.
62pub const OPT_LEVEL: &str = "majordomus.level";
63/// The section must carry content, not only exist.
64pub const OPT_NON_EMPTY: &str = "majordomus.non_empty";
65
66/// The enum-value option carrying the string the value is written as.
67pub const OPT_VALUE: &str = "majordomus.value";
68
69// The message names this layer gives meaning to.
70/// The message naming the whole document: the header and the body.
71pub const MSG_DOCUMENT: &str = "Document";
72/// The message describing the YAML front matter.
73pub const MSG_HEADER: &str = "Header";
74/// The message naming the sections of the Markdown body.
75pub const MSG_BODY: &str = "Body";
76
77/// The suffix of a document schema written in proto.
78pub const PROTO_SUFFIX: &str = ".proto";
79
80/// One parsed `.proto` document schema.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ProtoFile {
83    /// Where it was read from, for diagnostics.
84    pub source: String,
85    /// The `package` declaration.
86    pub package: String,
87    /// `option (majordomus.schema_id)`, the identity that fixes this file's path.
88    pub schema_id: String,
89    /// `option (majordomus.allow_list)`; absent means the kind projects no allow-list.
90    pub allow_list: Option<String>,
91    /// `option (majordomus.document_format)`.
92    pub document_format: String,
93    /// Every message, in declaration order.
94    pub messages: Vec<Message>,
95    /// Every enum, in declaration order.
96    pub enums: Vec<Enum>,
97}
98
99/// One `message`.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Message {
102    /// The message name as declared.
103    pub name: String,
104    /// The comment block above it, joined into one paragraph.
105    pub doc: String,
106    /// `option (majordomus.closed)`: no key beyond the fields declared here.
107    pub closed: bool,
108    /// `option (majordomus.body)`: the fields are body sections, not data.
109    pub body: bool,
110    /// Its fields, in declaration order.
111    pub fields: Vec<Field>,
112}
113
114/// One field of a message.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Field {
117    /// The proto field name. The key it names in the document is [`Field::key`].
118    pub name: String,
119    /// The comment block above it, joined into one paragraph.
120    pub doc: String,
121    /// The declared type: a scalar (`string`, `uint32`, `int64`, `bool`) or a message or
122    /// enum name declared in the same file.
123    pub type_name: String,
124    /// Declared `repeated`: the document carries a list.
125    pub repeated: bool,
126    /// The proto field number. Unused by the projections; kept so the file round-trips.
127    pub number: u32,
128    /// The custom options it carries.
129    pub options: FieldOptions,
130}
131
132impl Field {
133    /// The key this field names in the document: the declared `key` option when it carries
134    /// one, otherwise the field name itself.
135    pub fn key(&self) -> &str {
136        self.options.key.as_deref().unwrap_or(&self.name)
137    }
138}
139
140/// The custom options a field may carry.
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct FieldOptions {
143    /// The key as written in the document, when the proto field name cannot spell it.
144    pub key: Option<String>,
145    /// The key must be present.
146    pub required: bool,
147    /// The exact string the value must equal.
148    pub const_value: Option<String>,
149    /// The anchored expression the value must match.
150    pub pattern: Option<String>,
151    /// Least length of a string; 1 means "not blank".
152    pub min_length: Option<u64>,
153    /// Least number of items of a repeated field.
154    pub min_items: Option<u64>,
155    /// Inclusive lower bound of an integer.
156    pub minimum: Option<i64>,
157    /// Inclusive upper bound of an integer.
158    pub maximum: Option<i64>,
159    /// For a body section: the heading text.
160    pub heading: Option<String>,
161    /// For a body section: the number of leading hashes.
162    pub level: Option<u64>,
163    /// For a body section: it must carry content, not only exist.
164    pub non_empty: bool,
165}
166
167/// One `enum`: a closed set of strings, each with the form it is written in.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Enum {
170    /// The enum name as declared.
171    pub name: String,
172    /// The comment block above it, joined into one paragraph.
173    pub doc: String,
174    /// Its values, in declaration order.
175    pub values: Vec<EnumValue>,
176}
177
178/// One value of an enum.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct EnumValue {
181    /// The proto value name, SCREAMING_SNAKE_CASE.
182    pub name: String,
183    /// The proto value number; zero is the unspecified one.
184    pub number: i64,
185    /// `option (majordomus.value)`: how it is written in a document. The zero value of a
186    /// proto3 enum is the unspecified one and carries none.
187    pub written: Option<String>,
188}
189
190impl ProtoFile {
191    /// The message with this name.
192    pub fn message(&self, name: &str) -> Option<&Message> {
193        self.messages.iter().find(|m| m.name == name)
194    }
195
196    /// The enum with this name.
197    pub fn enumeration(&self, name: &str) -> Option<&Enum> {
198        self.enums.iter().find(|e| e.name == name)
199    }
200
201    /// The `Header` message, which every document schema must declare.
202    pub fn header(&self) -> Result<&Message> {
203        self.message(MSG_HEADER).ok_or_else(|| Error::KindSchema {
204            reason: format!("{}: declares no message {MSG_HEADER}", self.source),
205        })
206    }
207
208    /// The `Body` message, when the kind describes one.
209    pub fn body(&self) -> Option<&Message> {
210        self.message(MSG_BODY).filter(|m| m.body)
211    }
212
213    /// The path this file's identity fixes, relative to the schema root:
214    /// `majordomus.adr/v1` is `majordomus/adr/adr.v1.proto`. The identity and the path are
215    /// checked against each other on load, so neither can drift from the other.
216    pub fn expected_path(schema_id: &str) -> Result<String> {
217        let (qualified, version) = schema_id.split_once('/').ok_or_else(|| Error::KindSchema {
218            reason: format!(
219                "schema id '{schema_id}' is not <vendor>.<name>/v<n>: it carries no '/'"
220            ),
221        })?;
222        let (vendor, name) = qualified
223            .rsplit_once('.')
224            .ok_or_else(|| Error::KindSchema {
225                reason: format!(
226                    "schema id '{schema_id}' is not <vendor>.<name>/v<n>: '{qualified}' carries no '.'"
227                ),
228            })?;
229        if vendor.is_empty() || name.is_empty() || !version.starts_with('v') || version.len() < 2 {
230            return Err(Error::KindSchema {
231                reason: format!("schema id '{schema_id}' is not <vendor>.<name>/v<n>"),
232            });
233        }
234        Ok(format!("{vendor}/{name}/{name}.{version}{PROTO_SUFFIX}"))
235    }
236
237    /// The identity a path under the schema root carries, the inverse of
238    /// [`Self::expected_path`]. A path that does not have the shape yields nothing.
239    pub fn identity_of(relative: &str) -> Option<String> {
240        let stem = relative.strip_suffix(PROTO_SUFFIX)?;
241        let mut parts = stem.split('/');
242        let vendor = parts.next()?;
243        let name = parts.next()?;
244        let file = parts.next()?;
245        if parts.next().is_some() {
246            return None;
247        }
248        let version = file.strip_prefix(&format!("{name}."))?;
249        Some(format!("{vendor}.{name}/{version}"))
250    }
251}
252
253/// Parse one `.proto` file read from `path`, with `source` naming it in every diagnostic.
254pub fn parse(source: &str, text: &str) -> Result<ProtoFile> {
255    Parser::new(source, text).file()
256}
257
258/// Read and parse one `.proto` file.
259pub fn read(path: &Path, source: &str) -> Result<ProtoFile> {
260    let text = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
261    parse(source, &text)
262}
263
264/// Every `.proto` under a schema root, keyed by the identity each declares, parsed. A root
265/// that does not exist yields nothing. A file whose declared identity disagrees with its
266/// path is an error naming both.
267pub fn read_dir(root: &Path, source_prefix: &str) -> Result<BTreeMap<String, ProtoFile>> {
268    let mut out = BTreeMap::new();
269    if !root.is_dir() {
270        return Ok(out);
271    }
272    for relative in proto_paths(root, "")? {
273        // options.proto is the vocabulary, not a document schema.
274        if relative == format!("majordomus/options{PROTO_SUFFIX}") {
275            continue;
276        }
277        let source = format!("{source_prefix}/{relative}");
278        let file = read(&root.join(&relative), &source)?;
279        let expected = ProtoFile::expected_path(&file.schema_id)?;
280        if expected != relative {
281            return Err(Error::KindSchema {
282                reason: format!(
283                    "{source}: declares schema id '{}', whose path is {expected}; \
284                     a schema's identity and its path fix each other",
285                    file.schema_id
286                ),
287            });
288        }
289        if let Some(first) = out.insert(file.schema_id.clone(), file) {
290            return Err(Error::KindSchema {
291                reason: format!(
292                    "schema id '{}' is declared by both {} and {source}",
293                    first.schema_id, first.source
294                ),
295            });
296        }
297    }
298    Ok(out)
299}
300
301/// Every `.proto` under `root`, as paths relative to it, sorted.
302fn proto_paths(root: &Path, prefix: &str) -> Result<Vec<String>> {
303    let dir = if prefix.is_empty() {
304        root.to_path_buf()
305    } else {
306        root.join(prefix)
307    };
308    let mut out = Vec::new();
309    for entry in std::fs::read_dir(&dir).map_err(|e| Error::io(&dir, e))? {
310        let entry = entry.map_err(|e| Error::io(&dir, e))?;
311        let Some(name) = entry.file_name().to_str().map(str::to_string) else {
312            continue;
313        };
314        let relative = if prefix.is_empty() {
315            name.clone()
316        } else {
317            format!("{prefix}/{name}")
318        };
319        if entry.path().is_dir() {
320            out.extend(proto_paths(root, &relative)?);
321        } else if name.ends_with(PROTO_SUFFIX) {
322            out.push(relative);
323        }
324    }
325    out.sort();
326    Ok(out)
327}
328
329// ---------------------------------------------------------------- the parser
330
331struct Parser<'a> {
332    source: String,
333    tokens: Vec<Token<'a>>,
334    at: usize,
335}
336
337impl<'a> Parser<'a> {
338    fn new(source: &str, text: &'a str) -> Self {
339        Parser {
340            source: source.to_string(),
341            tokens: Tokenizer::new(text).collect(),
342            at: 0,
343        }
344    }
345
346    fn err<T>(&self, what: impl std::fmt::Display) -> Result<T> {
347        let line = self.tokens.get(self.at).map(|t| t.line).unwrap_or(0);
348        Err(Error::KindSchema {
349            reason: format!("{}:{line}: {what}", self.source),
350        })
351    }
352
353    fn peek(&self) -> Option<&Token<'a>> {
354        self.tokens.get(self.at)
355    }
356
357    fn next(&mut self) -> Option<Token<'a>> {
358        let t = self.tokens.get(self.at).cloned();
359        if t.is_some() {
360            self.at += 1;
361        }
362        t
363    }
364
365    /// The next token's text, whatever it is.
366    fn word(&mut self) -> Result<Token<'a>> {
367        match self.next() {
368            Some(t) => Ok(t),
369            None => self.err("unexpected end of file"),
370        }
371    }
372
373    /// Consume the exact punctuation or keyword `want`.
374    fn expect(&mut self, want: &str) -> Result<()> {
375        let t = self.word()?;
376        if t.text == want {
377            Ok(())
378        } else {
379            self.at -= 1;
380            self.err(format!("expected '{want}', found '{}'", t.text))
381        }
382    }
383
384    /// Consume `want` when it is next; say whether it was.
385    fn eat(&mut self, want: &str) -> bool {
386        if self.peek().map(|t| t.text) == Some(want) {
387            self.at += 1;
388            true
389        } else {
390            false
391        }
392    }
393
394    fn file(mut self) -> Result<ProtoFile> {
395        let mut package = String::new();
396        let mut options: BTreeMap<String, OptionValue> = BTreeMap::new();
397        let mut messages = Vec::new();
398        let mut enums = Vec::new();
399
400        while let Some(token) = self.peek().cloned() {
401            match token.text {
402                "syntax" => {
403                    self.at += 1;
404                    self.expect("=")?;
405                    let v = self.word()?;
406                    if v.string() != Some("proto3".to_string()) {
407                        self.at -= 1;
408                        return self.err("only proto3 is read here");
409                    }
410                    self.expect(";")?;
411                }
412                "package" => {
413                    self.at += 1;
414                    package = self.dotted_name()?;
415                    self.expect(";")?;
416                }
417                "import" => {
418                    self.at += 1;
419                    self.word()?;
420                    self.expect(";")?;
421                }
422                "option" => {
423                    self.at += 1;
424                    let (name, value) = self.option_assignment()?;
425                    self.expect(";")?;
426                    options.insert(name, value);
427                }
428                "message" => {
429                    self.at += 1;
430                    messages.push(self.message(token.doc)?);
431                }
432                "enum" => {
433                    self.at += 1;
434                    enums.push(self.enumeration(token.doc)?);
435                }
436                "extend" => {
437                    self.at += 1;
438                    // The option vocabulary itself; it declares no document.
439                    self.dotted_name()?;
440                    self.skip_block()?;
441                }
442                other => {
443                    let other = other.to_string();
444                    return self.err(format!("unexpected '{other}' at the top level"));
445                }
446            }
447        }
448
449        let schema_id = match options.get(OPT_SCHEMA_ID) {
450            Some(OptionValue::Str(s)) => s.clone(),
451            _ => {
452                return Err(Error::KindSchema {
453                    reason: format!("{}: declares no option ({OPT_SCHEMA_ID})", self.source),
454                })
455            }
456        };
457        let document_format = match options.get(OPT_DOCUMENT_FORMAT) {
458            Some(OptionValue::Str(s)) => s.clone(),
459            _ => {
460                return Err(Error::KindSchema {
461                    reason: format!(
462                        "{}: declares no option ({OPT_DOCUMENT_FORMAT})",
463                        self.source
464                    ),
465                })
466            }
467        };
468        if document_format != "markdown" {
469            return Err(Error::KindSchema {
470                reason: format!(
471                    "{}: document_format '{document_format}' is not 'markdown'; a YAML kind is described by JSON Schema",
472                    self.source
473                ),
474            });
475        }
476        let allow_list = match options.get(OPT_ALLOW_LIST) {
477            Some(OptionValue::Str(s)) => Some(s.clone()),
478            _ => None,
479        };
480
481        Ok(ProtoFile {
482            source: self.source,
483            package,
484            schema_id,
485            allow_list,
486            document_format,
487            messages,
488            enums,
489        })
490    }
491
492    fn dotted_name(&mut self) -> Result<String> {
493        let mut out = self.word()?.text.to_string();
494        while self.eat(".") {
495            out.push('.');
496            out.push_str(self.word()?.text);
497        }
498        Ok(out)
499    }
500
501    /// `(majordomus.required) = true` or `java_package = "x"`, after the `option` keyword.
502    fn option_assignment(&mut self) -> Result<(String, OptionValue)> {
503        let name = if self.eat("(") {
504            let n = self.dotted_name()?;
505            self.expect(")")?;
506            n
507        } else {
508            self.dotted_name()?
509        };
510        self.expect("=")?;
511        let token = self.word()?;
512        let value = if let Some(s) = token.string() {
513            OptionValue::Str(s)
514        } else if token.text == "true" {
515            OptionValue::Bool(true)
516        } else if token.text == "false" {
517            OptionValue::Bool(false)
518        } else if let Ok(n) = token.text.parse::<i64>() {
519            OptionValue::Int(n)
520        } else {
521            self.at -= 1;
522            return self.err(format!(
523                "option value '{}' is not a string, a boolean or an integer",
524                token.text
525            ));
526        };
527        Ok((name, value))
528    }
529
530    fn message(&mut self, doc: String) -> Result<Message> {
531        let name = self.word()?.text.to_string();
532        self.expect("{")?;
533        let mut closed = false;
534        let mut body = false;
535        let mut fields = Vec::new();
536        loop {
537            let token = match self.peek().cloned() {
538                Some(t) => t,
539                None => return self.err("unexpected end of file inside a message"),
540            };
541            match token.text {
542                "}" => {
543                    self.at += 1;
544                    break;
545                }
546                "option" => {
547                    self.at += 1;
548                    let (name, value) = self.option_assignment()?;
549                    self.expect(";")?;
550                    match (name.as_str(), value) {
551                        (OPT_CLOSED, OptionValue::Bool(b)) => closed = b,
552                        (OPT_BODY, OptionValue::Bool(b)) => body = b,
553                        (other, _) => return self.err(format!("unknown message option ({other})")),
554                    }
555                }
556                _ => fields.push(self.field(token.doc)?),
557            }
558        }
559        Ok(Message {
560            name,
561            doc,
562            closed,
563            body,
564            fields,
565        })
566    }
567
568    fn field(&mut self, doc: String) -> Result<Field> {
569        let repeated = self.eat("repeated");
570        if self.eat("optional") || self.eat("required") {
571            return self.err(
572                "proto3 label 'optional'/'required' is not read here; use (majordomus.required)",
573            );
574        }
575        let type_name = self.dotted_name()?;
576        let name = self.word()?.text.to_string();
577        self.expect("=")?;
578        let number_token = self.word()?;
579        let number: u32 = match number_token.text.parse() {
580            Ok(n) => n,
581            Err(_) => {
582                self.at -= 1;
583                return self.err(format!(
584                    "field number '{}' is not an integer",
585                    number_token.text
586                ));
587            }
588        };
589        let mut options = FieldOptions::default();
590        if self.eat("[") {
591            loop {
592                let (name, value) = self.option_assignment()?;
593                self.apply_field_option(&name, value, &mut options)?;
594                if !self.eat(",") {
595                    break;
596                }
597            }
598            self.expect("]")?;
599        }
600        self.expect(";")?;
601        Ok(Field {
602            name,
603            doc,
604            type_name,
605            repeated,
606            number,
607            options,
608        })
609    }
610
611    fn apply_field_option(
612        &mut self,
613        name: &str,
614        value: OptionValue,
615        out: &mut FieldOptions,
616    ) -> Result<()> {
617        match (name, value) {
618            (OPT_KEY, OptionValue::Str(s)) => out.key = Some(s),
619            (OPT_REQUIRED, OptionValue::Bool(b)) => out.required = b,
620            (OPT_NON_EMPTY, OptionValue::Bool(b)) => out.non_empty = b,
621            (OPT_CONST, OptionValue::Str(s)) => out.const_value = Some(s),
622            (OPT_PATTERN, OptionValue::Str(s)) => out.pattern = Some(s),
623            (OPT_HEADING, OptionValue::Str(s)) => out.heading = Some(s),
624            (OPT_MIN_LENGTH, OptionValue::Int(n)) if n >= 0 => out.min_length = Some(n as u64),
625            (OPT_MIN_ITEMS, OptionValue::Int(n)) if n >= 0 => out.min_items = Some(n as u64),
626            (OPT_LEVEL, OptionValue::Int(n)) if n >= 0 => out.level = Some(n as u64),
627            (OPT_MINIMUM, OptionValue::Int(n)) => out.minimum = Some(n),
628            (OPT_MAXIMUM, OptionValue::Int(n)) => out.maximum = Some(n),
629            (other, _) => {
630                return self.err(format!(
631                    "unknown or ill-typed field option ({other}); the vocabulary is share/schemas/majordomus/options.proto"
632                ))
633            }
634        }
635        Ok(())
636    }
637
638    fn enumeration(&mut self, doc: String) -> Result<Enum> {
639        let name = self.word()?.text.to_string();
640        self.expect("{")?;
641        let mut values = Vec::new();
642        loop {
643            if self.eat("}") {
644                break;
645            }
646            let value_name = self.word()?.text.to_string();
647            self.expect("=")?;
648            let number_token = self.word()?;
649            let number: i64 = match number_token.text.parse() {
650                Ok(n) => n,
651                Err(_) => {
652                    self.at -= 1;
653                    return self.err(format!(
654                        "enum value number '{}' is not an integer",
655                        number_token.text
656                    ));
657                }
658            };
659            let mut written = None;
660            if self.eat("[") {
661                loop {
662                    let (option_name, value) = self.option_assignment()?;
663                    match (option_name.as_str(), value) {
664                        (OPT_VALUE, OptionValue::Str(s)) => written = Some(s),
665                        (other, _) => {
666                            return self.err(format!("unknown enum value option ({other})"))
667                        }
668                    }
669                    if !self.eat(",") {
670                        break;
671                    }
672                }
673                self.expect("]")?;
674            }
675            self.expect(";")?;
676            values.push(EnumValue {
677                name: value_name,
678                number,
679                written,
680            });
681        }
682        Ok(Enum { name, doc, values })
683    }
684
685    /// Skip a `{ ... }` block whose contents this reader does not model.
686    fn skip_block(&mut self) -> Result<()> {
687        self.expect("{")?;
688        let mut depth = 1;
689        while depth > 0 {
690            let t = self.word()?;
691            match t.text {
692                "{" => depth += 1,
693                "}" => depth -= 1,
694                _ => {}
695            }
696        }
697        Ok(())
698    }
699}
700
701/// The value of an option: the three kinds this dialect uses.
702#[derive(Debug, Clone, PartialEq, Eq)]
703enum OptionValue {
704    Str(String),
705    Bool(bool),
706    Int(i64),
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    const ADR: &str = r#"
714syntax = "proto3";
715package majordomus.adr.v1;
716import "majordomus/options.proto";
717option (majordomus.schema_id) = "majordomus.adr/v1";
718option (majordomus.allow_list) = "adr";
719option (majordomus.document_format) = "markdown";
720
721// The front matter.
722message Header {
723  option (majordomus.closed) = true;
724  // The format version.
725  string schema = 1 [(majordomus.required) = true, (majordomus.const) = "adr/v1"];
726  repeated string tags = 7 [(majordomus.pattern) = "^[a-z][a-z0-9-]*$"];
727  Status status = 5 [(majordomus.required) = true];
728}
729
730enum Status {
731  STATUS_UNSPECIFIED = 0;
732  STATUS_PROPOSED = 1 [(majordomus.value) = "proposed"];
733}
734
735message Body {
736  option (majordomus.body) = true;
737  Section context = 1 [(majordomus.heading) = "Context", (majordomus.level) = 2, (majordomus.required) = true, (majordomus.non_empty) = true];
738}
739
740message Section {
741  string heading = 1;
742  uint32 level = 2;
743  bool empty = 3;
744}
745"#;
746
747    fn adr() -> ProtoFile {
748        parse("adr.v1.proto", ADR).expect("parses")
749    }
750
751    #[test]
752    fn reads_the_file_options() {
753        let f = adr();
754        assert_eq!(f.package, "majordomus.adr.v1");
755        assert_eq!(f.schema_id, "majordomus.adr/v1");
756        assert_eq!(f.allow_list.as_deref(), Some("adr"));
757        assert_eq!(f.document_format, "markdown");
758    }
759
760    #[test]
761    fn reads_fields_their_options_and_their_comments() {
762        let f = adr();
763        let header = f.header().expect("Header");
764        assert!(header.closed);
765        let schema = &header.fields[0];
766        assert_eq!(schema.name, "schema");
767        assert_eq!(schema.doc, "The format version.");
768        assert!(schema.options.required);
769        assert_eq!(schema.options.const_value.as_deref(), Some("adr/v1"));
770        let tags = &header.fields[1];
771        assert!(tags.repeated);
772        assert_eq!(tags.options.pattern.as_deref(), Some("^[a-z][a-z0-9-]*$"));
773    }
774
775    #[test]
776    fn reads_an_enum_with_the_form_each_value_is_written_in() {
777        let f = adr();
778        let status = f.enumeration("Status").expect("Status");
779        assert_eq!(status.values.len(), 2);
780        assert_eq!(status.values[0].written, None);
781        assert_eq!(status.values[1].written.as_deref(), Some("proposed"));
782    }
783
784    #[test]
785    fn reads_the_body_sections_in_order() {
786        let f = adr();
787        let body = f.body().expect("Body");
788        assert_eq!(body.fields[0].options.heading.as_deref(), Some("Context"));
789        assert_eq!(body.fields[0].options.level, Some(2));
790        assert!(body.fields[0].options.non_empty);
791    }
792
793    #[test]
794    fn an_identity_and_a_path_fix_each_other() {
795        assert_eq!(
796            ProtoFile::expected_path("majordomus.adr/v1").unwrap(),
797            "majordomus/adr/adr.v1.proto"
798        );
799        assert_eq!(
800            ProtoFile::identity_of("majordomus/adr/adr.v1.proto").as_deref(),
801            Some("majordomus.adr/v1")
802        );
803        assert!(ProtoFile::expected_path("adr").is_err());
804        assert!(ProtoFile::expected_path("majordomus.adr").is_err());
805        assert_eq!(ProtoFile::identity_of("majordomus/adr.proto"), None);
806    }
807
808    #[test]
809    fn refuses_a_dialect_it_does_not_model() {
810        let bad = ADR.replace("syntax = \"proto3\";", "syntax = \"proto2\";");
811        assert!(parse("x.proto", &bad).is_err());
812        let yaml = ADR.replace("\"markdown\"", "\"yaml\"");
813        let e = parse("x.proto", &yaml).unwrap_err().to_string();
814        assert!(e.contains("described by JSON Schema"), "{e}");
815        let unknown = ADR.replace("(majordomus.const)", "(majordomus.nonesuch)");
816        assert!(parse("x.proto", &unknown).is_err());
817    }
818}