use super::rust_index::{FileFacts, RustIndex, TestSummary};
use super::seams::{RepoSeam, RequiredDiscriminator};
use crate::domain::{ValueContext, ValueFact};
use std::collections::BTreeMap;
#[derive(Default)]
pub(crate) struct ValueEnvFacts {
body_clean: String,
let_bindings: BTreeMap<String, String>,
rstest_cases: Vec<Vec<String>>,
case_param_names: Vec<String>,
table_bindings: BTreeMap<String, Vec<String>>,
module_constants: BTreeMap<String, String>,
struct_field_bindings: BTreeMap<String, StructFieldBinding>,
struct_field_invalidations: BTreeMap<String, Vec<SourcePosition>>,
bare_std_path_imported: bool,
bare_std_path_buf_imported: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct StructFieldBinding {
position: SourcePosition,
fields: BTreeMap<String, String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SourcePosition {
line: usize,
column: usize,
}
impl SourcePosition {
fn at_or_before(self, other: Self) -> bool {
self.line < other.line || (self.line == other.line && self.column <= other.column)
}
}
impl ValueEnvFacts {
pub(crate) fn build(test: &TestSummary, index: &RustIndex) -> Self {
let body_clean = strip_comments_and_strings(&test.body);
let let_bindings = extract_let_bindings(&body_clean);
let (rstest_cases, case_param_names) = extract_rstest_cases(test);
let test_param_names = extract_fn_param_names(&body_clean);
let table_bindings = extract_table_bindings(&body_clean);
let module_constants = file_facts_for(test, index)
.map(|facts| extract_module_constants(&facts.source))
.unwrap_or_default();
let path_constructor_imports = file_facts_for(test, index)
.map(|facts| extract_path_constructor_imports(&facts.source))
.unwrap_or_default();
let (struct_field_bindings, struct_field_invalidations) =
extract_struct_field_bindings(&body_clean, test.start_line, &test_param_names);
Self {
body_clean,
let_bindings,
rstest_cases,
case_param_names,
table_bindings,
module_constants,
struct_field_bindings,
struct_field_invalidations,
bare_std_path_imported: path_constructor_imports.path,
bare_std_path_buf_imported: path_constructor_imports.path_buf,
}
}
}
pub(crate) struct ValueEnv<'a> {
seam: &'a RepoSeam,
facts: &'a ValueEnvFacts,
}
impl<'a> ValueEnv<'a> {
pub(crate) fn new(seam: &'a RepoSeam, facts: &'a ValueEnvFacts) -> Self {
Self { seam, facts }
}
#[cfg(test)]
pub(crate) fn resolve(&self, arg: &str) -> Vec<(String, ValueContext)> {
let trimmed = arg.trim().trim_end_matches([',', ';']);
self.resolve_expr_at_position(
trimmed,
SourcePosition {
line: usize::MAX,
column: usize::MAX,
},
true,
)
}
#[cfg(test)]
pub(crate) fn resolve_at(&self, arg: &str, call_line: usize) -> Vec<(String, ValueContext)> {
self.resolve_at_position(
arg,
SourcePosition {
line: call_line,
column: usize::MAX,
},
)
}
pub(crate) fn resolve_at_call(
&self,
arg: &str,
call_line: usize,
call_name: &str,
call_text: &str,
) -> Vec<(String, ValueContext)> {
self.resolve_at_position(arg, call_position(call_line, call_name, call_text))
}
fn resolve_at_position(
&self,
arg: &str,
call_position: SourcePosition,
) -> Vec<(String, ValueContext)> {
let trimmed = arg.trim().trim_end_matches([',', ';']);
self.resolve_expr_at_position(trimmed, call_position, true)
}
fn resolve_expr_at_position(
&self,
trimmed: &str,
call_position: SourcePosition,
allow_shared_borrow: bool,
) -> Vec<(String, ValueContext)> {
if trimmed.is_empty() {
return Vec::new();
}
if allow_shared_borrow && let Some(inner) = unwrap_shared_borrow(trimmed) {
return self.resolve_expr_at_position(inner, call_position, false);
}
if let Some(inner) = unwrap_option_or_result(trimmed) {
return self.resolve_identifier_or_literal_at(inner.as_str(), call_position);
}
if let Some(inner) = unwrap_path_literal_constructor(trimmed, self.facts) {
return self.resolve_identifier_or_literal_at(inner.as_str(), call_position);
}
self.resolve_identifier_or_literal_at(trimmed, call_position)
}
fn resolve_identifier_or_literal_at(
&self,
expr: &str,
call_position: SourcePosition,
) -> Vec<(String, ValueContext)> {
if looks_like_literal(expr) {
return vec![(expr.to_string(), ValueContext::FunctionArgument)];
}
if let Some((object, field)) = field_projection(expr)
&& let Some(binding) = self.facts.struct_field_bindings.get(object)
&& binding.position.at_or_before(call_position)
&& !self
.facts
.struct_field_invalidations
.get(object)
.is_some_and(|positions| {
positions
.iter()
.any(|position| position.at_or_before(call_position))
})
&& let Some(value) = binding.fields.get(field)
{
return vec![(value.clone(), ValueContext::FunctionArgument)];
}
if !is_simple_identifier(expr) {
return Vec::new();
}
if let Some(value) = self.facts.let_bindings.get(expr) {
return vec![(value.clone(), ValueContext::FunctionArgument)];
}
if let Some(idx) = self.facts.case_param_names.iter().position(|n| n == expr) {
let mut out = Vec::new();
for case in &self.facts.rstest_cases {
if let Some(value) = case.get(idx) {
out.push((value.clone(), ValueContext::TableRow));
}
}
if !out.is_empty() {
return out;
}
}
if let Some(values) = self.facts.table_bindings.get(expr) {
return values
.iter()
.map(|v| (v.clone(), ValueContext::TableRow))
.collect();
}
if let Some(value) = self.facts.module_constants.get(expr) {
return vec![(value.clone(), ValueContext::FunctionArgument)];
}
Vec::new()
}
pub(crate) fn builder_facts(&self) -> Vec<ValueFact> {
let allowed = self.allowed_builder_method_names();
if allowed.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for cap in scan_builder_calls(&self.facts.body_clean) {
if !builder_method_matches_allowed(&cap.method, &allowed) {
continue;
}
for value in extract_inner_literals(&cap.arg) {
out.push(ValueFact {
line: cap.line,
text: format!(".{}({})", cap.method, cap.arg),
value,
context: ValueContext::BuilderMethod,
});
}
}
out
}
fn allowed_builder_method_names(&self) -> std::collections::BTreeSet<String> {
use std::collections::BTreeSet;
let mut allowed: BTreeSet<String> = BTreeSet::new();
let rd_text = match self.seam.required_discriminator() {
RequiredDiscriminator::BoundaryValue { description }
| RequiredDiscriminator::ReturnValue { description } => description.as_str(),
RequiredDiscriminator::ErrorVariant { variant } => variant.as_str(),
RequiredDiscriminator::FieldValue { field } => field.as_str(),
RequiredDiscriminator::Effect { sink } => sink.as_str(),
RequiredDiscriminator::MatchArmTaken { arm } => arm.as_str(),
RequiredDiscriminator::CallSite { target } => target.as_str(),
};
for token in identifier_tokens(rd_text) {
allowed.insert(token);
}
for token in identifier_tokens(self.seam.expected_sink().as_str()) {
allowed.insert(token);
}
allowed
}
}
fn file_facts_for<'a>(test: &TestSummary, index: &'a RustIndex) -> Option<&'a FileFacts> {
index.files.get(&test.file)
}
#[derive(Default)]
struct PathConstructorImports {
path: bool,
path_buf: bool,
}
fn extract_path_constructor_imports(file_source: &str) -> PathConstructorImports {
let cleaned = strip_comments_and_strings(file_source);
let mut imports = PathConstructorImports::default();
for line in cleaned.lines() {
let trimmed = line.trim();
let Some(import) = trimmed.strip_prefix("use ") else {
continue;
};
collect_std_path_constructor_imports(import.trim(), &mut imports);
}
let shadows = path_constructor_shadows(&cleaned);
PathConstructorImports {
path: imports.path && !shadows.path,
path_buf: imports.path_buf && !shadows.path_buf,
}
}
fn collect_std_path_constructor_imports(import: &str, imports: &mut PathConstructorImports) {
let import = import.trim_end_matches(';').trim();
match import {
"std::path::Path" | "::std::path::Path" => {
imports.path = true;
return;
}
"std::path::PathBuf" | "::std::path::PathBuf" => {
imports.path_buf = true;
return;
}
_ => {}
}
let Some(rest) = import
.strip_prefix("std::path::{")
.or_else(|| import.strip_prefix("::std::path::{"))
else {
return;
};
let Some(body) = rest.strip_suffix('}') else {
return;
};
for item in body.split(',').map(str::trim) {
match item {
"Path" => imports.path = true,
"PathBuf" => imports.path_buf = true,
_ => {}
}
}
}
fn path_constructor_shadows(cleaned_source: &str) -> PathConstructorImports {
let mut shadows = PathConstructorImports::default();
for line in cleaned_source.lines() {
let trimmed = line.trim();
if item_defines_name(trimmed, "Path") {
shadows.path = true;
}
if item_defines_name(trimmed, "PathBuf") {
shadows.path_buf = true;
}
}
shadows
}
fn item_defines_name(line: &str, name: &str) -> bool {
let line = strip_visibility_prefix(line);
for prefix in ["struct ", "enum ", "type ", "trait ", "mod ", "union "] {
if let Some(rest) = line.strip_prefix(prefix) {
let ident = rest
.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
.next()
.unwrap_or_default();
if ident == name {
return true;
}
}
}
false
}
fn strip_visibility_prefix(line: &str) -> &str {
let Some(rest) = line.strip_prefix("pub") else {
return line;
};
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('(')
&& let Some((_, after_visibility)) = rest.split_once(')')
{
return after_visibility.trim_start();
}
rest
}
fn extract_let_bindings(body: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let cleaned = strip_comments_and_strings(body);
for start in find_all(&cleaned, "let ") {
let after_let = &cleaned[start + 4..];
let stmt_end = top_level_semicolon(after_let).unwrap_or(after_let.len());
let stmt = &after_let[..stmt_end];
let Some(eq_idx) = first_single_eq(stmt) else {
continue;
};
let (lhs, rhs) = stmt.split_at(eq_idx);
let rhs = rhs[1..].trim();
let ident_part = lhs.split(':').next().unwrap_or(lhs).trim();
let ident = ident_part.strip_prefix("mut ").unwrap_or(ident_part).trim();
if !is_simple_identifier(ident) {
continue;
}
if !looks_like_literal(rhs) {
continue;
}
out.insert(ident.to_string(), rhs.to_string());
}
out
}
fn top_level_semicolon(text: &str) -> Option<usize> {
let mut depth: i32 = 0;
for (i, b) in text.bytes().enumerate() {
match b {
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b';' if depth == 0 => return Some(i),
_ => {}
}
}
None
}
fn first_single_eq(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
let mut depth: i32 = 0;
for i in 0..bytes.len() {
match bytes[i] {
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b'=' if depth == 0 => {
let next = bytes.get(i + 1).copied();
let prev = if i > 0 { Some(bytes[i - 1]) } else { None };
if next == Some(b'=') {
continue;
}
if matches!(prev, Some(b'!') | Some(b'<') | Some(b'>')) {
continue;
}
return Some(i);
}
_ => {}
}
}
None
}
fn extract_module_constants(file_source: &str) -> BTreeMap<String, String> {
let cleaned = strip_comments_and_strings(file_source);
let mut out = BTreeMap::new();
for line in cleaned.lines() {
let trimmed = line.trim();
let rest = trimmed
.strip_prefix("pub const ")
.or_else(|| trimmed.strip_prefix("const "))
.or_else(|| trimmed.strip_prefix("pub static "))
.or_else(|| trimmed.strip_prefix("static "))
.or_else(|| trimmed.strip_prefix("pub(crate) const "))
.or_else(|| trimmed.strip_prefix("pub(crate) static "));
let Some(rest) = rest else { continue };
let rest = rest.trim_end_matches(';').trim();
let Some(eq_idx) = rest.find('=') else {
continue;
};
if rest.as_bytes().get(eq_idx + 1) == Some(&b'=') {
continue;
}
let (lhs, rhs) = rest.split_at(eq_idx);
let rhs = rhs[1..].trim();
let ident = lhs.split(':').next().unwrap_or(lhs).trim();
let ident = ident.strip_prefix("mut ").unwrap_or(ident);
if !is_simple_identifier(ident) {
continue;
}
if !looks_like_literal(rhs) {
continue;
}
out.insert(ident.to_string(), rhs.to_string());
}
out
}
fn extract_struct_field_bindings(
body: &str,
start_line: usize,
invalid_idents: &[String],
) -> (
BTreeMap<String, StructFieldBinding>,
BTreeMap<String, Vec<SourcePosition>>,
) {
let mut out = BTreeMap::new();
let mut invalidations: BTreeMap<String, Vec<SourcePosition>> = invalid_idents
.iter()
.map(|ident| {
(
ident.clone(),
vec![SourcePosition {
line: start_line,
column: 0,
}],
)
})
.collect();
let cleaned = strip_comments_and_strings(body);
for start in find_all(&cleaned, "let ") {
let position = position_at_offset(&cleaned, start, start_line);
let after_let = &cleaned[start + 4..];
let stmt_end = top_level_semicolon(after_let).unwrap_or(after_let.len());
let stmt = &after_let[..stmt_end];
let Some(eq_idx) = first_single_eq(stmt) else {
continue;
};
let (lhs, rhs) = stmt.split_at(eq_idx);
let rhs = rhs[1..].trim();
let Some((ident, is_mut)) = let_binding_ident(lhs) else {
continue;
};
if out.contains_key(ident) {
push_invalidation(&mut invalidations, ident, position);
continue;
}
if is_mut {
push_invalidation(&mut invalidations, ident, position);
continue;
}
let fields = extract_struct_literal_fields(rhs);
if !fields.is_empty() {
out.insert(ident.to_string(), StructFieldBinding { position, fields });
} else {
push_invalidation(&mut invalidations, ident, position);
}
}
for ident in out.keys() {
let mut lines = Vec::new();
lines.extend(non_simple_let_shadowing_lines(&cleaned, ident, start_line));
lines.extend(field_assignment_lines(&cleaned, ident, start_line));
lines.extend(non_let_shadowing_lines(&cleaned, ident, start_line));
for position in lines {
push_invalidation(&mut invalidations, ident, position);
}
}
(out, invalidations)
}
fn push_invalidation(
invalidations: &mut BTreeMap<String, Vec<SourcePosition>>,
ident: &str,
position: SourcePosition,
) {
invalidations
.entry(ident.to_string())
.or_default()
.push(position);
}
fn position_at_offset(text: &str, offset: usize, start_line: usize) -> SourcePosition {
let offset = offset.min(text.len());
let prefix = &text[..offset];
let line = start_line + prefix.bytes().filter(|b| *b == b'\n').count();
let line_start = prefix.rfind('\n').map(|idx| idx + 1).unwrap_or(0);
let line_end = text[offset..]
.find('\n')
.map(|idx| offset + idx)
.unwrap_or(text.len());
let raw_column = offset.saturating_sub(line_start);
let leading = text[line_start..line_end]
.bytes()
.take_while(|b| b.is_ascii_whitespace())
.count();
SourcePosition {
line,
column: raw_column.saturating_sub(leading),
}
}
fn call_position(call_line: usize, call_name: &str, call_text: &str) -> SourcePosition {
let needle = format!("{call_name}(");
let column = call_text
.find(&needle)
.or_else(|| call_text.find('('))
.unwrap_or(0);
SourcePosition {
line: call_line,
column,
}
}
fn let_binding_ident(lhs: &str) -> Option<(&str, bool)> {
let ident_part = lhs.split(':').next().unwrap_or(lhs).trim();
let (ident, is_mut) = if let Some(rest) = ident_part.strip_prefix("mut ") {
(rest.trim(), true)
} else {
(ident_part, false)
};
is_simple_identifier(ident).then_some((ident, is_mut))
}
fn non_simple_let_shadowing_lines(
body: &str,
ident: &str,
start_line: usize,
) -> Vec<SourcePosition> {
let mut positions = Vec::new();
for start in find_all(body, "let ") {
let after_let = &body[start + 4..];
let stmt_end = top_level_semicolon(after_let).unwrap_or(after_let.len());
let stmt = &after_let[..stmt_end];
let Some(eq_idx) = first_single_eq(stmt) else {
continue;
};
let (lhs, _) = stmt.split_at(eq_idx);
if let_binding_ident(lhs).is_none() && contains_identifier_token(lhs, ident) {
positions.push(position_at_offset(body, start, start_line));
}
}
positions
}
fn extract_struct_literal_fields(rhs: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let rhs = rhs.trim();
let Some(open) = rhs.find('{') else {
return out;
};
if !rhs.ends_with('}') {
return out;
}
let type_part = rhs[..open].trim();
if type_part.is_empty()
|| !type_part
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':' || ch.is_whitespace())
{
return out;
}
let Some(inner) = rhs[open..]
.strip_prefix('{')
.and_then(|text| text.strip_suffix('}'))
else {
return out;
};
for field in split_top_level(inner) {
let Some((name, value)) = split_field_literal(&field) else {
continue;
};
out.insert(name.to_string(), value.to_string());
}
out
}
fn split_field_literal(field: &str) -> Option<(&str, &str)> {
let (name, value) = field.split_once(':')?;
let name = name.trim();
let value = value.trim();
if !is_simple_identifier(name) || !looks_like_literal(value) {
return None;
}
Some((name, value))
}
fn field_assignment_lines(body: &str, ident: &str, start_line: usize) -> Vec<SourcePosition> {
let mut positions = Vec::new();
let needle = format!("{ident}.");
let mut search_from = 0;
while let Some(rel) = body[search_from..].find(&needle) {
let abs = search_from + rel;
let boundary_ok = abs == 0
|| body
.as_bytes()
.get(abs - 1)
.is_some_and(|b| !(b.is_ascii_alphanumeric() || *b == b'_'));
let after_start = abs + needle.len();
if !boundary_ok {
search_from = after_start;
continue;
}
let after = &body[after_start..];
let field_len = after
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
.map(char::len_utf8)
.sum::<usize>();
if field_len > 0 && is_assignment_operator(after[field_len..].trim_start()) {
positions.push(position_at_offset(body, abs, start_line));
}
search_from = after_start;
}
positions
}
fn non_let_shadowing_lines(body: &str, ident: &str, start_line: usize) -> Vec<SourcePosition> {
let mut positions = Vec::new();
for (idx, line) in body.lines().enumerate() {
if has_for_binding(line, ident)
|| has_let_pattern_binding(line, "if let ", ident)
|| has_let_pattern_binding(line, "while let ", ident)
|| has_closure_param_binding(line, ident)
|| has_match_arm_binding(line, ident)
{
positions.push(SourcePosition {
line: start_line + idx,
column: 0,
});
}
}
positions
}
fn has_for_binding(line: &str, ident: &str) -> bool {
let mut rest = line;
while let Some(idx) = rest.find("for ") {
let after = &rest[idx + 4..];
let pattern_end = after.find(" in ").unwrap_or(after.len());
if contains_identifier_token(&after[..pattern_end], ident) {
return true;
}
rest = &after[pattern_end..];
}
false
}
fn has_let_pattern_binding(line: &str, prefix: &str, ident: &str) -> bool {
let mut rest = line;
while let Some(idx) = rest.find(prefix) {
let after = &rest[idx + prefix.len()..];
let pattern_end = first_single_eq(after).unwrap_or(after.len());
if contains_identifier_token(&after[..pattern_end], ident) {
return true;
}
rest = &after[pattern_end..];
}
false
}
fn has_closure_param_binding(line: &str, ident: &str) -> bool {
let mut rest = line;
while let Some(start) = rest.find('|') {
let after_start = &rest[start + 1..];
let Some(end) = after_start.find('|') else {
return false;
};
if contains_identifier_token(&after_start[..end], ident) {
return true;
}
rest = &after_start[end + 1..];
}
false
}
fn has_match_arm_binding(line: &str, ident: &str) -> bool {
let Some(arm) = line.find("=>") else {
return false;
};
contains_identifier_token(&line[..arm], ident)
}
fn contains_identifier_token(text: &str, ident: &str) -> bool {
if ident.is_empty() {
return false;
}
let bytes = text.as_bytes();
let ident_bytes = ident.as_bytes();
let mut start = 0;
while let Some(rel) = text[start..].find(ident) {
let abs = start + rel;
let before_ok = abs == 0
|| bytes
.get(abs - 1)
.is_some_and(|b| !(b.is_ascii_alphanumeric() || *b == b'_'));
let after_idx = abs + ident_bytes.len();
let after_ok = after_idx >= bytes.len()
|| bytes
.get(after_idx)
.is_some_and(|b| !(b.is_ascii_alphanumeric() || *b == b'_'));
if before_ok && after_ok {
return true;
}
start = after_idx;
}
false
}
fn is_assignment_operator(text: &str) -> bool {
if text.starts_with("==")
|| text.starts_with("=>")
|| text.starts_with(">=")
|| text.starts_with("<=")
{
return false;
}
text.starts_with('=')
|| ["+=", "-=", "*=", "/=", "%=", "&=", "|=", "^="]
.iter()
.any(|op| text.starts_with(op))
}
fn extract_rstest_cases(test: &TestSummary) -> (Vec<Vec<String>>, Vec<String>) {
let mut cases: Vec<Vec<String>> = Vec::new();
let mut is_rstest = false;
for attr in &test.attrs {
if attr_matches_name_or_call(attr, "rstest") {
is_rstest = true;
continue;
}
if let Some(args) = attr_call_args(attr, "case") {
if args.is_empty() {
continue;
}
cases.push(split_top_level(args));
}
}
if !is_rstest && cases.is_empty() {
return (Vec::new(), Vec::new());
}
let params = extract_fn_param_names(&test.body);
(cases, params)
}
fn attr_matches_name_or_call(attr: &str, name: &str) -> bool {
let Some(inner) = attr_inner(attr) else {
return false;
};
if inner == name {
return true;
}
attr_call_args(attr, name).is_some()
}
fn attr_call_args<'a>(attr: &'a str, name: &str) -> Option<&'a str> {
let inner = attr_inner(attr)?;
let rest = inner.strip_prefix(name)?.trim_start();
let args = rest.strip_prefix('(')?.strip_suffix(')')?.trim();
Some(args)
}
fn attr_inner(attr: &str) -> Option<&str> {
let inner = attr.trim().strip_prefix("#[")?.strip_suffix(']')?.trim();
Some(inner)
}
fn extract_fn_param_names(body: &str) -> Vec<String> {
let Some(open) = body.find('(') else {
return Vec::new();
};
let after = &body[open + 1..];
let Some(close) = after.find(')') else {
return Vec::new();
};
let raw = &after[..close];
let mut out = Vec::new();
for part in split_top_level(raw) {
let part = part.trim();
if part.is_empty() || part == "self" || part.starts_with('&') {
continue;
}
let ident = part.split(':').next().unwrap_or(part).trim();
let ident = ident.strip_prefix("mut ").unwrap_or(ident).trim();
if is_simple_identifier(ident) {
out.push(ident.to_string());
}
}
out
}
fn extract_table_bindings(body: &str) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
for cap in scan_for_table_loops(body) {
for (col, idents) in cap.idents.iter().enumerate() {
for row in &cap.rows {
if let Some(value) = row.get(col)
&& let Some(ident) = idents
&& is_simple_identifier(ident)
{
out.entry(ident.clone()).or_default().push(value.clone());
}
}
}
}
out
}
struct TableLoopCapture {
idents: Vec<Option<String>>,
rows: Vec<Vec<String>>,
}
fn scan_for_table_loops(body: &str) -> Vec<TableLoopCapture> {
let mut out = Vec::new();
for line_start in find_all(body, "for ") {
let after_for = &body[line_start + 4..];
let Some(in_idx) = after_for.find(" in ") else {
continue;
};
let pattern = after_for[..in_idx].trim();
let after_in = after_for[in_idx + 4..].trim_start();
let after_in = after_in.strip_prefix('&').unwrap_or(after_in);
let after_in = after_in.trim_start();
let pattern = pattern.strip_prefix('&').unwrap_or(pattern).trim();
let Some(pattern_inner) = pattern.strip_prefix('(').and_then(|s| s.strip_suffix(')'))
else {
continue;
};
let idents: Vec<Option<String>> = split_top_level(pattern_inner)
.into_iter()
.map(|p| {
let p = p.trim();
if p == "_" || p.is_empty() {
None
} else if is_simple_identifier(p) {
Some(p.to_string())
} else {
None
}
})
.collect();
if idents.is_empty() || idents.iter().all(|i| i.is_none()) {
continue;
}
let Some(arr_inner) = balanced_bracket_contents(after_in, '[', ']') else {
continue;
};
let mut rows: Vec<Vec<String>> = Vec::new();
for row_text in split_top_level_at_brackets(arr_inner) {
let row_text = row_text.trim();
let Some(row_inner) = row_text.strip_prefix('(').and_then(|s| s.strip_suffix(')'))
else {
continue;
};
let parts = split_top_level(row_inner);
if parts.len() != idents.len() {
continue;
}
if !parts.iter().all(|p| looks_like_literal(p.trim())) {
continue;
}
rows.push(parts.into_iter().map(|p| p.trim().to_string()).collect());
}
if !rows.is_empty() {
out.push(TableLoopCapture { idents, rows });
}
}
out
}
struct BuilderCallCapture {
method: String,
arg: String,
line: usize,
}
fn scan_builder_calls(body: &str) -> Vec<BuilderCallCapture> {
let mut out = Vec::new();
let bytes = body.as_bytes();
let mut line: usize = 1;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\n' {
line += 1;
i += 1;
continue;
}
if bytes[i] != b'.' {
i += 1;
continue;
}
let name_start = i + 1;
let mut name_end = name_start;
while name_end < bytes.len()
&& (bytes[name_end].is_ascii_alphanumeric() || bytes[name_end] == b'_')
{
name_end += 1;
}
if name_end == name_start || name_end >= bytes.len() || bytes[name_end] != b'(' {
i += 1;
continue;
}
let method = &body[name_start..name_end];
let arg_start = name_end + 1;
let mut depth: i32 = 1;
let mut j = arg_start;
while j < bytes.len() && depth > 0 {
match bytes[j] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
if depth == 0 {
break;
}
j += 1;
}
if depth != 0 {
break;
}
let arg = &body[arg_start..j];
out.push(BuilderCallCapture {
method: method.to_string(),
arg: arg.to_string(),
line,
});
i = j + 1;
}
out
}
fn extract_inner_literals(arg: &str) -> Vec<String> {
let trimmed = arg.trim().trim_end_matches([',', ';']);
if looks_like_literal(trimmed) {
return vec![trimmed.to_string()];
}
Vec::new()
}
fn unwrap_option_or_result(text: &str) -> Option<String> {
for ctor in ["Some(", "Ok(", "Err("] {
if let Some(rest) = text.strip_prefix(ctor)
&& let Some(inner) = rest.strip_suffix(')')
{
return Some(inner.trim().to_string());
}
}
None
}
fn unwrap_path_literal_constructor(text: &str, facts: &ValueEnvFacts) -> Option<String> {
let trimmed = text.trim();
for (ctor, allowed) in [
("Path::new(", facts.bare_std_path_imported),
("std::path::Path::new(", true),
("::std::path::Path::new(", true),
("PathBuf::from(", facts.bare_std_path_buf_imported),
("std::path::PathBuf::from(", true),
("::std::path::PathBuf::from(", true),
] {
if !allowed {
continue;
}
if let Some(rest) = trimmed.strip_prefix(ctor)
&& let Some(inner) = rest.strip_suffix(')')
{
let inner = inner.trim();
if looks_like_literal(inner) {
return Some(inner.to_string());
}
}
}
None
}
fn unwrap_shared_borrow(text: &str) -> Option<&str> {
let rest = text.trim().strip_prefix('&')?.trim_start();
if rest.starts_with("mut ") {
return None;
}
(!rest.is_empty()).then_some(rest)
}
fn looks_like_literal(expr: &str) -> bool {
let trimmed = expr.trim().trim_end_matches([',', ';']);
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with('"') || trimmed.starts_with('\'') {
return true;
}
let body = trimmed.strip_prefix('-').unwrap_or(trimmed);
if !body.is_empty()
&& body.chars().next().is_some_and(|c| c.is_ascii_digit())
&& body
.chars()
.all(|c| c.is_ascii_digit() || c == '_' || c == '.')
{
return true;
}
if matches!(trimmed, "true" | "false" | "None") {
return true;
}
if trimmed.contains("::")
&& trimmed
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == ':')
{
return true;
}
false
}
fn field_projection(expr: &str) -> Option<(&str, &str)> {
let (object, field) = expr.trim().split_once('.')?;
if field.contains('.') {
return None;
}
let object = object.trim();
let field = field.trim();
if is_simple_identifier(object) && is_simple_identifier(field) {
Some((object, field))
} else {
None
}
}
fn is_simple_identifier(text: &str) -> bool {
!text.is_empty()
&& text
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& text.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn split_top_level(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut depth: i32 = 0;
let mut start = 0;
let bytes = text.as_bytes();
for i in 0..bytes.len() {
match bytes[i] {
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b',' if depth == 0 => {
out.push(text[start..i].to_string());
start = i + 1;
}
_ => {}
}
}
if start <= text.len() {
let tail = text[start..].trim();
if !tail.is_empty() {
out.push(tail.to_string());
}
}
out
}
fn split_top_level_at_brackets(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth: i32 = 0;
let mut start = 0;
let bytes = text.as_bytes();
for i in 0..bytes.len() {
match bytes[i] {
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b',' if depth == 0 => {
out.push(&text[start..i]);
start = i + 1;
}
_ => {}
}
}
if start <= text.len() {
out.push(&text[start..]);
}
out
}
fn balanced_bracket_contents(text: &str, open: char, close: char) -> Option<&str> {
let bytes = text.as_bytes();
let open_b = open as u8;
let close_b = close as u8;
let start = bytes.iter().position(|&b| b == open_b)?;
let mut depth: i32 = 0;
for i in start..bytes.len() {
if bytes[i] == open_b {
depth += 1;
} else if bytes[i] == close_b {
depth -= 1;
if depth == 0 {
return Some(&text[start + 1..i]);
}
}
}
None
}
fn find_all(haystack: &str, needle: &str) -> Vec<usize> {
let mut out = Vec::new();
let mut start = 0;
while let Some(idx) = haystack[start..].find(needle) {
let abs = start + idx;
let ok = abs == 0
|| haystack
.as_bytes()
.get(abs - 1)
.is_some_and(|b| !(b.is_ascii_alphanumeric() || *b == b'_'));
if ok {
out.push(abs);
}
start = abs + 1;
}
out
}
fn identifier_tokens(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
current.push(ch);
} else {
if !current.is_empty() && current.len() > 2 {
out.push(current.clone());
}
current.clear();
}
}
if !current.is_empty() && current.len() > 2 {
out.push(current);
}
out
}
fn builder_method_matches_allowed(
method: &str,
allowed: &std::collections::BTreeSet<String>,
) -> bool {
allowed.iter().any(|token| {
method == token
|| method.strip_prefix("with_") == Some(token.as_str())
|| method.strip_prefix("set_") == Some(token.as_str())
|| method
.strip_suffix(token.as_str())
.is_some_and(|prefix| prefix.ends_with('_'))
|| method
.strip_prefix(token.as_str())
.is_some_and(|suffix| suffix.starts_with('_'))
})
}
fn strip_comments_and_strings(source: &str) -> String {
let mut out = String::with_capacity(source.len());
for raw_line in source.lines() {
let without_comment = match raw_line.find("//") {
Some(idx) => &raw_line[..idx],
None => raw_line,
};
let mut in_string = false;
let mut escaped = false;
for ch in without_comment.chars() {
if in_string {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'"' => {
in_string = false;
out.push('"');
}
_ => {}
}
continue;
}
if ch == '"' {
in_string = true;
out.push('"');
continue;
}
out.push(ch);
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::seams::{ExpectedSink, RepoSeam, SeamKind};
fn predicate_seam() -> RepoSeam {
RepoSeam::new(
"src/pricing.rs",
"pricing::discounted_total",
SeamKind::PredicateBoundary,
0,
1,
"amount >= discount_threshold",
RequiredDiscriminator::BoundaryValue {
description: "amount >= discount_threshold".to_string(),
},
ExpectedSink::ReturnValue,
)
}
fn invalidation_lines(
invalidations: &BTreeMap<String, Vec<SourcePosition>>,
ident: &str,
) -> Vec<usize> {
invalidations
.get(ident)
.into_iter()
.flatten()
.map(|position| position.line)
.collect()
}
#[test]
fn extract_let_bindings_picks_up_literal_rhs_and_skips_expressions() {
let body = "let a = 100;\nlet b: i32 = 200;\nlet mut c = 300;\nlet d = a + 1;\n";
let bindings = extract_let_bindings(body);
assert_eq!(bindings.get("a").map(String::as_str), Some("100"));
assert_eq!(bindings.get("b").map(String::as_str), Some("200"));
assert_eq!(bindings.get("c").map(String::as_str), Some("300"));
assert!(!bindings.contains_key("d"), "non-literal RHS must not bind");
}
#[test]
fn extract_struct_field_bindings_picks_up_literal_fields_only() -> Result<(), String> {
let body = "let case = DiscountCase { amount: 100, threshold: 100, \
computed: make_amount() };\n";
let (bindings, invalidations) = extract_struct_field_bindings(body, 1, &[]);
let binding = bindings
.get("case")
.ok_or_else(|| "same-test struct literal should be indexed".to_string())?;
let fields = &binding.fields;
assert_eq!(fields.get("amount").map(String::as_str), Some("100"));
assert_eq!(fields.get("threshold").map(String::as_str), Some("100"));
assert!(
!fields.contains_key("computed"),
"non-literal struct fields must stay unresolved"
);
assert!(
!invalidations.contains_key("case"),
"literal-only struct binding should not be invalidated"
);
Ok(())
}
#[test]
fn extract_struct_field_bindings_records_shadow_or_mutation_lines() {
let shadowed =
"let case = DiscountCase { amount: 100 };\nlet case = make_discount_case();\n";
let (bindings, invalidations) = extract_struct_field_bindings(shadowed, 1, &[]);
assert!(
bindings.contains_key("case"),
"literal binding remains available for calls before the shadow"
);
assert_eq!(invalidation_lines(&invalidations, "case"), vec![2]);
let mutated = "let case = DiscountCase { amount: 100 };\ncase.amount = make_amount();\n";
let (bindings, invalidations) = extract_struct_field_bindings(mutated, 1, &[]);
assert!(
bindings.contains_key("case"),
"literal binding remains available for calls before mutation"
);
assert_eq!(invalidation_lines(&invalidations, "case"), vec![2]);
let mutable = "let mut case = DiscountCase { amount: 100 };\n";
let (bindings, invalidations) = extract_struct_field_bindings(mutable, 1, &[]);
assert!(
!bindings.contains_key("case"),
"mutable fixture bindings must stay unresolved"
);
assert_eq!(invalidation_lines(&invalidations, "case"), vec![1]);
}
#[test]
fn extract_struct_field_bindings_records_test_function_parameter_invalidations() {
let body = "fn via_param(case: DiscountCase) { \
discounted_total(case.amount, case.threshold); \
let case = DiscountCase { amount: 100, threshold: 100 }; \
}\n";
let param_names = extract_fn_param_names(body);
let (_bindings, invalidations) = extract_struct_field_bindings(body, 1, ¶m_names);
assert!(
invalidation_lines(&invalidations, "case").contains(&1),
"fixture parameter names must invalidate same-name projection resolution"
);
}
#[test]
fn extract_struct_field_bindings_records_common_non_let_shadowing_lines() {
for (body, ident) in [
(
"let case = DiscountCase { amount: 100 };\n\
for case in helper_cases() { discounted_total(case.amount, 100); }\n",
"case",
),
(
"let q = Quote { amount: 100 };\n\
for q in helper_cases() { discounted_total(q.amount, 100); }\n",
"q",
),
(
"let case = DiscountCase { amount: 100 };\n\
if let Some(case) = make_case() { discounted_total(case.amount, 100); }\n",
"case",
),
(
"let case = DiscountCase { amount: 100 };\n\
cases.iter().for_each(|case| discounted_total(case.amount, 100));\n",
"case",
),
(
"let case = DiscountCase { amount: 100 };\n\
match make_case() { Some(case) => discounted_total(case.amount, 100), _ => 0 };\n",
"case",
),
] {
let (bindings, invalidations) = extract_struct_field_bindings(body, 1, &[]);
assert!(
bindings.contains_key(ident),
"literal binding remains available for calls before non-let shadowing: {body}"
);
assert!(
invalidation_lines(&invalidations, ident)
.iter()
.any(|line| *line >= 2),
"non-let shadowing binders must invalidate projection values at their line: {body}"
);
}
}
#[test]
fn extract_struct_field_bindings_records_non_simple_let_pattern_shadowing_lines() {
for body in [
"let case = DiscountCase { amount: 100 };\n\
let Some(case) = make_case() else { return; };\n\
discounted_total(case.amount, 100);\n",
"let case = DiscountCase { amount: 100 };\n\
let (case, _) = helper_case();\n\
discounted_total(case.amount, 100);\n",
] {
let (bindings, invalidations) = extract_struct_field_bindings(body, 1, &[]);
assert!(
bindings.contains_key("case"),
"literal binding remains available for calls before let-pattern shadowing: {body}"
);
assert!(
invalidation_lines(&invalidations, "case").contains(&2),
"non-simple let pattern binders must invalidate projection values at their line: {body}"
);
}
}
#[test]
fn resolve_same_test_struct_field_projection() {
let seam = predicate_seam();
let (struct_field_bindings, struct_field_invalidations) = extract_struct_field_bindings(
"let case = DiscountCase { amount: 100, discount_threshold: 100 };\n",
1,
&[],
);
let facts = ValueEnvFacts {
struct_field_bindings,
struct_field_invalidations,
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert_eq!(
env.resolve("case.amount"),
vec![("100".to_string(), ValueContext::FunctionArgument)]
);
assert!(
env.resolve("make_case().amount").is_empty(),
"helper-built fixture projections must remain opaque"
);
}
#[test]
fn resolve_shared_borrowed_identifier_once() {
let seam = predicate_seam();
let facts = ValueEnvFacts {
let_bindings: BTreeMap::from([("amount".to_string(), "100".to_string())]),
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert_eq!(
env.resolve("&amount"),
vec![("100".to_string(), ValueContext::FunctionArgument)]
);
assert!(
env.resolve("&mut amount").is_empty(),
"mutable borrows stay opaque until mutation ordering is tracked"
);
assert!(
env.resolve("&make_amount()").is_empty(),
"borrowed helper expressions must not invent activation values"
);
}
#[test]
fn resolve_same_test_struct_field_projection_is_source_order_scoped() {
let seam = predicate_seam();
let body = "let case = DiscountCase { amount: 100, discount_threshold: 100 };\n\
discounted_total(case.amount, case.discount_threshold);\n\
let case = make_discount_case();\n";
let (struct_field_bindings, struct_field_invalidations) =
extract_struct_field_bindings(body, 10, &[]);
let facts = ValueEnvFacts {
struct_field_bindings,
struct_field_invalidations,
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert_eq!(
env.resolve_at("case.amount", 11),
vec![("100".to_string(), ValueContext::FunctionArgument)],
"later shadowing must not erase values for an earlier owner call"
);
assert!(
env.resolve_at("case.amount", 12).is_empty(),
"projection values must stay unresolved once the shadowing line is reached"
);
}
#[test]
fn resolve_same_test_struct_field_projection_requires_value_visible_at_call() {
let seam = predicate_seam();
let body = "discounted_total(case.amount, case.discount_threshold);\n\
let case = DiscountCase { amount: 100, discount_threshold: 100 };\n";
let (struct_field_bindings, struct_field_invalidations) =
extract_struct_field_bindings(body, 10, &[]);
let facts = ValueEnvFacts {
struct_field_bindings,
struct_field_invalidations,
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert!(
env.resolve_at("case.amount", 10).is_empty(),
"later literals must not explain owner-call projections before the binding"
);
assert_eq!(
env.resolve_at("case.amount", 11),
vec![("100".to_string(), ValueContext::FunctionArgument)],
"the literal becomes available only once the binding line is reached"
);
}
#[test]
fn resolve_same_test_struct_field_projection_is_column_scoped_on_same_line() {
let seam = predicate_seam();
let late_body = "discounted_total(case.amount, case.discount_threshold); \
let case = DiscountCase { amount: 100, discount_threshold: 100 };\n";
let (struct_field_bindings, struct_field_invalidations) =
extract_struct_field_bindings(late_body, 10, &[]);
let facts = ValueEnvFacts {
struct_field_bindings,
struct_field_invalidations,
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert!(
env.resolve_at_call("case.amount", 10, "discounted_total", late_body.trim())
.is_empty(),
"same-line literals after the owner call must not explain earlier field projections"
);
let visible_body = "let case = DiscountCase { amount: 100, discount_threshold: 100 }; \
discounted_total(case.amount, case.discount_threshold);\n";
let (struct_field_bindings, struct_field_invalidations) =
extract_struct_field_bindings(visible_body, 10, &[]);
let facts = ValueEnvFacts {
struct_field_bindings,
struct_field_invalidations,
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert_eq!(
env.resolve_at_call("case.amount", 10, "discounted_total", visible_body.trim()),
vec![("100".to_string(), ValueContext::FunctionArgument)],
"same-line literals before the owner call remain safe activation values"
);
}
#[test]
fn resolve_same_test_struct_field_projection_is_mutation_scoped() {
let seam = predicate_seam();
let body = "let case = DiscountCase { amount: 100, discount_threshold: 100 };\n\
discounted_total(case.amount, case.discount_threshold);\n\
case.amount = make_amount();\n";
let (struct_field_bindings, struct_field_invalidations) =
extract_struct_field_bindings(body, 10, &[]);
let facts = ValueEnvFacts {
struct_field_bindings,
struct_field_invalidations,
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert_eq!(
env.resolve_at("case.amount", 11),
vec![("100".to_string(), ValueContext::FunctionArgument)],
"later mutation must not erase values for an earlier owner call"
);
assert!(
env.resolve_at("case.amount", 12).is_empty(),
"projection values must stay unresolved once the mutation line is reached"
);
}
#[test]
fn resolve_at_ignores_empty_arguments() {
let seam = predicate_seam();
let facts = ValueEnvFacts::default();
let env = ValueEnv::new(&seam, &facts);
assert!(
env.resolve_at(" ", 1).is_empty(),
"empty argument text must not produce activation values"
);
}
#[test]
fn resolve_at_unwraps_literal_path_constructors_only() {
let seam = predicate_seam();
let facts = ValueEnvFacts {
bare_std_path_imported: true,
bare_std_path_buf_imported: true,
..ValueEnvFacts::default()
};
let env = ValueEnv::new(&seam, &facts);
assert_eq!(
env.resolve_at(r#"Path::new("target/ripr/workflow")"#, 1),
vec![(
r#""target/ripr/workflow""#.to_string(),
ValueContext::FunctionArgument
)],
"Path::new string literals should become concrete activation values"
);
assert_eq!(
env.resolve_at(r#"PathBuf::from("target/ripr/workflow")"#, 1),
vec![(
r#""target/ripr/workflow""#.to_string(),
ValueContext::FunctionArgument
)],
"imported PathBuf::from string literals should become concrete activation values"
);
assert_eq!(
env.resolve_at(r#"std::path::Path::new(".")"#, 1),
vec![(r#"".""#.to_string(), ValueContext::FunctionArgument)],
"fully qualified Path::new literals should become concrete activation values"
);
assert_eq!(
env.resolve_at(r#"::std::path::Path::new(".")"#, 1),
vec![(r#"".""#.to_string(), ValueContext::FunctionArgument)],
"root-qualified Path::new literals should become concrete activation values"
);
assert_eq!(
env.resolve_at(r#"std::path::PathBuf::from(".")"#, 1),
vec![(r#"".""#.to_string(), ValueContext::FunctionArgument)],
"PathBuf::from string literals should become concrete activation values"
);
assert_eq!(
env.resolve_at(r#"::std::path::PathBuf::from(".")"#, 1),
vec![(r#"".""#.to_string(), ValueContext::FunctionArgument)],
"root-qualified PathBuf::from literals should become concrete activation values"
);
assert!(
env.resolve_at("Path::new(out_dir)", 1).is_empty(),
"non-literal path constructors must remain unresolved"
);
}
#[test]
fn resolve_at_requires_std_import_for_bare_path_constructors() {
let seam = predicate_seam();
let facts = ValueEnvFacts::default();
let env = ValueEnv::new(&seam, &facts);
assert!(
env.resolve_at(r#"Path::new("target/ripr/workflow")"#, 1)
.is_empty(),
"bare Path::new must stay unresolved without same-file std import evidence"
);
assert!(
env.resolve_at(r#"PathBuf::from("target/ripr/workflow")"#, 1)
.is_empty(),
"bare PathBuf::from must stay unresolved without same-file std import evidence"
);
assert_eq!(
env.resolve_at(r#"std::path::Path::new("target/ripr/workflow")"#, 1),
vec![(
r#""target/ripr/workflow""#.to_string(),
ValueContext::FunctionArgument
)],
"fully qualified std path constructors do not need bare import evidence"
);
}
#[test]
fn extract_path_constructor_imports_excludes_same_file_shadows() {
let imports = extract_path_constructor_imports(
r#"
use std::path::{Path, PathBuf};
fn uses_path(path: &Path) {}
"#,
);
assert!(imports.path, "brace import should enable bare Path::new");
assert!(
imports.path_buf,
"brace import should enable bare PathBuf::from"
);
let alias = extract_path_constructor_imports("use std::path::Path as StdPath;\n");
assert!(
!alias.path,
"aliased std Path import must not enable bare Path::new"
);
let shadow = extract_path_constructor_imports(
r#"
use std::path::Path;
struct Path;
"#,
);
assert!(
!shadow.path,
"same-file Path shadow must keep bare Path::new unresolved"
);
let visible_shadow = extract_path_constructor_imports(
r#"
use std::path::PathBuf;
pub(super) struct PathBuf;
"#,
);
assert!(
!visible_shadow.path_buf,
"same-file visible PathBuf shadow must keep bare PathBuf::from unresolved"
);
}
#[test]
fn extract_module_constants_finds_const_and_static_top_level() {
let source = "pub const A: i32 = 1;\nstatic B: i32 = 2;\n\
pub(crate) const C: i32 = 3;\n";
let consts = extract_module_constants(source);
assert_eq!(consts.get("A").map(String::as_str), Some("1"));
assert_eq!(consts.get("B").map(String::as_str), Some("2"));
assert_eq!(consts.get("C").map(String::as_str), Some("3"));
}
#[test]
fn looks_like_literal_accepts_numbers_strings_bools_paths_and_rejects_others() {
for ok in [
"100",
"-5",
"1_000",
"1.5",
"\"hi\"",
"true",
"false",
"None",
"Color::Red",
"MyError::ParseError",
] {
assert!(looks_like_literal(ok), "{ok} should look like a literal");
}
for bad in ["amount", "make_quote()", "x + 1"] {
assert!(
!looks_like_literal(bad),
"{bad} must not look like a literal"
);
}
}
#[test]
fn unwrap_option_or_result_peels_one_level_only() {
assert_eq!(unwrap_option_or_result("Some(100)").as_deref(), Some("100"));
assert_eq!(unwrap_option_or_result("Ok(42)").as_deref(), Some("42"));
assert_eq!(
unwrap_option_or_result("Err(MyError::A)").as_deref(),
Some("MyError::A")
);
assert_eq!(unwrap_option_or_result("100"), None);
}
#[test]
fn resolve_option_result_constructor_keeps_unresolved_inner_opaque() {
let seam = predicate_seam();
let facts = ValueEnvFacts::default();
let env = ValueEnv::new(&seam, &facts);
assert!(
env.resolve("Some(make_amount())").is_empty(),
"opaque constructor payloads must not become observed values"
);
}
#[test]
fn extract_rstest_cases_preserves_string_literal_whitespace() {
let test = TestSummary {
name: "t".to_string(),
file: std::path::PathBuf::from("tests/x.rs"),
start_line: 1,
end_line: 1,
body: "fn t(input: &str) { check(input); }".to_string(),
calls: Vec::new(),
assertions: Vec::new(),
literals: Vec::new(),
attrs: vec!["#[rstest]".to_string(), "#[case(\"a b\")]".to_string()],
};
let (cases, params) = extract_rstest_cases(&test);
assert_eq!(params, vec!["input"]);
assert_eq!(cases, vec![vec!["\"a b\"".to_string()]]);
}
#[test]
fn strip_comments_and_strings_removes_line_comments_and_string_contents() {
let input = "let x = 1; // let x = 999;\nlet s = \"shadow = 0\";\n";
let cleaned = strip_comments_and_strings(input);
assert!(
!cleaned.contains("999"),
"comment-shadowed value must be stripped"
);
assert!(
!cleaned.contains("shadow = 0"),
"string-shadowed value must be stripped"
);
}
#[test]
fn scan_for_table_loops_extracts_named_columns() {
let body = "for (a, b, c) in [(1, 2, 3), (4, 5, 6)] { let _ = (a, b, c); }\n";
let captures = scan_for_table_loops(body);
assert_eq!(captures.len(), 1);
let cap = &captures[0];
assert_eq!(cap.idents.len(), 3);
assert_eq!(cap.rows.len(), 2);
assert_eq!(cap.rows[0], vec!["1", "2", "3"]);
assert_eq!(cap.rows[1], vec!["4", "5", "6"]);
}
#[test]
fn scan_builder_calls_finds_method_chain_arguments() {
let body = "let q = Quote::new().amount(100).threshold(200).build();\n";
let calls = scan_builder_calls(body);
let methods: Vec<&str> = calls.iter().map(|c| c.method.as_str()).collect();
assert!(methods.contains(&"amount"));
assert!(methods.contains(&"threshold"));
assert!(methods.contains(&"build"));
}
#[test]
fn builder_method_match_accepts_fixture_override_prefixes_and_rejects_unrelated_methods() {
let allowed: std::collections::BTreeSet<String> = ["amount", "threshold"]
.into_iter()
.map(str::to_string)
.collect();
assert!(builder_method_matches_allowed("amount", &allowed));
assert!(builder_method_matches_allowed("with_amount", &allowed));
assert!(builder_method_matches_allowed("set_threshold", &allowed));
assert!(builder_method_matches_allowed("amount_cents", &allowed));
assert!(!builder_method_matches_allowed("with_seed", &allowed));
assert!(!builder_method_matches_allowed("discount", &allowed));
}
#[test]
fn allowed_builder_method_names_includes_required_discriminator_tokens() {
let seam = predicate_seam();
let test = TestSummary {
name: "t".to_string(),
file: std::path::PathBuf::from("tests/x.rs"),
start_line: 1,
end_line: 1,
body: String::new(),
calls: Vec::new(),
assertions: Vec::new(),
literals: Vec::new(),
attrs: Vec::new(),
};
let facts = ValueEnvFacts::default();
let env = ValueEnv::new(&seam, &facts);
let _ = &test;
let allowed = env.allowed_builder_method_names();
assert!(allowed.contains("amount"));
assert!(allowed.contains("discount_threshold"));
}
}