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 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}