use super::sample_url::has_url_scheme;
use std::collections::BTreeMap;
pub const SAMPLE_URL_TEMPLATE_CONFIG_KEY: &str = "[crates.e2e.snippets].sample_url_template";
pub const SAMPLE_URL_VARS_FIXTURE_KEY: &str = "docs.sample_url_vars";
const PATH_VARIABLE: &str = "path";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidSampleUrlTemplate {
#[error("`{SAMPLE_URL_TEMPLATE_CONFIG_KEY}` is empty; remove the key to disable per-fixture template resolution")]
Empty,
#[error("`{SAMPLE_URL_TEMPLATE_CONFIG_KEY}` must contain no whitespace, got `{value}`")]
Whitespace { value: String },
#[error(
"`{SAMPLE_URL_TEMPLATE_CONFIG_KEY}` must be absolute and name a scheme (e.g. \
`https://cdn.example.org/objects/{{digest}}`), got `{value}`"
)]
Relative { value: String },
#[error("`{SAMPLE_URL_TEMPLATE_CONFIG_KEY}` has an unmatched `{{` with no closing `}}`, got `{value}`")]
UnbalancedBraces { value: String },
#[error("`{SAMPLE_URL_TEMPLATE_CONFIG_KEY}` has an empty `{{}}` placeholder, got `{value}`")]
EmptyPlaceholder { value: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TemplateSegment {
Literal(String),
Placeholder(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SampleUrlTemplate {
raw: String,
segments: Vec<TemplateSegment>,
}
impl SampleUrlTemplate {
pub fn resolve(configured: Option<&str>) -> Result<Option<Self>, InvalidSampleUrlTemplate> {
let Some(value) = configured else {
return Ok(None);
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(InvalidSampleUrlTemplate::Empty);
}
if trimmed.chars().any(char::is_whitespace) {
return Err(InvalidSampleUrlTemplate::Whitespace {
value: trimmed.to_string(),
});
}
if !has_url_scheme(trimmed) {
return Err(InvalidSampleUrlTemplate::Relative {
value: trimmed.to_string(),
});
}
let segments = parse_segments(trimmed)?;
Ok(Some(Self {
raw: trimmed.to_string(),
segments,
}))
}
pub fn render(&self, path: &str, vars: &BTreeMap<String, String>) -> Option<String> {
let mut result = String::with_capacity(self.raw.len());
for segment in &self.segments {
match segment {
TemplateSegment::Literal(text) => result.push_str(text),
TemplateSegment::Placeholder(name) if name == PATH_VARIABLE => result.push_str(path),
TemplateSegment::Placeholder(name) => result.push_str(vars.get(name)?),
}
}
Some(result)
}
pub fn as_str(&self) -> &str {
&self.raw
}
}
fn parse_segments(template: &str) -> Result<Vec<TemplateSegment>, InvalidSampleUrlTemplate> {
let mut segments = Vec::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
if open > 0 {
segments.push(TemplateSegment::Literal(rest[..open].to_string()));
}
let after_open = &rest[open + 1..];
let Some(close) = after_open.find('}') else {
return Err(InvalidSampleUrlTemplate::UnbalancedBraces {
value: template.to_string(),
});
};
let name = &after_open[..close];
if name.is_empty() {
return Err(InvalidSampleUrlTemplate::EmptyPlaceholder {
value: template.to_string(),
});
}
segments.push(TemplateSegment::Placeholder(name.to_string()));
rest = &after_open[close + 1..];
}
if rest.contains('}') {
return Err(InvalidSampleUrlTemplate::UnbalancedBraces {
value: template.to_string(),
});
}
if !rest.is_empty() {
segments.push(TemplateSegment::Literal(rest.to_string()));
}
Ok(segments)
}
pub fn resolve_templated_sample_url(
template: Option<&SampleUrlTemplate>,
path: &str,
vars: &BTreeMap<String, String>,
) -> Option<String> {
template.and_then(|template| template.render(path, vars))
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
#[test]
fn an_unconfigured_template_resolves_to_none() {
assert_eq!(
SampleUrlTemplate::resolve(None).expect("no configuration always resolves"),
None
);
}
#[test]
fn a_configured_template_renders_the_path_placeholder() {
let template = SampleUrlTemplate::resolve(Some("https://cdn.example.org{path}"))
.expect("valid template resolves")
.expect("a configured value produces a template");
assert_eq!(
template.render("/pdf/memo.pdf", &BTreeMap::new()),
Some("https://cdn.example.org/pdf/memo.pdf".to_string())
);
}
#[test]
fn a_content_addressed_template_resolves_from_fixture_vars_alone() {
let template = SampleUrlTemplate::resolve(Some("https://cdn.example.org/objects/{digest}"))
.expect("valid template resolves")
.expect("a configured value produces a template");
assert_eq!(
template.render(
"/pdf/memo.pdf",
&vars(&[("digest", "9f86d081884c7d659a2feaa0c55ad015")])
),
Some("https://cdn.example.org/objects/9f86d081884c7d659a2feaa0c55ad015".to_string())
);
}
#[test]
fn a_template_mixing_path_and_fixture_vars_resolves_both() {
let template = SampleUrlTemplate::resolve(Some("https://cdn.example.org{path}?digest={digest}"))
.expect("valid template resolves")
.expect("a configured value produces a template");
assert_eq!(
template.render("/pdf/memo.pdf", &vars(&[("digest", "abc123")])),
Some("https://cdn.example.org/pdf/memo.pdf?digest=abc123".to_string())
);
}
#[test]
fn rendering_fails_when_a_referenced_variable_is_not_supplied() {
let template = SampleUrlTemplate::resolve(Some("https://cdn.example.org/objects/{digest}"))
.expect("valid template resolves")
.expect("a configured value produces a template");
assert_eq!(template.render("/pdf/memo.pdf", &BTreeMap::new()), None);
}
#[test]
fn resolve_templated_sample_url_falls_back_to_none_with_no_template_configured() {
assert_eq!(
resolve_templated_sample_url(None, "/pdf/memo.pdf", &BTreeMap::new()),
None
);
}
#[test]
fn resolve_templated_sample_url_delegates_to_the_template_when_configured() {
let template = SampleUrlTemplate::resolve(Some("https://cdn.example.org/objects/{digest}"))
.expect("valid template resolves")
.expect("a configured value produces a template");
assert_eq!(
resolve_templated_sample_url(Some(&template), "/pdf/memo.pdf", &vars(&[("digest", "abc123")])),
Some("https://cdn.example.org/objects/abc123".to_string())
);
}
#[test]
fn an_empty_configured_template_is_rejected() {
assert_eq!(
SampleUrlTemplate::resolve(Some(" ")).expect_err("an empty template cannot form a URL"),
InvalidSampleUrlTemplate::Empty
);
}
#[test]
fn a_template_with_whitespace_is_rejected() {
let error = SampleUrlTemplate::resolve(Some("https://cdn.example.org/my objects/{digest}"))
.expect_err("whitespace is invalid");
assert!(matches!(error, InvalidSampleUrlTemplate::Whitespace { .. }));
}
#[test]
fn a_scheme_less_template_is_rejected() {
let error = SampleUrlTemplate::resolve(Some("cdn.example.org/objects/{digest}"))
.expect_err("a relative template is invalid");
assert!(matches!(error, InvalidSampleUrlTemplate::Relative { .. }));
}
#[test]
fn an_unclosed_placeholder_is_rejected() {
let error =
SampleUrlTemplate::resolve(Some("https://cdn.example.org/objects/{digest")).expect_err("unbalanced braces");
assert!(matches!(error, InvalidSampleUrlTemplate::UnbalancedBraces { .. }));
}
#[test]
fn a_stray_closing_brace_is_rejected() {
let error =
SampleUrlTemplate::resolve(Some("https://cdn.example.org/objects/digest}")).expect_err("unbalanced braces");
assert!(matches!(error, InvalidSampleUrlTemplate::UnbalancedBraces { .. }));
}
#[test]
fn an_empty_placeholder_is_rejected() {
let error =
SampleUrlTemplate::resolve(Some("https://cdn.example.org/objects/{}")).expect_err("empty placeholder");
assert!(matches!(error, InvalidSampleUrlTemplate::EmptyPlaceholder { .. }));
}
}