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