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