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}