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 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};
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 walk_project(path).filter(|e| {
275            e.path().is_file()
276                && e.path()
277                    .extension()
278                    .is_some_and(|ext| ext == extension)
279                // Skip test files for main analysis
280                && !e.file_name().to_str().is_some_and(|n| n.starts_with("test_"))
281        }) {
282            files.push(entry.path().to_path_buf());
283
284            // Apply file limit (E03 mitigation)
285            if files.len() >= MAX_FILES {
286                break;
287            }
288        }
289    }
290
291    Ok(files)
292}
293
294/// Find test directories by convention.
295fn find_test_dirs(project_path: &Path) -> Vec<PathBuf> {
296    let mut candidates = Vec::new();
297
298    // Check common test directory names
299    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    // Check for test_*.py files in the project root
307    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
323// =============================================================================
324// Sub-Analysis Sweepers
325// =============================================================================
326
327/// Sweep contracts analysis over all files.
328fn 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        // Find all functions in the file and analyze each
340        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
377/// Analyze contracts for all functions in a file.
378fn 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, // Skip functions that fail to analyze
390        }
391    }
392
393    Ok(reports)
394}
395
396/// Extract function names from source code.
397fn extract_function_names(source: &str, _language: Language) -> ContractsResult<Vec<String>> {
398    // Simple regex-based extraction for Python
399    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
414/// Sweep specs extraction from test directory.
415fn 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
441/// Sweep bounds analysis over all files.
442fn sweep_bounds(
443    _files: &[PathBuf],
444    _language: Language,
445    _detail: Option<&str>,
446) -> SubAnalysisResult {
447    let start = Instant::now();
448
449    // Bounds analysis is expensive - for now, return a stub
450    // TODO: Implement when bounds command is integrated
451    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
461/// Sweep dead stores detection over all files.
462fn sweep_dead_stores(
463    _files: &[PathBuf],
464    _language: Language,
465    _detail: Option<&str>,
466) -> SubAnalysisResult {
467    let start = Instant::now();
468
469    // Dead stores requires SSA analysis for each function
470    // TODO: Implement when dead_stores command is fully integrated
471    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
481// =============================================================================
482// Summary Building
483// =============================================================================
484
485/// Build the verify summary from sub-analysis results.
486fn build_verify_summary(
487    sub_results: &HashMap<String, SubAnalysisResult>,
488    total_files: u32,
489) -> VerifySummary {
490    // Count items from each sub-analysis
491    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    // Compute coverage from contracts data
504    let coverage = compute_coverage(sub_results, total_files);
505
506    VerifySummary {
507        spec_count,
508        invariant_count,
509        contract_count,
510        annotated_count: 0,  // Not yet implemented
511        behavioral_count: 0, // Not yet implemented
512        pattern_count: 0,
513        pattern_high_confidence: 0,
514        coverage,
515    }
516}
517
518/// Compute function coverage from analysis results.
519fn 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    // Extract function info from contracts results
527    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                        // Check if function has any constraints
535                        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    // If no functions found, use file count as proxy
558    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 // Round to 1 decimal
567    } else {
568        0.0
569    };
570
571    CoverageInfo {
572        constrained_functions: constrained,
573        total_functions: total,
574        coverage_pct,
575    }
576}
577
578/// Count failures from error message.
579fn count_failures_from_error(error: &str) -> u32 {
580    // Count semicolons (our error separator) + 1
581    (error.matches(';').count() + 1) as u32
582}
583
584// =============================================================================
585// Output Formatting
586// =============================================================================
587
588/// Format verify report as human-readable text.
589pub 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    // Add errors if any
625    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// =============================================================================
640// Tests
641// =============================================================================
642
643#[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    // -------------------------------------------------------------------------
672    // Full Sweep Tests
673    // -------------------------------------------------------------------------
674
675    #[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        // Should have sub_results
689        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        // Quick mode should skip invariants and bounds
702        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        // Create a file that will cause parse errors
718        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        // Should still produce a report (partial results)
724        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        // Create more than MAX_FILES Python files
732        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        // Should serialize to valid JSON
771        let json = serde_json::to_string(&report);
772        assert!(json.is_ok());
773
774        // Verify expected fields
775        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        // Should still run all analyses but detail is informational
803        assert!(report.sub_results.contains_key("contracts"));
804    }
805
806    // -------------------------------------------------------------------------
807    // Helper Function Tests
808    // -------------------------------------------------------------------------
809
810    #[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}