1use std::collections::{HashMap, HashSet};
25use std::path::{Path, PathBuf};
26use std::time::Instant;
27
28use anyhow::Result;
29use clap::Args;
30use tldr_core::walker::walk_project;
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 walk_project(path).filter(|e| {
275 e.path().is_file()
276 && e.path()
277 .extension()
278 .is_some_and(|ext| ext == extension)
279 && !e.file_name().to_str().is_some_and(|n| n.starts_with("test_"))
281 }) {
282 files.push(entry.path().to_path_buf());
283
284 if files.len() >= MAX_FILES {
286 break;
287 }
288 }
289 }
290
291 Ok(files)
292}
293
294fn find_test_dirs(project_path: &Path) -> Vec<PathBuf> {
296 let mut candidates = Vec::new();
297
298 for name in &["tests", "test"] {
300 let dir = project_path.join(name);
301 if dir.is_dir() {
302 candidates.push(dir);
303 }
304 }
305
306 if let Ok(entries) = std::fs::read_dir(project_path) {
308 for entry in entries.filter_map(|e| e.ok()) {
309 let path = entry.path();
310 if path.is_file() {
311 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
312 if name.starts_with("test_") && name.ends_with(".py") {
313 candidates.push(path);
314 }
315 }
316 }
317 }
318 }
319
320 candidates
321}
322
323fn sweep_contracts(
329 files: &[PathBuf],
330 language: Language,
331 _detail: Option<&str>,
332) -> SubAnalysisResult {
333 let start = Instant::now();
334 let mut total_contracts = 0u32;
335 let mut all_results: Vec<ContractsReport> = Vec::new();
336 let mut errors: Vec<String> = Vec::new();
337
338 for file in files {
339 match analyze_file_contracts(file, language) {
341 Ok(reports) => {
342 for report in reports {
343 total_contracts += report.preconditions.len() as u32;
344 total_contracts += report.postconditions.len() as u32;
345 total_contracts += report.invariants.len() as u32;
346 all_results.push(report);
347 }
348 }
349 Err(e) => {
350 errors.push(format!("{}: {}", file.display(), e));
351 }
352 }
353 }
354
355 let status = if errors.is_empty() {
356 SubAnalysisStatus::Success
357 } else if !all_results.is_empty() {
358 SubAnalysisStatus::Partial
359 } else {
360 SubAnalysisStatus::Failed
361 };
362
363 SubAnalysisResult {
364 name: "contracts".to_string(),
365 status,
366 items_found: total_contracts,
367 elapsed_ms: start.elapsed().as_millis() as u64,
368 error: if errors.is_empty() {
369 None
370 } else {
371 Some(errors.join("; "))
372 },
373 data: Some(serde_json::to_value(&all_results).unwrap_or(serde_json::Value::Null)),
374 }
375}
376
377fn analyze_file_contracts(
379 file: &Path,
380 language: Language,
381) -> ContractsResult<Vec<ContractsReport>> {
382 let source = std::fs::read_to_string(file)?;
383 let functions = extract_function_names(&source, language)?;
384
385 let mut reports = Vec::new();
386 for func_name in functions {
387 match run_contracts(file, &func_name, language, 100) {
388 Ok(report) => reports.push(report),
389 Err(_) => continue, }
391 }
392
393 Ok(reports)
394}
395
396fn extract_function_names(source: &str, _language: Language) -> ContractsResult<Vec<String>> {
398 let mut names = Vec::new();
400 for line in source.lines() {
401 let trimmed = line.trim();
402 if trimmed.starts_with("def ") {
403 if let Some(name_end) = trimmed.find('(') {
404 let name = &trimmed[4..name_end].trim();
405 if !name.is_empty() {
406 names.push(name.to_string());
407 }
408 }
409 }
410 }
411 Ok(names)
412}
413
414fn sweep_specs(test_path: &Path, _detail: Option<&str>) -> SubAnalysisResult {
416 let start = Instant::now();
417
418 match run_specs(test_path, None) {
419 Ok(report) => {
420 let total_specs = report.summary.total_specs;
421 SubAnalysisResult {
422 name: "specs".to_string(),
423 status: SubAnalysisStatus::Success,
424 items_found: total_specs,
425 elapsed_ms: start.elapsed().as_millis() as u64,
426 error: None,
427 data: Some(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)),
428 }
429 }
430 Err(e) => SubAnalysisResult {
431 name: "specs".to_string(),
432 status: SubAnalysisStatus::Failed,
433 items_found: 0,
434 elapsed_ms: start.elapsed().as_millis() as u64,
435 error: Some(e.to_string()),
436 data: None,
437 },
438 }
439}
440
441fn sweep_bounds(
443 _files: &[PathBuf],
444 _language: Language,
445 _detail: Option<&str>,
446) -> SubAnalysisResult {
447 let start = Instant::now();
448
449 SubAnalysisResult {
452 name: "bounds".to_string(),
453 status: SubAnalysisStatus::Skipped,
454 items_found: 0,
455 elapsed_ms: start.elapsed().as_millis() as u64,
456 error: Some("Bounds sweep not yet integrated".to_string()),
457 data: None,
458 }
459}
460
461fn sweep_dead_stores(
463 _files: &[PathBuf],
464 _language: Language,
465 _detail: Option<&str>,
466) -> SubAnalysisResult {
467 let start = Instant::now();
468
469 SubAnalysisResult {
472 name: "dead_stores".to_string(),
473 status: SubAnalysisStatus::Skipped,
474 items_found: 0,
475 elapsed_ms: start.elapsed().as_millis() as u64,
476 error: Some("Dead stores sweep not yet integrated".to_string()),
477 data: None,
478 }
479}
480
481fn build_verify_summary(
487 sub_results: &HashMap<String, SubAnalysisResult>,
488 total_files: u32,
489) -> VerifySummary {
490 let spec_count = sub_results.get("specs").map(|r| r.items_found).unwrap_or(0);
492
493 let contract_count = sub_results
494 .get("contracts")
495 .map(|r| r.items_found)
496 .unwrap_or(0);
497
498 let invariant_count = sub_results
499 .get("invariants")
500 .map(|r| r.items_found)
501 .unwrap_or(0);
502
503 let coverage = compute_coverage(sub_results, total_files);
505
506 VerifySummary {
507 spec_count,
508 invariant_count,
509 contract_count,
510 annotated_count: 0, behavioral_count: 0, pattern_count: 0,
513 pattern_high_confidence: 0,
514 coverage,
515 }
516}
517
518fn compute_coverage(
520 sub_results: &HashMap<String, SubAnalysisResult>,
521 total_files: u32,
522) -> CoverageInfo {
523 let mut constrained_functions: HashSet<String> = HashSet::new();
524 let mut total_functions: HashSet<String> = HashSet::new();
525
526 if let Some(contracts_result) = sub_results.get("contracts") {
528 if let Some(data) = &contracts_result.data {
529 if let Some(reports) = data.as_array() {
530 for report in reports {
531 if let Some(func_name) = report.get("function").and_then(|f| f.as_str()) {
532 total_functions.insert(func_name.to_string());
533
534 let has_pre = report
536 .get("preconditions")
537 .and_then(|p| p.as_array())
538 .is_some_and(|a| !a.is_empty());
539 let has_post = report
540 .get("postconditions")
541 .and_then(|p| p.as_array())
542 .is_some_and(|a| !a.is_empty());
543 let has_inv = report
544 .get("invariants")
545 .and_then(|i| i.as_array())
546 .is_some_and(|a| !a.is_empty());
547
548 if has_pre || has_post || has_inv {
549 constrained_functions.insert(func_name.to_string());
550 }
551 }
552 }
553 }
554 }
555 }
556
557 let total = if total_functions.is_empty() {
559 total_files
560 } else {
561 total_functions.len() as u32
562 };
563
564 let constrained = constrained_functions.len() as u32;
565 let coverage_pct = if total > 0 {
566 (constrained as f64 / total as f64 * 100.0).round() / 1.0 } else {
568 0.0
569 };
570
571 CoverageInfo {
572 constrained_functions: constrained,
573 total_functions: total,
574 coverage_pct,
575 }
576}
577
578fn count_failures_from_error(error: &str) -> u32 {
580 (error.matches(';').count() + 1) as u32
582}
583
584pub fn format_verify_text(report: &VerifyReport) -> String {
590 let s = &report.summary;
591 let cov = &s.coverage;
592
593 let mut lines = vec![
594 format!("Verification: {}", report.path.display()),
595 "=".repeat(50),
596 format!("Test Specs: {} behavioral specs extracted", s.spec_count),
597 format!("Invariants: {} inferred invariants", s.invariant_count),
598 format!(
599 "Contracts: {} pre/postconditions inferred",
600 s.contract_count
601 ),
602 format!(
603 "Annotations: {} Annotated[T] constraints found",
604 s.annotated_count
605 ),
606 format!(
607 "Behaviors: {} functions with behavioral models",
608 s.behavioral_count
609 ),
610 format!(
611 "Patterns: {} project patterns ({} high-confidence)",
612 s.pattern_count, s.pattern_high_confidence
613 ),
614 String::new(),
615 "Constraint Coverage:".to_string(),
616 format!(
617 " Functions with any constraint: {}/{} ({:.1}%)",
618 cov.constrained_functions, cov.total_functions, cov.coverage_pct
619 ),
620 String::new(),
621 format!("Elapsed: {}ms", report.total_elapsed_ms),
622 ];
623
624 let failed: Vec<&str> = report
626 .sub_results
627 .iter()
628 .filter(|(_, r)| matches!(r.status, SubAnalysisStatus::Failed))
629 .map(|(name, _)| name.as_str())
630 .collect();
631
632 if !failed.is_empty() {
633 lines.push(format!("Errors: {}", failed.join(", ")));
634 }
635
636 lines.join("\n")
637}
638
639#[cfg(test)]
644mod tests {
645 use super::*;
646 use std::fs;
647 use tempfile::TempDir;
648
649 const PYTHON_WITH_CONTRACTS: &str = r#"
650def constrained(x):
651 if x < 0:
652 raise ValueError("x must be non-negative")
653 return x * 2
654
655def unconstrained(y):
656 return y * 3
657"#;
658
659 const PYTHON_TEST_FILE: &str = r#"
660import pytest
661from mymodule import add, validate
662
663def test_add():
664 assert add(2, 3) == 5
665
666def test_validate_raises():
667 with pytest.raises(ValueError):
668 validate("")
669"#;
670
671 #[test]
676 fn test_verify_full_sweep() {
677 let temp = TempDir::new().unwrap();
678 let src_dir = temp.path().join("src");
679 let test_dir = temp.path().join("tests");
680 fs::create_dir(&src_dir).unwrap();
681 fs::create_dir(&test_dir).unwrap();
682
683 fs::write(src_dir.join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
684 fs::write(test_dir.join("test_module.py"), PYTHON_TEST_FILE).unwrap();
685
686 let report = run_verify(temp.path(), Language::Python, false, None).unwrap();
687
688 assert!(report.sub_results.contains_key("contracts"));
690 assert!(report.sub_results.contains_key("specs"));
691 assert!(report.total_elapsed_ms > 0);
692 }
693
694 #[test]
695 fn test_verify_quick_mode() {
696 let temp = TempDir::new().unwrap();
697 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
698
699 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
700
701 if let Some(invariants) = report.sub_results.get("invariants") {
703 assert!(
704 matches!(
705 invariants.status,
706 SubAnalysisStatus::Skipped | SubAnalysisStatus::Failed
707 ),
708 "Invariants should be skipped in quick mode"
709 );
710 }
711 }
712
713 #[test]
714 fn test_verify_partial_failure() {
715 let temp = TempDir::new().unwrap();
716
717 fs::write(temp.path().join("broken.py"), "def broken( syntax error").unwrap();
719 fs::write(temp.path().join("valid.py"), "def valid(): pass").unwrap();
720
721 let report = run_verify(temp.path(), Language::Python, false, None).unwrap();
722
723 assert!(report.sub_results.contains_key("contracts"));
725 }
726
727 #[test]
728 fn test_verify_file_limit() {
729 let temp = TempDir::new().unwrap();
730
731 for i in 0..600 {
733 fs::write(
734 temp.path().join(format!("module_{}.py", i)),
735 format!("def func_{i}(): pass"),
736 )
737 .unwrap();
738 }
739
740 let files = collect_source_files(temp.path(), Language::Python).unwrap();
741
742 assert!(
743 files.len() <= MAX_FILES,
744 "Should limit to {} files, got {}",
745 MAX_FILES,
746 files.len()
747 );
748 }
749
750 #[test]
751 fn test_verify_coverage_calculation() {
752 let temp = TempDir::new().unwrap();
753
754 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
755
756 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
757
758 let cov = &report.summary.coverage;
759 assert!(cov.total_functions > 0 || report.files_analyzed > 0);
760 assert!(cov.coverage_pct >= 0.0 && cov.coverage_pct <= 100.0);
761 }
762
763 #[test]
764 fn test_verify_json_output() {
765 let temp = TempDir::new().unwrap();
766 fs::write(temp.path().join("module.py"), "def foo(): pass").unwrap();
767
768 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
769
770 let json = serde_json::to_string(&report);
772 assert!(json.is_ok());
773
774 let json_value: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap();
776 assert!(json_value.get("path").is_some());
777 assert!(json_value.get("sub_results").is_some());
778 assert!(json_value.get("summary").is_some());
779 assert!(json_value.get("total_elapsed_ms").is_some());
780 }
781
782 #[test]
783 fn test_verify_text_output() {
784 let temp = TempDir::new().unwrap();
785 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
786
787 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
788 let text = format_verify_text(&report);
789
790 assert!(text.contains("Verification:"));
791 assert!(text.contains("Constraint Coverage:"));
792 assert!(text.contains("Elapsed:"));
793 }
794
795 #[test]
796 fn test_verify_detail_filter() {
797 let temp = TempDir::new().unwrap();
798 fs::write(temp.path().join("module.py"), PYTHON_WITH_CONTRACTS).unwrap();
799
800 let report = run_verify(temp.path(), Language::Python, true, Some("contracts")).unwrap();
801
802 assert!(report.sub_results.contains_key("contracts"));
804 }
805
806 #[test]
811 fn test_find_test_dirs() {
812 let temp = TempDir::new().unwrap();
813 let tests_dir = temp.path().join("tests");
814 fs::create_dir(&tests_dir).unwrap();
815
816 let dirs = find_test_dirs(temp.path());
817 assert!(!dirs.is_empty());
818 assert!(dirs[0].ends_with("tests"));
819 }
820
821 #[test]
822 fn test_find_test_dirs_none() {
823 let temp = TempDir::new().unwrap();
824
825 let dirs = find_test_dirs(temp.path());
826 assert!(dirs.is_empty());
827 }
828
829 #[test]
830 fn test_extract_function_names() {
831 let source = r#"
832def foo():
833 pass
834
835def bar(x):
836 return x
837
838def baz(a, b):
839 return a + b
840"#;
841
842 let names = extract_function_names(source, Language::Python).unwrap();
843 assert_eq!(names.len(), 3);
844 assert!(names.contains(&"foo".to_string()));
845 assert!(names.contains(&"bar".to_string()));
846 assert!(names.contains(&"baz".to_string()));
847 }
848
849 #[test]
850 fn test_empty_directory() {
851 let temp = TempDir::new().unwrap();
852
853 let report = run_verify(temp.path(), Language::Python, true, None).unwrap();
854
855 assert_eq!(report.files_analyzed, 0);
856 assert_eq!(report.summary.coverage.total_functions, 0);
857 }
858}