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}}")
}
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"));
}
}