use std::collections::BTreeMap;
use std::path::Path;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const SAMPLE_URL_MANIFEST_CONFIG_KEY: &str = "[crates.e2e.snippets].sample_url_manifest";
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SampleUrlManifestConfig {
pub path: String,
pub variable: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidSampleUrlManifest {
#[error(
"`{SAMPLE_URL_MANIFEST_CONFIG_KEY}.path` is empty; remove the `sample_url_manifest` table to disable \
manifest-backed template variables"
)]
EmptyPath,
#[error(
"`{SAMPLE_URL_MANIFEST_CONFIG_KEY}.variable` is empty; name the template placeholder this manifest's \
values populate"
)]
EmptyVariable,
#[error("`{SAMPLE_URL_MANIFEST_CONFIG_KEY}.path` names `{path}`, which could not be read: {reason}")]
Unreadable { path: String, reason: String },
#[error("`{SAMPLE_URL_MANIFEST_CONFIG_KEY}.path` names `{path}`, which is not a valid manifest: {reason}")]
Malformed { path: String, reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SampleUrlManifest {
variable: String,
values: BTreeMap<String, String>,
}
impl SampleUrlManifest {
pub fn resolve(
config: Option<&SampleUrlManifestConfig>,
project_root: &Path,
) -> Result<Option<Self>, InvalidSampleUrlManifest> {
let Some(config) = config else {
return Ok(None);
};
let path = config.path.trim();
if path.is_empty() {
return Err(InvalidSampleUrlManifest::EmptyPath);
}
let variable = config.variable.trim();
if variable.is_empty() {
return Err(InvalidSampleUrlManifest::EmptyVariable);
}
let full_path = project_root.join(path);
let content = std::fs::read_to_string(&full_path).map_err(|error| InvalidSampleUrlManifest::Unreadable {
path: path.to_string(),
reason: error.to_string(),
})?;
let entries: ManifestEntries =
serde_json::from_str(&content).map_err(|error| InvalidSampleUrlManifest::Malformed {
path: path.to_string(),
reason: error.to_string(),
})?;
Ok(Some(Self {
variable: variable.to_string(),
values: entries.0,
}))
}
pub fn vars_for(&self, body_file: &str) -> Option<BTreeMap<String, String>> {
self.values
.get(body_file)
.map(|value| BTreeMap::from([(self.variable.clone(), value.clone())]))
}
}
pub fn merge_manifest_vars(
manifest: Option<&SampleUrlManifest>,
body_file: Option<&str>,
fixture_vars: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut merged = body_file
.and_then(|body_file| manifest.and_then(|manifest| manifest.vars_for(body_file)))
.unwrap_or_default();
merged.extend(fixture_vars.iter().map(|(key, value)| (key.clone(), value.clone())));
merged
}
struct ManifestEntries(BTreeMap<String, String>);
impl<'de> Deserialize<'de> for ManifestEntries {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct EntriesVisitor;
impl<'de> serde::de::Visitor<'de> for EntriesVisitor {
type Value = ManifestEntries;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a JSON object mapping a corpus-relative path to a string value")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut entries = BTreeMap::new();
while let Some((key, value)) = map.next_entry::<String, String>()? {
if entries.insert(key.clone(), value).is_some() {
return Err(serde::de::Error::custom(format!("duplicate manifest key `{key}`")));
}
}
Ok(ManifestEntries(entries))
}
}
deserializer.deserialize_map(EntriesVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_manifest(directory: &Path, name: &str, content: &str) -> std::path::PathBuf {
let path = directory.join(name);
std::fs::write(&path, content).expect("write manifest fixture");
path
}
fn config(path: &str, variable: &str) -> SampleUrlManifestConfig {
SampleUrlManifestConfig {
path: path.to_string(),
variable: variable.to_string(),
}
}
#[test]
fn an_unconfigured_manifest_resolves_to_none() {
assert_eq!(
SampleUrlManifest::resolve(None, Path::new(".")).expect("no configuration always resolves"),
None
);
}
#[test]
fn a_configured_manifest_resolves_the_variable_it_names_for_a_covered_path() {
let directory = tempfile::tempdir().expect("temp dir");
write_manifest(
directory.path(),
"manifest.json",
r#"{"pdf/memo.pdf": "9f86d081884c7d659a2feaa0c55ad015"}"#,
);
let config = config("manifest.json", "digest");
let manifest = SampleUrlManifest::resolve(Some(&config), directory.path())
.expect("valid manifest resolves")
.expect("a configured value produces a manifest");
assert_eq!(
manifest.vars_for("pdf/memo.pdf"),
Some(BTreeMap::from([(
"digest".to_string(),
"9f86d081884c7d659a2feaa0c55ad015".to_string()
)]))
);
}
#[test]
fn a_path_the_manifest_does_not_cover_resolves_to_none() {
let directory = tempfile::tempdir().expect("temp dir");
write_manifest(
directory.path(),
"manifest.json",
r#"{"pdf/memo.pdf": "9f86d081884c7d659a2feaa0c55ad015"}"#,
);
let config = config("manifest.json", "digest");
let manifest = SampleUrlManifest::resolve(Some(&config), directory.path())
.expect("valid manifest resolves")
.expect("a configured value produces a manifest");
assert_eq!(manifest.vars_for("images/logo.png"), None);
}
#[test]
fn a_missing_manifest_file_errors_naming_the_path() {
let directory = tempfile::tempdir().expect("temp dir");
let config = config("does-not-exist.json", "digest");
let error =
SampleUrlManifest::resolve(Some(&config), directory.path()).expect_err("a missing manifest must fail");
match error {
InvalidSampleUrlManifest::Unreadable { path, reason } => {
assert_eq!(path, "does-not-exist.json", "the error must name the configured path");
assert!(!reason.is_empty(), "the underlying I/O reason must be preserved");
}
other => panic!("expected Unreadable, got {other:?}"),
}
}
#[test]
fn a_manifest_that_is_not_a_json_object_is_rejected() {
let directory = tempfile::tempdir().expect("temp dir");
write_manifest(directory.path(), "manifest.json", r#"["pdf/memo.pdf"]"#);
let config = config("manifest.json", "digest");
let error =
SampleUrlManifest::resolve(Some(&config), directory.path()).expect_err("a non-object manifest must fail");
assert!(matches!(error, InvalidSampleUrlManifest::Malformed { path, .. } if path == "manifest.json"));
}
#[test]
fn a_manifest_entry_with_a_non_string_value_is_rejected() {
let directory = tempfile::tempdir().expect("temp dir");
write_manifest(
directory.path(),
"manifest.json",
r#"{"pdf/memo.pdf": {"digest": "abc"}}"#,
);
let config = config("manifest.json", "digest");
let error = SampleUrlManifest::resolve(Some(&config), directory.path())
.expect_err("a non-string manifest value must fail");
assert!(matches!(error, InvalidSampleUrlManifest::Malformed { path, .. } if path == "manifest.json"));
}
#[test]
fn a_manifest_with_a_duplicate_key_is_rejected() {
let directory = tempfile::tempdir().expect("temp dir");
write_manifest(
directory.path(),
"manifest.json",
r#"{"pdf/memo.pdf": "first", "pdf/memo.pdf": "second"}"#,
);
let config = config("manifest.json", "digest");
let error = SampleUrlManifest::resolve(Some(&config), directory.path()).expect_err("a duplicate key must fail");
assert!(matches!(error, InvalidSampleUrlManifest::Malformed { path, .. } if path == "manifest.json"));
}
#[test]
fn an_empty_path_is_rejected() {
let config = config("", "digest");
assert_eq!(
SampleUrlManifest::resolve(Some(&config), Path::new(".")).expect_err("an empty path cannot resolve"),
InvalidSampleUrlManifest::EmptyPath
);
}
#[test]
fn an_empty_variable_name_is_rejected() {
let config = config("manifest.json", "");
assert_eq!(
SampleUrlManifest::resolve(Some(&config), Path::new("."))
.expect_err("an empty variable name cannot resolve"),
InvalidSampleUrlManifest::EmptyVariable
);
}
fn manifest_with(entries: &[(&str, &str)], variable: &str) -> SampleUrlManifest {
SampleUrlManifest {
variable: variable.to_string(),
values: entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
}
}
#[test]
fn merge_falls_back_to_an_empty_map_with_no_manifest_and_no_fixture_vars() {
assert_eq!(merge_manifest_vars(None, None, &BTreeMap::new()), BTreeMap::new());
}
#[test]
fn merge_uses_the_manifest_value_when_the_fixture_declares_nothing_for_that_key() {
let manifest = manifest_with(&[("pdf/memo.pdf", "abc123")], "digest");
let merged = merge_manifest_vars(Some(&manifest), Some("pdf/memo.pdf"), &BTreeMap::new());
assert_eq!(merged, BTreeMap::from([("digest".to_string(), "abc123".to_string())]));
}
#[test]
fn merge_lets_an_explicit_fixture_var_override_the_manifest_for_the_same_key() {
let manifest = manifest_with(&[("pdf/memo.pdf", "from-manifest")], "digest");
let fixture_vars = BTreeMap::from([("digest".to_string(), "from-fixture".to_string())]);
let merged = merge_manifest_vars(Some(&manifest), Some("pdf/memo.pdf"), &fixture_vars);
assert_eq!(
merged,
BTreeMap::from([("digest".to_string(), "from-fixture".to_string())]),
"an explicit fixture declaration must win over the manifest for the same key"
);
}
#[test]
fn merge_combines_disjoint_manifest_and_fixture_keys() {
let manifest = manifest_with(&[("pdf/memo.pdf", "abc123")], "digest");
let fixture_vars = BTreeMap::from([("region".to_string(), "us-east".to_string())]);
let merged = merge_manifest_vars(Some(&manifest), Some("pdf/memo.pdf"), &fixture_vars);
assert_eq!(
merged,
BTreeMap::from([
("digest".to_string(), "abc123".to_string()),
("region".to_string(), "us-east".to_string()),
])
);
}
}