use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::ids::is_kebab;
use crate::stages::{WorldTime, WorldWeather};
pub const CONCEPT_DIR: &str = "concept";
pub const REFERENCE_DIR: &str = "reference";
pub const REFERENCE_DIRS: [&str; 2] = [CONCEPT_DIR, REFERENCE_DIR];
pub const IMAGE_EXTENSIONS: [&str; 4] = ["jpeg", "jpg", "png", "webp"];
#[must_use]
pub fn is_image_extension(ext: &str) -> bool {
IMAGE_EXTENSIONS.contains(&ext)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DesignContent {
#[schemars(length(min = 1))]
pub references: Vec<Reference>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Reference {
pub name: String,
pub shows: String,
pub time: WorldTime,
pub weather: WorldWeather,
}
impl Reference {
#[must_use]
pub fn is_valid_name(&self) -> bool {
REFERENCE_DIRS.iter().any(|dir| {
self.name
.strip_prefix(dir)
.and_then(|r| r.strip_prefix('/'))
.is_some_and(is_kebab)
})
}
#[must_use]
pub fn name_form() -> String {
REFERENCE_DIRS
.iter()
.map(|d| format!("`{d}/<kebab>`"))
.collect::<Vec<_>>()
.join(" or ")
}
#[must_use]
pub fn directory(&self) -> Option<&'static str> {
REFERENCE_DIRS
.iter()
.copied()
.find(|dir| self.name.starts_with(&format!("{dir}/")))
}
}
pub fn check(c: &crate::envelope::Campaign, d: &mut Vec<crate::diagnostic::Diagnostic>) {
use crate::diagnostic::{Diagnostic, codes};
let Some(env) = &c.design else {
return;
};
let rows = &env.content.references;
if rows.is_empty() {
d.push(Diagnostic::error(
codes::SCHEMA,
crate::envelope::Stage::Design.name(),
"/content/references",
format!(
"`references` is empty. The design record's schema requires at least one row \
(`minItems: 1`): a record of nothing is not a record, and it is not how a \
campaign says it has approved no design — that campaign ships no `design.json` \
at all. Either AUTHOR the row for an approved image, naming it and the sky it \
was drawn under (`delvec schema --stage {stage}`), or DELETE `design.json`.",
stage = crate::envelope::Stage::Design.name(),
),
));
}
let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for (i, r) in rows.iter().enumerate() {
if !r.is_valid_name() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
crate::envelope::Stage::Design.name(),
format!("/content/references/{i}/name"),
format!(
"reference name `{name}` is not a path this record can resolve. A name is the \
image's stem relative to `design/`, under one of the two directories an \
approved image lives in: {form}. `{concept}/` holds a picture of one scene \
and `{reference}/` a view of the whole map; nothing else under `design/` is \
an approved image. Write the name without its extension — the row resolves \
against the directory, so it names the picture rather than one encoding of \
it.",
name = r.name,
form = Reference::name_form(),
concept = CONCEPT_DIR,
reference = REFERENCE_DIR,
),
));
}
if let Some(first) = seen.get(r.name.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
crate::envelope::Stage::Design.name(),
format!("/content/references/{i}/name"),
format!(
"reference name `{name}` is already used by row {first}. One approved image \
has one row: two rows for one picture are two answers to the question this \
record exists to answer — which sky was that image approved under. DELETE \
the duplicate row, or CORRECT its `name` to the image it is really about.",
name = r.name,
),
));
} else {
seen.insert(r.name.as_str(), i);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(name: &str) -> Reference {
Reference {
name: name.to_string(),
shows: "a thing".into(),
time: WorldTime::Night,
weather: WorldWeather::Clear,
}
}
#[test]
fn a_name_is_one_of_two_directories_and_a_kebab_stem() {
assert!(row("concept/shore-far").is_valid_name());
assert!(row("reference/whole-map-1").is_valid_name());
assert!(!row("shore-far").is_valid_name());
assert!(!row("sketches/shore-far").is_valid_name());
assert!(!row("concept/Shore_Far").is_valid_name());
assert!(!row("concept/shore/far").is_valid_name());
assert!(!row("concept/").is_valid_name());
}
#[test]
fn the_directory_is_read_off_the_name() {
assert_eq!(row("concept/a").directory(), Some("concept"));
assert_eq!(row("reference/a").directory(), Some("reference"));
assert_eq!(row("nope/a").directory(), None);
}
#[test]
fn only_image_extensions_are_images() {
for ext in IMAGE_EXTENSIONS {
assert!(is_image_extension(ext));
}
assert!(!is_image_extension("json"));
assert!(!is_image_extension("md"));
assert!(!is_image_extension("PNG"), "the caller lowercases");
}
}