Skip to main content

shep_core/config/
scaffold.rs

1//! Building the commented Flockfile `shep init` writes, in every format
2//! shep can read.
3//!
4//! # Why this lives here and not in the CLI
5//!
6//! A scaffold is a Flockfile that has not been filled in yet, so it belongs
7//! beside the grammar it is a specimen of. Every fact it needs -- which
8//! fields exist, what each is for, what a plausible value looks like -- comes
9//! from the same schema [`crate::config::flockfile_schema_json`] serves, and
10//! the document shapes it emits are the ones
11//! [`crate::config::Flockfile::parse`] accepts two hundred lines up. Putting
12//! the generator anywhere else would mean a second copy of the grammar's
13//! shape, kept in step by hand.
14//!
15//! # The one idea that makes four formats tractable
16//!
17//! A scaffold is almost entirely commented out: the reader uncomments the
18//! lines they want. The obvious way to build one is to write each format's
19//! commented text directly, and it does not work -- the marker ends up
20//! threaded through every structural fragment, so `app:` and `[` and `{` all
21//! have to carry it, and each nesting level becomes its own special case.
22//!
23//! So this module **builds the document uncommented, then comments it in one
24//! final pass.** Every line is tagged as prose or as code, one step emits the
25//! real Flockfile a format would accept, and a second step puts the marker
26//! on. The marker never touches the structure, which is why adding a format
27//! is a table entry rather than a rewrite.
28//!
29//! # The comment convention, which is load-bearing
30//!
31//! Two kinds of comment line, and a test relies on telling them apart:
32//!
33//! - **Prose**, for a reader: marker then a SPACE. `# Every app gets one.`
34//! - **Commented-out config**, meant to be uncommented: marker then the
35//!   config, no space. `#name = "api"`
36//!
37//! Uncommenting therefore means stripping the marker from exactly the lines
38//! whose next character is not a space, which is what lets a test uncomment
39//! each format mechanically and prove the result parses -- that the scaffold
40//! is a real Flockfile rather than plausible-looking prose.
41//!
42//! # Strict JSON is the exception, and it cannot be argued away
43//!
44//! JSON has no comment syntax. Not an awkward one -- none. So the product
45//! this module exists to make cannot be made in it, and [`Scaffold::build`]
46//! emits a live minimal document there instead: real values, no guidance.
47//! [`Depth::All`] is refused for JSON rather than fudged, because a JSON
48//! document naming all forty fields would pin every default explicitly,
49//! which is a Flockfile you would tell somebody not to commit. Rin's call,
50//! 2026-08-23. `.json5` is the format with JSON's syntax and comments, and
51//! the refusal says so.
52
53use core::fmt;
54
55use crate::config::FlockFormat;
56
57/// How much of the Flockfile grammar a scaffold shows.
58///
59/// Verbosity belongs to the moment rather than to the template: a newcomer
60/// and a veteran want the same file at different depths.
61///
62/// Only [`Depth::All`] is machine-checkable. The drift test compares it
63/// against the generated schema, which works precisely because that level is
64/// meant to be exhaustive. [`Depth::Curated`] is editorial judgement about
65/// what matters on day one, and no test can tell anyone it has gone stale,
66/// so the friendly level is the expensive one to maintain.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Depth {
69    /// The fields somebody needs on their first day. A file to read.
70    Curated,
71    /// Every option the grammar has, for somebody who knows what they want
72    /// and cannot remember what it is called.
73    All,
74}
75
76/// Why a scaffold could not be built.
77#[derive(Debug, Clone, PartialEq, Eq)]
78#[non_exhaustive]
79pub enum ScaffoldError {
80    /// [`Depth::All`] was asked for in a format with no comments.
81    ///
82    /// Carries the format so the message can name it, and names `json5` as
83    /// the way out, since it is JSON's syntax with comments added.
84    NoCommentsForAll(FlockFormat),
85}
86
87impl fmt::Display for ScaffoldError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            // `Syntax`'s own label rather than a `Display` on `FlockFormat`:
91            // the human name of a format is already recorded once, and a
92            // second spelling could drift from it.
93            Self::NoCommentsForAll(format) => write!(
94                f,
95                "{} has no comment syntax, so a full scaffold would pin \
96                 every default instead of explaining it; write a .json5 \
97                 Flockfile for the same syntax with comments, or drop --all",
98                Syntax::of(*format).label
99            ),
100        }
101    }
102}
103
104impl core::error::Error for ScaffoldError {}
105
106/// The fields [`Depth::Curated`] shows, in the order it shows them.
107///
108/// An explicit ordered list rather than a flag scattered across `AppConfig`'s
109/// attributes, because membership and ORDER are one editorial decision and
110/// belong somewhere a person can read at a glance. The order is a narrative:
111/// what is it, what runs, keep it alive, where it runs.
112///
113/// Generation cannot supply this. schemars emits properties into a sorted
114/// map, so a derived curated file would read `autorestart, cwd, name,
115/// script`: alphabetical, and meaningless to somebody opening it first.
116pub const CURATED: &[&str] = &["name", "script", "autorestart", "cwd"];
117
118/// Group order for [`Depth::All`], coarsest concern first: what it is and
119/// what runs, then what it receives, then how it is kept alive, then when.
120///
121/// Fields carrying no `group` sort after all of these. That is deliberate
122/// rather than tidy: half of `AppConfig` is currently ungrouped, so half the
123/// full scaffold is still alphabetical, and leaving those at the end makes
124/// the gap visible instead of hiding it in the middle.
125const GROUP_ORDER: &[&str] = &["process", "inputs", "control", "cron"];
126
127/// One line of a scaffold, before any comment marker is applied.
128///
129/// The split is the whole trick: [`render`] prefixes prose with a marker and
130/// a space, and code with a bare marker, which is what makes uncommenting
131/// mechanical rather than a guess.
132#[derive(Debug, Clone, PartialEq, Eq)]
133enum Line {
134    /// Explanation for a reader. Never uncommented, and dropped entirely by
135    /// a format that cannot carry it.
136    Prose(String),
137    /// A real line of the document, commented out until somebody wants it.
138    Code(String),
139    /// A separator, emitted bare in every format.
140    Blank,
141}
142
143/// One format's syntax, as data rather than as a branch per nesting level.
144struct Syntax {
145    /// Line comment marker, or `None` for a format that has none.
146    marker: Option<&'static str>,
147    /// What the preamble calls this format.
148    label: &'static str,
149    /// Lines that open the document and its one example app.
150    open: &'static [&'static str],
151    /// Prefix on each field line.
152    indent: &'static str,
153    /// Between a field's name and its value.
154    separator: &'static str,
155    /// Lines that close the document.
156    close: &'static [&'static str],
157    /// What follows every field but the last.
158    ///
159    /// JSON and JSON5 separate object members with a comma. TOML and YAML
160    /// separate them with a newline and want nothing here, which is a
161    /// different question from whether a TRAILING one is legal.
162    member_sep: &'static str,
163    /// Whether [`Syntax::member_sep`] may follow the LAST field too.
164    ///
165    /// JSON5 allows a trailing comma, so last-ness never has to be tracked
166    /// there. Strict JSON does not.
167    trailing_sep: bool,
168    /// Whether field names are quoted.
169    quoted_keys: bool,
170}
171
172impl Syntax {
173    const fn of(format: FlockFormat) -> Self {
174        match format {
175            // `[[app]]` needs no closing line and no indent: a TOML array of
176            // tables ends where the next one begins.
177            FlockFormat::Toml => Self {
178                marker: Some("#"),
179                label: "TOML",
180                open: &["[[app]]"],
181                indent: "",
182                separator: " = ",
183                close: &[],
184                member_sep: "",
185                trailing_sep: false,
186                quoted_keys: false,
187            },
188            // The lone `-` is deliberate. A sequence item whose value is a
189            // block mapping on the following lines is valid YAML, and
190            // writing it that way means the first field needs no special
191            // case for the dash.
192            FlockFormat::Yaml => Self {
193                marker: Some("#"),
194                label: "YAML",
195                open: &["app:", "  -"],
196                indent: "    ",
197                separator: ": ",
198                close: &[],
199                member_sep: "",
200                trailing_sep: false,
201                quoted_keys: false,
202            },
203            FlockFormat::Json5 => Self {
204                marker: Some("//"),
205                label: "JSON5",
206                open: &["{", "  app: [", "    {"],
207                indent: "      ",
208                separator: ": ",
209                close: &["    },", "  ],", "}"],
210                member_sep: ",",
211                trailing_sep: true,
212                quoted_keys: false,
213            },
214            FlockFormat::Json => Self {
215                marker: None,
216                label: "JSON",
217                open: &["{", "  \"app\": [", "    {"],
218                indent: "      ",
219                separator: ": ",
220                close: &["    }", "  ]", "}"],
221                member_sep: ",",
222                trailing_sep: false,
223                quoted_keys: true,
224            },
225        }
226    }
227}
228
229/// A scaffold request: which format, and how much of the grammar.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub struct Scaffold {
232    format: FlockFormat,
233    depth: Depth,
234}
235
236impl Scaffold {
237    /// A scaffold for `format` at `depth`.
238    #[must_use]
239    pub const fn new(format: FlockFormat, depth: Depth) -> Self {
240        Self { format, depth }
241    }
242
243    /// The scaffold's text.
244    ///
245    /// In a format that has comments the result is entirely commented out,
246    /// so it parses as a document declaring no apps until somebody
247    /// uncomments a line. In strict JSON it is a live minimal Flockfile,
248    /// because there is no comment to hide behind.
249    ///
250    /// # Errors
251    /// - [`ScaffoldError::NoCommentsForAll`] -- [`Depth::All`] in a format
252    ///   with no comment syntax, where the result would pin every default
253    ///   rather than explain it.
254    ///
255    /// # Panics
256    /// If a name in [`CURATED`] is not a field of `AppConfig`. That is a
257    /// build-time editorial mistake, not a runtime condition, and there is a
258    /// test that fails first.
259    #[track_caller]
260    pub fn build(self) -> Result<String, ScaffoldError> {
261        let syntax = Syntax::of(self.format);
262        if syntax.marker.is_none() && self.depth == Depth::All {
263            return Err(ScaffoldError::NoCommentsForAll(self.format));
264        }
265        Ok(render(&syntax, &document(&syntax, &self.field_names())))
266    }
267
268    /// The field names this scaffold shows, in the order it shows them.
269    fn field_names(self) -> Vec<String> {
270        match self.depth {
271            Depth::Curated => CURATED.iter().map(|name| (*name).to_owned()).collect(),
272            Depth::All => grouped_order(),
273        }
274    }
275}
276
277/// Every field name: the curated four first, then the rest by
278/// [`GROUP_ORDER`] and alphabetically within each group.
279///
280/// The curated names lead because within a group the order is alphabetical,
281/// which buried `name` and `script` at the ninth and twelfth lines of the
282/// full scaffold. Those are the two fields `normalize` actually requires, so
283/// a reader meeting the file for the first time should not have to hunt for
284/// them. [`CURATED`] already records what matters first and in what order,
285/// and reusing it here means one editorial decision rather than two that can
286/// disagree.
287fn grouped_order() -> Vec<String> {
288    let schema = crate::config::flockfile_schema_json();
289    let props = properties(&schema);
290
291    let rank = |name: &str| -> usize {
292        let group = props[name]["init"]["group"].as_str().unwrap_or_default();
293        GROUP_ORDER
294            .iter()
295            .position(|known| *known == group)
296            .unwrap_or(GROUP_ORDER.len())
297    };
298
299    // `props` is already alphabetical (schemars emits a sorted map), and a
300    // stable sort by rank alone therefore leaves each group alphabetical.
301    let mut rest: Vec<String> = props
302        .keys()
303        .filter(|name| !CURATED.contains(&name.as_str()))
304        .cloned()
305        .collect();
306    rest.sort_by_key(|name| rank(name));
307
308    let mut names: Vec<String> = CURATED.iter().map(|name| (*name).to_owned()).collect();
309    names.extend(rest);
310    names
311}
312
313/// `AppConfig`'s properties, as the schema describes them.
314fn properties(schema: &schemars::Schema) -> &serde_json::Map<String, serde_json::Value> {
315    schema
316        .pointer("#/$defs/AppConfig/properties")
317        .expect("app config properties must exist")
318        .as_object()
319        .expect("props must be an object")
320}
321
322/// The document a format would accept, uncommented, one [`Line`] per line.
323///
324/// This is the whole scaffold as a real Flockfile. Nothing here knows what a
325/// comment is.
326#[track_caller]
327fn document(syntax: &Syntax, names: &[String]) -> Vec<Line> {
328    let schema = crate::config::flockfile_schema_json();
329    let props = properties(&schema);
330
331    let mut lines = Vec::new();
332    if syntax.marker.is_some() {
333        lines.push(Line::Prose("Manage your app in a Flockfile".to_owned()));
334        lines.push(Line::Prose(format!(
335            "Add as many apps as you would like using {} syntax",
336            syntax.label
337        )));
338        lines.push(Line::Blank);
339    }
340    for line in syntax.open {
341        lines.push(Line::Code((*line).to_owned()));
342    }
343
344    for (index, name) in names.iter().enumerate() {
345        let field = props
346            .get(name)
347            .unwrap_or_else(|| panic!("`{name}` is not a field of AppConfig"));
348
349        if syntax.marker.is_some() {
350            for line in blurb(name, field).lines() {
351                lines.push(Line::Prose(line.to_owned()));
352            }
353        }
354
355        let last = index + 1 == names.len();
356        let comma = if last && !syntax.trailing_sep {
357            ""
358        } else {
359            syntax.member_sep
360        };
361        let key = if syntax.quoted_keys {
362            format!("\"{name}\"")
363        } else {
364            name.clone()
365        };
366        lines.push(Line::Code(format!(
367            "{}{key}{}{}{comma}",
368            syntax.indent,
369            syntax.separator,
370            literal(syntax, field),
371        )));
372    }
373
374    for line in syntax.close {
375        lines.push(Line::Code((*line).to_owned()));
376    }
377    lines
378}
379
380/// Puts `syntax`'s comment marker on, and nothing else.
381///
382/// Prose gets the marker and a space; code gets the marker alone. A format
383/// with no marker drops prose entirely and emits code bare, which is what
384/// makes strict JSON's live document fall out of the same builder rather
385/// than needing one of its own.
386fn render(syntax: &Syntax, lines: &[Line]) -> String {
387    let mut out = String::new();
388    for line in lines {
389        match (syntax.marker, line) {
390            (_, Line::Blank) => {}
391            (None, Line::Prose(_)) => continue,
392            (None, Line::Code(code)) => out.push_str(code),
393            (Some(marker), Line::Prose(text)) => {
394                out.push_str(marker);
395                out.push(' ');
396                out.push_str(text);
397            }
398            // The marker goes AFTER the line's own indentation, not before
399            // it. Prose is "marker then a space" and code is "marker then
400            // content", which is what makes uncommenting mechanical -- but a
401            // nested format indents its code, so a marker written first
402            // would be followed by a space and would read as prose. Putting
403            // the indentation outside keeps the marker glued to real
404            // content at every depth, and uncommenting leaves the
405            // indentation exactly where the document needs it.
406            (Some(marker), Line::Code(code)) => {
407                let content = code.trim_start_matches(' ');
408                let indent = &code[..code.len() - content.len()];
409                out.push_str(indent);
410                out.push_str(marker);
411                out.push_str(content);
412            }
413        }
414        out.push('\n');
415    }
416    out
417}
418
419/// What a field's line should explain.
420///
421/// `init.blurb` and the `///` doc have different readers, and this takes the
422/// blurb. Several of `AppConfig`'s docs cite internal type names and spec
423/// section numbers, which mean nothing to somebody editing a Flockfile, and
424/// they carry em dashes because nothing an operator reads renders them.
425///
426/// # Panics
427/// If `field` has no `init.blurb`. Falling back to the `///` doc is what
428/// this used to do, and it is worse than failing: the operator gets prose
429/// written for somebody reading the source, in a file that otherwise reads
430/// as documentation, and nothing says so.
431/// `tests::every_field_carries_a_group_and_a_blurb` makes this unreachable,
432/// so a panic here means that test was removed rather than that a Flockfile
433/// was odd.
434#[track_caller]
435fn blurb(name: &str, field: &serde_json::Value) -> String {
436    field["init"]
437        .as_object()
438        .and_then(|init| init.get("blurb"))
439        .and_then(serde_json::Value::as_str)
440        .unwrap_or_else(|| panic!("`{name}` has no `init.blurb`; add one in config/app.rs"))
441        .to_owned()
442}
443
444/// A field's placeholder value, written the way `syntax` spells literals.
445///
446/// A field's schema `default` is only usable when it is both present and
447/// non-empty: `Option<T>` fields serialize their `None` as `null`, but a
448/// required `String` field still gets a `default` from `#[serde(default)]`
449/// at the struct level, holding `String::new()`. That empty string is not a
450/// value anyone would want uncommented, so it is treated the same as no
451/// default at all, and both fall through to `init.example`.
452fn literal(syntax: &Syntax, field: &serde_json::Value) -> String {
453    let has_no_real_default = field["default"].is_null() || field["default"].as_str() == Some("");
454    let value = if has_no_real_default {
455        field["init"]
456            .as_object()
457            .and_then(|init| init.get("example"))
458            .cloned()
459            .unwrap_or_else(|| serde_json::Value::String(String::new()))
460    } else {
461        field["default"].clone()
462    };
463
464    // JSON's literal grammar is a subset of YAML's and of JSON5's, so one
465    // rendering serves three of the four formats. TOML is the odd one:
466    // `toml::Value`'s Display is what knows to write an array inline and a
467    // string with TOML's own escaping.
468    if syntax.separator == " = " {
469        toml::Value::try_from(&value)
470            .expect("a schema example must be representable as TOML")
471            .to_string()
472    } else {
473        serde_json::to_string(&value).expect("a serde_json value re-serializes")
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::config::{Flockfile, FlockfileError};
481
482    /// The three formats whose scaffold is a commented template.
483    const COMMENTED: [FlockFormat; 3] = [FlockFormat::Toml, FlockFormat::Yaml, FlockFormat::Json5];
484    const DEPTHS: [Depth; 2] = [Depth::Curated, Depth::All];
485
486    /// Strips `marker` from exactly the lines whose next character is not a
487    /// space, which is what a reader does by hand.
488    ///
489    /// The marker sits after any indentation, so this has to look past the
490    /// leading spaces to find it and then put them back.
491    fn uncomment(text: &str, marker: &str) -> String {
492        text.lines()
493            .map(|line| {
494                let trimmed = line.trim_start_matches(' ');
495                let indent = &line[..line.len() - trimmed.len()];
496                match trimmed.strip_prefix(marker) {
497                    Some(rest) if !rest.starts_with(' ') => format!("{indent}{rest}"),
498                    _ => line.to_owned(),
499                }
500            })
501            .collect::<Vec<_>>()
502            .join("\n")
503    }
504
505    fn marker_of(format: FlockFormat) -> &'static str {
506        Syntax::of(format)
507            .marker
508            .expect("a commented format has a marker")
509    }
510
511    #[test]
512    fn every_commented_format_uncomments_into_a_working_flockfile() {
513        for format in COMMENTED {
514            for depth in DEPTHS {
515                let scaffold = Scaffold::new(format, depth).build().expect("builds");
516                let live = uncomment(&scaffold, marker_of(format));
517
518                let parsed = Flockfile::parse(&live, format).unwrap_or_else(|err| {
519                    panic!(
520                        "the uncommented {format:?} scaffold at {depth:?} must parse: {err}\n\
521                         --- what was parsed ---\n{live}"
522                    )
523                });
524
525                assert_eq!(parsed.apps.len(), 1, "{format:?}/{depth:?}:\n{live}");
526                assert!(
527                    !parsed.apps[0].name.is_empty(),
528                    "{format:?}/{depth:?} needs a name"
529                );
530                assert!(
531                    !parsed.apps[0].script.is_empty(),
532                    "{format:?}/{depth:?} needs a script"
533                );
534            }
535        }
536    }
537
538    #[test]
539    fn a_commented_scaffold_never_declares_an_app_until_somebody_uncomments_it() {
540        // The file shep writes is a template, not a running configuration,
541        // so none of these may hand back an app. HOW they decline differs by
542        // language and the difference is real rather than a wart worth
543        // hiding: TOML and YAML both read a comments-only file as an empty
544        // document, so they parse and find nothing. JSON5 requires a value,
545        // and a file that is entirely comments does not contain one, so it
546        // refuses at the parser.
547        //
548        // Both readings say the same thing to an operator who ran `shep
549        // start` on a template they had not filled in yet, which is the only
550        // way anybody meets this.
551        for format in COMMENTED {
552            let scaffold = Scaffold::new(format, Depth::Curated)
553                .build()
554                .expect("builds");
555            match Flockfile::parse(&scaffold, format) {
556                Err(FlockfileError::NoApps) => {
557                    assert_ne!(
558                        format,
559                        FlockFormat::Json5,
560                        "json5 cannot parse a valueless file"
561                    );
562                }
563                Err(_) => assert_eq!(
564                    format,
565                    FlockFormat::Json5,
566                    "only json5 refuses a comments-only file at the parser:\n{scaffold}"
567                ),
568                Ok(flock) => panic!(
569                    "{format:?} handed back {} apps from a template nobody has \
570                     uncommented:\n{scaffold}",
571                    flock.apps.len()
572                ),
573            }
574        }
575    }
576
577    #[test]
578    fn the_json_scaffold_is_live_because_json_cannot_carry_guidance() {
579        let scaffold = Scaffold::new(FlockFormat::Json, Depth::Curated)
580            .build()
581            .expect("json builds at the curated depth");
582
583        let parsed = Flockfile::parse(&scaffold, FlockFormat::Json)
584            .unwrap_or_else(|err| panic!("the json scaffold parses as written: {err}\n{scaffold}"));
585        assert_eq!(parsed.apps.len(), 1);
586        assert!(!parsed.apps[0].name.is_empty());
587        assert!(!parsed.apps[0].script.is_empty());
588        assert!(!scaffold.contains('#'), "json has no comments to write");
589    }
590
591    #[test]
592    fn json_refuses_the_full_depth_and_points_at_json5() {
593        let err = Scaffold::new(FlockFormat::Json, Depth::All)
594            .build()
595            .expect_err("all forty fields in json would pin every default");
596        let shown = err.to_string();
597        assert!(shown.contains("JSON"), "{shown}");
598        assert!(
599            shown.contains("json5"),
600            "the way out has to be named: {shown}"
601        );
602    }
603
604    #[test]
605    fn the_all_depth_names_every_option_the_schema_knows() {
606        let schema = crate::config::flockfile_schema_json();
607        let props = properties(&schema);
608
609        for format in COMMENTED {
610            let text = Scaffold::new(format, Depth::All).build().expect("builds");
611            let missing: Vec<&String> = props
612                .keys()
613                .filter(|f| !text.contains(f.as_str()))
614                .collect();
615            assert!(
616                missing.is_empty(),
617                "--all must name every option the grammar has; {format:?} is missing {}: {missing:?}",
618                missing.len()
619            );
620        }
621    }
622
623    #[test]
624    fn every_field_carries_a_group_and_a_blurb() {
625        // Without this the gap is invisible: a field with no `group` sorts
626        // after every grouped one and a field with no `blurb` silently falls
627        // back to its `///` doc, which is written for somebody reading the
628        // source. Several of those cite internal type names and spec section
629        // numbers, so the scaffold reads fine right up until the line that
630        // does not.
631        let schema = crate::config::flockfile_schema_json();
632        let props = properties(&schema);
633
634        let mut faults: Vec<String> = Vec::new();
635        for (name, field) in props {
636            let init = field["init"].as_object();
637            let group = init.and_then(|i| i.get("group")).and_then(|g| g.as_str());
638            let blurb = init.and_then(|i| i.get("blurb")).and_then(|b| b.as_str());
639
640            match group {
641                None => faults.push(format!("{name}: no `group`")),
642                Some(group) if !GROUP_ORDER.contains(&group) => {
643                    faults.push(format!(
644                        "{name}: unknown group {group:?}, expected one of {GROUP_ORDER:?}"
645                    ));
646                }
647                Some(_) => {}
648            }
649            match blurb {
650                None => faults.push(format!("{name}: no `blurb`")),
651                Some(blurb) if blurb.trim().is_empty() => {
652                    faults.push(format!("{name}: empty `blurb`"));
653                }
654                // The scaffold puts these in a column of comments, so they
655                // are consistent or they look broken. No dash anywhere a
656                // person reads is a project-wide rule; the missing full stop
657                // is the house style the first five set.
658                Some(blurb) if blurb.contains('\u{2014}') || blurb.contains('\u{2013}') => {
659                    faults.push(format!("{name}: `blurb` has a dash in it"));
660                }
661                Some(blurb) if blurb.trim_end().ends_with('.') => {
662                    faults.push(format!(
663                        "{name}: `blurb` ends with a full stop; the others do not"
664                    ));
665                }
666                Some(_) => {}
667            }
668        }
669
670        assert!(
671            faults.is_empty(),
672            "every AppConfig field needs `init.group` and `init.blurb`, set with \
673             #[cfg_attr(feature = \"schema\", schemars(extend(\"init\" = {{ .. }})))] \
674             in config/app.rs:\n  {}",
675            faults.join("\n  ")
676        );
677    }
678
679    #[test]
680    fn every_curated_field_is_a_real_field() {
681        let schema = crate::config::flockfile_schema_json();
682        let props = properties(&schema);
683        for name in CURATED {
684            assert!(
685                props.contains_key(*name),
686                "`{name}` is not a field of AppConfig"
687            );
688        }
689    }
690
691    #[test]
692    fn the_curated_depth_stays_short() {
693        // The mirror of the drift test above. Nothing else pins that
694        // Curated is SHORT, so a swapped match arm could hand a newcomer all
695        // forty options and no test would notice.
696        for format in COMMENTED {
697            let text = Scaffold::new(format, Depth::Curated)
698                .build()
699                .expect("builds");
700            let schema = crate::config::flockfile_schema_json();
701            let named = properties(&schema)
702                .keys()
703                .filter(|f| text.contains(f.as_str()))
704                .count();
705            assert!(
706                named <= CURATED.len() + 2,
707                "{format:?}'s curated scaffold names {named} fields; it is meant to show {}",
708                CURATED.len()
709            );
710        }
711    }
712}