use core::fmt;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[cfg(feature = "schema")]
use schemars::Schema;
use serde::{Deserialize, Serialize};
use crate::config::AppConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Flockfile {
pub apps: Vec<AppConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeclaredApp {
pub config: AppConfig,
pub declared: BTreeSet<String>,
pub declared_env: BTreeSet<String>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(rename = "Flockfile"))]
#[serde(deny_unknown_fields)]
struct RawFlockfile {
#[serde(default, rename = "$schema")]
schema: Option<String>,
#[serde(default)]
#[cfg_attr(
feature = "schema",
schemars(with = "Option<BTreeMap<String, serde_json::Value>>")
)]
dog: Option<BTreeMap<String, serde::de::IgnoredAny>>,
#[serde(default, rename = "app")]
apps: Vec<AppConfig>,
}
#[cfg(feature = "schema")]
pub const COMMITTED: &str = include_str!("../../assets/flockfile.schema.json");
#[cfg(feature = "schema")]
#[allow(dead_code)]
const REGENERATE: &str =
"cargo run --bin shep -- schema > crates/shep-core/assets/flockfile.schema.json";
#[cfg(feature = "schema")]
#[track_caller]
#[must_use]
pub fn flockfile_schema_string() -> String {
let schema = flockfile_schema_json();
let mut rendered =
serde_json::to_string_pretty(&schema).expect("a schemars Schema always serializes");
rendered.push('\n');
rendered
}
#[cfg(feature = "schema")]
#[track_caller]
#[must_use]
pub fn flockfile_schema_json() -> Schema {
schemars::schema_for!(RawFlockfile)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlockFormat {
Toml,
Yaml,
Json,
Json5,
}
impl FlockFormat {
#[must_use]
pub fn from_path(path: &Path) -> Option<Self> {
match path.extension()?.to_str()? {
"toml" => Some(Self::Toml),
"yaml" | "yml" => Some(Self::Yaml),
"json" => Some(Self::Json),
"json5" => Some(Self::Json5),
_ => None,
}
}
}
impl Flockfile {
pub fn parse(source: &str, format: FlockFormat) -> Result<Self, FlockfileError> {
let raw = parse_into::<RawFlockfile>(source, format)?;
let RawFlockfile {
schema: _schema,
dog: _dog,
apps,
} = raw;
if apps.is_empty() {
return Err(FlockfileError::NoApps);
}
Ok(Self { apps })
}
pub fn parse_declared(
text: &str,
format: FlockFormat,
) -> Result<Vec<DeclaredApp>, FlockfileError> {
let raw = parse_into::<RawFlockfile>(text, format)?;
let RawFlockfile {
schema: _schema,
dog: _dog,
apps,
} = raw;
if apps.is_empty() {
return Err(FlockfileError::NoApps);
}
let value = parse_into::<serde_json::Value>(text, format)?;
let tables: Vec<Option<&serde_json::Map<String, serde_json::Value>>> = value
.get("app")
.and_then(serde_json::Value::as_array)
.map(|apps| apps.iter().map(serde_json::Value::as_object).collect())
.unwrap_or_default();
Ok(apps
.into_iter()
.enumerate()
.map(|(index, config)| {
let table = tables.get(index).copied().flatten();
let declared = table
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
let declared_env = table
.and_then(|t| t.get("env"))
.and_then(serde_json::Value::as_object)
.map(|e| e.keys().cloned().collect())
.unwrap_or_default();
DeclaredApp {
config,
declared,
declared_env,
}
})
.collect())
}
}
fn parse_into<T: serde::de::DeserializeOwned>(
source: &str,
format: FlockFormat,
) -> Result<T, FlockfileError> {
match format {
FlockFormat::Toml => {
toml::from_str(source).map_err(|e| FlockfileError::Toml(e.to_string()))
}
FlockFormat::Yaml => {
serde_saphyr::from_str(source).map_err(|e| FlockfileError::Yaml(e.to_string()))
}
FlockFormat::Json => {
serde_json::from_str(source).map_err(|e| FlockfileError::Json(e.to_string()))
}
FlockFormat::Json5 => {
if json5_nesting_depth(source) > MAX_JSON5_NESTING_DEPTH {
return Err(FlockfileError::Json5(
"nesting depth exceeds 64".to_string(),
));
}
json5::from_str(source).map_err(|e| FlockfileError::Json5(e.to_string()))
}
}
}
const MAX_JSON5_NESTING_DEPTH: u32 = 64;
fn json5_nesting_depth(source: &str) -> u32 {
let mut depth: u32 = 0;
let mut max_depth: u32 = 0;
let mut in_string: Option<char> = None;
let mut chars = source.chars().peekable();
while let Some(c) = chars.next() {
if let Some(quote) = in_string {
match c {
'\\' => {
chars.next(); }
q if q == quote => in_string = None,
_ => {}
}
continue;
}
match c {
'/' if chars.peek() == Some(&'/') => {
chars.next(); for c2 in chars.by_ref() {
if c2 == '\n' {
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next(); let mut prev = '\0';
let mut closed = false;
for c2 in chars.by_ref() {
if prev == '*' && c2 == '/' {
closed = true;
break;
}
prev = c2;
}
if !closed {
return u32::MAX; }
}
'"' | '\'' => in_string = Some(c),
'[' | '{' => {
depth = depth.saturating_add(1);
max_depth = max_depth.max(depth);
}
']' | '}' => depth = depth.saturating_sub(1),
_ => {}
}
}
if in_string.is_some() {
return u32::MAX; }
max_depth
}
const DISCOVERY_ORDER: [&str; 10] = [
"Flockfile.toml",
"Flockfile.yaml",
"Flockfile.yml",
"Flockfile.json",
"Flockfile.json5",
"flockfile.toml",
"flockfile.yaml",
"flockfile.yml",
"flockfile.json",
"flockfile.json5",
];
#[must_use]
pub fn discover(dir: &Path) -> Option<PathBuf> {
DISCOVERY_ORDER
.iter()
.map(|name| dir.join(name))
.find(|p| p.is_file())
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FlockfileError {
Toml(String),
Yaml(String),
Json(String),
Json5(String),
NoApps,
}
impl fmt::Display for FlockfileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(m) => write!(f, "invalid TOML Flockfile: {m}"),
Self::Yaml(m) => write!(f, "invalid YAML Flockfile: {m}"),
Self::Json(m) => write!(f, "invalid JSON Flockfile: {m}"),
Self::Json5(m) => write!(f, "invalid JSON5 Flockfile: {m}"),
Self::NoApps => f.write_str("Flockfile declares no apps"),
}
}
}
impl core::error::Error for FlockfileError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toml_array_of_tables() {
let src = r#"
[[app]]
name = "web"
script = "./srv"
[[app]]
name = "worker"
script = "python3"
args = ["job.py"]
"#;
let flock = Flockfile::parse(src, FlockFormat::Toml).unwrap();
assert_eq!(flock.apps.len(), 2);
assert_eq!(flock.apps[1].name, "worker");
}
#[test]
fn json_and_json5_and_yaml() {
let json = r#"{ "app": [{ "name": "web", "script": "./srv" }] }"#;
assert_eq!(
Flockfile::parse(json, FlockFormat::Json)
.unwrap()
.apps
.len(),
1
);
let json5 = r#"{ app: [{ name: "web", script: "./srv" }], /* comment */ }"#;
assert_eq!(
Flockfile::parse(json5, FlockFormat::Json5)
.unwrap()
.apps
.len(),
1
);
let yaml = "app:\n - name: web\n script: ./srv\n";
assert_eq!(
Flockfile::parse(yaml, FlockFormat::Yaml)
.unwrap()
.apps
.len(),
1
);
}
#[test]
fn empty_app_list_is_an_error() {
assert_eq!(
Flockfile::parse("app: []\n", FlockFormat::Yaml).unwrap_err(),
FlockfileError::NoApps
);
}
#[test]
fn parse_errors_carry_the_backend_message() {
match Flockfile::parse("not toml [[", FlockFormat::Toml).unwrap_err() {
FlockfileError::Toml(msg) => assert!(!msg.is_empty()),
other => panic!("expected Toml error, got {other:?}"),
}
}
#[test]
fn format_from_path() {
use std::path::Path;
assert_eq!(
FlockFormat::from_path(Path::new("Flockfile.toml")),
Some(FlockFormat::Toml)
);
assert_eq!(
FlockFormat::from_path(Path::new("f.yml")),
Some(FlockFormat::Yaml)
);
assert_eq!(
FlockFormat::from_path(Path::new("f.json5")),
Some(FlockFormat::Json5)
);
assert_eq!(FlockFormat::from_path(Path::new("f.js")), None);
}
#[test]
fn discover_prefers_toml_then_capitalized() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("flockfile.json"), "{}").unwrap();
std::fs::write(dir.path().join("Flockfile.yaml"), "").unwrap();
assert_eq!(
discover(dir.path()),
Some(dir.path().join("Flockfile.yaml"))
);
std::fs::write(dir.path().join("Flockfile.toml"), "").unwrap();
assert_eq!(
discover(dir.path()),
Some(dir.path().join("Flockfile.toml"))
);
}
#[test]
fn discovery_never_names_a_js_file_and_stays_ten_names() {
assert_eq!(DISCOVERY_ORDER.len(), 10);
for name in DISCOVERY_ORDER {
assert!(
!name.ends_with(".js"),
"{name} would let `shep start` execute a repo's JavaScript"
);
assert!(FlockFormat::from_path(Path::new(name)).is_some());
}
}
#[test]
fn yaml_deep_nesting_is_rejected_without_crashing() {
let deep = "[".repeat(5000);
let result = Flockfile::parse(&deep, FlockFormat::Yaml);
assert!(matches!(result, Err(FlockfileError::Yaml(_))));
}
#[test]
fn yaml_alias_bomb_is_bounded() {
let mut bomb = String::from("a: &a [\"x\",\"x\"]\n");
for i in 1..9 {
bomb.push_str(&format!(
"{c}: &{c} [*{p},*{p}]\n",
c = (b'a' + i) as char,
p = (b'a' + i - 1) as char
));
}
let result = Flockfile::parse(&bomb, FlockFormat::Yaml);
assert!(result.is_err(), "alias bomb must not produce a valid flock");
}
#[test]
fn json5_beyond_max_nesting_depth_is_rejected_without_crashing() {
let src = "[".repeat(5000);
assert_eq!(
Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
FlockfileError::Json5("nesting depth exceeds 64".to_string())
);
}
#[test]
fn json5_nesting_depth_counts_concurrently_open_brackets() {
let nested = format!("{}{}", "[".repeat(10), "]".repeat(10));
assert_eq!(json5_nesting_depth(&nested), 10);
}
#[test]
fn json5_nesting_depth_ignores_brackets_inside_strings() {
let src = r#"{ "a": "[[[[[[[[[[", "b": "esc\"aped [ too" }"#;
assert_eq!(json5_nesting_depth(src), 1); }
#[test]
fn json5_legitimately_nested_doc_still_parses() {
let src = r#"{
app: [{
name: "web",
script: "./srv",
readiness_probe: { kind: "http", target: "http://localhost/x" },
}],
}"#;
let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
assert_eq!(flock.apps.len(), 1);
}
#[test]
fn json5_line_comment_apostrophe_does_not_hide_deep_nesting() {
let src = format!("// don't nest\n{}", "[".repeat(5000));
assert_eq!(
Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
FlockfileError::Json5("nesting depth exceeds 64".to_string())
);
}
#[test]
fn json5_block_comment_apostrophe_does_not_hide_deep_nesting() {
let src = format!("/* it's fine */\n{}", "[".repeat(5000));
assert_eq!(
Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
FlockfileError::Json5("nesting depth exceeds 64".to_string())
);
}
#[test]
fn json5_benign_comment_does_not_undercount_a_real_document() {
let src = r#"{
/* it's the app list */
app: [{
name: "web",
script: "./srv",
readiness_probe: { kind: "http", target: "http://localhost/x" },
}],
}"#;
let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
assert_eq!(flock.apps.len(), 1);
}
#[cfg(feature = "schema")]
fn resolved<'a>(
root: &'a serde_json::Value,
node: &'a serde_json::Value,
) -> &'a serde_json::Value {
match node.get("$ref").and_then(serde_json::Value::as_str) {
Some(r) => {
let name = r
.strip_prefix("#/$defs/")
.expect("every $ref in this schema points into $defs");
&root["$defs"][name]
}
None => node,
}
}
#[cfg(feature = "schema")]
#[test]
fn the_committed_schema_is_current() {
assert_eq!(
flockfile_schema_string(),
COMMITTED,
"crates/shep-core/assets/flockfile.schema.json is stale. Regenerate it:\n {REGENERATE}\n\
A doc-comment edit on AppConfig counts; schemars puts doc comments \
into `description`."
);
}
#[cfg(feature = "schema")]
#[test]
fn the_schema_describes_a_document_not_one_app() {
let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
assert!(schema["properties"]["app"].is_object(), "{schema}");
assert_eq!(schema["properties"]["app"]["type"], "array", "{schema}");
assert!(
schema["properties"]["name"].is_null(),
"root must not be an app: {schema}"
);
assert!(schema["$defs"]["AppConfig"].is_object(), "{schema}");
}
#[cfg(feature = "schema")]
#[test]
fn kill_signal_stays_an_unconstrained_string() {
let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
let field = resolved(
&schema,
&schema["$defs"]["AppConfig"]["properties"]["kill_signal"],
);
let types = field["type"]
.as_array()
.unwrap_or_else(|| panic!("kill_signal must carry a type array: {field}"));
assert!(
types.iter().any(|t| t == "string"),
"kill_signal must accept a string: {field}"
);
assert!(
field.get("enum").is_none(),
"kill_signal must not become an enum of the four signal names: {field}"
);
assert!(
field.get("pattern").is_none(),
"kill_signal must not become pattern-constrained: {field}"
);
}
#[cfg(feature = "schema")]
#[test]
fn duration_and_memory_fields_are_string_shaped() {
let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
let app = &schema["$defs"]["AppConfig"]["properties"];
let min_uptime = resolved(&schema, &app["min_uptime"]);
assert_eq!(min_uptime["type"], "string", "{min_uptime}");
assert_eq!(min_uptime["pattern"], r"^\d+(ms|h|m|s)?$", "{min_uptime}");
let any_of = app["max_memory"]["anyOf"]
.as_array()
.unwrap_or_else(|| panic!("max_memory must be anyOf: {}", app["max_memory"]));
let ref_node = any_of
.iter()
.find(|v| v.get("$ref").is_some())
.unwrap_or_else(|| panic!("max_memory's anyOf must carry a $ref: {any_of:?}"));
let max_memory = resolved(&schema, ref_node);
assert_eq!(max_memory["type"], "string", "{max_memory}");
assert_eq!(max_memory["pattern"], r"^\d+(G|M|K)?$", "{max_memory}");
}
#[test]
fn a_dog_table_is_accepted_and_ignored() {
let src = r#"
[dog.deploy]
command = "npm run build"
artifacts = ["dist/app.js"]
[dog.some-other-dog]
anything = { nested = true, count = 3 }
[[app]]
name = "web"
script = "./srv"
"#;
let flock =
Flockfile::parse(src, FlockFormat::Toml).expect("a dog's table is not an error");
assert_eq!(flock.apps.len(), 1);
assert_eq!(flock.apps[0].name, "web");
}
#[test]
fn a_dog_that_is_not_a_table_is_refused() {
for value in ["5", "\"nope\"", "[1, 2]", "true"] {
let src = format!("dog = {value}\n\n[[app]]\nname = \"web\"\nscript = \"./srv\"\n");
assert!(
Flockfile::parse(&src, FlockFormat::Toml).is_err(),
"`dog = {value}` is not a table and must be refused"
);
}
}
#[test]
fn a_key_that_is_not_dog_still_fails() {
let src = r#"
[build]
command = "npm run build"
[[app]]
name = "web"
script = "./srv"
"#;
let err = Flockfile::parse(src, FlockFormat::Toml)
.expect_err("an unknown top-level key must still be refused");
assert!(
format!("{err}").contains("build"),
"the refusal must name the key: {err}"
);
}
#[test]
fn a_schema_key_is_accepted_and_ignored() {
let src = r#"{ "$schema": "./flockfile.schema.json",
"app": [{ "name": "web", "script": "./srv" }] }"#;
let flock = Flockfile::parse(src, FlockFormat::Json).unwrap();
assert_eq!(flock.apps.len(), 1);
}
#[test]
fn one_more_key_is_legal_and_no_others_are() {
let src = r#"{ "schema": "x", "app": [{ "name": "w", "script": "./s" }] }"#;
assert!(
matches!(
Flockfile::parse(src, FlockFormat::Json),
Err(FlockfileError::Json(_))
),
"bare `schema` (no $) must still be an unknown field"
);
}
#[test]
fn a_toml_flockfile_takes_the_key_too() {
let src = "\"$schema\" = \"./flockfile.schema.json\"\n\
[[app]]\nname = \"web\"\nscript = \"./srv\"\n";
assert_eq!(
Flockfile::parse(src, FlockFormat::Toml).unwrap().apps.len(),
1
);
}
#[test]
fn declared_reports_keys_the_document_wrote_even_at_their_default() {
let text = r#"
[[app]]
name = "web"
script = "./srv"
autorestart = true
"#;
let apps = Flockfile::parse_declared(text, FlockFormat::Toml).unwrap();
assert_eq!(apps.len(), 1);
let declared = &apps[0].declared;
assert!(declared.contains("autorestart"), "declared: {declared:?}");
assert!(declared.contains("name"));
assert!(declared.contains("script"));
assert!(
!declared.contains("max_memory"),
"a key nobody wrote is not declared"
);
assert_eq!(declared.len(), 3);
}
#[test]
fn declared_env_reports_the_keys_inside_the_env_table() {
let text = r#"
[[app]]
name = "web"
script = "./srv"
env = { DB_HOST = "", NODE_ENV = "production" }
"#;
let apps = Flockfile::parse_declared(text, FlockFormat::Toml).unwrap();
assert_eq!(
apps[0].declared_env.iter().collect::<Vec<_>>(),
vec!["DB_HOST", "NODE_ENV"]
);
assert!(apps[0].declared.contains("env"));
}
#[test]
fn declared_survives_every_parse_format() {
let cases: [(FlockFormat, &str); 4] = [
(
FlockFormat::Toml,
"[[app]]\nname = \"web\"\nscript = \"./srv\"\nautorestart = true\n",
),
(
FlockFormat::Yaml,
"app:\n - name: web\n script: ./srv\n autorestart: true\n",
),
(
FlockFormat::Json,
r#"{"app":[{"name":"web","script":"./srv","autorestart":true}]}"#,
),
(
FlockFormat::Json5,
"{ app: [{ name: \"web\", script: \"./srv\", autorestart: true }] }",
),
];
for (format, text) in cases {
let apps = Flockfile::parse_declared(text, format)
.unwrap_or_else(|e| panic!("{format:?} failed to parse: {e}"));
assert!(
apps[0].declared.contains("autorestart"),
"{format:?}: declared {:?}",
apps[0].declared
);
}
}
}