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