use crate::config::AppConfig;
use super::{
declared_app::DeclaredApp, error::FlockfileError, format::FlockFormat, parse::parse_into,
raw::RawFlockfile,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Flockfile {
pub apps: Vec<AppConfig>,
}
impl Flockfile {
pub fn parse(source: &str, format: FlockFormat) -> Result<Self, FlockfileError> {
Ok(Self {
apps: parse_nonempty_apps(source, format)?,
})
}
pub fn parse_declared(
text: &str,
format: FlockFormat,
) -> Result<Vec<DeclaredApp>, FlockfileError> {
let apps = parse_nonempty_apps(text, format)?;
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_nonempty_apps(
source: &str,
format: FlockFormat,
) -> Result<Vec<AppConfig>, FlockfileError> {
let RawFlockfile {
schema: _schema,
dog: _dog,
apps,
} = RawFlockfile::new(source, format)?;
if apps.is_empty() {
return Err(FlockfileError::NoApps);
}
Ok(apps)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::flockfile::format::FlockFormat;
#[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 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
);
}
}
#[test]
fn a_misspelled_flockfile_key_is_refused_and_named() {
let err = Flockfile::parse(
"[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\n",
FlockFormat::Toml,
)
.expect_err("a typo must be refused");
let FlockfileError::UnknownKeys { keys } = err else {
panic!("expected UnknownKeys, got {err:?}");
};
assert!(
keys.iter().any(|k| k.contains("max_restrts")),
"got {keys:?}"
);
}
#[test]
fn a_misspelled_key_inside_a_probe_is_also_named() {
let err = Flockfile::parse(
"[[app]]\nname = \"web\"\nscript = \"./srv\"\n[app.readiness_probe]\nkind = \"http\"\ntarget = \"http://localhost/x\"\ntimeuot = \"5s\"\n",
FlockFormat::Toml,
)
.expect_err("a nested typo must be refused");
let FlockfileError::UnknownKeys { keys } = err else {
panic!("expected UnknownKeys, got {err:?}");
};
assert!(
keys.iter()
.any(|key| key.contains("readiness_probe") && key.contains("timeuot")),
"the nested path should name both the probe and the key: {keys:?}"
);
}
#[test]
fn two_misspelled_keys_are_both_named_in_one_error() {
let err = Flockfile::parse(
"[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\ninstences = 2\n",
FlockFormat::Toml,
)
.expect_err("both typos must be refused");
let FlockfileError::UnknownKeys { keys } = err else {
panic!("expected UnknownKeys, got {err:?}");
};
assert!(
keys.iter().any(|k| k.contains("max_restrts")),
"got {keys:?}"
);
assert!(keys.iter().any(|k| k.contains("instences")), "got {keys:?}");
}
}