#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CountUnit {
P,
W,
C,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CountSpec {
pub n: u32,
pub unit: Option<CountUnit>,
}
pub fn parse_count(input: &str) -> Result<CountSpec, String> {
if input.is_empty() {
return Err(format!(
"invalid --sample-count '{input}': expected N or N{{p|w|c}}"
));
}
let bytes = input.as_bytes();
let last = *bytes.last().unwrap();
let (digits, unit) = if last.is_ascii_digit() {
(input, None)
} else {
let unit = match last {
b'p' => CountUnit::P,
b'w' => CountUnit::W,
b'c' => CountUnit::C,
_ => {
return Err(format!(
"invalid --sample-count '{input}': expected N or N{{p|w|c}}"
));
}
};
(&input[..input.len() - 1], Some(unit))
};
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"invalid --sample-count '{input}': expected N or N{{p|w|c}}"
));
}
let n: u32 = digits
.parse()
.map_err(|_| format!("invalid --sample-count '{input}': number out of range"))?;
Ok(CountSpec { n, unit })
}
use std::collections::HashMap;
pub fn expand_template(
template: &str,
vars: &HashMap<&str, String>,
) -> Result<String, String> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
let close = after.find("}}").ok_or_else(|| {
format!("unterminated placeholder in template near '{}'", &rest[open..])
})?;
let key = &after[..close];
match vars.get(key) {
Some(v) => out.push_str(v),
None => return Err(format!("unknown placeholder {{{{{key}}}}} in template")),
}
rest = &after[close + 2..];
}
out.push_str(rest);
Ok(out)
}
pub fn expand_env(input: &str) -> Result<String, String> {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
match chars.peek() {
Some('$') => {
chars.next();
out.push('$');
}
Some('{') => {
chars.next(); let mut name = String::new();
let mut closed = false;
for nc in chars.by_ref() {
if nc == '}' {
closed = true;
break;
}
name.push(nc);
}
if !closed {
return Err(format!("unterminated ${{…}} reference in '{input}'"));
}
let value = std::env::var(&name).map_err(|_| {
format!("config references ${{{name}}} which is not set in the environment")
})?;
out.push_str(&value);
}
_ => {
out.push('$');
}
}
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleMode {
Bulk,
PerItem,
Local,
}
#[derive(Debug, Clone)]
pub struct SampleSpec {
pub mode: SampleMode,
pub default_format: String,
pub count: u32,
pub description: String,
pub urls: HashMap<String, String>,
pub headers: Vec<String>,
pub basic_auth: Option<String>,
pub count_ignored: bool,
}
impl SampleSpec {
pub fn supported_formats(&self) -> Vec<&str> {
let mut v: Vec<&str> = self.urls.keys().map(String::as_str).collect();
v.sort();
v
}
}
pub fn builtin_samples() -> HashMap<String, SampleSpec> {
fn entry(
mode: SampleMode,
default_format: &str,
count: u32,
description: &str,
urls: &[(&str, &str)],
count_ignored: bool,
) -> SampleSpec {
SampleSpec {
mode,
default_format: default_format.to_string(),
count,
description: description.to_string(),
urls: urls
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
headers: Vec::new(),
basic_auth: None,
count_ignored,
}
}
let mut m = HashMap::new();
m.insert(
"customer".into(),
entry(
SampleMode::Bulk,
"json",
10,
"Customer profiles (users)",
&[("json", "https://dummyjson.com/users?limit={{count}}")],
false,
),
);
m.insert(
"product".into(),
entry(
SampleMode::Bulk,
"json",
10,
"Products with price, category, and images",
&[("json", "https://dummyjson.com/products?limit={{count}}")],
false,
),
);
m.insert(
"order".into(),
entry(
SampleMode::Bulk,
"json",
10,
"Orders / carts with line items",
&[("json", "https://dummyjson.com/carts?limit={{count}}")],
false,
),
);
m.insert(
"category".into(),
entry(
SampleMode::Bulk,
"json",
10,
"Product category list",
&[("json", "https://dummyjson.com/products/categories")],
true, ),
);
m.insert(
"address".into(),
entry(
SampleMode::Bulk,
"json",
10,
"Postal addresses",
&[("json", "https://fakerapi.it/api/v2/addresses?_quantity={{count}}")],
false,
),
);
m.insert(
"image".into(),
entry(
SampleMode::PerItem,
"jpg",
1,
"Random placeholder image (JPEG)",
&[("jpg", "https://picsum.photos/400/300")],
false,
),
);
m.insert(
"lorem".into(),
SampleSpec {
mode: SampleMode::Local,
default_format: "txt".into(),
count: 1,
description: "Local lorem ipsum text (supports p/w/c units)".into(),
urls: HashMap::new(),
headers: Vec::new(),
basic_auth: None,
count_ignored: false,
},
);
m
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleSource {
BuiltIn,
Config,
Overridden,
}
#[derive(Debug, Clone)]
pub struct ResolvedSample {
pub name: String,
pub spec: SampleSpec,
pub format: String,
pub count: CountSpec,
#[allow(dead_code)]
pub source_tag: SampleSource,
}
pub fn resolve(
name: &str,
format_override: Option<&str>,
count_override: Option<CountSpec>,
config: &HashMap<String, crate::config::SampleDataConfig>,
) -> Result<ResolvedSample, String> {
let builtins = builtin_samples();
let in_config = config.contains_key(name);
let in_builtin = builtins.contains_key(name);
let (spec, source_tag) = match (in_config, in_builtin) {
(true, true) => (spec_from_config(name, &config[name])?, SampleSource::Overridden),
(true, false) => (spec_from_config(name, &config[name])?, SampleSource::Config),
(false, true) => (builtins[name].clone(), SampleSource::BuiltIn),
(false, false) => {
return Err(format!(
"unknown sample '{name}'; try --sample-list"
));
}
};
let format = format_override.map(str::to_string).unwrap_or_else(|| spec.default_format.clone());
if spec.mode != SampleMode::Local && !spec.urls.contains_key(&format) {
let mut supported = spec.supported_formats();
supported.sort();
return Err(format!(
"sample '{name}' does not support format '{format}'; supported: {}",
supported.join(", ")
));
}
let count = match count_override {
Some(c) => c,
None => CountSpec { n: spec.count, unit: None },
};
if count.unit.is_some() && spec.mode != SampleMode::Local {
return Err(format!(
"sample '{name}' does not accept count units"
));
}
Ok(ResolvedSample {
name: name.to_string(),
spec,
format,
count,
source_tag,
})
}
#[derive(Debug, Clone)]
pub struct SampleListEntry {
pub name: String,
pub description: String,
pub mode: SampleMode,
pub default_format: String,
pub formats: Vec<String>,
pub count: u32,
pub source_tag: SampleSource,
}
pub fn list_samples(
config: &HashMap<String, crate::config::SampleDataConfig>,
) -> Vec<SampleListEntry> {
let builtins = builtin_samples();
let mut names: Vec<String> = builtins.keys().cloned().collect();
for k in config.keys() {
if !names.iter().any(|n| n == k) {
names.push(k.clone());
}
}
names.sort();
let mut out = Vec::with_capacity(names.len());
for name in names {
let (spec, tag) = match (config.contains_key(&name), builtins.contains_key(&name)) {
(true, true) => match spec_from_config(&name, &config[&name]) {
Ok(s) => (s, SampleSource::Overridden),
Err(e) => {
eprintln!("warning: config override for '{name}' is invalid; falling back to built-in: {e}");
(builtins[&name].clone(), SampleSource::BuiltIn)
}
},
(true, false) => match spec_from_config(&name, &config[&name]) {
Ok(s) => (s, SampleSource::Config),
Err(e) => {
eprintln!("warning: sample '{name}' in config is invalid and was skipped: {e}");
continue;
}
},
(false, true) => (builtins[&name].clone(), SampleSource::BuiltIn),
(false, false) => continue,
};
let mut formats: Vec<String> = spec.urls.keys().cloned().collect();
formats.sort();
if formats.is_empty() {
formats.push(spec.default_format.clone()); }
out.push(SampleListEntry {
name,
description: spec.description.clone(),
mode: spec.mode,
default_format: spec.default_format.clone(),
formats,
count: spec.count,
source_tag: tag,
});
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SampleArg {
pub name: String,
pub format: Option<String>,
pub count: Option<CountSpec>,
}
pub fn parse_sample_arg(raw: &str) -> Result<SampleArg, String> {
let parts: Vec<&str> = raw.split(':').collect();
if parts.len() > 3 {
return Err(format!(
"invalid --sample value '{raw}': expected NAME[:FORMAT[:COUNT]]"
));
}
let name = parts[0].trim();
if name.is_empty() {
return Err(format!(
"invalid --sample value '{raw}': name is empty"
));
}
let format = parts.get(1).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let count = match parts.get(2).map(|s| s.trim()).filter(|s| !s.is_empty()) {
Some(c) => Some(parse_count(c)?),
None => None,
};
Ok(SampleArg {
name: name.to_string(),
format,
count,
})
}
fn spec_from_config(
name: &str,
cfg: &crate::config::SampleDataConfig,
) -> Result<SampleSpec, String> {
let mode = match cfg.mode.as_deref().unwrap_or("bulk") {
"bulk" => SampleMode::Bulk,
"per_item" => SampleMode::PerItem,
"local" => {
return Err(format!(
"sample '{name}': mode = \"local\" is reserved for built-in samples"
));
}
other => {
return Err(format!(
"sample '{name}': unknown mode '{other}'; expected 'bulk' or 'per_item'"
));
}
};
let default_format = cfg
.default_format
.clone()
.ok_or_else(|| format!("sample '{name}': 'default_format' is required"))?;
if cfg.urls.is_empty() {
return Err(format!(
"sample '{name}': at least one URL is required under [sampledata.{name}.urls]"
));
}
if !cfg.urls.contains_key(&default_format) {
return Err(format!(
"sample '{name}': default_format '{default_format}' has no matching urls.{default_format}"
));
}
let mut urls = HashMap::new();
for (k, v) in &cfg.urls {
urls.insert(k.clone(), expand_env(v)?);
}
if mode != SampleMode::PerItem {
for (fmt, url_tpl) in &urls {
if url_tpl.contains("{{n}}") {
return Err(format!(
"sample '{name}': URL for format '{fmt}' contains {{{{n}}}} but mode is not 'per_item'"
));
}
}
}
let mut headers = Vec::with_capacity(cfg.headers.len());
for h in &cfg.headers {
headers.push(expand_env(h)?);
}
let basic_auth = match &cfg.basic_auth {
Some(s) => Some(expand_env(s)?),
None => None,
};
Ok(SampleSpec {
mode,
default_format,
count: cfg.count.unwrap_or(10),
description: cfg
.description
.clone()
.unwrap_or_else(|| format!("User-defined sample '{name}'")),
urls,
headers,
basic_auth,
count_ignored: false,
})
}
use anyhow::{anyhow, Context, Result as AnyhowResult};
use reqwest::blocking::{Client, RequestBuilder};
use std::time::Duration;
pub fn expand_sample_url(
resolved: &ResolvedSample,
iteration: Option<u32>,
) -> Result<String, String> {
let url_tpl = resolved
.spec
.urls
.get(&resolved.format)
.ok_or_else(|| format!("no URL for format '{}'", resolved.format))?;
let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("count", resolved.count.n.to_string());
vars.insert("format", resolved.format.clone());
vars.insert(
"unit",
match resolved.count.unit {
Some(CountUnit::P) => "p".into(),
Some(CountUnit::W) => "w".into(),
Some(CountUnit::C) => "c".into(),
None => String::new(),
},
);
if let Some(i) = iteration {
vars.insert("n", i.to_string());
}
expand_template(url_tpl, &vars)
}
pub fn build_request(
client: &Client,
resolved: &ResolvedSample,
url: &str,
timeout: u64,
) -> AnyhowResult<RequestBuilder> {
let mut req = client.get(url);
for h in &resolved.spec.headers {
let (name, value) = h
.split_once(':')
.ok_or_else(|| anyhow!("invalid header '{h}': expected 'Name: Value'"))?;
req = req.header(name.trim(), value.trim());
}
if let Some(ba) = &resolved.spec.basic_auth {
let (user, pass) = ba
.split_once(':')
.ok_or_else(|| anyhow!("invalid basic_auth '{ba}': expected 'user:pass'"))?;
req = req.basic_auth(user, Some(pass));
}
req = req.timeout(Duration::from_secs(timeout));
Ok(req)
}
pub fn build_client(timeout: u64, insecure: bool) -> AnyhowResult<Client> {
Client::builder()
.use_rustls_tls()
.danger_accept_invalid_certs(insecure)
.user_agent(concat!("recon/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(timeout))
.build()
.context("failed to build sample HTTP client")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_count_plain_number() {
assert_eq!(parse_count("10"), Ok(CountSpec { n: 10, unit: None }));
assert_eq!(parse_count("0"), Ok(CountSpec { n: 0, unit: None }));
assert_eq!(parse_count("1000000"), Ok(CountSpec { n: 1_000_000, unit: None }));
}
#[test]
fn parse_count_with_unit_suffix() {
assert_eq!(parse_count("2p"), Ok(CountSpec { n: 2, unit: Some(CountUnit::P) }));
assert_eq!(parse_count("50w"), Ok(CountSpec { n: 50, unit: Some(CountUnit::W) }));
assert_eq!(parse_count("1000c"), Ok(CountSpec { n: 1000, unit: Some(CountUnit::C) }));
}
#[test]
fn parse_count_rejects_invalid() {
assert!(parse_count("").is_err());
assert!(parse_count("abc").is_err());
assert!(parse_count("10x").is_err());
assert!(parse_count("p10").is_err());
assert!(parse_count("50ww").is_err());
assert!(parse_count("5.0").is_err());
assert!(parse_count("-3").is_err());
assert!(parse_count("p").is_err());
}
#[test]
fn parse_count_error_message() {
let err = parse_count("10x").unwrap_err();
assert!(err.contains("10x"), "error should echo input, got: {err}");
assert!(err.contains("N{p|w|c}") || err.contains("p|w|c"),
"error should describe grammar, got: {err}");
}
use std::collections::HashMap;
#[test]
fn expand_template_substitutes_known_placeholders() {
let mut vars = HashMap::new();
vars.insert("count", "10".to_string());
vars.insert("format", "json".to_string());
let out = expand_template(
"https://api/x?limit={{count}}&fmt={{format}}",
&vars,
).unwrap();
assert_eq!(out, "https://api/x?limit=10&fmt=json");
}
#[test]
fn expand_template_errors_on_unknown_placeholder() {
let vars = HashMap::new();
let err = expand_template("hello {{name}}", &vars).unwrap_err();
assert!(err.contains("{{name}}"), "error should name the placeholder: {err}");
}
#[test]
fn expand_template_preserves_literal_braces_when_not_placeholder() {
let vars = HashMap::new();
let out = expand_template("plain {x} text", &vars).unwrap();
assert_eq!(out, "plain {x} text");
}
#[test]
fn expand_env_substitutes() {
std::env::set_var("RECON_SAMPLE_TEST_A", "hello");
let out = expand_env("prefix-${RECON_SAMPLE_TEST_A}-suffix").unwrap();
assert_eq!(out, "prefix-hello-suffix");
std::env::remove_var("RECON_SAMPLE_TEST_A");
}
#[test]
fn expand_env_errors_on_unset_var() {
std::env::remove_var("RECON_SAMPLE_DEFINITELY_UNSET");
let err = expand_env("${RECON_SAMPLE_DEFINITELY_UNSET}").unwrap_err();
assert!(err.contains("RECON_SAMPLE_DEFINITELY_UNSET"),
"error should name the variable: {err}");
}
#[test]
fn expand_env_escapes_double_dollar() {
let out = expand_env("cost is $$5").unwrap();
assert_eq!(out, "cost is $5");
}
#[test]
fn expand_env_leaves_standalone_dollar_alone() {
let out = expand_env("no vars here $").unwrap();
assert_eq!(out, "no vars here $");
}
#[test]
fn builtin_samples_contains_expected_names() {
let all = builtin_samples();
for name in ["customer", "product", "order", "category", "address", "image", "lorem"] {
assert!(all.contains_key(name), "missing built-in: {name}");
}
assert_eq!(all.len(), 7);
}
#[test]
fn builtin_lorem_is_local_mode() {
let all = builtin_samples();
let lorem = all.get("lorem").unwrap();
assert_eq!(lorem.mode, SampleMode::Local);
assert!(lorem.urls.is_empty(), "local mode has no URLs");
assert_eq!(lorem.default_format, "txt");
}
#[test]
fn builtin_image_is_per_item_mode() {
let all = builtin_samples();
let image = all.get("image").unwrap();
assert_eq!(image.mode, SampleMode::PerItem);
assert!(image.urls.contains_key("jpg"));
}
#[test]
fn builtin_customer_is_bulk_mode() {
let all = builtin_samples();
let c = all.get("customer").unwrap();
assert_eq!(c.mode, SampleMode::Bulk);
assert!(c.urls.get("json").unwrap().contains("{{count}}"));
}
#[test]
fn builtin_category_has_count_ignored_flag() {
let all = builtin_samples();
let cat = all.get("category").unwrap();
assert!(cat.count_ignored, "category should have count_ignored = true");
}
use crate::config::SampleDataConfig;
fn empty_cfg() -> HashMap<String, SampleDataConfig> {
HashMap::new()
}
#[test]
fn resolve_builtin_customer_defaults() {
let r = resolve("customer", None, None, &empty_cfg()).unwrap();
assert_eq!(r.name, "customer");
assert_eq!(r.format, "json");
assert_eq!(r.count.n, 10);
assert_eq!(r.spec.mode, SampleMode::Bulk);
}
#[test]
fn resolve_cli_overrides() {
let r = resolve("customer", Some("json"), Some(CountSpec { n: 25, unit: None }), &empty_cfg()).unwrap();
assert_eq!(r.count.n, 25);
}
#[test]
fn resolve_unknown_sample() {
let err = resolve("doesnotexist", None, None, &empty_cfg()).unwrap_err();
assert!(err.contains("doesnotexist"));
assert!(err.contains("--sample-list"));
}
#[test]
fn resolve_unknown_format_for_sample() {
let err = resolve("customer", Some("xml"), None, &empty_cfg()).unwrap_err();
assert!(err.contains("customer"));
assert!(err.contains("xml"));
assert!(err.contains("json"), "error should list supported formats");
}
#[test]
fn resolve_unit_rejected_for_non_lorem() {
let err = resolve(
"customer",
None,
Some(CountSpec { n: 5, unit: Some(CountUnit::P) }),
&empty_cfg(),
).unwrap_err();
assert!(err.contains("customer"));
assert!(err.contains("unit"));
}
#[test]
fn resolve_unit_accepted_for_lorem() {
let r = resolve(
"lorem",
None,
Some(CountSpec { n: 3, unit: Some(CountUnit::P) }),
&empty_cfg(),
).unwrap();
assert_eq!(r.count.n, 3);
assert_eq!(r.count.unit, Some(CountUnit::P));
}
#[test]
fn resolve_config_overrides_builtin() {
let mut cfg = HashMap::new();
cfg.insert(
"customer".into(),
SampleDataConfig {
mode: Some("bulk".into()),
default_format: Some("xml".into()),
count: Some(50),
description: Some("Custom customer".into()),
urls: {
let mut u = HashMap::new();
u.insert("xml".into(), "https://internal/x?n={{count}}".into());
u
},
headers: vec![],
basic_auth: None,
},
);
let r = resolve("customer", None, None, &cfg).unwrap();
assert_eq!(r.format, "xml");
assert_eq!(r.count.n, 50);
assert_eq!(r.source_tag, SampleSource::Overridden);
}
#[test]
fn resolve_config_new_sample() {
let mut cfg = HashMap::new();
cfg.insert(
"myapi".into(),
SampleDataConfig {
mode: Some("bulk".into()),
default_format: Some("json".into()),
count: Some(7),
description: None,
urls: {
let mut u = HashMap::new();
u.insert("json".into(), "https://my.internal/x".into());
u
},
headers: vec![],
basic_auth: None,
},
);
let r = resolve("myapi", None, None, &cfg).unwrap();
assert_eq!(r.source_tag, SampleSource::Config);
assert_eq!(r.count.n, 7);
}
#[test]
fn resolve_rejects_config_mode_local() {
let mut cfg = HashMap::new();
cfg.insert(
"bad".into(),
SampleDataConfig {
mode: Some("local".into()),
default_format: Some("txt".into()),
count: None,
description: None,
urls: HashMap::new(),
headers: vec![],
basic_auth: None,
},
);
let err = resolve("bad", None, None, &cfg).unwrap_err();
assert!(err.contains("local"));
}
#[test]
fn list_samples_merges_builtins_and_config() {
let mut cfg = HashMap::new();
cfg.insert(
"customer".into(), SampleDataConfig {
mode: Some("bulk".into()),
default_format: Some("json".into()),
count: Some(50),
description: Some("Override".into()),
urls: {
let mut u = HashMap::new();
u.insert("json".into(), "https://x/users".into());
u
},
headers: vec![],
basic_auth: None,
},
);
cfg.insert(
"myapi".into(), SampleDataConfig {
mode: Some("bulk".into()),
default_format: Some("json".into()),
count: Some(5),
description: None,
urls: {
let mut u = HashMap::new();
u.insert("json".into(), "https://my/api".into());
u
},
headers: vec![],
basic_auth: None,
},
);
let list = list_samples(&cfg);
assert!(list.iter().any(|e| e.name == "customer" && e.source_tag == SampleSource::Overridden));
assert!(list.iter().any(|e| e.name == "myapi" && e.source_tag == SampleSource::Config));
assert!(list.iter().any(|e| e.name == "product" && e.source_tag == SampleSource::BuiltIn));
}
#[test]
fn parse_sample_arg_plain_name() {
let p = parse_sample_arg("customer").unwrap();
assert_eq!(p.name, "customer");
assert_eq!(p.format, None);
assert_eq!(p.count, None);
}
#[test]
fn parse_sample_arg_with_format() {
let p = parse_sample_arg("customer:csv").unwrap();
assert_eq!(p.name, "customer");
assert_eq!(p.format.as_deref(), Some("csv"));
assert_eq!(p.count, None);
}
#[test]
fn parse_sample_arg_with_all_three() {
let p = parse_sample_arg("customer:csv:25").unwrap();
assert_eq!(p.name, "customer");
assert_eq!(p.format.as_deref(), Some("csv"));
assert_eq!(p.count, Some(CountSpec { n: 25, unit: None }));
}
#[test]
fn parse_sample_arg_empty_slots_are_none() {
let p = parse_sample_arg("customer::5").unwrap();
assert_eq!(p.name, "customer");
assert_eq!(p.format, None);
assert_eq!(p.count, Some(CountSpec { n: 5, unit: None }));
let p = parse_sample_arg("customer:csv:").unwrap();
assert_eq!(p.format.as_deref(), Some("csv"));
assert_eq!(p.count, None);
}
#[test]
fn parse_sample_arg_empty_name_errors() {
assert!(parse_sample_arg("").is_err());
assert!(parse_sample_arg(":csv").is_err());
}
#[test]
fn parse_sample_arg_too_many_parts_errors() {
assert!(parse_sample_arg("customer:csv:5:extra").is_err());
}
#[test]
fn parse_sample_arg_lorem_with_unit() {
let p = parse_sample_arg("lorem::3p").unwrap();
assert_eq!(p.count, Some(CountSpec { n: 3, unit: Some(CountUnit::P) }));
}
#[test]
fn resolve_rejects_n_placeholder_in_bulk_url() {
let mut cfg = HashMap::new();
cfg.insert(
"bad".into(),
SampleDataConfig {
mode: Some("bulk".into()),
default_format: Some("json".into()),
count: None,
description: None,
urls: {
let mut u = HashMap::new();
u.insert("json".into(), "https://x/?i={{n}}".into());
u
},
headers: vec![],
basic_auth: None,
},
);
let err = resolve("bad", None, None, &cfg).unwrap_err();
assert!(err.contains("{{n}}"));
assert!(err.contains("per_item"));
}
#[test]
fn resolve_accepts_n_placeholder_in_per_item_url() {
let mut cfg = HashMap::new();
cfg.insert(
"img2".into(),
SampleDataConfig {
mode: Some("per_item".into()),
default_format: Some("jpg".into()),
count: None,
description: None,
urls: {
let mut u = HashMap::new();
u.insert("jpg".into(), "https://img/?i={{n}}".into());
u
},
headers: vec![],
basic_auth: None,
},
);
let ok = resolve("img2", None, None, &cfg);
assert!(ok.is_ok(), "expected Ok, got {:?}", ok);
}
}