Skip to main content

tldr_cli/commands/contracts/
verify.rs

1//! Verify command - Aggregated verification dashboard combining multiple analyses.
2//!
3//! Provides a unified view of code constraints including:
4//! - Contracts (pre/postconditions) from source analysis
5//! - Specs from test files
6//! - Bounds analysis warnings
7//! - Dead store detection
8//!
9//! # ELEPHANT Mitigations Addressed
10//! - E02: Capture all sub-analysis errors, report in summary
11//! - E03: Partial failure handling - continue and report
12//! - E07: Clear intermediate results after each file
13//! - E09: Concurrent access - unique temp dirs
14//!
15//! # Example
16//!
17//! ```bash
18//! tldr verify ./src
19//! tldr verify ./src --quick
20//! tldr verify ./src --detail contracts
21//! tldr verify ./src --format text
22//! ```
23
24use 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};
44// validate_file_path is available but currently unused
45// use super::validation::validate_file_path;
46
47// =============================================================================
48// Resource Limits (E03 Mitigation)
49// =============================================================================
50
51/// Maximum number of files to analyze (E03 mitigation)
52const MAX_FILES: usize = 500;
53
54// =============================================================================
55// CLI Arguments
56// =============================================================================
57
58/// Aggregated verification dashboard combining multiple analyses.
59///
60/// Runs contracts, specs, bounds, and dead-stores analyses on a project
61/// directory and provides a unified coverage report.
62///
63/// # Example
64///
65/// ```bash
66/// tldr verify ./src
67/// tldr verify ./src --quick
68/// tldr verify ./src --detail contracts
69/// ```
70#[derive(Debug, Args)]
71pub struct VerifyArgs {
72    /// Directory to analyze (defaults to current directory)
73    #[arg(default_value = ".")]
74    pub path: PathBuf,
75
76    /// Output format (json or text). Prefer global --format/-f flag.
77    #[arg(
78        long = "output-format",
79        short = 'o',
80        hide = true,
81        default_value = "json"
82    )]
83    pub output_format: ContractsOutputFormat,
84
85    /// Programming language override (auto-detected if not specified)
86    #[arg(long, short = 'l')]
87    pub lang: Option<Language>,
88
89    /// Show specific sub-analysis detail
90    #[arg(long)]
91    pub detail: Option<String>,
92
93    /// Quick mode - skip expensive analyses (invariants, patterns)
94    #[arg(long)]
95    pub quick: bool,
96}
97
98impl VerifyArgs {
99    /// Run the verify command
100    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
101        let writer = OutputWriter::new(format, quiet);
102
103        // Validate path exists
104        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        // Determine language (auto-detect from directory, default to Python)
119        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        // Run verification
128        let report = run_verify(
129            &canonical_path,
130            language,
131            self.quick,
132            self.detail.as_deref(),
133        )?;
134
135        // Output based on format
136        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
150// =============================================================================
151// Core Analysis Functions
152// =============================================================================
153
154/// Run the full verification dashboard.
155///
156/// # Arguments
157/// * `path` - Directory or file to analyze
158/// * `language` - Programming language for analysis
159/// * `quick` - If true, skip expensive analyses (invariants, patterns)
160/// * `detail` - If Some, show only the specified sub-analysis
161///
162/// # Returns
163/// VerifyReport with all sub-analysis results and coverage summary.
164pub 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    // Collect files to analyze
173    let files = collect_source_files(path, language)?;
174    let files_analyzed = files.len() as u32;
175
176    // Initialize report
177    let mut sub_results: HashMap<String, SubAnalysisResult> = HashMap::new();
178    let mut files_failed = 0u32;
179
180    // Run sub-analyses
181    // 1. Contracts sweep
182    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    // 2. Specs extraction (if test directory exists)
189    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    // 3. Bounds analysis (skip in quick mode - it's expensive)
208    if !quick {
209        let bounds_result = sweep_bounds(&files, language, detail);
210        sub_results.insert("bounds".to_string(), bounds_result);
211    }
212
213    // 4. Dead stores detection
214    let dead_stores_result = sweep_dead_stores(&files, language, detail);
215    sub_results.insert("dead_stores".to_string(), dead_stores_result);
216
217    // 5. Invariants (skip in quick mode - requires test execution)
218    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    // Compute coverage from results
235    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    // Determine if we have partial results
240    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
258/// Collect source files for analysis.
259fn 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", // Default to Python
267    };
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                    // Skip test files for main analysis
283                    && !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            // Apply file limit (E03 mitigation)
289            if files.len() >= MAX_FILES {
290                break;
291            }
292        }
293    }
294
295    Ok(files)
296}
297
298/// Find test directories by convention.
299fn find_test_dirs(project_path: &Path) -> Vec<PathBuf> {
300    let mut candidates = Vec::new();
301
302    // Check common test directory names
303    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    // Check for test_*.py files in the project root
311    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
327// =============================================================================
328// Sub-Analysis Sweepers
329// =============================================================================
330
331/// Sweep contracts analysis over all files.
332fn 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        // Find all functions in the file and analyze each
344        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
381/// Analyze contracts for all functions in a file.
382fn 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, // Skip functions that fail to analyze
394        }
395    }
396
397    Ok(reports)
398}
399
400/// Extract function names from source code.
401fn extract_function_names(source: &str, _language: Language) -> ContractsResult<Vec<String>> {
402    // Simple regex-based extraction for Python
403    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
418/// Sweep specs extraction from test directory.
419fn 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
445/// Sweep bounds analysis over all files.
446fn sweep_bounds(
447    _files: &[PathBuf],
448    _language: Language,
449    _detail: Option<&str>,
450) -> SubAnalysisResult {
451    let start = Instant::now();
452
453    // Bounds analysis is expensive - for now, return a stub
454    // TODO: Implement when bounds command is integrated
455    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
465/// Sweep dead stores detection over all files.
466fn sweep_dead_stores(
467    _files: &[PathBuf],
468    _language: Language,
469    _detail: Option<&str>,
470) -> SubAnalysisResult {
471    let start = Instant::now();
472
473    // Dead stores requires SSA analysis for each function
474    // TODO: Implement when dead_stores command is fully integrated
475    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
485// =============================================================================
486// Summary Building
487// =============================================================================
488
489/// Build the verify summary from sub-analysis results.
490fn build_verify_summary(
491    sub_results: &HashMap<String, SubAnalysisResult>,
492    total_files: u32,
493) -> VerifySummary {
494    // Count items from each sub-analysis
495    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    // Compute coverage from contracts data
508    let coverage = compute_coverage(sub_results, total_files);
509
510    VerifySummary {
511        spec_count,
512        invariant_count,
513        contract_count,
514        annotated_count: 0,  // Not yet implemented
515        behavioral_count: 0, // Not yet implemented
516        pattern_count: 0,
517        pattern_high_confidence: 0,
518        coverage,
519    }
520}
521
522/// Compute function coverage from analysis results.
523fn 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    // Extract function info from contracts results
531    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                        // Check if function has any constraints
539                        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    // If no functions found, use file count as proxy
562    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 // Round to 1 decimal
571    } else {
572        0.0
573    };
574
575    CoverageInfo {
576        constrained_functions: constrained,
577        total_functions: total,
578        coverage_pct,
579    }
580}
581
582/// Count failures from error message.
583fn count_failures_from_error(error: &str) -> u32 {
584    // Count semicolons (our error separator) + 1
585    (error.matches(';').count() + 1) as u32
586}
587
588// =============================================================================
589// Output Formatting
590// =============================================================================
591
592/// Format verify report as human-readable text.
593pub 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    // Add errors if any
629    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// =============================================================================
644// Tests
645// =============================================================================
646
647#[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    // -------------------------------------------------------------------------
676    // Full Sweep Tests
677    // -------------------------------------------------------------------------
678
679    #[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        // Should have sub_results
693        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        // Quick mode should skip invariants and bounds
706        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        // Create a file that will cause parse errors
722        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        // Should still produce a report (partial results)
728        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        // Create more than MAX_FILES Python files
736        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        // Should serialize to valid JSON
775        let json = serde_json::to_string(&report);
776        assert!(json.is_ok());
777
778        // Verify expected fields
779        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        // Should still run all analyses but detail is informational
807        assert!(report.sub_results.contains_key("contracts"));
808    }
809
810    // -------------------------------------------------------------------------
811    // Helper Function Tests
812    // -------------------------------------------------------------------------
813
814    #[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}