use super::*;
pub(crate) fn run_infer(
filename: &str,
function_filter: Option<&str>,
output_path: Option<&str>,
dry_run: bool,
focus: Option<&str>,
output_mode: assura_config::OutputMode,
) {
let source = fs::read_to_string(filename).unwrap_or_else(|e| {
if output_mode == assura_config::OutputMode::Json {
let report = serde_json::json!({
"ok": false,
"success": false,
"file": filename,
"error": format!("{e}"),
"message": format!("{filename}: {e}"),
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
eprintln!("Error: {filename}: {e}");
}
process::exit(2);
});
let focus_patterns: Vec<&str> = focus
.map(|f| f.split(',').map(str::trim).collect())
.unwrap_or_default();
if filename.ends_with(".rs") {
run_infer_heuristic(
filename,
&source,
&focus_patterns,
dry_run,
output_path,
output_mode,
);
return;
}
let signatures = extract_rust_fn_signatures(&source);
if signatures.is_empty() {
if output_mode == assura_config::OutputMode::Json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"file": filename,
"function_count": 0,
"suggestion_count": 0,
"suggestions": [],
"message": format!("No public function signatures found in {filename}"),
"success": false,
}))
.unwrap()
);
} else {
eprintln!("No public function signatures found in {filename}");
}
process::exit(1);
}
let filtered: Vec<&RustFnSig> = if let Some(name) = function_filter {
let matches: Vec<_> = signatures.iter().filter(|s| s.name == name).collect();
if matches.is_empty() {
if output_mode == assura_config::OutputMode::Json {
let names: Vec<_> = signatures
.iter()
.filter(|s| s.is_pub)
.map(|s| s.name.as_str())
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"file": filename,
"function_count": 0,
"suggestion_count": 0,
"suggestions": [],
"message": format!("Function '{name}' not found in {filename}"),
"available": names,
"success": false,
}))
.unwrap()
);
} else {
eprintln!("Function '{name}' not found in {filename}");
let names: Vec<_> = signatures
.iter()
.filter(|s| s.is_pub)
.map(|s| s.name.as_str())
.collect();
eprintln!("Available public functions: {}", names.join(", "));
}
process::exit(1);
}
matches
} else {
signatures.iter().filter(|s| s.is_pub).collect()
};
if filtered.is_empty() {
if output_mode == assura_config::OutputMode::Json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"file": filename,
"function_count": 0,
"suggestion_count": 0,
"suggestions": [],
"message": format!("No public function signatures found in {filename}"),
"success": false,
}))
.unwrap()
);
} else {
eprintln!("No public function signatures found in {filename}");
}
process::exit(1);
}
let module_path = derive_rust_module_path(filename);
let mut out_buf = String::new();
out_buf.push_str(&format!(
"// Generated by: assura infer {filename}\n// Review and refine these contracts before use.\n\n"
));
for sig in &filtered {
generate_bind_skeleton(&module_path, sig, &mut out_buf);
}
out_buf.push_str(&format!(
"\n// {} function(s) analyzed from {filename}\n",
filtered.len()
));
if let Some(path) = output_path {
fs::write(path, &out_buf).unwrap_or_else(|e| {
if output_mode == assura_config::OutputMode::Json {
let report = serde_json::json!({
"ok": false,
"success": false,
"file": filename,
"output": path,
"error": format!("cannot write {path}: {e}"),
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
eprintln!("Error: cannot write {path}: {e}");
}
process::exit(2);
});
if output_mode == assura_config::OutputMode::Json {
let report = serde_json::json!({
"ok": true,
"success": true,
"file": filename,
"output": path,
"function_count": filtered.len(),
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
eprintln!("Wrote {} contract(s) to {path}", filtered.len());
}
} else if output_mode == assura_config::OutputMode::Json {
print!("{out_buf}");
} else {
print!("{out_buf}");
}
}
pub(crate) struct InferSuggestion {
line: usize,
fn_name: String,
params: Vec<String>,
annotations: Vec<String>,
pattern: String,
}
pub(crate) fn run_infer_heuristic(
filename: &str,
source: &str,
focus: &[&str],
dry_run: bool,
output_path: Option<&str>,
output_mode: assura_config::OutputMode,
) {
let mut suggestions = Vec::new();
let file = match syn::parse_file(source) {
Ok(f) => f,
Err(e) => {
if output_mode == assura_config::OutputMode::Json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"file": filename,
"function_count": 0,
"suggestion_count": 0,
"suggestions": [],
"message": format!("Error parsing {filename}: {e}"),
"success": false,
}))
.unwrap()
);
} else {
eprintln!("Error parsing {filename}: {e}");
}
process::exit(1);
}
};
for item in &file.items {
match item {
syn::Item::Fn(func) => {
let suggs = analyze_function_body(func, source, focus);
suggestions.extend(suggs);
}
syn::Item::Impl(imp) => {
for impl_item in &imp.items {
if let syn::ImplItem::Fn(method) = impl_item {
let suggs = analyze_method_body(method, source, focus);
suggestions.extend(suggs);
}
}
}
_ => {}
}
}
if suggestions.is_empty() {
let signatures = extract_rust_fn_signatures(source);
let public: Vec<_> = signatures.iter().filter(|s| s.is_pub).collect();
if output_path.is_some() && !dry_run && focus.is_empty() && !public.is_empty() {
let module_path = derive_rust_module_path(filename);
let mut out_buf = String::new();
out_buf.push_str(&format!(
"// Generated by: assura infer {filename}\n// No risk-pattern hits; skeleton binds for all public functions.\n// Review and refine these contracts before use.\n\n"
));
for sig in &public {
generate_bind_skeleton(&module_path, sig, &mut out_buf);
}
if let Some(path) = output_path {
fs::write(path, &out_buf).unwrap_or_else(|e| {
if output_mode == assura_config::OutputMode::Json {
let report = serde_json::json!({
"ok": false,
"success": false,
"file": filename,
"output": path,
"error": format!("cannot write {path}: {e}"),
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
eprintln!("Error writing {path}: {e}");
}
process::exit(2);
});
if output_mode == assura_config::OutputMode::Json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"file": filename,
"output": path,
"function_count": public.len(),
"suggestion_count": 0,
"status": "ok",
"message": "no risk-pattern hits; wrote skeleton binds",
"functions": public.iter().map(|s| &s.name).collect::<Vec<_>>(),
}))
.unwrap()
);
} else {
eprintln!(
"Wrote {} skeleton contract(s) to {path} (no risk-pattern hits)",
public.len()
);
}
}
return;
}
if output_mode == assura_config::OutputMode::Json {
let report = serde_json::json!({
"file": filename,
"suggestion_count": 0,
"function_count": public.len(),
"functions": public.iter().map(|s| &s.name).collect::<Vec<_>>(),
"suggestions": [],
"message": format!("No contract suggestions for {filename}"),
"focus": focus,
"success": true,
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
println!("No contract suggestions for {filename}");
if !focus.is_empty() {
println!(" (filtered by: {})", focus.join(", "));
}
}
return;
}
let emit_contracts = output_path.is_some() && !dry_run;
let mut output = String::new();
if emit_contracts {
output.push_str(&format!(
"// Generated by: assura infer {filename}\n// Review and refine before use.\n\n"
));
for s in &suggestions {
output.push_str(&format!("// From {filename}:{} [{}]\n", s.line, s.pattern));
output.push_str(&format!(
"contract {} {{\n",
sanitize_contract_name(&s.fn_name)
));
if s.params.is_empty() {
output.push_str(" input(x: Int)\n");
} else {
let parts: Vec<String> = s.params.iter().map(|p| format!("{p}: Int")).collect();
output.push_str(&format!(" input({})\n", parts.join(", ")));
}
let mut has_clause = false;
for ann in &s.annotations {
let body = ann
.trim()
.trim_start_matches('/')
.trim()
.trim_start_matches('@');
if body.starts_with("REVIEW") || body.starts_with("// REVIEW") {
continue;
}
if let Some(idx) = body.find("requires") {
let rest = body[idx + "requires".len()..].trim();
let rest = rest.split("//").next().unwrap_or(rest).trim();
if !rest.is_empty() && !rest.contains('<') {
output.push_str(&format!(" requires {{ {rest} }}\n"));
has_clause = true;
}
} else if let Some(idx) = body.find("ensures") {
let rest = body[idx + "ensures".len()..].trim();
let rest = rest.split("//").next().unwrap_or(rest).trim();
if !rest.is_empty() && !rest.contains('<') {
output.push_str(&format!(" ensures {{ {rest} }}\n"));
has_clause = true;
}
}
}
if !has_clause {
output.push_str(" requires { true }\n");
}
output.push_str("}\n\n");
}
} else {
let mode = if dry_run { " --dry-run" } else { "" };
output.push_str(&format!(
"// Contract suggestions for {filename}\n// Generated by: assura infer{mode}\n// Review each suggestion before accepting.\n\n"
));
for s in &suggestions {
output.push_str(&format!(
"// [{pattern}] {fn_name} (line {line})\n",
pattern = s.pattern,
fn_name = s.fn_name,
line = s.line
));
for ann in &s.annotations {
output.push_str(&format!("// {ann}\n"));
}
output.push('\n');
}
output.push_str(&format!(
"// {} suggestion(s) for {} function(s)\n",
suggestions
.iter()
.map(|s| s.annotations.len())
.sum::<usize>(),
suggestions.len()
));
}
let ann_count: usize = suggestions.iter().map(|s| s.annotations.len()).sum();
if output_mode == assura_config::OutputMode::Json && (dry_run || output_path.is_none()) {
let report = serde_json::json!({
"file": filename,
"suggestion_count": ann_count,
"function_count": suggestions.len(),
"suggestions": suggestions.iter().map(|s| serde_json::json!({
"function": s.fn_name,
"line": s.line,
"pattern": s.pattern,
"params": s.params,
"annotations": s.annotations,
})).collect::<Vec<_>>(),
"text": output,
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
return;
}
if dry_run || output_path.is_none() {
print!("{output}");
}
if let Some(path) = output_path
&& !dry_run
{
fs::write(path, &output).unwrap_or_else(|e| {
if output_mode == assura_config::OutputMode::Json {
let report = serde_json::json!({
"ok": false,
"success": false,
"file": filename,
"output": path,
"error": format!("cannot write {path}: {e}"),
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
eprintln!("Error writing {path}: {e}");
}
process::exit(2);
});
if output_mode == assura_config::OutputMode::Json {
println!(
"{}",
serde_json::json!({
"file": filename,
"output": path,
"function_count": suggestions.len(),
"suggestion_count": ann_count,
"status": "ok",
})
);
} else {
eprintln!("Wrote {} contract skeleton(s) to {path}", suggestions.len());
}
}
if output_mode != assura_config::OutputMode::Json {
println!(
"\n{ann_count} suggestion(s) found across {} function(s)",
suggestions.len()
);
}
}
fn sanitize_contract_name(name: &str) -> String {
let mut out = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_ascii_alphanumeric() || c == '_' {
if i == 0 && c.is_ascii_digit() {
out.push('_');
}
out.push(c);
} else {
out.push('_');
}
}
if out.is_empty() {
"Inferred".into()
} else {
let mut chars = out.chars();
match chars.next() {
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
None => "Inferred".into(),
}
}
}
pub(crate) fn analyze_function_body(
func: &syn::ItemFn,
source: &str,
focus: &[&str],
) -> Vec<InferSuggestion> {
let name = func.sig.ident.to_string();
let body_str = extract_block_text(&func.block, source);
let line = func.sig.fn_token.span.start().line + 1;
let params = extract_param_names(&func.sig);
analyze_body_text(&name, &body_str, line, ¶ms, focus)
}
pub(crate) fn analyze_method_body(
method: &syn::ImplItemFn,
source: &str,
focus: &[&str],
) -> Vec<InferSuggestion> {
let name = method.sig.ident.to_string();
let body_str = extract_block_text(&method.block, source);
let line = method.sig.fn_token.span.start().line + 1;
let params = extract_param_names(&method.sig);
analyze_body_text(&name, &body_str, line, ¶ms, focus)
}
pub(crate) fn extract_block_text(block: &syn::Block, _source: &str) -> String {
use quote::ToTokens;
block.to_token_stream().to_string()
}
pub(crate) fn extract_param_names(sig: &syn::Signature) -> Vec<String> {
sig.inputs
.iter()
.filter_map(|arg| match arg {
syn::FnArg::Typed(pt) => {
use quote::ToTokens;
Some(pt.pat.to_token_stream().to_string())
}
syn::FnArg::Receiver(_) => None,
})
.collect()
}
pub(crate) fn analyze_body_text(
fn_name: &str,
body: &str,
line: usize,
params: &[String],
focus: &[&str],
) -> Vec<InferSuggestion> {
let mut suggestions = Vec::new();
let focus_all = focus.is_empty();
if (focus_all || focus.contains(&"division")) && body.contains('/') {
for param in params {
if body.contains(&format!("/ {param}"))
|| body.contains(&format!("/{param}"))
|| body.contains(&format!("% {param}"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
params: params.to_vec(),
annotations: vec![format!("/// @requires {param} != 0")],
pattern: "division".to_string(),
});
}
}
}
if (focus_all || focus.contains(&"unwrap"))
&& (body.contains(".unwrap()") || body.contains(". unwrap ()"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
params: params.to_vec(),
annotations: vec![
"/// @requires <value>.is_some() // or .is_ok()".to_string(),
"// REVIEW: .unwrap() panics on None/Err".to_string(),
],
pattern: "unwrap".to_string(),
});
}
if (focus_all || focus.contains(&"index")) && body.contains('[') {
for param in params {
if body.contains(&format!("[{param}]"))
|| body.contains(&format!("[ {param} ]"))
|| body.contains(&format!("[{param} ]"))
|| body.contains(&format!("[ {param}]"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
params: params.to_vec(),
annotations: vec![format!("/// @requires {param} < <collection>.len()")],
pattern: "index".to_string(),
});
}
}
}
if (focus_all || focus.contains(&"unsafe"))
&& (body.contains("unsafe") || body.contains("unsafe {"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
params: params.to_vec(),
annotations: vec![
"// REVIEW: contains unsafe block, manual contract required".to_string(),
],
pattern: "unsafe".to_string(),
});
}
if (focus_all || focus.contains(&"panic"))
&& (body.contains("panic!")
|| body.contains("panic !")
|| body.contains("todo!")
|| body.contains("todo !")
|| body.contains("unimplemented!")
|| body.contains("unimplemented !"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
params: params.to_vec(),
annotations: vec![
"// REVIEW: contains panic!/todo!/unimplemented!, add @requires to prevent"
.to_string(),
],
pattern: "panic".to_string(),
});
}
suggestions
}
pub(crate) struct RustFnSig {
pub(crate) name: String,
pub(crate) params: Vec<(String, String)>, pub(crate) return_type: String,
pub(crate) is_pub: bool,
pub(crate) is_unsafe: bool,
}
pub(crate) fn extract_rust_fn_signatures(source: &str) -> Vec<RustFnSig> {
let mut sigs = Vec::new();
let lines: Vec<&str> = source.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
let (is_pub, is_unsafe, fn_part) = match strip_fn_prefix(line) {
Some(triple) => triple,
None => {
i += 1;
continue;
}
};
let mut full_sig = fn_part.to_string();
let mut j = i + 1;
while !full_sig.contains('{') && !full_sig.contains(';') && j < lines.len() {
full_sig.push(' ');
full_sig.push_str(lines[j].trim());
j += 1;
}
if let Some(sig) = parse_fn_signature(&full_sig, is_pub, is_unsafe) {
sigs.push(sig);
}
i = j.max(i + 1);
}
sigs
}
pub(crate) fn strip_fn_prefix(line: &str) -> Option<(bool, bool, &str)> {
let mut rest = line;
let mut is_pub = false;
let mut is_unsafe = false;
if let Some(after_pub) = rest.strip_prefix("pub") {
is_pub = true;
rest = after_pub;
let trimmed = rest.trim_start();
if let Some(after_paren) = trimmed.strip_prefix('(') {
let close = after_paren.find(')')?;
rest = &after_paren[close + 1..];
} else {
rest = trimmed;
}
}
loop {
let trimmed = rest.trim_start();
if let Some(after) = trimmed.strip_prefix("async ") {
rest = after;
} else if let Some(after) = trimmed.strip_prefix("const ") {
rest = after;
} else if let Some(after) = trimmed.strip_prefix("unsafe ") {
is_unsafe = true;
rest = after;
} else {
rest = trimmed;
break;
}
}
let after_fn = rest.strip_prefix("fn ")?;
Some((is_pub, is_unsafe, after_fn))
}
pub(crate) fn parse_fn_signature(sig: &str, is_pub: bool, is_unsafe: bool) -> Option<RustFnSig> {
let paren_open = sig.find('(')?;
let raw_name = sig[..paren_open].trim();
let name = if let Some(angle) = raw_name.find('<') {
raw_name[..angle].trim().to_string()
} else {
raw_name.to_string()
};
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
let after_open = &sig[paren_open + 1..];
let mut depth = 1i32;
let mut close_offset = 0;
for (i, ch) in after_open.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
close_offset = i;
break;
}
}
_ => {}
}
}
let params_str = &after_open[..close_offset];
let params = parse_param_list(params_str);
let after_close = &after_open[close_offset + 1..];
let return_type = if let Some(arrow_pos) = after_close.find("->") {
let ret = after_close[arrow_pos + 2..].trim();
let ret = ret
.split('{')
.next()
.unwrap_or(ret)
.split("where")
.next()
.unwrap_or(ret)
.trim();
ret.to_string()
} else {
"()".to_string()
};
Some(RustFnSig {
name,
params,
return_type,
is_pub,
is_unsafe,
})
}
pub(crate) fn parse_param_list(params: &str) -> Vec<(String, String)> {
let params = params.trim();
if params.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut depth = 0i32;
let mut paren_depth = 0i32;
let mut start = 0;
let mut segments = Vec::new();
for (i, ch) in params.char_indices() {
match ch {
'<' => depth += 1,
'>' if depth > 0 => depth -= 1,
'(' => paren_depth += 1,
')' if paren_depth > 0 => paren_depth -= 1,
',' if depth == 0 && paren_depth == 0 => {
segments.push(¶ms[start..i]);
start = i + 1;
}
_ => {}
}
}
segments.push(¶ms[start..]);
for seg in segments {
let seg = seg.trim();
if seg == "self" || seg == "&self" || seg == "&mut self" {
continue;
}
if let Some(colon_pos) = seg.find(':') {
let name = seg[..colon_pos].trim();
let ty = seg[colon_pos + 1..].trim();
if !name.is_empty() {
result.push((name.to_string(), ty.to_string()));
}
}
}
result
}
pub(crate) fn derive_rust_module_path(file_path: &str) -> String {
let path = Path::new(file_path);
let components: Vec<_> = path.components().collect();
let mut crate_name: Option<String> = None;
let mut src_index: Option<usize> = None;
for (i, comp) in components.iter().enumerate() {
if comp.as_os_str() == "src" {
src_index = Some(i);
let crate_root: std::path::PathBuf = if i > 0 {
components[..i].iter().collect()
} else {
std::path::PathBuf::from(".")
};
let cargo_path = crate_root.join("Cargo.toml");
if let Ok(content) = fs::read_to_string(&cargo_path) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("name") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let rest = rest.trim();
let name = rest.trim_matches('"').trim_matches('\'');
crate_name = Some(name.replace('-', "_"));
break;
}
}
}
}
break;
}
}
let crate_segment = crate_name.unwrap_or_else(|| "crate".to_string());
if let Some(si) = src_index {
if si + 1 < components.len() {
let after_src: std::path::PathBuf = components[si + 1..].iter().collect();
let rel_str = after_src
.to_string_lossy()
.trim_end_matches(".rs")
.replace(['/', '\\'], "::");
if rel_str == "lib" || rel_str == "mod" {
crate_segment
} else if rel_str.ends_with("::mod") || rel_str.ends_with("::lib") {
let trimmed = rel_str.trim_end_matches("::mod").trim_end_matches("::lib");
format!("{crate_segment}::{trimmed}")
} else {
format!("{crate_segment}::{rel_str}")
}
} else {
crate_segment
}
} else {
file_path
.trim_end_matches(".rs")
.replace('/', "::")
.replace('-', "_")
}
}
pub(crate) fn generate_bind_skeleton(module_path: &str, sig: &RustFnSig, out: &mut String) {
use assura_codegen::type_map::rust_type_to_assura;
let rust_path = format!("{module_path}::{}", sig.name);
out.push_str(&format!("bind \"{}\" as {} {{\n", rust_path, sig.name));
if !sig.params.is_empty() {
out.push_str(" input(");
let params: Vec<String> = sig
.params
.iter()
.map(|(name, ty)| format!("{}: {}", name, rust_type_to_assura(ty)))
.collect();
out.push_str(¶ms.join(", "));
out.push_str(")\n");
}
let assura_ret = rust_type_to_assura(&sig.return_type);
if assura_ret != "Unit" {
out.push_str(&format!(" output(result: {assura_ret})\n"));
}
let mut has_clause = false;
for (name, rust_ty) in &sig.params {
let param_assura = rust_type_to_assura(rust_ty);
if matches!(param_assura.as_str(), "Int" | "Nat" | "Float") {
out.push_str(&format!(" requires {{ {name} >= 0 }}\n"));
has_clause = true;
}
}
if matches!(assura_ret.as_str(), "Int" | "Nat" | "Float") {
out.push_str(" ensures { result >= 0 }\n");
has_clause = true;
}
if !has_clause {
out.push_str(" // TODO: add requires clauses (preconditions)\n");
out.push_str(" // TODO: add ensures clauses (postconditions)\n");
}
out.push_str("}\n\n");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_pub_fn() {
let (is_pub, is_unsafe, rest) = strip_fn_prefix("pub fn foo()").unwrap();
assert!(is_pub);
assert!(!is_unsafe);
assert_eq!(rest, "foo()");
}
#[test]
fn strip_plain_fn() {
let (is_pub, is_unsafe, rest) = strip_fn_prefix("fn bar()").unwrap();
assert!(!is_pub);
assert!(!is_unsafe);
assert_eq!(rest, "bar()");
}
#[test]
fn strip_pub_crate_fn() {
let (is_pub, is_unsafe, rest) = strip_fn_prefix("pub(crate) fn baz()").unwrap();
assert!(is_pub);
assert!(!is_unsafe);
assert_eq!(rest, "baz()");
}
#[test]
fn strip_pub_async_fn() {
let (is_pub, is_unsafe, rest) = strip_fn_prefix("pub async fn fetch()").unwrap();
assert!(is_pub);
assert!(!is_unsafe);
assert_eq!(rest, "fetch()");
}
#[test]
fn strip_pub_unsafe_fn() {
let (is_pub, is_unsafe, rest) = strip_fn_prefix("pub unsafe fn danger()").unwrap();
assert!(is_pub);
assert!(is_unsafe);
assert_eq!(rest, "danger()");
}
#[test]
fn extract_marks_unsafe_per_function_not_file() {
let source = r#"
pub fn clean(x: i32) -> i32 { x }
pub unsafe fn danger(p: *const u8) -> u8 { *p }
pub fn also_clean() { let _ = 1; }
"#;
let sigs = extract_rust_fn_signatures(source);
let by_name: std::collections::HashMap<_, _> =
sigs.into_iter().map(|s| (s.name.clone(), s)).collect();
assert!(!by_name["clean"].is_unsafe);
assert!(!by_name["also_clean"].is_unsafe);
assert!(by_name["danger"].is_unsafe);
}
#[test]
fn strip_pub_const_fn() {
let (is_pub, is_unsafe, rest) = strip_fn_prefix("pub const fn SIZE()").unwrap();
assert!(is_pub);
assert!(!is_unsafe);
assert_eq!(rest, "SIZE()");
}
#[test]
fn strip_non_fn_line() {
assert!(strip_fn_prefix("let x = 5;").is_none());
assert!(strip_fn_prefix("struct Foo {}").is_none());
assert!(strip_fn_prefix("// fn comment").is_none());
}
#[test]
fn parse_empty_params() {
let params = parse_param_list("");
assert!(params.is_empty());
}
#[test]
fn parse_single_param() {
let params = parse_param_list("x: i64");
assert_eq!(params.len(), 1);
assert_eq!(params[0].0, "x");
assert_eq!(params[0].1, "i64");
}
#[test]
fn parse_multiple_params() {
let params = parse_param_list("a: i32, b: &str, c: bool");
assert_eq!(params.len(), 3);
assert_eq!(params[0].0, "a");
assert_eq!(params[1].0, "b");
assert_eq!(params[2].0, "c");
}
#[test]
fn parse_skips_self() {
let params = parse_param_list("&self, x: i64");
assert_eq!(params.len(), 1);
assert_eq!(params[0].0, "x");
}
#[test]
fn parse_generic_params() {
let params = parse_param_list("items: Vec<String>, idx: usize");
assert_eq!(params.len(), 2);
assert_eq!(params[0].1, "Vec<String>");
assert_eq!(params[1].0, "idx");
}
#[test]
fn parse_nested_generic_params() {
let params = parse_param_list("m: HashMap<String, Vec<i32>>");
assert_eq!(params.len(), 1);
assert_eq!(params[0].1, "HashMap<String, Vec<i32>>");
}
#[test]
fn parse_simple_signature() {
let sig = parse_fn_signature("add(a: i32, b: i32) -> i32 {", true, false).unwrap();
assert_eq!(sig.name, "add");
assert_eq!(sig.params.len(), 2);
assert_eq!(sig.return_type, "i32");
assert!(sig.is_pub);
assert!(!sig.is_unsafe);
}
#[test]
fn parse_no_return_type() {
let sig = parse_fn_signature("init() {", false, false).unwrap();
assert_eq!(sig.name, "init");
assert_eq!(sig.return_type, "()");
}
#[test]
fn parse_generic_fn() {
let sig =
parse_fn_signature("encode<T: Serialize>(value: T) -> String {", true, false).unwrap();
assert_eq!(sig.name, "encode");
assert_eq!(sig.params.len(), 1);
}
#[test]
fn parse_where_clause_stripped() {
let sig = parse_fn_signature(
"process(data: Vec<u8>) -> Result<(), Error> where T: Clone {",
true,
false,
)
.unwrap();
assert_eq!(sig.return_type, "Result<(), Error>");
}
#[test]
fn extract_pub_fns_from_source() {
let source = "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\nfn private_helper() {\n}\n\npub fn greet(name: &str) -> String {\n format!(\"Hello, {name}\")\n}\n";
let sigs = extract_rust_fn_signatures(source);
assert_eq!(sigs.len(), 3);
let pub_sigs: Vec<_> = sigs.iter().filter(|s| s.is_pub).collect();
assert_eq!(pub_sigs.len(), 2);
assert_eq!(pub_sigs[0].name, "add");
assert_eq!(pub_sigs[1].name, "greet");
}
#[test]
fn module_path_no_src() {
let path = derive_rust_module_path("foo/bar.rs");
assert_eq!(path, "foo::bar");
}
#[test]
fn detects_division_pattern() {
let suggs = analyze_body_text(
"divide",
"result = a / b",
10,
&["a".into(), "b".into()],
&[],
);
assert!(suggs.iter().any(|s| s.pattern == "division"));
}
#[test]
fn detects_unwrap_pattern() {
let suggs = analyze_body_text("process", "x.unwrap()", 5, &[], &[]);
assert!(suggs.iter().any(|s| s.pattern == "unwrap"));
}
#[test]
fn detects_index_pattern() {
let suggs = analyze_body_text(
"lookup",
"items[idx]",
1,
&["items".into(), "idx".into()],
&[],
);
assert!(suggs.iter().any(|s| s.pattern == "index"));
}
#[test]
fn detects_unsafe_pattern() {
let suggs = analyze_body_text("raw_op", "unsafe { *ptr }", 1, &[], &[]);
assert!(suggs.iter().any(|s| s.pattern == "unsafe"));
}
#[test]
fn detects_panic_pattern() {
let suggs = analyze_body_text("bail", "panic!(\"oh no\")", 1, &[], &[]);
assert!(suggs.iter().any(|s| s.pattern == "panic"));
}
#[test]
fn focus_filters_patterns() {
let suggs = analyze_body_text(
"mixed",
"x.unwrap(); items[idx]; a / b",
1,
&["a".into(), "b".into(), "idx".into()],
&["division"],
);
assert!(suggs.iter().all(|s| s.pattern == "division"));
}
#[test]
fn no_false_positives_on_clean_body() {
let suggs = analyze_body_text(
"clean",
"a + b * c",
1,
&["a".into(), "b".into(), "c".into()],
&[],
);
assert!(suggs.is_empty());
}
#[test]
fn bind_skeleton_has_input_and_output() {
let sig = RustFnSig {
name: "add".to_string(),
params: vec![
("a".to_string(), "i64".to_string()),
("b".to_string(), "i64".to_string()),
],
return_type: "i64".to_string(),
is_pub: true,
is_unsafe: false,
};
let mut out = String::new();
generate_bind_skeleton("my_crate", &sig, &mut out);
assert!(out.contains("bind \"my_crate::add\" as add"));
assert!(out.contains("input(a: Int, b: Int)"));
assert!(out.contains("output(result: Int)"));
}
#[test]
fn bind_skeleton_unit_return_omits_output() {
let sig = RustFnSig {
name: "init".to_string(),
params: vec![],
return_type: "()".to_string(),
is_pub: true,
is_unsafe: false,
};
let mut out = String::new();
generate_bind_skeleton("my_crate", &sig, &mut out);
assert!(!out.contains("output"));
}
#[test]
fn clean_public_fn_has_sig_for_minus_o_fallback() {
let source = "pub fn add(a: i64, b: i64) -> i64 { a + b }\nfn private() {}\n";
let sigs = extract_rust_fn_signatures(source);
let public: Vec<_> = sigs.iter().filter(|s| s.is_pub).collect();
assert_eq!(public.len(), 1);
assert_eq!(public[0].name, "add");
let mut out = String::new();
generate_bind_skeleton("lib", public[0], &mut out);
assert!(out.contains("bind \"lib::add\" as add"));
}
}