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