use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::Args;
use tree_sitter::{Node, Parser};
use tree_sitter_python::LANGUAGE as PYTHON_LANGUAGE;
use walkdir::WalkDir;
use crate::output::{OutputFormat, OutputWriter};
use super::error::{ContractsError, ContractsResult};
use super::types::{
Confidence, FunctionInvariants, Invariant, InvariantKind, InvariantsReport, InvariantsSummary,
OutputFormat as ContractsOutputFormat,
};
use super::validation::read_file_safe;
const MAX_AST_DEPTH: usize = 100;
#[derive(Debug, Args)]
pub struct InvariantsArgs {
pub file: PathBuf,
#[arg(long = "from-tests", short = 't')]
pub from_tests: PathBuf,
#[arg(
long = "output-format",
short = 'o',
hide = true,
default_value = "json"
)]
pub output_format: ContractsOutputFormat,
#[arg(long)]
pub function: Option<String>,
#[arg(long, default_value = "1")]
pub min_obs: u32,
#[arg(long, short = 'l')]
pub lang: Option<String>,
}
impl InvariantsArgs {
pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
let writer = OutputWriter::new(format, quiet);
if !self.file.exists() {
return Err(ContractsError::FileNotFound {
path: self.file.clone(),
}
.into());
}
if !self.from_tests.exists() {
return Err(ContractsError::TestPathNotFound {
path: self.from_tests.clone(),
}
.into());
}
writer.progress(&format!(
"Inferring invariants for {} from {}...",
self.file.display(),
self.from_tests.display()
));
let report = run_invariants(
&self.file,
&self.from_tests,
self.function.as_deref(),
self.min_obs,
)?;
let use_text = matches!(self.output_format, ContractsOutputFormat::Text)
|| matches!(format, OutputFormat::Text);
if use_text {
let text = format_invariants_text(&report);
writer.write_text(&text)?;
} else {
writer.write(&report)?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
struct Observation {
function_name: String,
args: Vec<ObservedValue>,
return_value: Option<ObservedValue>,
}
#[derive(Debug, Clone)]
enum ObservedValue {
Int(i64),
Float(f64),
String(String),
Bool(bool),
None,
List(Vec<ObservedValue>),
Other(String), }
impl ObservedValue {
fn type_name(&self) -> &'static str {
match self {
ObservedValue::Int(_) => "int",
ObservedValue::Float(_) => "float",
ObservedValue::String(s) => {
let _ = s.len();
"str"
}
ObservedValue::Bool(b) => {
let _ = *b;
"bool"
}
ObservedValue::None => "NoneType",
ObservedValue::List(items) => {
let _ = items.len();
"list"
}
ObservedValue::Other(text) => {
let _ = text.len();
"unknown"
}
}
}
fn is_none(&self) -> bool {
matches!(self, ObservedValue::None)
}
fn as_f64(&self) -> Option<f64> {
match self {
ObservedValue::Int(i) => Some(*i as f64),
ObservedValue::Float(f) => Some(*f),
_ => None,
}
}
}
pub fn run_invariants(
_source_path: &Path,
test_path: &Path,
function_filter: Option<&str>,
min_obs: u32,
) -> ContractsResult<InvariantsReport> {
let observations = collect_observations(test_path, function_filter)?;
let mut by_function: HashMap<String, Vec<Observation>> = HashMap::new();
for obs in observations {
by_function
.entry(obs.function_name.clone())
.or_default()
.push(obs);
}
let mut functions = Vec::new();
let mut total_observations = 0u32;
let mut total_invariants = 0u32;
let mut by_kind: HashMap<String, u32> = HashMap::new();
for (func_name, obs_list) in by_function.iter() {
let obs_count = obs_list.len() as u32;
total_observations += obs_count;
if obs_count < min_obs {
continue;
}
let (preconditions, postconditions) = infer_invariants_for_function(obs_list);
let preconditions: Vec<_> = preconditions
.into_iter()
.filter(|inv| inv.observations >= min_obs)
.collect();
let postconditions: Vec<_> = postconditions
.into_iter()
.filter(|inv| inv.observations >= min_obs)
.collect();
for inv in preconditions.iter().chain(postconditions.iter()) {
let kind_str = inv.kind.to_string();
*by_kind.entry(kind_str).or_default() += 1;
total_invariants += 1;
}
functions.push(FunctionInvariants {
function_name: func_name.clone(),
preconditions,
postconditions,
observation_count: obs_count,
});
}
functions.sort_by(|a, b| a.function_name.cmp(&b.function_name));
Ok(InvariantsReport {
functions,
summary: InvariantsSummary {
total_observations,
total_invariants,
by_kind,
},
})
}
fn collect_observations(
test_path: &Path,
function_filter: Option<&str>,
) -> ContractsResult<Vec<Observation>> {
let mut observations = Vec::new();
if test_path.is_file() {
let file_obs = extract_observations_from_file(test_path, function_filter)?;
observations.extend(file_obs);
} else {
for entry in WalkDir::new(test_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name.starts_with("test_") && file_name.ends_with(".py") {
match extract_observations_from_file(path, function_filter) {
Ok(file_obs) => observations.extend(file_obs),
Err(_) => continue, }
}
}
}
Ok(observations)
}
fn extract_observations_from_file(
path: &Path,
function_filter: Option<&str>,
) -> ContractsResult<Vec<Observation>> {
let source = read_file_safe(path)?;
let mut parser = Parser::new();
parser
.set_language(&PYTHON_LANGUAGE.into())
.map_err(|e| ContractsError::ParseError {
file: path.to_path_buf(),
message: e.to_string(),
})?;
let tree = parser
.parse(&source, None)
.ok_or_else(|| ContractsError::ParseError {
file: path.to_path_buf(),
message: "Failed to parse file".to_string(),
})?;
let root = tree.root_node();
let mut observations = Vec::new();
let mut current_test_function = String::new();
extract_observations_recursive(
&root,
&source,
&mut observations,
&mut current_test_function,
function_filter,
0,
);
Ok(observations)
}
fn extract_observations_recursive(
node: &Node,
source: &str,
observations: &mut Vec<Observation>,
current_test_function: &mut String,
function_filter: Option<&str>,
depth: usize,
) {
if depth > MAX_AST_DEPTH {
return;
}
match node.kind() {
"function_definition" => {
if let Some(name_node) = node.child_by_field_name("name") {
let name = node_text(name_node, source);
if name.starts_with("test_") {
*current_test_function = name;
}
}
}
"assert_statement" => {
if !current_test_function.is_empty() {
if let Some(obs) = extract_observation_from_assert(
node,
source,
function_filter,
) {
observations.push(obs);
}
}
}
"call" => {
if !current_test_function.is_empty() {
if let Some(obs) = extract_observation_from_call(
node,
source,
function_filter,
) {
observations.push(obs);
}
}
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
extract_observations_recursive(
&child,
source,
observations,
current_test_function,
function_filter,
depth + 1,
);
}
}
fn extract_observation_from_assert(
node: &Node,
source: &str,
function_filter: Option<&str>,
) -> Option<Observation> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "comparison_operator" {
if let Some(call_node) = find_call_in_subtree(&child) {
return extract_observation_from_call_with_expected(
&call_node,
&child,
source,
function_filter,
);
}
} else if child.kind() == "call" {
return extract_observation_from_call(&child, source, function_filter);
}
}
None
}
fn find_call_in_subtree<'a>(node: &Node<'a>) -> Option<Node<'a>> {
if node.kind() == "call" {
return Some(*node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(call) = find_call_in_subtree(&child) {
return Some(call);
}
}
None
}
fn extract_observation_from_call_with_expected(
call_node: &Node,
comparison_node: &Node,
source: &str,
function_filter: Option<&str>,
) -> Option<Observation> {
let function_name = extract_function_name(call_node, source)?;
if let Some(filter) = function_filter {
if function_name != filter {
return None;
}
}
let args = extract_call_arguments(call_node, source);
let return_value = extract_expected_value(comparison_node, call_node, source);
Some(Observation {
function_name,
args,
return_value,
})
}
fn extract_observation_from_call(
call_node: &Node,
source: &str,
function_filter: Option<&str>,
) -> Option<Observation> {
let function_name = extract_function_name(call_node, source)?;
if let Some(filter) = function_filter {
if function_name != filter {
return None;
}
}
let args = extract_call_arguments(call_node, source);
Some(Observation {
function_name,
args,
return_value: None,
})
}
fn extract_function_name(call_node: &Node, source: &str) -> Option<String> {
let func_node = call_node.child_by_field_name("function")?;
match func_node.kind() {
"identifier" => Some(node_text(func_node, source)),
"attribute" => {
func_node
.child_by_field_name("attribute")
.map(|n| node_text(n, source))
}
_ => None,
}
}
fn extract_call_arguments(call_node: &Node, source: &str) -> Vec<ObservedValue> {
let mut args = Vec::new();
if let Some(args_node) = call_node.child_by_field_name("arguments") {
let mut cursor = args_node.walk();
for child in args_node.children(&mut cursor) {
if child.kind() != "(" && child.kind() != ")" && child.kind() != "," {
if child.kind() != "keyword_argument" {
args.push(parse_value(&child, source));
}
}
}
}
args
}
fn extract_expected_value(
comparison_node: &Node,
call_node: &Node,
source: &str,
) -> Option<ObservedValue> {
let mut cursor = comparison_node.walk();
for child in comparison_node.children(&mut cursor) {
if child.id() != call_node.id()
&& child.kind() != "=="
&& child.kind() != "!="
&& child.kind() != "comparison_operator"
{
return Some(parse_value(&child, source));
}
}
None
}
fn parse_value(node: &Node, source: &str) -> ObservedValue {
let text = node_text(*node, source);
match node.kind() {
"integer" => text
.parse::<i64>()
.map(ObservedValue::Int)
.unwrap_or(ObservedValue::Other(text)),
"float" => text
.parse::<f64>()
.map(ObservedValue::Float)
.unwrap_or(ObservedValue::Other(text)),
"string" | "concatenated_string" => {
let trimmed = text
.trim_start_matches(['"', '\''])
.trim_end_matches(['"', '\'']);
ObservedValue::String(trimmed.to_string())
}
"true" => ObservedValue::Bool(true),
"false" => ObservedValue::Bool(false),
"none" => ObservedValue::None,
"list" => {
let mut items = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "[" && child.kind() != "]" && child.kind() != "," {
items.push(parse_value(&child, source));
}
}
ObservedValue::List(items)
}
"unary_operator" => {
if text.starts_with('-') {
if let Ok(i) = text.parse::<i64>() {
return ObservedValue::Int(i);
}
if let Ok(f) = text.parse::<f64>() {
return ObservedValue::Float(f);
}
}
ObservedValue::Other(text)
}
_ => ObservedValue::Other(text),
}
}
fn node_text(node: Node, source: &str) -> String {
source[node.byte_range()].to_string()
}
fn infer_invariants_for_function(observations: &[Observation]) -> (Vec<Invariant>, Vec<Invariant>) {
let n = observations.len() as u32;
if n == 0 {
return (Vec::new(), Vec::new());
}
let confidence = confidence_from_observations(n);
let mut preconditions = Vec::new();
let mut postconditions = Vec::new();
let max_args = observations.iter().map(|o| o.args.len()).max().unwrap_or(0);
for arg_idx in 0..max_args {
let values: Vec<_> = observations
.iter()
.filter_map(|o| o.args.get(arg_idx))
.collect();
if values.is_empty() {
continue;
}
let param_name = format!("arg{}", arg_idx);
if let Some(inv) = infer_type_invariant(¶m_name, &values, n, confidence) {
preconditions.push(inv);
}
if let Some(inv) = infer_non_null_invariant(¶m_name, &values, n, confidence) {
preconditions.push(inv);
}
let numeric_values: Vec<f64> = values.iter().filter_map(|v| v.as_f64()).collect();
if !numeric_values.is_empty() && numeric_values.len() == values.len() {
if let Some(inv) =
infer_non_negative_invariant(¶m_name, &numeric_values, n, confidence)
{
preconditions.push(inv);
}
if let Some(inv) = infer_positive_invariant(¶m_name, &numeric_values, n, confidence)
{
preconditions.push(inv);
}
if let Some(inv) = infer_range_invariant(¶m_name, &numeric_values, n, confidence) {
preconditions.push(inv);
}
}
}
for i in 0..max_args {
for j in (i + 1)..max_args {
if let Some(inv) = infer_relation_invariant(observations, i, j, n, confidence) {
preconditions.push(inv);
}
}
}
let return_values: Vec<_> = observations
.iter()
.filter_map(|o| o.return_value.as_ref())
.collect();
if !return_values.is_empty() {
if let Some(inv) = infer_type_invariant("result", &return_values, n, confidence) {
postconditions.push(inv);
}
if let Some(inv) = infer_non_null_invariant("result", &return_values, n, confidence) {
postconditions.push(inv);
}
let numeric_results: Vec<f64> = return_values.iter().filter_map(|v| v.as_f64()).collect();
if !numeric_results.is_empty() && numeric_results.len() == return_values.len() {
if let Some(inv) =
infer_non_negative_invariant("result", &numeric_results, n, confidence)
{
postconditions.push(inv);
}
if let Some(inv) = infer_positive_invariant("result", &numeric_results, n, confidence) {
postconditions.push(inv);
}
if let Some(inv) = infer_range_invariant("result", &numeric_results, n, confidence) {
postconditions.push(inv);
}
}
}
(preconditions, postconditions)
}
fn confidence_from_observations(n: u32) -> Confidence {
if n >= 10 {
Confidence::High
} else if n >= 5 {
Confidence::Medium
} else {
Confidence::Low
}
}
fn infer_type_invariant(
variable: &str,
values: &[&ObservedValue],
obs_count: u32,
confidence: Confidence,
) -> Option<Invariant> {
if values.is_empty() {
return None;
}
let first_type = values[0].type_name();
if values.iter().all(|v| v.type_name() == first_type) && first_type != "unknown" {
Some(Invariant {
variable: variable.to_string(),
kind: InvariantKind::Type,
expression: format!("{}: {}", variable, first_type),
confidence,
observations: obs_count,
counterexample_count: 0,
})
} else {
None
}
}
fn infer_non_null_invariant(
variable: &str,
values: &[&ObservedValue],
obs_count: u32,
confidence: Confidence,
) -> Option<Invariant> {
if values.is_empty() {
return None;
}
if values.iter().all(|v| !v.is_none()) {
Some(Invariant {
variable: variable.to_string(),
kind: InvariantKind::NonNull,
expression: format!("{} is not None", variable),
confidence,
observations: obs_count,
counterexample_count: 0,
})
} else {
None
}
}
fn infer_non_negative_invariant(
variable: &str,
values: &[f64],
obs_count: u32,
confidence: Confidence,
) -> Option<Invariant> {
if values.is_empty() {
return None;
}
if values.iter().all(|v| *v >= 0.0) && values.contains(&0.0) {
Some(Invariant {
variable: variable.to_string(),
kind: InvariantKind::NonNegative,
expression: format!("{} >= 0", variable),
confidence,
observations: obs_count,
counterexample_count: 0,
})
} else {
None
}
}
fn infer_positive_invariant(
variable: &str,
values: &[f64],
obs_count: u32,
confidence: Confidence,
) -> Option<Invariant> {
if values.is_empty() {
return None;
}
if values.iter().all(|v| *v > 0.0) {
Some(Invariant {
variable: variable.to_string(),
kind: InvariantKind::Positive,
expression: format!("{} > 0", variable),
confidence,
observations: obs_count,
counterexample_count: 0,
})
} else {
None
}
}
fn infer_range_invariant(
variable: &str,
values: &[f64],
obs_count: u32,
confidence: Confidence,
) -> Option<Invariant> {
if values.is_empty() {
return None;
}
let min_val = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max_val = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if min_val < max_val {
Some(Invariant {
variable: variable.to_string(),
kind: InvariantKind::Range,
expression: format!("{} <= {} <= {}", min_val, variable, max_val),
confidence,
observations: obs_count,
counterexample_count: 0,
})
} else {
None
}
}
fn infer_relation_invariant(
observations: &[Observation],
idx1: usize,
idx2: usize,
obs_count: u32,
confidence: Confidence,
) -> Option<Invariant> {
let pairs: Vec<(f64, f64)> = observations
.iter()
.filter_map(|o| {
let v1 = o.args.get(idx1)?.as_f64()?;
let v2 = o.args.get(idx2)?.as_f64()?;
Some((v1, v2))
})
.collect();
if pairs.is_empty() {
return None;
}
let param1 = format!("arg{}", idx1);
let param2 = format!("arg{}", idx2);
if pairs.iter().all(|(v1, v2)| v1 < v2) {
return Some(Invariant {
variable: format!("{},{}", param1, param2),
kind: InvariantKind::Relation,
expression: format!("{} < {}", param1, param2),
confidence,
observations: obs_count,
counterexample_count: 0,
});
}
if pairs.iter().all(|(v1, v2)| v1 <= v2) {
return Some(Invariant {
variable: format!("{},{}", param1, param2),
kind: InvariantKind::Relation,
expression: format!("{} <= {}", param1, param2),
confidence,
observations: obs_count,
counterexample_count: 0,
});
}
if pairs.iter().all(|(v1, v2)| v1 > v2) {
return Some(Invariant {
variable: format!("{},{}", param1, param2),
kind: InvariantKind::Relation,
expression: format!("{} > {}", param1, param2),
confidence,
observations: obs_count,
counterexample_count: 0,
});
}
if pairs.iter().all(|(v1, v2)| v1 >= v2) {
return Some(Invariant {
variable: format!("{},{}", param1, param2),
kind: InvariantKind::Relation,
expression: format!("{} >= {}", param1, param2),
confidence,
observations: obs_count,
counterexample_count: 0,
});
}
None
}
pub fn format_invariants_text(report: &InvariantsReport) -> String {
let mut lines = Vec::new();
for fi in &report.functions {
lines.push(format!(
"Function: {} ({} observations)",
fi.function_name, fi.observation_count
));
if !fi.preconditions.is_empty() {
for inv in &fi.preconditions {
lines.push(format!(
" Requires: {} [{}]",
inv.expression, inv.confidence
));
}
}
if !fi.postconditions.is_empty() {
for inv in &fi.postconditions {
lines.push(format!(
" Ensures: {} [{}]",
inv.expression, inv.confidence
));
}
}
if fi.preconditions.is_empty() && fi.postconditions.is_empty() {
lines.push(" (no invariants inferred)".to_string());
}
lines.push(String::new());
}
lines.push(format!(
"Summary: {} observations, {} invariants",
report.summary.total_observations, report.summary.total_invariants
));
if !report.summary.by_kind.is_empty() {
let kinds: Vec<_> = report
.summary
.by_kind
.iter()
.map(|(k, v)| format!("{}: {}", k, v))
.collect();
lines.push(format!("By kind: {}", kinds.join(", ")));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_files(temp: &TempDir, source: &str, test: &str) -> (PathBuf, PathBuf) {
let src_path = temp.path().join("src.py");
let test_path = temp.path().join("test_src.py");
fs::write(&src_path, source).unwrap();
fs::write(&test_path, test).unwrap();
(src_path, test_path)
}
#[test]
fn test_invariants_type_inference() {
let temp = TempDir::new().unwrap();
let (src_path, test_path) = create_test_files(
&temp,
"def compute(x, y): return x + y",
r#"
from src import compute
def test_compute_ints():
assert compute(1, 2) == 3
assert compute(5, 10) == 15
assert compute(0, 0) == 0
"#,
);
let report = run_invariants(&src_path, &test_path, None, 1).unwrap();
assert!(!report.functions.is_empty());
let func = report
.functions
.iter()
.find(|f| f.function_name == "compute");
assert!(func.is_some());
let func = func.unwrap();
let type_invs: Vec<_> = func
.preconditions
.iter()
.filter(|i| i.kind == InvariantKind::Type)
.collect();
assert!(!type_invs.is_empty(), "Should detect type invariants");
}
#[test]
fn test_invariants_non_null() {
let temp = TempDir::new().unwrap();
let (src_path, test_path) = create_test_files(
&temp,
"def process(data): return data.strip()",
r#"
from src import process
def test_process_strings():
assert process("hello") == "hello"
assert process(" world ") == "world"
assert process("test") == "test"
"#,
);
let report = run_invariants(&src_path, &test_path, None, 1).unwrap();
let func = report
.functions
.iter()
.find(|f| f.function_name == "process");
assert!(func.is_some());
let func = func.unwrap();
let non_null_invs: Vec<_> = func
.preconditions
.iter()
.filter(|i| i.kind == InvariantKind::NonNull)
.collect();
assert!(
!non_null_invs.is_empty(),
"Should detect non-null invariant"
);
}
#[test]
fn test_invariants_numeric_bounds() {
let temp = TempDir::new().unwrap();
let (src_path, test_path) = create_test_files(
&temp,
"def square(x): return x * x",
r#"
from src import square
def test_square_positive():
assert square(1) == 1
assert square(2) == 4
assert square(3) == 9
assert square(10) == 100
"#,
);
let report = run_invariants(&src_path, &test_path, None, 1).unwrap();
let func = report
.functions
.iter()
.find(|f| f.function_name == "square");
assert!(func.is_some());
let func = func.unwrap();
let positive_invs: Vec<_> = func
.preconditions
.iter()
.filter(|i| i.kind == InvariantKind::Positive)
.collect();
assert!(
!positive_invs.is_empty(),
"Should detect positive invariant"
);
}
#[test]
fn test_invariants_ordering_relations() {
let temp = TempDir::new().unwrap();
let (src_path, test_path) = create_test_files(
&temp,
"def bounded_compute(start, end): return end - start",
r#"
from src import bounded_compute
def test_bounded_compute():
assert bounded_compute(0, 10) == 10
assert bounded_compute(5, 15) == 10
assert bounded_compute(100, 200) == 100
"#,
);
let report = run_invariants(&src_path, &test_path, None, 1).unwrap();
let func = report
.functions
.iter()
.find(|f| f.function_name == "bounded_compute");
assert!(func.is_some());
let func = func.unwrap();
let relation_invs: Vec<_> = func
.preconditions
.iter()
.filter(|i| i.kind == InvariantKind::Relation)
.collect();
assert!(!relation_invs.is_empty(), "Should detect ordering relation");
}
#[test]
fn test_invariants_confidence_scoring() {
let temp = TempDir::new().unwrap();
let src_path = temp.path().join("func.py");
let test_path = temp.path().join("test_func.py");
fs::write(&src_path, "def identity(x): return x").unwrap();
let mut test_code = String::from("from func import identity\n\n");
for i in 0..15 {
test_code.push_str(&format!(
"def test_identity_{}(): assert identity({}) == {}\n",
i, i, i
));
}
fs::write(&test_path, test_code).unwrap();
let report = run_invariants(&src_path, &test_path, None, 1).unwrap();
let func = report
.functions
.iter()
.find(|f| f.function_name == "identity");
assert!(func.is_some());
let func = func.unwrap();
assert!(func.observation_count >= 10);
for inv in &func.preconditions {
assert_eq!(
inv.confidence,
Confidence::High,
"Should have high confidence with 15 observations"
);
}
}
#[test]
fn test_invariants_min_obs_filter() {
let temp = TempDir::new().unwrap();
let (src_path, test_path) = create_test_files(
&temp,
"def add(a, b): return a + b",
r#"
from src import add
def test_add(): assert add(1, 2) == 3
"#,
);
let report = run_invariants(&src_path, &test_path, None, 5).unwrap();
for func in &report.functions {
assert!(
func.preconditions.is_empty(),
"Should filter out invariants with < 5 observations"
);
}
}
#[test]
fn test_invariants_json_output() {
let temp = TempDir::new().unwrap();
let (src_path, test_path) = create_test_files(
&temp,
"def add(a, b): return a + b",
r#"
from src import add
def test_add():
assert add(1, 2) == 3
assert add(2, 3) == 5
"#,
);
let report = run_invariants(&src_path, &test_path, None, 1).unwrap();
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("functions"));
assert!(json.contains("summary"));
}
#[test]
fn test_invariants_text_output() {
let temp = TempDir::new().unwrap();
let (src_path, test_path) = create_test_files(
&temp,
"def add(a, b): return a + b",
r#"
from src import add
def test_add():
assert add(1, 2) == 3
"#,
);
let report = run_invariants(&src_path, &test_path, None, 1).unwrap();
let text = format_invariants_text(&report);
assert!(text.contains("Function:"));
assert!(text.contains("observations"));
}
}