1use std::collections::{HashMap, HashSet};
25use std::path::{Path, PathBuf};
26use std::time::Instant;
27
28use anyhow::Result;
29use clap::Args;
30use walkdir::WalkDir;
31
32use tldr_core::Language;
33
34use crate::output::{OutputFormat, OutputWriter};
35
36use super::contracts::run_contracts;
37use super::error::{ContractsError, ContractsResult};
38use super::specs::run_specs;
39use super::types::ContractsReport;
40use super::types::{
41 CoverageInfo, OutputFormat as ContractsOutputFormat, SubAnalysisResult, SubAnalysisStatus,
42 VerifyReport, VerifySummary,
43};
44const MAX_FILES: usize = 500;
53
54#[derive(Debug, Args)]
71pub struct VerifyArgs {
72 #[arg(default_value = ".")]
74 pub path: PathBuf,
75
76 #[arg(
78 long = "output-format",
79 short = 'o',
80 hide = true,
81 default_value = "json"
82 )]
83 pub output_format: ContractsOutputFormat,
84
85 #[arg(long, short = 'l')]
87 pub lang: Option<Language>,
88
89 #[arg(long)]
91 pub detail: Option<String>,
92
93 #[arg(long)]
95 pub quick: bool,
96}
97
98impl VerifyArgs {
99 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
101 let writer = OutputWriter::new(format, quiet);
102
103 let canonical_path = if self.path.exists() {
105 std::fs::canonicalize(&self.path).unwrap_or_else(|_| self.path.clone())
106 } else {
107 return Err(ContractsError::FileNotFound {
108 path: self.path.clone(),
109 }
110 .into());
111 };
112
113 writer.progress(&format!(
114 "Running verification on {}...",
115 self.path.display()
116 ));
117
118 let language = self.lang.unwrap_or_else(|| {
120 if self.path.is_file() {
121 Language::from_path(&self.path).unwrap_or(Language::Python)
122 } else {
123 Language::from_directory(&self.path).unwrap_or(Language::Python)
124 }
125 });
126
127 let report = run_verify(
129 &canonical_path,
130 language,
131 self.quick,
132 self.detail.as_deref(),
133 )?;
134
135 let use_text = matches!(self.output_format, ContractsOutputFormat::Text)
137 || matches!(format, OutputFormat::Text);
138
139 if use_text {
140 let text = format_verify_text(&report);
141 writer.write_text(&text)?;
142 } else {
143 writer.write(&report)?;
144 }
145
146 Ok(())
147 }
148}
149
150pub fn run_verify(
165 path: &Path,
166 language: Language,
167 quick: bool,
168 detail: Option<&str>,
169) -> ContractsResult<VerifyReport> {
170 let start_time = Instant::now();
171
172 let files = collect_source_files(path, language)?;
174 let files_analyzed = files.len() as u32;
175
176 let mut sub_results: HashMap<String, SubAnalysisResult> = HashMap::new();
178 let mut files_failed = 0u32;
179
180 let contracts_result = sweep_contracts(&files, language, detail);
183 if let Some(ref err) = contracts_result.error {
184 files_failed += count_failures_from_error(err);
185 }
186 sub_results.insert("contracts".to_string(), contracts_result);
187
188 let test_dirs = find_test_dirs(path);
190 if !test_dirs.is_empty() {
191 let specs_result = sweep_specs(&test_dirs[0], detail);
192 sub_results.insert("specs".to_string(), specs_result);
193 } else {
194 sub_results.insert(
195 "specs".to_string(),
196 SubAnalysisResult {
197 name: "specs".to_string(),
198 status: SubAnalysisStatus::Failed,
199 items_found: 0,
200 elapsed_ms: 0,
201 error: Some("No test directory found".to_string()),
202 data: None,
203 },
204 );
205 }
206
207 if !quick {
209 let bounds_result = sweep_bounds(&files, language, detail);
210 sub_results.insert("bounds".to_string(), bounds_result);
211 }
212
213 let dead_stores_result = sweep_dead_stores(&files, language, detail);
215 sub_results.insert("dead_stores".to_string(), dead_stores_result);
216
217 if !quick && !test_dirs.is_empty() {
219 sub_results.insert(
220 "invariants".to_string(),
221 SubAnalysisResult {
222 name: "invariants".to_string(),
223 status: SubAnalysisStatus::Skipped,
224 items_found: 0,
225 elapsed_ms: 0,
226 error: Some(
227 "Invariants analysis requires test execution (not yet implemented)".to_string(),
228 ),
229 data: None,
230 },
231 );
232 }
233
234 let summary = build_verify_summary(&sub_results, files_analyzed);
236
237 let total_elapsed_ms = (start_time.elapsed().as_millis() as u64).max(1);
238
239 let partial_results = sub_results.values().any(|r| {
241 matches!(
242 r.status,
243 SubAnalysisStatus::Partial | SubAnalysisStatus::Failed
244 )
245 });
246
247 Ok(VerifyReport {
248 path: path.to_path_buf(),
249 sub_results,
250 summary,
251 total_elapsed_ms,
252 files_analyzed,
253 files_failed,
254 partial_results,
255 })
256}
257
258fn collect_source_files(path: &Path, language: Language) -> ContractsResult<Vec<PathBuf>> {
260 let extension = match language {
261 Language::Python => "py",
262 Language::TypeScript | Language::JavaScript => "ts",
263 Language::Rust => "rs",
264 Language::Go => "go",
265 Language::Java => "java",
266 _ => "py", };
268
269 let mut files = Vec::new();
270
271 if path.is_file() {
272 files.push(path.to_path_buf());
273 } else {
274 for entry in WalkDir::new(path)
275 .into_iter()
276 .filter_map(|e| e.ok())
277 .filter(|e| {
278 e.path().is_file()
279 && e.path()
280 .extension()
281 .is_some_and(|ext| ext == extension)
282 && !e.file_name().to_str().is_some_and(|n| n.starts_with("test_"))
284 })
285 {
286 files.push(entry.path().to_path_buf());
287
288 if files.len() >= MAX_FILES {
290 break;
291 }
292 }
293 }
294
295 Ok(files)
296}
297
298fn find_test_dirs(project_path: &Path) -> Vec<PathBuf> {
300 let mut candidates = Vec::new();
301
302 for name in &["tests", "test"] {
304 let dir = project_path.join(name);
305 if dir.is_dir() {
306 candidates.push(dir);
307 }
308 }
309
310 if let Ok(entries) = std::fs::read_dir(project_path) {
312 for entry in entries.filter_map(|e| e.ok()) {
313 let path = entry.path();
314 if path.is_file() {
315 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
316 if name.starts_with("test_") && name.ends_with(".py") {
317 candidates.push(path);
318 }
319 }
320 }
321 }
322 }
323
324 candidates
325}
326
327fn sweep_contracts(
333 files: &[PathBuf],
334 language: Language,
335 _detail: Option<&str>,
336) -> SubAnalysisResult {
337 let start = Instant::now();
338 let mut total_contracts = 0u32;
339 let mut all_results: Vec<ContractsReport> = Vec::new();
340 let mut errors: Vec<String> = Vec::new();
341
342 for file in files {
343 match analyze_file_contracts(file, language) {
345 Ok(reports) => {
346 for report in reports {
347 total_contracts += report.preconditions.len() as u32;
348 total_contracts += report.postconditions.len() as u32;
349 total_contracts += report.invariants.len() as u32;
350 all_results.push(report);
351 }
352 }
353 Err(e) => {
354 errors.push(format!("{}: {}", file.display(), e));
355 }
356 }
357 }
358
359 let status = if errors.is_empty() {
360 SubAnalysisStatus::Success
361 } else if !all_results.is_empty() {
362 SubAnalysisStatus::Partial
363 } else {
364 SubAnalysisStatus::Failed
365 };
366
367 SubAnalysisResult {
368 name: "contracts".to_string(),
369 status,
370 items_found: total_contracts,
371 elapsed_ms: start.elapsed().as_millis() as u64,
372 error: if errors.is_empty() {
373 None
374 } else {
375 Some(errors.join("; "))
376 },
377 data: Some(serde_json::to_value(&all_results).unwrap_or(serde_json::Value::Null)),
378 }
379}
380
381fn analyze_file_contracts(
383 file: &Path,
384 language: Language,
385) -> ContractsResult<Vec<ContractsReport>> {
386 let source = std::fs::read_to_string(file)?;
387 let functions = extract_function_names(&source, language)?;
388
389 let mut reports = Vec::new();
390 for func_name in functions {
391 match run_contracts(file, &func_name, language, 100) {
392 Ok(report) => reports.push(report),
393 Err(_) => continue, }
395 }
396
397 Ok(reports)
398}
399
400fn extract_function_names(source: &str, _language: Language) -> ContractsResult<Vec<String>> {
402 let mut names = Vec::new();
404 for line in source.lines() {
405 let trimmed = line.trim();
406 if trimmed.starts_with("def ") {
407 if let Some(name_end) = trimmed.find('(') {
408 let name = &trimmed[4..name_end].trim();
409 if !name.is_empty() {
410 names.push(name.to_string());
411 }
412 }
413 }
414 }
415 Ok(names)
416}
417
418fn sweep_specs(test_path: &Path, _detail: Option<&str>) -> SubAnalysisResult {
420 let start = Instant::now();
421
422 match run_specs(test_path, None) {
423 Ok(report) => {
424 let total_specs = report.summary.total_specs;
425 SubAnalysisResult {
426 name: "specs".to_string(),
427 status: SubAnalysisStatus::Success,
428 items_found: total_specs,
429 elapsed_ms: start.elapsed().as_millis() as u64,
430 error: None,
431 data: Some(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)),
432 }
433 }
434 Err(e) => SubAnalysisResult {
435 name: "specs".to_string(),
436 status: SubAnalysisStatus::Failed,
437 items_found: 0,
438 elapsed_ms: start.elapsed().as_millis() as u64,
439 error: Some(e.to_string()),
440 data: None,
441 },
442 }
443}
444
445fn sweep_bounds(
447 _files: &[PathBuf],
448 _language: Language,
449 _detail: Option<&str>,
450) -> SubAnalysisResult {
451 let start = Instant::now();
452
453 SubAnalysisResult {
456 name: "bounds".to_string(),
457 status: SubAnalysisStatus::Skipped,
458 items_found: 0,
459 elapsed_ms: start.elapsed().as_millis() as u64,
460 error: Some("Bounds sweep not yet integrated".to_string()),
461 data: None,
462 }
463}
464
465fn sweep_dead_stores(
467 _files: &[PathBuf],
468 _language: Language,
469 _detail: Option<&str>,
470) -> SubAnalysisResult {
471 let start = Instant::now();
472
473 SubAnalysisResult {
476 name: "dead_stores".to_string(),
477 status: SubAnalysisStatus::Skipped,
478 items_found: 0,
479 elapsed_ms: start.elapsed().as_millis() as u64,
480 error: Some("Dead stores sweep not yet integrated".to_string()),
481 data: None,
482 }
483}
484
485fn build_verify_summary(
491 sub_results: &HashMap<String, SubAnalysisResult>,
492 total_files: u32,
493) -> VerifySummary {
494 let spec_count = sub_results.get("specs").map(|r| r.items_found).unwrap_or(0);
496
497 let contract_count = sub_results
498 .get("contracts")
499 .map(|r| r.items_found)
500 .unwrap_or(0);
501
502 let invariant_count = sub_results
503 .get("invariants")
504 .map(|r| r.items_found)
505 .unwrap_or(0);
506
507 let coverage = compute_coverage(sub_results, total_files);
509
510 VerifySummary {
511 spec_count,
512 invariant_count,
513 contract_count,
514 annotated_count: 0, behavioral_count: 0, pattern_count: 0,
517 pattern_high_confidence: 0,
518 coverage,
519 }
520}
521
522fn compute_coverage(
524 sub_results: &HashMap<String, SubAnalysisResult>,
525 total_files: u32,
526) -> CoverageInfo {
527 let mut constrained_functions: HashSet<String> = HashSet::new();
528 let mut total_functions: HashSet<String> = HashSet::new();
529
530 if let Some(contracts_result) = sub_results.get("contracts") {
532 if let Some(data) = &contracts_result.data {
533 if let Some(reports) = data.as_array() {
534 for report in reports {
535 if let Some(func_name) = report.get("function").and_then(|f| f.as_str()) {
536 total_functions.insert(func_name.to_string());
537
538 let has_pre = report
540 .get("preconditions")
541 .and_then(|p| p.as_array())
542 .is_some_and(|a| !a.is_empty());
543 let has_post = report
544 .get("postconditions")
545 .and_then(|p| p.as_array())
546 .is_some_and(|a| !a.is_empty());
547 let has_inv = report
548 .get("invariants")
549 .and_then(|i| i.as_array())
550 .is_some_and(|a| !a.is_empty());
551
552 if has_pre || has_post || has_inv {
553 constrained_functions.insert(func_name.to_string());
554 }
555 }
556 }
557 }
558 }
559 }
560
561 let total = if total_functions.is_empty() {
563 total_files
564 } else {
565 total_functions.len() as u32
566 };
567
568 let constrained = constrained_functions.len() as u32;
569 let coverage_pct = if total > 0 {
570 (constrained as f64 / total as f64 * 100.0).round() / 1.0 } else {
572 0.0
573 };
574
575 CoverageInfo {
576 constrained_functions: constrained,
577 total_functions: total,
578 coverage_pct,
579 }
580}
581
582fn count_failures_from_error(error: &str) -> u32 {
584 (error.matches(';').count() + 1) as u32
586}
587
588pub fn format_verify_text(report: &VerifyReport) -> String {
594 let s = &report.summary;
595 let cov = &s.coverage;
596
597 let mut lines = vec![
598 format!("Verification: {}", report.path.display()),
599 "=".repeat(50),
600 format!("Test Specs: {} behavioral specs extracted", s.spec_count),
601 format!("Invariants: {} inferred invariants", s.invariant_count),
602 format!(
603 "Contracts: {} pre/postconditions inferred",
604 s.contract_count
605 ),
606 format!(
607 "Annotations: {} Annotated[T] constraints found",
608 s.annotated_count
609 ),
610 format!(
611 "Behaviors: {} functions with behavioral models",
612 s.behavioral_count
613 ),
614 format!(
615 "Patterns: {} project patterns ({} high-confidence)",
616 s.pattern_count, s.pattern_high_confidence
617 ),
618 String::new(),
619 "Constraint Coverage:".to_string(),
620 format!(
621 " Functions with any constraint: {}/{} ({:.1}%)",
622 cov.constrained_functions, cov.total_functions, cov.coverage_pct
623 ),
624 String::new(),
625 format!("Elapsed: {}ms", report.total_elapsed_ms),
626 ];
627
628 let failed: Vec<&str> = report
630 .sub_results
631 .iter()
632 .filter(|(_, r)| matches!(r.status, SubAnalysisStatus::Failed))
633 .map(|(name, _)| name.as_str())
634 .collect();
635
636 if !failed.is_empty() {
637 lines.push(format!("Errors: {}", failed.join(", ")));
638 }
639
640 lines.join("\n")
641}
642
643#[cfg(test)]
648mod tests {
649 use super::*;
650 use std::fs;
651 use tempfile::TempDir;
652
653 const PYTHON_WITH_CONTRACTS: &str = r#"
654def constrained(x):
655 if x < 0:
656 raise ValueError("x must be non-negative")
657 return x * 2
658
659def unconstrained(y):
660 return y * 3
661"#;
662
663 const PYTHON_TEST_FILE: &str = r#"
664import pytest
665from mymodule import add, validate
666
667def test_add():
668 assert add(2, 3) == 5
669
670def test_validate_raises():
671 with pytest.raises(ValueError):
672 validate("")
673"#;
674
675 #[test]
680 fn test_verify_full_sweep() {
681 let temp = TempDir::new().unwrap();
682 let src_dir = temp.path().join("src");
683 let test_dir = temp.path().join("tests");
684 fs::create_dir(&src_dir).unwrap();
685 fs::create_dir(&test_dir).unwrap();
686
687 fs::write(src_dir.join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
688 fs::write(test_dir.join("test_module.py"), PYTHON_TEST_FILE).unwrap();
689
690 let report = run_verify(temp.path(), Language::Python, false, None).unwrap();
691
692 assert!(report.sub_results.contains_key("contracts"));
694 assert!(report.sub_results.contains_key("specs"));
695 assert!(report.total_elapsed_ms > 0);
696 }
697
698 #[test]
699 fn test_verify_quick_mode() {
700 let temp = TempDir::new().unwrap();
701 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
702
703 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
704
705 if let Some(invariants) = report.sub_results.get("invariants") {
707 assert!(
708 matches!(
709 invariants.status,
710 SubAnalysisStatus::Skipped | SubAnalysisStatus::Failed
711 ),
712 "Invariants should be skipped in quick mode"
713 );
714 }
715 }
716
717 #[test]
718 fn test_verify_partial_failure() {
719 let temp = TempDir::new().unwrap();
720
721 fs::write(temp.path().join("broken.py"), "def broken( syntax error").unwrap();
723 fs::write(temp.path().join("valid.py"), "def valid(): pass").unwrap();
724
725 let report = run_verify(temp.path(), Language::Python, false, None).unwrap();
726
727 assert!(report.sub_results.contains_key("contracts"));
729 }
730
731 #[test]
732 fn test_verify_file_limit() {
733 let temp = TempDir::new().unwrap();
734
735 for i in 0..600 {
737 fs::write(
738 temp.path().join(format!("module_{}.py", i)),
739 format!("def func_{i}(): pass"),
740 )
741 .unwrap();
742 }
743
744 let files = collect_source_files(temp.path(), Language::Python).unwrap();
745
746 assert!(
747 files.len() <= MAX_FILES,
748 "Should limit to {} files, got {}",
749 MAX_FILES,
750 files.len()
751 );
752 }
753
754 #[test]
755 fn test_verify_coverage_calculation() {
756 let temp = TempDir::new().unwrap();
757
758 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
759
760 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
761
762 let cov = &report.summary.coverage;
763 assert!(cov.total_functions > 0 || report.files_analyzed > 0);
764 assert!(cov.coverage_pct >= 0.0 && cov.coverage_pct <= 100.0);
765 }
766
767 #[test]
768 fn test_verify_json_output() {
769 let temp = TempDir::new().unwrap();
770 fs::write(temp.path().join("module.py"), "def foo(): pass").unwrap();
771
772 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
773
774 let json = serde_json::to_string(&report);
776 assert!(json.is_ok());
777
778 let json_value: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap();
780 assert!(json_value.get("path").is_some());
781 assert!(json_value.get("sub_results").is_some());
782 assert!(json_value.get("summary").is_some());
783 assert!(json_value.get("total_elapsed_ms").is_some());
784 }
785
786 #[test]
787 fn test_verify_text_output() {
788 let temp = TempDir::new().unwrap();
789 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
790
791 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
792 let text = format_verify_text(&report);
793
794 assert!(text.contains("Verification:"));
795 assert!(text.contains("Constraint Coverage:"));
796 assert!(text.contains("Elapsed:"));
797 }
798
799 #[test]
800 fn test_verify_detail_filter() {
801 let temp = TempDir::new().unwrap();
802 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
803
804 let report = run_verify(temp.path(), Language::Python, true, Some("contracts")).unwrap();
805
806 assert!(report.sub_results.contains_key("contracts"));
808 }
809
810 #[test]
815 fn test_find_test_dirs() {
816 let temp = TempDir::new().unwrap();
817 let tests_dir = temp.path().join("tests");
818 fs::create_dir(&tests_dir).unwrap();
819
820 let dirs = find_test_dirs(temp.path());
821 assert!(!dirs.is_empty());
822 assert!(dirs[0].ends_with("tests"));
823 }
824
825 #[test]
826 fn test_find_test_dirs_none() {
827 let temp = TempDir::new().unwrap();
828
829 let dirs = find_test_dirs(temp.path());
830 assert!(dirs.is_empty());
831 }
832
833 #[test]
834 fn test_extract_function_names() {
835 let source = r#"
836def foo():
837 pass
838
839def bar(x):
840 return x
841
842def baz(a, b):
843 return a + b
844"#;
845
846 let names = extract_function_names(source, Language::Python).unwrap();
847 assert_eq!(names.len(), 3);
848 assert!(names.contains(&"foo".to_string()));
849 assert!(names.contains(&"bar".to_string()));
850 assert!(names.contains(&"baz".to_string()));
851 }
852
853 #[test]
854 fn test_empty_directory() {
855 let temp = TempDir::new().unwrap();
856
857 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
858
859 assert_eq!(report.files_analyzed, 0);
860 assert_eq!(report.summary.coverage.total_functions, 0);
861 }
862}