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}