Skip to main content

perl_tdd_support/tdd/
tdd_workflow.rs

1//! TDD (Test-Driven Development) workflow integration for LSP
2//!
3//! Provides a complete red-green-refactor cycle support with
4//! automatic test generation, continuous testing, and refactoring suggestions.
5
6use crate::ast::Node;
7
8// Re-use Diagnostic from tdd_basic to avoid duplication
9use crate::tdd_basic::{Diagnostic, DiagnosticSeverity};
10use crate::test_generator::{CoverageReport, TestResults, TestRunner};
11use crate::test_generator::{RefactoringSuggester, RefactoringSuggestion};
12use crate::test_generator::{TestCase, TestFramework, TestGenerator};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16
17/// TDD workflow manager
18pub struct TddWorkflow {
19    /// Test generator
20    generator: TestGenerator,
21    /// Test runner
22    runner: TestRunner,
23    /// Refactoring suggester
24    suggester: RefactoringSuggester,
25    /// Current workflow state
26    state: WorkflowState,
27    /// Test results cache
28    test_cache: HashMap<PathBuf, TestResults>,
29    /// Coverage tracking
30    coverage_tracker: CoverageTracker,
31    /// Configuration
32    config: TddConfig,
33}
34
35/// Represents the current phase of the TDD (Test-Driven Development) workflow cycle.
36///
37/// Tracks the developer's position in the red-green-refactor cycle, enabling context-aware
38/// suggestions and automation during test development and implementation.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub enum WorkflowState {
41    /// Writing test (Red phase)
42    Red,
43    /// Making test pass (Green phase)
44    Green,
45    /// Refactoring code (Refactor phase)
46    Refactor,
47    /// Not in TDD cycle
48    Idle,
49}
50
51/// Configuration options for TDD workflow automation and behavior.
52///
53/// Customizes how the TDD workflow manager generates tests, runs them, and provides
54/// feedback. All settings have sensible defaults suitable for typical Perl development.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct TddConfig {
57    /// Automatically generate tests for new code
58    pub auto_generate_tests: bool,
59    /// Run tests on save
60    pub test_on_save: bool,
61    /// Show coverage inline
62    pub show_inline_coverage: bool,
63    /// Test framework preference
64    pub test_framework: String,
65    /// Test file naming pattern
66    pub test_file_pattern: String,
67    /// Minimum coverage threshold
68    pub coverage_threshold: f64,
69    /// Enable continuous testing
70    pub continuous_testing: bool,
71    /// Auto-suggest refactorings after green
72    pub auto_suggest_refactorings: bool,
73}
74
75impl Default for TddConfig {
76    fn default() -> Self {
77        Self {
78            auto_generate_tests: true,
79            test_on_save: true,
80            show_inline_coverage: true,
81            test_framework: "Test::More".to_string(),
82            test_file_pattern: "t/{name}.t".to_string(),
83            coverage_threshold: 80.0,
84            continuous_testing: true,
85            auto_suggest_refactorings: true,
86        }
87    }
88}
89
90/// Coverage tracking for TDD
91pub struct CoverageTracker {
92    /// Line coverage data
93    line_coverage: HashMap<PathBuf, Vec<LineCoverage>>,
94    /// Branch coverage data (architectural placeholder for future implementation)
95    #[allow(dead_code)]
96    branch_coverage: HashMap<PathBuf, Vec<BranchCoverage>>,
97    /// Overall coverage percentage
98    total_coverage: f64,
99}
100
101/// Line-level code coverage information
102#[derive(Debug, Clone)]
103pub struct LineCoverage {
104    /// Line number in the source file
105    pub line: usize,
106    /// Number of times this line was executed
107    pub hits: usize,
108    /// Whether this line is considered covered
109    pub covered: bool,
110}
111
112/// Branch-level code coverage information
113#[derive(Debug, Clone)]
114pub struct BranchCoverage {
115    /// Line number where the branch occurs
116    pub line: usize,
117    /// Unique identifier for this branch within the line
118    pub branch_id: usize,
119    /// Whether this branch was taken at least once
120    pub taken: bool,
121    /// Number of times this branch was executed
122    pub hits: usize,
123}
124
125impl TddWorkflow {
126    /// Create a new TDD workflow manager with the given configuration
127    pub fn new(config: TddConfig) -> Self {
128        let framework = match config.test_framework.as_str() {
129            "Test2::V0" => TestFramework::Test2V0,
130            "Test::Simple" => TestFramework::TestSimple,
131            "Test::Class" => TestFramework::TestClass,
132            _ => TestFramework::TestMore,
133        };
134
135        Self {
136            generator: TestGenerator::new(framework),
137            runner: TestRunner::new(),
138            suggester: RefactoringSuggester::new(),
139            state: WorkflowState::Idle,
140            test_cache: HashMap::new(),
141            coverage_tracker: CoverageTracker::new(),
142            config,
143        }
144    }
145
146    /// Start a new TDD cycle
147    pub fn start_cycle(&mut self, test_name: &str) -> TddCycleResult {
148        self.state = WorkflowState::Red;
149
150        TddCycleResult {
151            phase: "Red".to_string(),
152            message: format!("Starting TDD cycle for '{}'", test_name),
153            actions: vec![
154                TddAction::GenerateTest(test_name.to_string()),
155                TddAction::CreateTestFile(self.get_test_file_path(test_name)),
156            ],
157        }
158    }
159
160    /// Generate tests for the given code
161    pub fn generate_tests(&self, ast: &Node, source: &str) -> Vec<TestCase> {
162        self.generator.generate_tests(ast, source)
163    }
164
165    /// Generate a specific test type
166    pub fn generate_test_for_function(
167        &self,
168        function_name: &str,
169        params: &[String],
170        test_type: TestType,
171    ) -> TestCase {
172        let test_name = format!("test_{}_{:?}", function_name, test_type);
173        let description = format!("{:?} test for {}", test_type, function_name);
174
175        let code = match test_type {
176            TestType::Basic => self.generate_basic_test(function_name, params),
177            TestType::EdgeCase => self.generate_edge_case_test(function_name, params),
178            TestType::ErrorHandling => self.generate_error_test(function_name, params),
179            TestType::Performance => self.generate_performance_test(function_name),
180            TestType::Integration => self.generate_integration_test(function_name, params),
181        };
182
183        TestCase {
184            name: test_name,
185            description,
186            code,
187            is_todo: matches!(test_type, TestType::Integration | TestType::Performance),
188        }
189    }
190
191    fn generate_basic_test(&self, name: &str, params: &[String]) -> String {
192        let args = params
193            .iter()
194            .enumerate()
195            .map(|(i, _)| format!("'test_value_{}'", i))
196            .collect::<Vec<_>>()
197            .join(", ");
198
199        format!(
200            "use Test::More;\n\n\
201             subtest '{}' => sub {{\n    \
202             my $result = {}({});\n    \
203             ok(defined $result, 'Returns defined value');\n    \
204             # PENDING: Add specific assertions\n\
205             }};\n\n\
206             done_testing();\n",
207            name, name, args
208        )
209    }
210
211    fn generate_edge_case_test(&self, name: &str, _params: &[String]) -> String {
212        format!(
213            "use Test::More;\n\n\
214             subtest '{} edge cases' => sub {{\n    \
215             # Test with undef\n    \
216             eval {{ {}(undef) }};\n    \
217             ok(!$@, 'Handles undef');\n    \n    \
218             # Test with empty values\n    \
219             eval {{ {}('') }};\n    \
220             ok(!$@, 'Handles empty string');\n    \n    \
221             # Test with special characters\n    \
222             eval {{ {}(\"\\n\\t\\0\") }};\n    \
223             ok(!$@, 'Handles special characters');\n\
224             }};\n\n\
225             done_testing();\n",
226            name, name, name, name
227        )
228    }
229
230    fn generate_error_test(&self, name: &str, _params: &[String]) -> String {
231        format!(
232            "use Test::More;\n\
233             use Test::Exception;\n\n\
234             subtest '{} error handling' => sub {{\n    \
235             # Test that errors are caught\n    \
236             dies_ok {{ {}(undef, undef, undef) }} 'Dies on invalid input';\n    \n    \
237             # Test error message\n    \
238             throws_ok {{ {}() }} qr/required/, 'Correct error message';\n\
239             }};\n\n\
240             done_testing();\n",
241            name, name, name
242        )
243    }
244
245    fn generate_performance_test(&self, name: &str) -> String {
246        format!(
247            "use Test::More;\n\
248             use Benchmark qw(timethis);\n\n\
249             subtest '{} performance' => sub {{\n    \
250             my $iterations = 10000;\n    \
251             my $result = timethis($iterations, sub {{ {}() }});\n    \
252             \n    \
253             # Check performance threshold\n    \
254             my $rate = $result->iters / $result->cpu_a;\n    \
255             cmp_ok($rate, '>', 1000, 'Performance exceeds 1000 ops/sec');\n\
256             }};\n\n\
257             done_testing();\n",
258            name, name
259        )
260    }
261
262    fn generate_integration_test(&self, name: &str, _params: &[String]) -> String {
263        format!(
264            "use Test::More;\n\n\
265             subtest '{} integration' => sub {{\n    \
266             # PENDING: Set up test environment\n    \
267             # PENDING: Call {} with real dependencies\n    \
268             # PENDING: Verify integration points\n    \
269             pass('Integration test placeholder');\n\
270             }};\n\n\
271             done_testing();\n",
272            name, name
273        )
274    }
275
276    /// Run tests and update state
277    pub fn run_tests(&mut self, test_files: &[PathBuf]) -> TddCycleResult {
278        let file_strings: Vec<String> =
279            test_files.iter().map(|p| p.to_string_lossy().to_string()).collect();
280
281        let results = self.runner.run_tests(&file_strings);
282
283        // Cache results
284        for file in test_files {
285            self.test_cache.insert(file.clone(), results.clone());
286        }
287
288        // Update state based on results
289        let (new_state, message) = if results.failed > 0 {
290            (WorkflowState::Red, format!("{} tests failed", results.failed))
291        } else if results.todo > 0 {
292            (WorkflowState::Green, format!("All tests pass, {} TODOs remaining", results.todo))
293        } else {
294            (WorkflowState::Refactor, "All tests pass! Ready to refactor".to_string())
295        };
296
297        self.state = new_state.clone();
298
299        let mut actions = vec![];
300
301        // Suggest refactorings if all tests pass
302        if new_state == WorkflowState::Refactor && self.config.auto_suggest_refactorings {
303            actions.push(TddAction::SuggestRefactorings);
304        }
305
306        TddCycleResult { phase: format!("{:?}", new_state), message, actions }
307    }
308
309    /// Get refactoring suggestions
310    pub fn get_refactoring_suggestions(
311        &mut self,
312        ast: &Node,
313        source: &str,
314    ) -> Vec<RefactoringSuggestion> {
315        self.suggester.analyze(ast, source)
316    }
317
318    /// Get current test coverage
319    pub fn get_coverage(&self) -> Option<CoverageReport> {
320        self.runner.get_coverage()
321    }
322
323    /// Update coverage data
324    pub fn update_coverage(&mut self, file: PathBuf, coverage: Vec<LineCoverage>) {
325        self.coverage_tracker.line_coverage.insert(file, coverage);
326        self.coverage_tracker.calculate_total_coverage();
327    }
328
329    /// Get inline coverage annotations
330    pub fn get_inline_coverage(&self, file: &Path) -> Vec<CoverageAnnotation> {
331        let mut annotations = Vec::new();
332
333        if let Some(coverage) = self.coverage_tracker.line_coverage.get(file) {
334            for line_cov in coverage {
335                if !line_cov.covered {
336                    annotations.push(CoverageAnnotation {
337                        line: line_cov.line,
338                        message: "Not covered by tests".to_string(),
339                        severity: AnnotationSeverity::Warning,
340                    });
341                } else if line_cov.hits == 0 {
342                    annotations.push(CoverageAnnotation {
343                        line: line_cov.line,
344                        message: "Never executed".to_string(),
345                        severity: AnnotationSeverity::Info,
346                    });
347                }
348            }
349        }
350
351        annotations
352    }
353
354    /// Check if coverage meets threshold
355    pub fn check_coverage_threshold(&self) -> bool {
356        self.coverage_tracker.total_coverage >= self.config.coverage_threshold
357    }
358
359    /// Get test file path for a given module
360    fn get_test_file_path(&self, name: &str) -> PathBuf {
361        let pattern = &self.config.test_file_pattern;
362        let path_str = pattern.replace("{name}", name);
363        PathBuf::from(path_str)
364    }
365
366    /// Get workflow status
367    pub fn get_status(&self) -> WorkflowStatus {
368        WorkflowStatus {
369            state: self.state.clone(),
370            coverage: self.coverage_tracker.total_coverage,
371            tests_passing: self.test_cache.values().all(|r| r.failed == 0),
372            suggestions_available: true, // Would check actual suggestions
373        }
374    }
375
376    /// Generate diagnostics for uncovered code
377    pub fn generate_coverage_diagnostics(&self, file: &Path) -> Vec<Diagnostic> {
378        let mut diagnostics = Vec::new();
379
380        if let Some(coverage) = self.coverage_tracker.line_coverage.get(file) {
381            for line_cov in coverage {
382                if !line_cov.covered {
383                    diagnostics.push(Diagnostic {
384                        range: (line_cov.line, line_cov.line),
385                        severity: DiagnosticSeverity::Warning,
386                        code: Some("tdd.uncovered".to_string()),
387                        message: "Line not covered by tests".to_string(),
388                        related_information: vec![],
389                        tags: vec![],
390                    });
391                }
392            }
393        }
394
395        diagnostics
396    }
397}
398
399impl CoverageTracker {
400    fn new() -> Self {
401        Self { line_coverage: HashMap::new(), branch_coverage: HashMap::new(), total_coverage: 0.0 }
402    }
403
404    fn calculate_total_coverage(&mut self) {
405        let mut total_lines = 0;
406        let mut covered_lines = 0;
407
408        for coverage in self.line_coverage.values() {
409            for line in coverage {
410                total_lines += 1;
411                if line.covered {
412                    covered_lines += 1;
413                }
414            }
415        }
416
417        if total_lines > 0 {
418            self.total_coverage = (covered_lines as f64 / total_lines as f64) * 100.0;
419        }
420    }
421}
422
423/// Result of a TDD cycle action
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct TddCycleResult {
426    /// Current phase of the TDD cycle (Red, Green, or Refactor)
427    pub phase: String,
428    /// Human-readable message describing the cycle state
429    pub message: String,
430    /// Recommended actions to take next
431    pub actions: Vec<TddAction>,
432}
433
434/// Actions that can be taken during a TDD cycle
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub enum TddAction {
437    /// Generate a test with the given name
438    GenerateTest(String),
439    /// Create a new test file at the specified path
440    CreateTestFile(PathBuf),
441    /// Execute the test suite
442    RunTests,
443    /// Request refactoring suggestions for the code
444    SuggestRefactorings,
445    /// Refresh code coverage data
446    UpdateCoverage,
447    /// Display test failure details
448    ShowFailures,
449}
450
451/// Types of tests that can be generated
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub enum TestType {
454    /// Simple happy-path test with typical inputs
455    Basic,
456    /// Tests for boundary conditions and unusual inputs
457    EdgeCase,
458    /// Tests for error conditions and exception handling
459    ErrorHandling,
460    /// Benchmarks and performance regression tests
461    Performance,
462    /// Tests involving multiple components or external dependencies
463    Integration,
464}
465
466/// Inline annotation for displaying coverage information in the editor
467#[derive(Debug, Clone)]
468pub struct CoverageAnnotation {
469    /// Line number to annotate
470    pub line: usize,
471    /// Description of the coverage status
472    pub message: String,
473    /// Severity level for display styling
474    pub severity: AnnotationSeverity,
475}
476
477/// Severity levels for coverage and diagnostic annotations
478#[derive(Debug, Clone)]
479pub enum AnnotationSeverity {
480    /// Critical issue that must be addressed
481    Error,
482    /// Potential problem that should be reviewed
483    Warning,
484    /// Informational message
485    Info,
486    /// Subtle suggestion or hint
487    Hint,
488}
489
490/// Summary of the current TDD workflow status
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct WorkflowStatus {
493    /// Current phase of the TDD cycle
494    pub state: WorkflowState,
495    /// Overall code coverage percentage
496    pub coverage: f64,
497    /// Whether all tests are currently passing
498    pub tests_passing: bool,
499    /// Whether refactoring suggestions are available
500    pub suggestions_available: bool,
501}
502
503/// LSP integration for TDD workflow
504#[cfg(feature = "lsp-compat")]
505pub mod lsp_integration {
506    use super::*;
507    use lsp_types::{
508        CodeAction, CodeActionKind, Command, Diagnostic as LspDiagnostic, DiagnosticSeverity,
509        Position, Range,
510    };
511
512    /// Convert TDD actions to LSP code actions
513    pub fn tdd_actions_to_code_actions(
514        actions: Vec<TddAction>,
515        _uri: &url::Url,
516    ) -> Vec<CodeAction> {
517        actions
518            .into_iter()
519            .map(|action| match action {
520                TddAction::GenerateTest(name) => CodeAction {
521                    title: format!("Generate test for '{}'", name),
522                    kind: Some(CodeActionKind::REFACTOR),
523                    command: Some(Command {
524                        title: "Generate Test".to_string(),
525                        command: "perl.tdd.generateTest".to_string(),
526                        arguments: Some(vec![serde_json::json!(name)]),
527                    }),
528                    ..Default::default()
529                },
530                TddAction::RunTests => CodeAction {
531                    title: "Run tests".to_string(),
532                    kind: Some(CodeActionKind::new("test.run")),
533                    command: Some(Command {
534                        title: "Run Tests".to_string(),
535                        command: "perl.tdd.runTests".to_string(),
536                        arguments: None,
537                    }),
538                    ..Default::default()
539                },
540                TddAction::SuggestRefactorings => CodeAction {
541                    title: "Get refactoring suggestions".to_string(),
542                    kind: Some(CodeActionKind::REFACTOR),
543                    command: Some(Command {
544                        title: "Suggest Refactorings".to_string(),
545                        command: "perl.tdd.suggestRefactorings".to_string(),
546                        arguments: None,
547                    }),
548                    ..Default::default()
549                },
550                _ => CodeAction {
551                    title: format!("{:?}", action),
552                    kind: Some(CodeActionKind::EMPTY),
553                    ..Default::default()
554                },
555            })
556            .collect()
557    }
558
559    /// Convert coverage annotations to LSP diagnostics
560    pub fn coverage_to_diagnostics(annotations: Vec<CoverageAnnotation>) -> Vec<LspDiagnostic> {
561        annotations
562            .into_iter()
563            .map(|ann| LspDiagnostic {
564                range: Range {
565                    start: Position { line: ann.line as u32, character: 0 },
566                    end: Position { line: ann.line as u32, character: 999 },
567                },
568                severity: Some(match ann.severity {
569                    AnnotationSeverity::Error => DiagnosticSeverity::ERROR,
570                    AnnotationSeverity::Warning => DiagnosticSeverity::WARNING,
571                    AnnotationSeverity::Info => DiagnosticSeverity::INFORMATION,
572                    AnnotationSeverity::Hint => DiagnosticSeverity::HINT,
573                }),
574                code: Some(lsp_types::NumberOrString::String("coverage".to_string())),
575                source: Some("TDD".to_string()),
576                message: ann.message,
577                ..Default::default()
578            })
579            .collect()
580    }
581
582    /// Create status bar message for TDD state
583    pub fn create_status_message(status: &WorkflowStatus) -> String {
584        format!(
585            "TDD: {:?} | Coverage: {:.1}% | Tests: {} | Refactor: {}",
586            status.state,
587            status.coverage,
588            if status.tests_passing { "✓" } else { "✗" },
589            if status.suggestions_available { "💡" } else { "" }
590        )
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::ast::NodeKind;
598    use crate::ast::SourceLocation;
599
600    #[test]
601    fn test_tdd_workflow_cycle() {
602        let config = TddConfig::default();
603        let mut workflow = TddWorkflow::new(config);
604
605        // Start a new cycle
606        let result = workflow.start_cycle("calculate_sum");
607        assert_eq!(workflow.state, WorkflowState::Red);
608        assert!(result.message.contains("calculate_sum"));
609    }
610
611    #[test]
612    fn test_generate_tests() {
613        let config = TddConfig::default();
614        let workflow = TddWorkflow::new(config);
615
616        let ast = Node::new(
617            NodeKind::Subroutine {
618                name: Some("multiply".to_string()),
619                name_span: Some(SourceLocation { start: 4, end: 12 }),
620                signature: None,
621                body: Box::new(Node::new(
622                    NodeKind::Block { statements: vec![] },
623                    SourceLocation { start: 0, end: 0 },
624                )),
625                attributes: vec![],
626                prototype: None,
627            },
628            SourceLocation { start: 0, end: 0 },
629        );
630
631        let tests = workflow.generate_tests(&ast, "sub multiply { }");
632        assert!(!tests.is_empty());
633    }
634
635    #[test]
636    fn test_coverage_tracking() {
637        let config = TddConfig::default();
638        let mut workflow = TddWorkflow::new(config);
639
640        let coverage = vec![
641            LineCoverage { line: 1, hits: 5, covered: true },
642            LineCoverage { line: 2, hits: 0, covered: false },
643            LineCoverage { line: 3, hits: 10, covered: true },
644        ];
645
646        workflow.update_coverage(PathBuf::from("test.pl"), coverage);
647
648        let annotations = workflow.get_inline_coverage(&PathBuf::from("test.pl"));
649        assert_eq!(annotations.len(), 1); // One uncovered line
650        assert_eq!(annotations[0].line, 2);
651    }
652
653    #[test]
654    fn test_refactoring_suggestions() {
655        let config = TddConfig::default();
656        let mut workflow = TddWorkflow::new(config);
657
658        // Create a subroutine with 8 parameters to trigger TooManyParameters suggestion
659        let parameters: Vec<Node> = (0..8)
660            .map(|i| {
661                Node::new(
662                    NodeKind::MandatoryParameter {
663                        variable: Box::new(Node::new(
664                            NodeKind::Variable {
665                                sigil: "$".to_string(),
666                                name: format!("param{}", i),
667                            },
668                            SourceLocation { start: 0, end: 0 },
669                        )),
670                    },
671                    SourceLocation { start: 0, end: 0 },
672                )
673            })
674            .collect();
675
676        let ast = Node::new(
677            NodeKind::Subroutine {
678                name: Some("complex_function".to_string()),
679                name_span: Some(SourceLocation { start: 4, end: 20 }),
680                signature: Some(Box::new(Node::new(
681                    NodeKind::Signature { parameters },
682                    SourceLocation { start: 0, end: 0 },
683                ))),
684                body: Box::new(Node::new(
685                    NodeKind::Block { statements: vec![] },
686                    SourceLocation { start: 0, end: 0 },
687                )),
688                attributes: vec![],
689                prototype: None,
690            },
691            SourceLocation { start: 0, end: 0 },
692        );
693
694        let suggestions = workflow.get_refactoring_suggestions(&ast, "sub complex_function { }");
695
696        // Should suggest refactoring for too many parameters
697        assert!(
698            suggestions.iter().any(
699                |s| s.category == crate::test_generator::RefactoringCategory::TooManyParameters
700            )
701        );
702    }
703
704    #[test]
705    fn test_specific_test_generation() {
706        let config = TddConfig::default();
707        let workflow = TddWorkflow::new(config);
708
709        let test = workflow.generate_test_for_function(
710            "validate_email",
711            &["$email".to_string()],
712            TestType::EdgeCase,
713        );
714
715        assert!(test.code.contains("edge cases"));
716        assert!(test.code.contains("undef"));
717        assert!(test.code.contains("empty"));
718    }
719}