use memstead_schema::{PUBLISHED_MEM_FORMAT, PublishedMemConfig};
use regex::Regex;
use std::sync::OnceLock;
use super::ValidationError;
const LEGACY_AUTHOR_FIELDS: &[&str] = &[
"writeGuidance",
"mediums",
"projections",
"rules",
"publish",
"readMems",
"vcs",
"language",
"community",
"defaultSchema",
];
pub fn parse_config_bytes(bytes: &[u8]) -> Result<PublishedMemConfig, ValidationError> {
let value: serde_json::Value =
serde_json::from_slice(bytes).map_err(|e| ValidationError::InvalidConfig {
reason: format!("malformed JSON: {e}"),
})?;
if let Some(obj) = value.as_object() {
for &field in LEGACY_AUTHOR_FIELDS {
if obj.contains_key(field) {
return Err(ValidationError::InvalidConfig {
reason: "legacy mem format — re-export via `memstead export`".to_string(),
});
}
}
} else {
return Err(ValidationError::InvalidConfig {
reason: "expected a JSON object".to_string(),
});
}
let config: PublishedMemConfig =
serde_json::from_value(value).map_err(|e| ValidationError::InvalidConfig {
reason: e.to_string(),
})?;
check_format(&config)?;
check_name(&config.name)?;
check_version(&config.version)?;
Ok(config)
}
fn check_format(config: &PublishedMemConfig) -> Result<(), ValidationError> {
if memstead_schema::published_format_accepted(config.format) {
return Ok(());
}
if config.format == 2 {
return Err(ValidationError::InvalidConfig {
reason: "legacy mem format (format: 2) — re-export via `memstead export`".to_string(),
});
}
Err(ValidationError::UnsupportedFormat {
got: config.format,
expected: PUBLISHED_MEM_FORMAT,
})
}
fn name_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$").unwrap())
}
fn check_name(name: &str) -> Result<(), ValidationError> {
if !name_regex().is_match(name) {
return Err(ValidationError::InvalidName {
reason: format!("name must match ^[a-z0-9][a-z0-9-]{{0,62}}[a-z0-9]$, got {name:?}"),
});
}
Ok(())
}
const MAX_VERSION_PRE_BUILD: usize = 128;
fn check_version(version: &semver::Version) -> Result<(), ValidationError> {
let pre_len = version.pre.as_str().len();
let build_len = version.build.as_str().len();
if pre_len + build_len > MAX_VERSION_PRE_BUILD {
return Err(ValidationError::InvalidVersion {
reason: format!(
"pre-release + build metadata length {} exceeds cap {}",
pre_len + build_len,
MAX_VERSION_PRE_BUILD
),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ok_config() -> serde_json::Value {
serde_json::json!({
"format": PUBLISHED_MEM_FORMAT,
"name": "example-mem",
"version": "0.1.0",
"schema": "default@1.0.0",
})
}
fn parse(value: serde_json::Value) -> Result<PublishedMemConfig, ValidationError> {
parse_config_bytes(value.to_string().as_bytes())
}
#[test]
fn accepts_minimal_valid_config() {
let config = parse(ok_config()).unwrap();
assert_eq!(config.format, PUBLISHED_MEM_FORMAT);
assert_eq!(config.name, "example-mem");
assert_eq!(config.version.to_string(), "0.1.0");
}
#[test]
fn rejects_malformed_json() {
let err = parse_config_bytes(b"not json").unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_non_object_root() {
let err = parse(serde_json::json!([])).unwrap_err();
assert!(
matches!(err, ValidationError::InvalidConfig { reason } if reason.contains("object"))
);
}
#[test]
fn rejects_legacy_author_fields_with_actionable_message() {
let mut v = ok_config();
v["writeGuidance"] = serde_json::json!({});
let err = parse(v).unwrap_err();
match err {
ValidationError::InvalidConfig { reason } => {
assert!(reason.contains("legacy mem format"), "reason={reason}");
}
other => panic!("expected InvalidConfig, got {other:?}"),
}
}
#[test]
fn rejects_unknown_top_level_key() {
let mut v = ok_config();
v["unexpected"] = serde_json::json!(42);
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_missing_format() {
let mut v = ok_config();
v.as_object_mut().unwrap().remove("format");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_forged_first_party_origin_claim() {
let mut v = ok_config();
v["origin"] = serde_json::json!("first-party");
let err = parse(v).unwrap_err();
assert!(
matches!(err, ValidationError::InvalidConfig { .. }),
"a forged first-party origin claim must be refused with a typed InvalidConfig, got {err:?}"
);
}
#[test]
fn rejects_wrong_format_version() {
let mut v = ok_config();
v["format"] = serde_json::json!(1);
let err = parse(v).unwrap_err();
assert!(
matches!(
err,
ValidationError::UnsupportedFormat {
got: 1,
expected: _
}
),
"unexpected err: {err:?}"
);
}
#[test]
fn rejects_format_2_with_actionable_reexport_hint() {
let mut v = ok_config();
v["format"] = serde_json::json!(2);
let err = parse(v).unwrap_err();
match err {
ValidationError::InvalidConfig { reason } => {
assert!(
reason.contains("format: 2") && reason.contains("memstead export"),
"reason={reason}"
);
}
other => panic!("expected InvalidConfig with re-export hint, got {other:?}"),
}
}
#[test]
fn rejects_name_uppercase() {
let mut v = ok_config();
v["name"] = serde_json::json!("Upper-Case");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidName { .. }));
}
#[test]
fn rejects_name_with_space() {
let mut v = ok_config();
v["name"] = serde_json::json!("has space");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidName { .. }));
}
#[test]
fn rejects_name_leading_hyphen() {
let mut v = ok_config();
v["name"] = serde_json::json!("-leading");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidName { .. }));
}
#[test]
fn rejects_name_trailing_hyphen() {
let mut v = ok_config();
v["name"] = serde_json::json!("trailing-");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidName { .. }));
}
#[test]
fn rejects_name_too_long() {
let mut v = ok_config();
v["name"] = serde_json::json!("a".repeat(65));
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidName { .. }));
}
#[test]
fn rejects_name_with_path_separator() {
let mut v = ok_config();
v["name"] = serde_json::json!("scope/name");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidName { .. }));
}
#[test]
fn rejects_name_with_at_prefix() {
let mut v = ok_config();
v["name"] = serde_json::json!("@scope");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidName { .. }));
}
#[test]
fn rejects_missing_schema() {
let mut v = ok_config();
v.as_object_mut().unwrap().remove("schema");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_schema_range_syntax() {
let mut v = ok_config();
v["schema"] = serde_json::json!("default@^1.0.0");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_schema_without_version() {
let mut v = ok_config();
v["schema"] = serde_json::json!("default");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_legacy_types_field() {
let mut v = ok_config();
v["types"] = serde_json::json!(["spec"]);
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_invalid_semver() {
let mut v = ok_config();
v["version"] = serde_json::json!("not-a-version");
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidConfig { .. }));
}
#[test]
fn rejects_pre_release_plus_build_too_long() {
let mut v = ok_config();
let long = "a".repeat(130);
v["version"] = serde_json::json!(format!("0.1.0-{long}"));
let err = parse(v).unwrap_err();
assert!(matches!(err, ValidationError::InvalidVersion { .. }));
}
#[test]
fn format_three_and_four_accepted_older_and_unknown_refused() {
let mut v3 = ok_config();
v3["format"] = serde_json::json!(3);
let parsed = parse(v3).expect("format 3 accepted");
assert_eq!(parsed.title, None);
assert_eq!(parsed.subject, None);
let mut v4 = ok_config();
v4["title"] = serde_json::json!("Einrichtungsbezogene Impfpflicht Deutschland");
v4["subject"] = serde_json::json!({
"scope": "Die Impfpflicht in Einrichtungen",
"method": "Primärquellen, händisch geprüft",
"exclusions": ["Länderverordnungen nach 2023", "Presseberichte"],
});
let parsed = parse(v4).expect("format 4 accepted");
assert_eq!(
parsed.title.as_deref(),
Some("Einrichtungsbezogene Impfpflicht Deutschland")
);
let subject = parsed.subject.expect("subject parsed");
assert_eq!(subject.scope, "Die Impfpflicht in Einrichtungen");
assert_eq!(
subject.method.as_deref(),
Some("Primärquellen, händisch geprüft")
);
assert_eq!(
subject.exclusions,
vec!["Länderverordnungen nach 2023", "Presseberichte"]
);
let mut v2 = ok_config();
v2["format"] = serde_json::json!(2);
let err = parse(v2).unwrap_err();
assert!(
matches!(err, ValidationError::InvalidConfig { ref reason } if reason.contains("format: 2")),
"format 2 keeps its actionable refusal: {err:?}"
);
let mut v1 = ok_config();
v1["format"] = serde_json::json!(1);
assert!(matches!(
parse(v1).unwrap_err(),
ValidationError::UnsupportedFormat { got: 1, .. }
));
let mut v5 = ok_config();
v5["format"] = serde_json::json!(5);
assert!(matches!(
parse(v5).unwrap_err(),
ValidationError::UnsupportedFormat { got: 5, .. }
));
let mut smuggle = ok_config();
smuggle["writeGuidance"] = serde_json::json!({"note": "leak"});
assert!(matches!(
parse(smuggle).unwrap_err(),
ValidationError::InvalidConfig { .. }
));
}
}