Skip to main content

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}