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