pub fn generate_snippet(keyword: &str, text: &str, has_table: bool, has_docstring: bool) -> String {
let (expression, params) = analyze_step_text(text);
let fn_name = to_snake_case_fn_name(&expression);
let attr = keyword_to_attribute(keyword);
let mut all_params = vec!["world: &mut BrowserWorld".to_string()];
for (i, param_type) in params.iter().enumerate() {
all_params.push(format!("arg{i}: {param_type}"));
}
if has_table {
all_params.push("table: Option<&DataTable>".to_string());
}
if has_docstring {
all_params.push("docstring: Option<&str>".to_string());
}
let params_str = all_params.join(", ");
format!("#[{attr}(\"{expression}\")]\nasync fn {fn_name}({params_str}) {{\n todo!(\"implement step\")\n}}")
}
#[must_use]
pub fn generate_js_snippet(keyword: &str, text: &str, has_table: bool, has_docstring: bool) -> String {
let (expression, params) = analyze_step_text(text);
let kw = match keyword.trim() {
"When" => "When",
"Then" => "Then",
_ => "Given",
};
let mut args: Vec<String> = (0..params.len()).map(|i| format!("arg{i}")).collect();
if has_table {
args.push("dataTable".to_string());
}
if has_docstring {
args.push("docString".to_string());
}
let arglist = args.join(", ");
let escaped = expression.replace('\\', "\\\\").replace('\'', "\\'");
format!(
"{kw}('{escaped}', async function ({arglist}) {{\n \
// Write code here that turns the phrase above into concrete actions\n \
return 'pending';\n}});"
)
}
fn analyze_step_text(text: &str) -> (String, Vec<&'static str>) {
let mut params: Vec<&'static str> = Vec::new();
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
let ch = chars[i];
if ch == '"' || ch == '\'' {
let quote = ch;
let mut j = i + 1;
while j < len && chars[j] != quote {
j += 1;
}
if j < len {
result.push_str("{string}");
params.push("String");
i = j + 1;
continue;
}
result.push(ch);
i += 1;
continue;
}
if is_number_start(&chars, i, len) {
let start = i;
if chars[i] == '-' {
i += 1;
}
let digit_start = i;
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
if i > digit_start {
if i < len && chars[i] == '.' && i + 1 < len && chars[i + 1].is_ascii_digit() {
i += 1; while i < len && chars[i].is_ascii_digit() {
i += 1;
}
if i >= len || !chars[i].is_alphanumeric() {
result.push_str("{float}");
params.push("f64");
continue;
}
} else if i >= len || !chars[i].is_alphanumeric() {
result.push_str("{int}");
params.push("i64");
continue;
}
}
i = start;
result.push(chars[i]);
i += 1;
continue;
}
result.push(ch);
i += 1;
}
(result, params)
}
fn is_number_start(chars: &[char], i: usize, len: usize) -> bool {
if i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_') {
return false;
}
if chars[i].is_ascii_digit() {
return true;
}
if chars[i] == '-' && i + 1 < len && chars[i + 1].is_ascii_digit() {
return true;
}
false
}
fn to_snake_case_fn_name(expression: &str) -> String {
let mut result = String::with_capacity(expression.len());
let chars: Vec<char> = expression.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] == '{' {
if let Some(j) = chars[i..].iter().position(|&c| c == '}') {
let placeholder: String = chars[i + 1..i + j].iter().collect();
result.push_str(&placeholder);
i += j + 1;
continue;
}
}
let ch = chars[i];
if ch.is_alphanumeric() {
result.push(ch.to_ascii_lowercase());
} else {
result.push('_');
}
i += 1;
}
let mut deduped = String::with_capacity(result.len());
let mut prev_underscore = false;
for ch in result.chars() {
if ch == '_' {
if !prev_underscore {
deduped.push('_');
}
prev_underscore = true;
} else {
deduped.push(ch);
prev_underscore = false;
}
}
let trimmed = deduped.trim_matches('_');
if trimmed.len() <= 60 {
return trimmed.to_string();
}
let truncated = &trimmed[..60];
if let Some(last_underscore) = truncated.rfind('_') {
if last_underscore > 30 {
return truncated[..last_underscore].to_string();
}
}
truncated.to_string()
}
fn keyword_to_attribute(keyword: &str) -> &'static str {
match keyword.trim() {
"Given" => "given",
"When" => "when",
"Then" => "then",
_ => "step",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_given() {
let snippet = generate_snippet("Given", "I have 3 \"apples\" in my cart", false, false);
assert!(snippet.contains("#[given(\"I have {int} {string} in my cart\")]"));
assert!(snippet.contains("arg0: i64"));
assert!(snippet.contains("arg1: String"));
assert!(snippet.contains("async fn i_have_int_string_in_my_cart("));
assert!(snippet.contains("todo!(\"implement step\")"));
}
#[test]
fn test_when_keyword() {
let snippet = generate_snippet("When", "I click the button", false, false);
assert!(snippet.contains("#[when(\"I click the button\")]"));
}
#[test]
fn test_then_keyword() {
let snippet = generate_snippet("Then", "I should see the result", false, false);
assert!(snippet.contains("#[then(\"I should see the result\")]"));
}
#[test]
fn test_and_keyword_maps_to_step() {
let snippet = generate_snippet("And", "something happens", false, false);
assert!(snippet.contains("#[step(\"something happens\")]"));
}
#[test]
fn test_but_keyword_maps_to_step() {
let snippet = generate_snippet("But", "nothing breaks", false, false);
assert!(snippet.contains("#[step(\"nothing breaks\")]"));
}
#[test]
fn test_star_keyword_maps_to_step() {
let snippet = generate_snippet("*", "do stuff", false, false);
assert!(snippet.contains("#[step(\"do stuff\")]"));
}
#[test]
fn test_float_detection() {
let snippet = generate_snippet("Given", "the price is 3.14", false, false);
assert!(snippet.contains("{float}"));
assert!(snippet.contains("arg0: f64"));
}
#[test]
fn test_negative_integer() {
let snippet = generate_snippet("Given", "the temperature is -5 degrees", false, false);
assert!(snippet.contains("{int}"));
assert!(snippet.contains("arg0: i64"));
}
#[test]
fn test_single_quoted_string() {
let snippet = generate_snippet("When", "I type 'hello world'", false, false);
assert!(snippet.contains("{string}"));
assert!(snippet.contains("arg0: String"));
}
#[test]
fn test_has_table() {
let snippet = generate_snippet("Given", "a table of users", true, false);
assert!(snippet.contains("table: Option<&DataTable>"));
}
#[test]
fn test_has_docstring() {
let snippet = generate_snippet("Given", "the following text", false, true);
assert!(snippet.contains("docstring: Option<&str>"));
}
#[test]
fn test_has_table_and_docstring() {
let snippet = generate_snippet("Given", "data", true, true);
assert!(snippet.contains("table: Option<&DataTable>"));
assert!(snippet.contains("docstring: Option<&str>"));
}
#[test]
fn test_fn_name_truncation() {
let long_text = "a very long step definition text that exceeds the sixty character limit for function names in generated snippets";
let snippet = generate_snippet("Given", long_text, false, false);
let fn_start = snippet.find("async fn ").unwrap() + "async fn ".len();
let fn_end = snippet[fn_start..].find('(').unwrap() + fn_start;
let fn_name = &snippet[fn_start..fn_end];
assert!(fn_name.len() <= 60, "fn name too long: {fn_name} ({})", fn_name.len());
}
#[test]
fn test_no_params_no_extras() {
let snippet = generate_snippet("Given", "the app is running", false, false);
assert!(snippet.contains("async fn the_app_is_running(world: &mut BrowserWorld)"));
}
#[test]
fn test_multiple_params() {
let snippet = generate_snippet("Given", "I have 5 \"items\" costing 9.99", false, false);
assert!(snippet.contains("{int}"));
assert!(snippet.contains("{string}"));
assert!(snippet.contains("{float}"));
assert!(snippet.contains("arg0: i64"));
assert!(snippet.contains("arg1: String"));
assert!(snippet.contains("arg2: f64"));
}
#[test]
fn js_snippet_is_cucumber_shaped() {
let s = generate_js_snippet("Given", "I have 3 \"apples\"", false, false);
assert!(s.starts_with("Given('"), "keyword + quoted expr: {s}");
assert!(s.contains("{int}") && s.contains("{string}"), "params templated: {s}");
assert!(s.contains("async function (arg0, arg1)"), "positional args: {s}");
assert!(s.contains("return 'pending';"), "pending body: {s}");
let t = generate_js_snippet("When", "I do it", true, true);
assert!(t.starts_with("When('"));
assert!(
t.contains("async function (dataTable, docString)"),
"table+docstring args: {t}"
);
assert!(generate_js_snippet("And", "x", false, false).starts_with("Given('"));
}
}