use std::collections::BTreeMap;
use serde::Deserialize;
use crate::config::AppConfig;
use super::{error::FlockfileError, format::FlockFormat, parse::parse_into_ignoring};
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(rename = "Flockfile"))]
#[serde(deny_unknown_fields)]
pub(super) struct RawFlockfile {
#[serde(default, rename = "$schema")]
pub(super) schema: Option<String>,
#[serde(default, deserialize_with = "deserialize_dog_table")]
#[cfg_attr(
feature = "schema",
schemars(with = "BTreeMap<String, serde_json::Value>")
)]
pub(super) dog: BTreeMap<String, serde::de::IgnoredAny>,
#[serde(default, rename = "app")]
pub(super) apps: Vec<AppConfig>,
}
fn deserialize_dog_table<'de, D>(
deserializer: D,
) -> Result<BTreeMap<String, serde::de::IgnoredAny>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct DogTableVisitor;
impl<'de> serde::de::Visitor<'de> for DogTableVisitor {
type Value = BTreeMap<String, serde::de::IgnoredAny>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a table")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut out = BTreeMap::new();
while let Some((key, value)) = map.next_entry()? {
out.insert(key, value);
}
Ok(out)
}
}
deserializer.deserialize_any(DogTableVisitor)
}
impl RawFlockfile {
pub(super) fn new(source: &str, format: FlockFormat) -> Result<Self, FlockfileError> {
let mut unknown = Vec::new();
let raw: RawFlockfile = parse_into_ignoring(source, format, |path| {
if !path.starts_with("dog.") {
unknown.push(path.to_string());
}
})?;
if !unknown.is_empty() {
return Err(FlockfileError::UnknownKeys { keys: unknown });
}
Ok(raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::flockfile::file::Flockfile;
use crate::config::flockfile::format::FlockFormat;
#[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_dog_that_is_null_is_refused_in_json() {
let json = r#"{ "dog": null, "app": [{ "name": "web", "script": "./srv" }] }"#;
assert!(
Flockfile::parse(json, FlockFormat::Json).is_err(),
"`dog: null` is not a table and must be refused in JSON"
);
}
#[test]
fn a_dog_that_is_null_is_refused_in_yaml() {
let yaml = "dog: null\napp:\n - name: web\n script: ./srv\n";
assert!(
Flockfile::parse(yaml, FlockFormat::Yaml).is_err(),
"`dog: null` is not a table and must be refused in YAML"
);
}
#[test]
fn a_dog_that_is_null_is_refused_in_json5() {
let json5 = "{ dog: null, app: [{ name: 'web', script: './srv' }] }";
assert!(
Flockfile::parse(json5, FlockFormat::Json5).is_err(),
"`dog: null` is not a table and must be refused in JSON5"
);
}
#[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
);
}
}