use core::fmt;
use crate::config::FlockFormat;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Depth {
Curated,
All,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScaffoldError {
NoCommentsForAll(FlockFormat),
}
impl fmt::Display for ScaffoldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoCommentsForAll(format) => write!(
f,
"{} has no comment syntax, so a full scaffold would pin \
every default instead of explaining it; write a .json5 \
Flockfile for the same syntax with comments, or drop --all",
Syntax::of(*format).label
),
}
}
}
impl core::error::Error for ScaffoldError {}
pub const CURATED: &[&str] = &["name", "script", "autorestart", "cwd"];
pub const GROUP_ORDER: &[&str] = &[
"process",
"logging",
"inputs",
"restart",
"readiness",
"shutdown",
"watch",
"cron",
];
#[derive(Debug, Clone, PartialEq, Eq)]
enum Line {
Prose(String),
Code(String),
Blank,
}
struct Syntax {
marker: Option<&'static str>,
label: &'static str,
open: &'static [&'static str],
indent: &'static str,
separator: &'static str,
close: &'static [&'static str],
member_sep: &'static str,
trailing_sep: bool,
quoted_keys: bool,
}
impl Syntax {
const fn of(format: FlockFormat) -> Self {
match format {
FlockFormat::Toml => Self {
marker: Some("#"),
label: "TOML",
open: &["[[app]]"],
indent: "",
separator: " = ",
close: &[],
member_sep: "",
trailing_sep: false,
quoted_keys: false,
},
FlockFormat::Yaml => Self {
marker: Some("#"),
label: "YAML",
open: &["app:", " -"],
indent: " ",
separator: ": ",
close: &[],
member_sep: "",
trailing_sep: false,
quoted_keys: false,
},
FlockFormat::Json5 => Self {
marker: Some("//"),
label: "JSON5",
open: &["{", " app: [", " {"],
indent: " ",
separator: ": ",
close: &[" },", " ],", "}"],
member_sep: ",",
trailing_sep: true,
quoted_keys: false,
},
FlockFormat::Json => Self {
marker: None,
label: "JSON",
open: &["{", " \"app\": [", " {"],
indent: " ",
separator: ": ",
close: &[" }", " ]", "}"],
member_sep: ",",
trailing_sep: false,
quoted_keys: true,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scaffold {
format: FlockFormat,
depth: Depth,
}
impl Scaffold {
#[must_use]
pub const fn new(format: FlockFormat, depth: Depth) -> Self {
Self { format, depth }
}
#[track_caller]
pub fn build(self) -> Result<String, ScaffoldError> {
let syntax = Syntax::of(self.format);
if syntax.marker.is_none() && self.depth == Depth::All {
return Err(ScaffoldError::NoCommentsForAll(self.format));
}
Ok(render(&syntax, &document(&syntax, &self.field_names())))
}
fn field_names(self) -> Vec<String> {
match self.depth {
Depth::Curated => CURATED.iter().map(|name| (*name).to_owned()).collect(),
Depth::All => grouped_order(),
}
}
}
fn grouped_order() -> Vec<String> {
let schema = crate::config::flockfile_schema_json();
let props = properties(&schema);
let rank = |name: &str| -> usize {
let group = props[name]["init"]["group"].as_str().unwrap_or_default();
GROUP_ORDER
.iter()
.position(|known| *known == group)
.unwrap_or(GROUP_ORDER.len())
};
let mut rest: Vec<String> = props
.keys()
.filter(|name| !CURATED.contains(&name.as_str()))
.cloned()
.collect();
rest.sort_by_key(|name| rank(name));
let mut names: Vec<String> = CURATED.iter().map(|name| (*name).to_owned()).collect();
names.extend(rest);
names
}
fn properties(schema: &schemars::Schema) -> &serde_json::Map<String, serde_json::Value> {
schema
.pointer("#/$defs/AppConfig/properties")
.expect("app config properties must exist")
.as_object()
.expect("props must be an object")
}
#[track_caller]
fn document(syntax: &Syntax, names: &[String]) -> Vec<Line> {
let schema = crate::config::flockfile_schema_json();
let props = properties(&schema);
let mut lines = Vec::new();
if syntax.marker.is_some() {
lines.push(Line::Prose("Manage your app in a Flockfile".to_owned()));
lines.push(Line::Prose(format!(
"Add as many apps as you would like using {} syntax",
syntax.label
)));
lines.push(Line::Blank);
}
for line in syntax.open {
lines.push(Line::Code((*line).to_owned()));
}
for (index, name) in names.iter().enumerate() {
let field = props
.get(name)
.unwrap_or_else(|| panic!("`{name}` is not a field of AppConfig"));
if syntax.marker.is_some() {
for line in blurb(name, field).lines() {
lines.push(Line::Prose(line.to_owned()));
}
}
let last = index + 1 == names.len();
let comma = if last && !syntax.trailing_sep {
""
} else {
syntax.member_sep
};
let key = if syntax.quoted_keys {
format!("\"{name}\"")
} else {
name.clone()
};
lines.push(Line::Code(format!(
"{}{key}{}{}{comma}",
syntax.indent,
syntax.separator,
literal(syntax, field),
)));
}
for line in syntax.close {
lines.push(Line::Code((*line).to_owned()));
}
lines
}
fn render(syntax: &Syntax, lines: &[Line]) -> String {
let mut out = String::new();
for line in lines {
match (syntax.marker, line) {
(_, Line::Blank) => {}
(None, Line::Prose(_)) => continue,
(None, Line::Code(code)) => out.push_str(code),
(Some(marker), Line::Prose(text)) => {
out.push_str(marker);
out.push(' ');
out.push_str(text);
}
(Some(marker), Line::Code(code)) => {
let content = code.trim_start_matches(' ');
let indent = &code[..code.len() - content.len()];
out.push_str(indent);
out.push_str(marker);
out.push_str(content);
}
}
out.push('\n');
}
out
}
#[track_caller]
fn blurb(name: &str, field: &serde_json::Value) -> String {
field["init"]
.as_object()
.and_then(|init| init.get("blurb"))
.and_then(serde_json::Value::as_str)
.unwrap_or_else(|| panic!("`{name}` has no `init.blurb`; add one in config/app.rs"))
.to_owned()
}
fn literal(syntax: &Syntax, field: &serde_json::Value) -> String {
let has_no_real_default = field["default"].is_null() || field["default"].as_str() == Some("");
let value = if has_no_real_default {
field["init"]
.as_object()
.and_then(|init| init.get("example"))
.cloned()
.unwrap_or_else(|| serde_json::Value::String(String::new()))
} else {
field["default"].clone()
};
if syntax.separator == " = " {
toml::Value::try_from(&value)
.expect("a schema example must be representable as TOML")
.to_string()
} else {
serde_json::to_string(&value).expect("a serde_json value re-serializes")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Flockfile, FlockfileError};
const COMMENTED: [FlockFormat; 3] = [FlockFormat::Toml, FlockFormat::Yaml, FlockFormat::Json5];
const DEPTHS: [Depth; 2] = [Depth::Curated, Depth::All];
fn uncomment(text: &str, marker: &str) -> String {
text.lines()
.map(|line| {
let trimmed = line.trim_start_matches(' ');
let indent = &line[..line.len() - trimmed.len()];
match trimmed.strip_prefix(marker) {
Some(rest) if !rest.starts_with(' ') => format!("{indent}{rest}"),
_ => line.to_owned(),
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn marker_of(format: FlockFormat) -> &'static str {
Syntax::of(format)
.marker
.expect("a commented format has a marker")
}
#[test]
fn every_commented_format_uncomments_into_a_working_flockfile() {
for format in COMMENTED {
for depth in DEPTHS {
let scaffold = Scaffold::new(format, depth).build().expect("builds");
let live = uncomment(&scaffold, marker_of(format));
let parsed = Flockfile::parse(&live, format).unwrap_or_else(|err| {
panic!(
"the uncommented {format:?} scaffold at {depth:?} must parse: {err}\n\
--- what was parsed ---\n{live}"
)
});
assert_eq!(parsed.apps.len(), 1, "{format:?}/{depth:?}:\n{live}");
assert!(
!parsed.apps[0].name.is_empty(),
"{format:?}/{depth:?} needs a name"
);
assert!(
!parsed.apps[0].script.is_empty(),
"{format:?}/{depth:?} needs a script"
);
}
}
}
#[test]
fn a_commented_scaffold_never_declares_an_app_until_somebody_uncomments_it() {
for format in COMMENTED {
let scaffold = Scaffold::new(format, Depth::Curated)
.build()
.expect("builds");
match Flockfile::parse(&scaffold, format) {
Err(FlockfileError::NoApps) => {
assert_ne!(
format,
FlockFormat::Json5,
"json5 cannot parse a valueless file"
);
}
Err(_) => assert_eq!(
format,
FlockFormat::Json5,
"only json5 refuses a comments-only file at the parser:\n{scaffold}"
),
Ok(flock) => panic!(
"{format:?} handed back {} apps from a template nobody has \
uncommented:\n{scaffold}",
flock.apps.len()
),
}
}
}
#[test]
fn the_json_scaffold_is_live_because_json_cannot_carry_guidance() {
let scaffold = Scaffold::new(FlockFormat::Json, Depth::Curated)
.build()
.expect("json builds at the curated depth");
let parsed = Flockfile::parse(&scaffold, FlockFormat::Json)
.unwrap_or_else(|err| panic!("the json scaffold parses as written: {err}\n{scaffold}"));
assert_eq!(parsed.apps.len(), 1);
assert!(!parsed.apps[0].name.is_empty());
assert!(!parsed.apps[0].script.is_empty());
assert!(!scaffold.contains('#'), "json has no comments to write");
}
#[test]
fn json_refuses_the_full_depth_and_points_at_json5() {
let err = Scaffold::new(FlockFormat::Json, Depth::All)
.build()
.expect_err("all forty fields in json would pin every default");
let shown = err.to_string();
assert!(shown.contains("JSON"), "{shown}");
assert!(
shown.contains("json5"),
"the way out has to be named: {shown}"
);
}
#[test]
fn the_all_depth_names_every_option_the_schema_knows() {
let schema = crate::config::flockfile_schema_json();
let props = properties(&schema);
for format in COMMENTED {
let text = Scaffold::new(format, Depth::All).build().expect("builds");
let missing: Vec<&String> = props
.keys()
.filter(|f| !text.contains(f.as_str()))
.collect();
assert!(
missing.is_empty(),
"--all must name every option the grammar has; {format:?} is missing {}: {missing:?}",
missing.len()
);
}
}
#[test]
fn the_all_depth_toml_scaffold_is_eighty_six_lines() {
let text = Scaffold::new(FlockFormat::Toml, Depth::All)
.build()
.expect("builds");
assert_eq!(
text.lines().count(),
86,
"the --all TOML scaffold's line count moved; update this and the \
86-line figure in web/src/pages/docs/first-flockfile.astro"
);
}
#[test]
fn every_field_carries_a_group_and_a_blurb() {
let schema = crate::config::flockfile_schema_json();
let props = properties(&schema);
let mut faults: Vec<String> = Vec::new();
for (name, field) in props {
let init = field["init"].as_object();
let group = init.and_then(|i| i.get("group")).and_then(|g| g.as_str());
let blurb = init.and_then(|i| i.get("blurb")).and_then(|b| b.as_str());
match group {
None => faults.push(format!("{name}: no `group`")),
Some(group) if !GROUP_ORDER.contains(&group) => {
faults.push(format!(
"{name}: unknown group {group:?}, expected one of {GROUP_ORDER:?}"
));
}
Some(_) => {}
}
match blurb {
None => faults.push(format!("{name}: no `blurb`")),
Some(blurb) if blurb.trim().is_empty() => {
faults.push(format!("{name}: empty `blurb`"));
}
Some(blurb) if blurb.contains('\u{2014}') || blurb.contains('\u{2013}') => {
faults.push(format!("{name}: `blurb` has a dash in it"));
}
Some(blurb) if blurb.trim_end().ends_with('.') => {
faults.push(format!(
"{name}: `blurb` ends with a full stop; the others do not"
));
}
Some(_) => {}
}
}
assert!(
faults.is_empty(),
"every AppConfig field needs `init.group` and `init.blurb`, set with \
#[cfg_attr(feature = \"schema\", schemars(extend(\"init\" = {{ .. }})))] \
in config/app.rs:\n {}",
faults.join("\n ")
);
}
#[test]
fn every_curated_field_is_a_real_field() {
let schema = crate::config::flockfile_schema_json();
let props = properties(&schema);
for name in CURATED {
assert!(
props.contains_key(*name),
"`{name}` is not a field of AppConfig"
);
}
}
#[test]
fn the_curated_depth_stays_short() {
for format in COMMENTED {
let text = Scaffold::new(format, Depth::Curated)
.build()
.expect("builds");
let schema = crate::config::flockfile_schema_json();
let named = properties(&schema)
.keys()
.filter(|f| text.contains(f.as_str()))
.count();
assert!(
named <= CURATED.len() + 2,
"{format:?}'s curated scaffold names {named} fields; it is meant to show {}",
CURATED.len()
);
}
}
}