use std::sync::LazyLock;
use regex::Regex;
use crate::error::{Result, RurlError};
use super::datafile::{DataFile, DataRow};
static PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{\{\s*(\w+)\s*\}\}").expect("placeholder regex is valid")
});
pub fn extract_placeholders(template: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for cap in PLACEHOLDER_RE.captures_iter(template) {
let name = cap[1].to_string();
if seen.insert(name.clone()) {
result.push(name);
}
}
result
}
pub fn substitute(template: &str, row: &DataRow) -> Result<String> {
let mut error: Option<RurlError> = None;
let result = PLACEHOLDER_RE.replace_all(template, |caps: ®ex::Captures<'_>| {
if error.is_some() {
return String::new();
}
let name = &caps[1];
match row.get(name) {
Some(value) => value.clone(),
None => {
let available: Vec<&str> = row.keys().map(String::as_str).collect();
let mut available_sorted = available;
available_sorted.sort_unstable();
error = Some(RurlError::SubstitutionError(format!(
"column '{}' not found in data row; available columns: [{}]",
name,
available_sorted.join(", ")
)));
String::new()
}
}
});
match error {
Some(e) => Err(e),
None => Ok(result.into_owned()),
}
}
pub fn validate_template(template: &str, columns: &[String]) -> Result<()> {
let placeholders = extract_placeholders(template);
let missing: Vec<&str> = placeholders
.iter()
.filter(|p| !columns.contains(p))
.map(String::as_str)
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(RurlError::SubstitutionError(format!(
"template references unknown columns: [{}]; available: [{}]",
missing.join(", "),
columns.join(", ")
)))
}
}
pub fn get_row_for_request(data_file: &DataFile, request_index: usize) -> &DataRow {
&data_file.rows()[request_index % data_file.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn row(pairs: &[(&str, &str)]) -> DataRow {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
fn cols(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_substitute_basic() {
let r = row(&[("user_id", "42")]);
let out = substitute("GET /users/{{user_id}}", &r).unwrap();
assert_eq!(out, "GET /users/42");
}
#[test]
fn test_substitute_multiple() {
let r = row(&[("host", "api.example.com"), ("version", "v2"), ("id", "7")]);
let out = substitute("https://{{host}}/{{version}}/items/{{id}}", &r).unwrap();
assert_eq!(out, "https://api.example.com/v2/items/7");
}
#[test]
fn test_substitute_no_placeholders() {
let r = row(&[("x", "1")]);
let out = substitute("https://example.com/static", &r).unwrap();
assert_eq!(out, "https://example.com/static");
}
#[test]
fn test_substitute_missing_column() {
let r = row(&[("name", "Alice")]);
let err = substitute("Hello {{missing_col}}", &r).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("missing_col"), "expected col name in: {}", msg);
assert!(msg.contains("name"), "expected available cols in: {}", msg);
}
#[test]
fn test_validate_template_ok() {
let result = validate_template(
"{{user_id}} {{api_key}}",
&cols(&["user_id", "api_key", "extra"]),
);
assert!(result.is_ok());
}
#[test]
fn test_validate_template_missing() {
let err = validate_template(
"{{user_id}} {{no_such_col}}",
&cols(&["user_id", "email"]),
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no_such_col"), "expected missing col in: {}", msg);
assert!(msg.contains("user_id"), "expected available in: {}", msg);
}
#[test]
fn test_substitute_in_url_header_body() {
let r = row(&[
("user_id", "99"),
("api_key", "secret-token"),
("payload", r#"{"name":"Alice"}"#),
]);
let url = substitute("https://api.example.com/users/{{user_id}}/profile", &r).unwrap();
assert_eq!(url, "https://api.example.com/users/99/profile");
let header = substitute("Authorization: Bearer {{api_key}}", &r).unwrap();
assert_eq!(header, "Authorization: Bearer secret-token");
let body = substitute(r#"{"id":{{user_id}},"data":{{payload}}}"#, &r).unwrap();
assert_eq!(body, r#"{"id":99,"data":{"name":"Alice"}}"#);
}
#[test]
fn test_substitute_whitespace_tolerant() {
let r = row(&[("user_id", "42")]);
let out = substitute("Hello {{ user_id }}, you are {{ user_id }}", &r).unwrap();
assert_eq!(out, "Hello 42, you are 42");
}
fn make_csv_datafile(filename: &str, csv_content: &str) -> DataFile {
use std::io::Write;
let path = std::env::temp_dir().join(filename);
let mut f = std::fs::File::create(&path).expect("create temp csv");
write!(f, "{}", csv_content).unwrap();
let df = DataFile::from_path(&path).expect("parse temp csv");
let _ = std::fs::remove_file(&path);
df
}
#[test]
fn test_get_row_cycling_basic() {
let df = make_csv_datafile(
"hurley_sub_cycle3.csv",
"id\nrow0\nrow1\nrow2",
);
assert_eq!(df.len(), 3);
assert_eq!(get_row_for_request(&df, 0).get("id").map(String::as_str), Some("row0"));
assert_eq!(get_row_for_request(&df, 1).get("id").map(String::as_str), Some("row1"));
assert_eq!(get_row_for_request(&df, 2).get("id").map(String::as_str), Some("row2"));
assert_eq!(get_row_for_request(&df, 3).get("id").map(String::as_str), Some("row0"));
assert_eq!(get_row_for_request(&df, 4).get("id").map(String::as_str), Some("row1"));
assert_eq!(get_row_for_request(&df, 5).get("id").map(String::as_str), Some("row2"));
}
#[test]
fn test_get_row_cycling_large() {
let mut csv = String::from("id\n");
for i in 0..100usize {
csv.push_str(&format!("row{}\n", i));
}
let df = make_csv_datafile("hurley_sub_cycle100.csv", &csv);
assert_eq!(df.len(), 100);
let target = get_row_for_request(&df, 999);
assert_eq!(target.get("id").map(String::as_str), Some("row99"));
}
#[test]
fn test_get_row_single_row() {
let df = make_csv_datafile("hurley_sub_cycle1.csv", "val\nonly");
assert_eq!(df.len(), 1);
for idx in [0, 1, 100, 9999] {
assert_eq!(
get_row_for_request(&df, idx).get("val").map(String::as_str),
Some("only"),
"request {} should return the sole row",
idx
);
}
}
#[test]
fn test_substitute_empty_placeholder() {
let r = row(&[]);
let out = substitute("before {{}} after", &r).unwrap();
assert_eq!(out, "before {{}} after", "empty braces must be left intact");
}
#[test]
fn test_substitute_nested_braces() {
let r = row(&[("col", "value")]);
let out = substitute("{{{col}}}", &r).unwrap();
assert_eq!(out, "{value}");
}
#[test]
fn test_substitute_json_body_no_false_match() {
let r = row(&[]);
let template = r#"{"key": "value", "num": 42}"#;
let out = substitute(template, &r).unwrap();
assert_eq!(out, template, "single-brace JSON must not be altered");
}
#[test]
fn test_substitute_repeated_placeholder() {
let r = row(&[("id", "42")]);
let out = substitute("{{id}}-{{id}}", &r).unwrap();
assert_eq!(out, "42-42");
}
#[test]
fn test_validate_template_no_placeholders() {
let result = validate_template("https://example.com/static", &cols(&[]));
assert!(result.is_ok(), "no-placeholder template must always pass");
}
#[test]
fn test_extract_placeholders_dedup() {
let names = extract_placeholders("{{a}} {{b}} {{a}} {{b}} {{c}}");
assert_eq!(names, vec!["a", "b", "c"], "duplicates must be deduplicated");
}
}