mockforge_recorder/
test_generation.rs

1//! AI-powered test generation from recorded API interactions
2//!
3//! This module provides functionality to automatically generate test cases
4//! from recorded API requests and responses using AI/LLM capabilities.
5
6use crate::models::{Protocol, RecordedRequest, RecordedResponse};
7use crate::query::{execute_query, QueryFilter};
8use crate::{RecorderDatabase, RecorderError, Result};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12
13/// Test format to generate
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum TestFormat {
17    /// Rust test using reqwest
18    RustReqwest,
19    /// HTTP file format (.http)
20    HttpFile,
21    /// cURL commands
22    Curl,
23    /// Postman collection
24    Postman,
25    /// k6 load test script
26    K6,
27    /// Python pytest
28    PythonPytest,
29    /// JavaScript/TypeScript Jest
30    JavaScriptJest,
31    /// Go test
32    GoTest,
33    /// Ruby RSpec
34    RubyRspec,
35    /// Java JUnit
36    JavaJunit,
37    /// C# xUnit
38    CSharpXunit,
39}
40
41/// Test generation configuration
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct TestGenerationConfig {
44    /// Test format to generate
45    pub format: TestFormat,
46    /// Include assertions for response validation
47    pub include_assertions: bool,
48    /// Include response body validation
49    pub validate_body: bool,
50    /// Include status code validation
51    pub validate_status: bool,
52    /// Include header validation
53    pub validate_headers: bool,
54    /// Include timing assertions
55    pub validate_timing: bool,
56    /// Maximum duration threshold in ms
57    pub max_duration_ms: Option<u64>,
58    /// Test suite name
59    pub suite_name: String,
60    /// Base URL for generated tests
61    pub base_url: Option<String>,
62    /// Use AI to generate intelligent test descriptions
63    pub ai_descriptions: bool,
64    /// LLM provider configuration (optional)
65    pub llm_config: Option<LlmConfig>,
66    /// Group tests by endpoint
67    pub group_by_endpoint: bool,
68    /// Include setup/teardown code
69    pub include_setup_teardown: bool,
70    /// Generate test data fixtures using AI
71    pub generate_fixtures: bool,
72    /// Suggest edge cases using AI
73    pub suggest_edge_cases: bool,
74    /// Perform test gap analysis
75    pub analyze_test_gaps: bool,
76    /// Deduplicate similar tests
77    pub deduplicate_tests: bool,
78    /// Optimize test execution order
79    pub optimize_test_order: bool,
80}
81
82impl Default for TestGenerationConfig {
83    fn default() -> Self {
84        Self {
85            format: TestFormat::RustReqwest,
86            include_assertions: true,
87            validate_body: true,
88            validate_status: true,
89            validate_headers: false,
90            validate_timing: false,
91            max_duration_ms: None,
92            suite_name: "generated_tests".to_string(),
93            base_url: Some("http://localhost:3000".to_string()),
94            ai_descriptions: false,
95            llm_config: None,
96            group_by_endpoint: true,
97            include_setup_teardown: true,
98            generate_fixtures: false,
99            suggest_edge_cases: false,
100            analyze_test_gaps: false,
101            deduplicate_tests: false,
102            optimize_test_order: false,
103        }
104    }
105}
106
107/// LLM configuration for AI-powered test generation
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct LlmConfig {
110    /// LLM provider (openai, anthropic, ollama)
111    pub provider: String,
112    /// API endpoint
113    pub api_endpoint: String,
114    /// API key
115    pub api_key: Option<String>,
116    /// Model name
117    pub model: String,
118    /// Temperature for generation
119    pub temperature: f64,
120}
121
122impl Default for LlmConfig {
123    fn default() -> Self {
124        Self {
125            provider: "ollama".to_string(),
126            api_endpoint: "http://localhost:11434/api/generate".to_string(),
127            api_key: None,
128            model: "llama2".to_string(),
129            temperature: 0.3,
130        }
131    }
132}
133
134/// Generated test case
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct GeneratedTest {
137    /// Test name/identifier
138    pub name: String,
139    /// Test description
140    pub description: String,
141    /// Test code
142    pub code: String,
143    /// Original request ID
144    pub request_id: String,
145    /// Endpoint being tested
146    pub endpoint: String,
147    /// HTTP method
148    pub method: String,
149}
150
151/// Test generation result
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TestGenerationResult {
154    /// Generated tests
155    pub tests: Vec<GeneratedTest>,
156    /// Test suite metadata
157    pub metadata: TestSuiteMetadata,
158    /// Full test file content
159    pub test_file: String,
160}
161
162/// Test suite metadata
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct TestSuiteMetadata {
165    /// Suite name
166    pub name: String,
167    /// Total number of tests
168    pub test_count: usize,
169    /// Number of endpoints covered
170    pub endpoint_count: usize,
171    /// Protocols covered
172    pub protocols: Vec<Protocol>,
173    /// Generation timestamp
174    pub generated_at: chrono::DateTime<chrono::Utc>,
175    /// Format used
176    pub format: TestFormat,
177    /// Generated fixtures (if enabled)
178    pub fixtures: Option<Vec<TestFixture>>,
179    /// Edge case suggestions (if enabled)
180    pub edge_cases: Option<Vec<EdgeCaseSuggestion>>,
181    /// Test gap analysis (if enabled)
182    pub gap_analysis: Option<TestGapAnalysis>,
183}
184
185/// Test data fixture generated by AI
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct TestFixture {
188    /// Fixture name
189    pub name: String,
190    /// Fixture description
191    pub description: String,
192    /// Fixture data in JSON format
193    pub data: Value,
194    /// Related endpoints
195    pub endpoints: Vec<String>,
196}
197
198/// Edge case suggestion
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct EdgeCaseSuggestion {
201    /// Endpoint being tested
202    pub endpoint: String,
203    /// HTTP method
204    pub method: String,
205    /// Edge case type
206    pub case_type: String,
207    /// Description of the edge case
208    pub description: String,
209    /// Suggested test input
210    pub suggested_input: Option<Value>,
211    /// Expected behavior
212    pub expected_behavior: String,
213    /// Priority (1-5, 5 being highest)
214    pub priority: u8,
215}
216
217/// Test gap analysis result
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct TestGapAnalysis {
220    /// Endpoints without tests
221    pub untested_endpoints: Vec<String>,
222    /// HTTP methods not covered per endpoint
223    pub missing_methods: HashMap<String, Vec<String>>,
224    /// Status codes not tested
225    pub missing_status_codes: HashMap<String, Vec<u16>>,
226    /// Common error scenarios not tested
227    pub missing_error_scenarios: Vec<String>,
228    /// Coverage percentage
229    pub coverage_percentage: f64,
230    /// Recommendations
231    pub recommendations: Vec<String>,
232}
233
234/// Test generator engine
235pub struct TestGenerator {
236    database: RecorderDatabase,
237    config: TestGenerationConfig,
238}
239
240impl TestGenerator {
241    /// Create a new test generator
242    pub fn new(database: RecorderDatabase, config: TestGenerationConfig) -> Self {
243        Self { database, config }
244    }
245
246    /// Create from an Arc<RecorderDatabase>
247    pub fn from_arc(
248        database: std::sync::Arc<RecorderDatabase>,
249        config: TestGenerationConfig,
250    ) -> Self {
251        Self {
252            database: (*database).clone(),
253            config,
254        }
255    }
256
257    /// Generate tests from a query filter
258    pub async fn generate_from_filter(&self, filter: QueryFilter) -> Result<TestGenerationResult> {
259        // Execute query to get recordings
260        let query_result = execute_query(&self.database, filter).await?;
261
262        if query_result.exchanges.is_empty() {
263            return Err(RecorderError::InvalidFilter(
264                "No recordings found matching the filter".to_string(),
265            ));
266        }
267
268        // Generate tests from exchanges
269        let mut tests = Vec::new();
270        let mut endpoints = std::collections::HashSet::new();
271        let mut protocols = std::collections::HashSet::new();
272
273        for exchange in &query_result.exchanges {
274            let request = &exchange.request;
275
276            // Skip exchanges without responses
277            let Some(response) = &exchange.response else {
278                continue;
279            };
280
281            endpoints.insert(format!("{} {}", request.method, request.path));
282            protocols.insert(request.protocol);
283
284            let test = self.generate_test_for_exchange(request, response).await?;
285            tests.push(test);
286        }
287
288        // Deduplicate tests if configured
289        if self.config.deduplicate_tests {
290            tests = self.deduplicate_tests(tests);
291        }
292
293        // Optimize test order if configured
294        if self.config.optimize_test_order {
295            tests = self.optimize_test_order(tests);
296        }
297
298        // Group tests by endpoint if configured
299        if self.config.group_by_endpoint {
300            tests.sort_by(|a, b| a.endpoint.cmp(&b.endpoint));
301        }
302
303        // Generate advanced AI features
304        let fixtures = if self.config.generate_fixtures {
305            Some(self.generate_test_fixtures(&query_result.exchanges).await?)
306        } else {
307            None
308        };
309
310        let edge_cases = if self.config.suggest_edge_cases {
311            Some(self.suggest_edge_cases(&query_result.exchanges).await?)
312        } else {
313            None
314        };
315
316        let gap_analysis = if self.config.analyze_test_gaps {
317            Some(self.analyze_test_gaps(&query_result.exchanges, &tests).await?)
318        } else {
319            None
320        };
321
322        // Generate full test file
323        let test_file = self.generate_test_file(&tests)?;
324
325        // Create metadata
326        let metadata = TestSuiteMetadata {
327            name: self.config.suite_name.clone(),
328            test_count: tests.len(),
329            endpoint_count: endpoints.len(),
330            protocols: protocols.into_iter().collect(),
331            generated_at: chrono::Utc::now(),
332            format: self.config.format.clone(),
333            fixtures,
334            edge_cases,
335            gap_analysis,
336        };
337
338        Ok(TestGenerationResult {
339            tests,
340            metadata,
341            test_file,
342        })
343    }
344
345    /// Generate a single test from an exchange
346    async fn generate_test_for_exchange(
347        &self,
348        request: &RecordedRequest,
349        response: &RecordedResponse,
350    ) -> Result<GeneratedTest> {
351        let test_name = self.generate_test_name(request);
352        let description = if self.config.ai_descriptions {
353            self.generate_ai_description(request, response).await?
354        } else {
355            format!("Test {} {}", request.method, request.path)
356        };
357
358        let code = match self.config.format {
359            TestFormat::RustReqwest => self.generate_rust_test(request, response)?,
360            TestFormat::HttpFile => self.generate_http_file(request, response)?,
361            TestFormat::Curl => self.generate_curl(request, response)?,
362            TestFormat::Postman => self.generate_postman(request, response)?,
363            TestFormat::K6 => self.generate_k6(request, response)?,
364            TestFormat::PythonPytest => self.generate_python_test(request, response)?,
365            TestFormat::JavaScriptJest => self.generate_javascript_test(request, response)?,
366            TestFormat::GoTest => self.generate_go_test(request, response)?,
367            TestFormat::RubyRspec => self.generate_ruby_test(request, response)?,
368            TestFormat::JavaJunit => self.generate_java_test(request, response)?,
369            TestFormat::CSharpXunit => self.generate_csharp_test(request, response)?,
370        };
371
372        Ok(GeneratedTest {
373            name: test_name,
374            description,
375            code,
376            request_id: request.id.clone(),
377            endpoint: request.path.clone(),
378            method: request.method.clone(),
379        })
380    }
381
382    /// Generate test name from request
383    fn generate_test_name(&self, request: &RecordedRequest) -> String {
384        let method = request.method.to_lowercase();
385        let path = request
386            .path
387            .trim_start_matches('/')
388            .replace(['/', '-'], "_")
389            .replace("{", "")
390            .replace("}", "");
391
392        format!("test_{}_{}", method, path)
393    }
394
395    /// Generate AI-powered test description
396    async fn generate_ai_description(
397        &self,
398        request: &RecordedRequest,
399        response: &RecordedResponse,
400    ) -> Result<String> {
401        if let Some(llm_config) = &self.config.llm_config {
402            // Use LLM to generate meaningful description
403            let prompt = format!(
404                "Generate a concise test description for this API call:\n\
405                Method: {}\n\
406                Path: {}\n\
407                Status: {}\n\
408                \n\
409                Describe what this endpoint does and what the test validates in one sentence.",
410                request.method, request.path, response.status_code
411            );
412
413            match self.call_llm(llm_config, &prompt).await {
414                Ok(description) => Ok(description),
415                Err(_) => Ok(format!("Test {} {}", request.method, request.path)),
416            }
417        } else {
418            Ok(format!("Test {} {}", request.method, request.path))
419        }
420    }
421
422    /// Call LLM for generation
423    async fn call_llm(&self, config: &LlmConfig, prompt: &str) -> Result<String> {
424        let client = reqwest::Client::new();
425
426        match config.provider.as_str() {
427            "ollama" => {
428                let body = serde_json::json!({
429                    "model": config.model,
430                    "prompt": prompt,
431                    "stream": false,
432                    "options": {
433                        "temperature": config.temperature
434                    }
435                });
436
437                let response = client
438                    .post(&config.api_endpoint)
439                    .json(&body)
440                    .send()
441                    .await
442                    .map_err(|e| RecorderError::Replay(format!("LLM request failed: {}", e)))?;
443
444                let result: Value = response.json().await.map_err(|e| {
445                    RecorderError::Replay(format!("Failed to parse JSON response: {}", e))
446                })?;
447
448                result
449                    .get("response")
450                    .and_then(|v| v.as_str())
451                    .map(|s| s.trim().to_string())
452                    .ok_or_else(|| RecorderError::Replay("Invalid LLM response".to_string()))
453            }
454            "openai" => {
455                let body = serde_json::json!({
456                    "model": config.model,
457                    "messages": [
458                        {"role": "system", "content": "You are a helpful assistant that generates concise test descriptions."},
459                        {"role": "user", "content": prompt}
460                    ],
461                    "temperature": config.temperature,
462                    "max_tokens": 100
463                });
464
465                let mut request_builder = client.post(&config.api_endpoint).json(&body);
466
467                if let Some(api_key) = &config.api_key {
468                    request_builder =
469                        request_builder.header("Authorization", format!("Bearer {}", api_key));
470                }
471
472                let response = request_builder
473                    .send()
474                    .await
475                    .map_err(|e| RecorderError::Replay(format!("LLM request failed: {}", e)))?;
476
477                let result: Value = response.json().await.map_err(|e| {
478                    RecorderError::Replay(format!("Failed to parse JSON response: {}", e))
479                })?;
480
481                result
482                    .get("choices")
483                    .and_then(|v| v.get(0))
484                    .and_then(|v| v.get("message"))
485                    .and_then(|v| v.get("content"))
486                    .and_then(|v| v.as_str())
487                    .map(|s| s.trim().to_string())
488                    .ok_or_else(|| RecorderError::Replay("Invalid LLM response".to_string()))
489            }
490            _ => {
491                Err(RecorderError::Replay(format!("Unsupported LLM provider: {}", config.provider)))
492            }
493        }
494    }
495
496    /// Generate Rust test code
497    fn generate_rust_test(
498        &self,
499        request: &RecordedRequest,
500        response: &RecordedResponse,
501    ) -> Result<String> {
502        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
503        let url = format!("{}{}", base_url, request.path);
504
505        let mut code = String::new();
506        let test_name = self.generate_test_name(request);
507
508        code.push_str("#[tokio::test]\n");
509        code.push_str(&format!("async fn {}() {{\n", test_name));
510        code.push_str("    let client = reqwest::Client::new();\n");
511        code.push_str(&format!(
512            "    let response = client.{}(\"{}\")\n",
513            request.method.to_lowercase(),
514            url
515        ));
516
517        // Add headers
518        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
519            for (key, value) in headers.iter() {
520                if key.to_lowercase() != "host" {
521                    code.push_str(&format!("        .header(\"{}\", \"{}\")\n", key, value));
522                }
523            }
524        }
525
526        // Add body if present
527        if let Some(body) = &request.body {
528            if !body.is_empty() {
529                code.push_str(&format!("        .body(r#\"{}\"#)\n", body));
530            }
531        }
532
533        code.push_str("        .send()\n");
534        code.push_str("        .await\n");
535        code.push_str("        .expect(\"Failed to send request\");\n\n");
536
537        // Add assertions
538        if self.config.validate_status {
539            code.push_str(&format!(
540                "    assert_eq!(response.status().as_u16(), {});\n",
541                response.status_code
542            ));
543        }
544
545        if self.config.validate_body && response.body.is_some() {
546            code.push_str(
547                "    let body = response.text().await.expect(\"Failed to read body\");\n",
548            );
549            if let Some(body) = &response.body {
550                // Try to parse as JSON for better validation
551                if let Ok(_json) = serde_json::from_str::<Value>(body) {
552                    code.push_str("    let json: serde_json::Value = serde_json::from_str(&body).expect(\"Invalid JSON\");\n");
553                    code.push_str("    // Validate response structure\n");
554                    code.push_str("    assert!(json.is_object() || json.is_array());\n");
555                }
556            }
557        }
558
559        if self.config.validate_timing {
560            if let Some(max_duration) = self.config.max_duration_ms {
561                code.push_str(&format!(
562                    "    // Note: Add timing validation (max {} ms)\n",
563                    max_duration
564                ));
565            }
566        }
567
568        code.push_str("}\n");
569
570        Ok(code)
571    }
572
573    /// Generate HTTP file format
574    fn generate_http_file(
575        &self,
576        request: &RecordedRequest,
577        _response: &RecordedResponse,
578    ) -> Result<String> {
579        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
580        let mut code = String::new();
581
582        code.push_str(&format!("### {} {}\n", request.method, request.path));
583        code.push_str(&format!("{} {}{}\n", request.method, base_url, request.path));
584
585        // Add headers
586        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
587            for (key, value) in headers.iter() {
588                if key.to_lowercase() != "host" {
589                    code.push_str(&format!("{}: {}\n", key, value));
590                }
591            }
592        }
593
594        // Add body
595        if let Some(body) = &request.body {
596            if !body.is_empty() {
597                code.push('\n');
598                code.push_str(body);
599                code.push('\n');
600            }
601        }
602
603        code.push('\n');
604        Ok(code)
605    }
606
607    /// Generate cURL command
608    fn generate_curl(
609        &self,
610        request: &RecordedRequest,
611        _response: &RecordedResponse,
612    ) -> Result<String> {
613        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
614        let url = format!("{}{}", base_url, request.path);
615
616        let mut code = format!("curl -X {} '{}'", request.method, url);
617
618        // Add headers
619        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
620            for (key, value) in headers.iter() {
621                if key.to_lowercase() != "host" {
622                    code.push_str(&format!(" \\\n  -H '{}: {}'", key, value));
623                }
624            }
625        }
626
627        // Add body
628        if let Some(body) = &request.body {
629            if !body.is_empty() {
630                let escaped_body = body.replace('\'', "'\\''");
631                code.push_str(&format!(" \\\n  -d '{}'", escaped_body));
632            }
633        }
634
635        Ok(code)
636    }
637
638    /// Generate Postman collection item
639    fn generate_postman(
640        &self,
641        request: &RecordedRequest,
642        _response: &RecordedResponse,
643    ) -> Result<String> {
644        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
645
646        let mut headers_vec = Vec::new();
647        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
648            for (key, value) in headers.iter() {
649                if key.to_lowercase() != "host" {
650                    headers_vec.push(serde_json::json!({
651                        "key": key,
652                        "value": value
653                    }));
654                }
655            }
656        }
657
658        let item = serde_json::json!({
659            "name": format!("{} {}", request.method, request.path),
660            "request": {
661                "method": request.method,
662                "header": headers_vec,
663                "url": {
664                    "raw": format!("{}{}", base_url, request.path),
665                    "protocol": "http",
666                    "host": ["localhost"],
667                    "port": "3000",
668                    "path": request.path.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>()
669                },
670                "body": if let Some(body) = &request.body {
671                    serde_json::json!({
672                        "mode": "raw",
673                        "raw": body
674                    })
675                } else {
676                    serde_json::json!({})
677                }
678            }
679        });
680
681        serde_json::to_string_pretty(&item).map_err(RecorderError::Serialization)
682    }
683
684    /// Generate k6 test script
685    fn generate_k6(
686        &self,
687        request: &RecordedRequest,
688        response: &RecordedResponse,
689    ) -> Result<String> {
690        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
691        let url = format!("{}{}", base_url, request.path);
692
693        let mut code = String::new();
694        code.push_str(&format!("  // {} {}\n", request.method, request.path));
695        code.push_str("  {\n");
696
697        let method = request.method.to_lowercase();
698
699        // Build params object
700        code.push_str("    const params = {\n");
701        code.push_str("      headers: {\n");
702        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
703            for (key, value) in headers.iter() {
704                if key.to_lowercase() != "host" {
705                    code.push_str(&format!("        '{}': '{}',\n", key, value));
706                }
707            }
708        }
709        code.push_str("      },\n");
710        code.push_str("    };\n");
711
712        // Make request
713        if let Some(body) = &request.body {
714            if !body.is_empty() {
715                code.push_str(&format!("    const payload = `{}`;\n", body));
716                code.push_str(&format!(
717                    "    const res = http.{}('{}', payload, params);\n",
718                    method, url
719                ));
720            } else {
721                code.push_str(&format!(
722                    "    const res = http.{}('{}', null, params);\n",
723                    method, url
724                ));
725            }
726        } else {
727            code.push_str(&format!("    const res = http.{}('{}', null, params);\n", method, url));
728        }
729
730        // Add checks
731        if self.config.validate_status {
732            code.push_str("    check(res, {\n");
733            code.push_str(&format!(
734                "      'status is {}': (r) => r.status === {},\n",
735                response.status_code, response.status_code
736            ));
737            code.push_str("    });\n");
738        }
739
740        code.push_str("  }\n");
741        Ok(code)
742    }
743
744    /// Generate Python pytest
745    fn generate_python_test(
746        &self,
747        request: &RecordedRequest,
748        response: &RecordedResponse,
749    ) -> Result<String> {
750        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
751        let url = format!("{}{}", base_url, request.path);
752        let test_name = self.generate_test_name(request);
753
754        let mut code = String::new();
755        code.push_str(&format!("def {}():\n", test_name));
756
757        // Build headers
758        code.push_str("    headers = {\n");
759        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
760            for (key, value) in headers.iter() {
761                if key.to_lowercase() != "host" {
762                    code.push_str(&format!("        '{}': '{}',\n", key, value));
763                }
764            }
765        }
766        code.push_str("    }\n");
767
768        // Make request
769        let method = request.method.to_lowercase();
770        if let Some(body) = &request.body {
771            if !body.is_empty() {
772                code.push_str(&format!("    data = r'''{}'''\n", body));
773                code.push_str(&format!(
774                    "    response = requests.{}('{}', headers=headers, data=data)\n",
775                    method, url
776                ));
777            } else {
778                code.push_str(&format!(
779                    "    response = requests.{}('{}', headers=headers)\n",
780                    method, url
781                ));
782            }
783        } else {
784            code.push_str(&format!(
785                "    response = requests.{}('{}', headers=headers)\n",
786                method, url
787            ));
788        }
789
790        // Assertions
791        if self.config.validate_status {
792            code.push_str(&format!(
793                "    assert response.status_code == {}\n",
794                response.status_code
795            ));
796        }
797
798        Ok(code)
799    }
800
801    /// Generate JavaScript/Jest test
802    fn generate_javascript_test(
803        &self,
804        request: &RecordedRequest,
805        response: &RecordedResponse,
806    ) -> Result<String> {
807        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
808        let url = format!("{}{}", base_url, request.path);
809        let test_name = format!("{} {}", request.method, request.path);
810
811        let mut code = String::new();
812        code.push_str(&format!("test('{}', async () => {{\n", test_name));
813
814        // Build fetch options
815        code.push_str("  const options = {\n");
816        code.push_str(&format!("    method: '{}',\n", request.method));
817        code.push_str("    headers: {\n");
818        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
819            for (key, value) in headers.iter() {
820                if key.to_lowercase() != "host" {
821                    code.push_str(&format!("      '{}': '{}',\n", key, value));
822                }
823            }
824        }
825        code.push_str("    },\n");
826
827        if let Some(body) = &request.body {
828            if !body.is_empty() {
829                code.push_str(&format!("    body: `{}`,\n", body));
830            }
831        }
832
833        code.push_str("  };\n");
834
835        code.push_str(&format!("  const response = await fetch('{}', options);\n", url));
836
837        if self.config.validate_status {
838            code.push_str(&format!("  expect(response.status).toBe({});\n", response.status_code));
839        }
840
841        if self.config.validate_body && response.body.is_some() {
842            code.push_str("  const data = await response.json();\n");
843            code.push_str("  expect(data).toBeDefined();\n");
844        }
845
846        code.push_str("});\n");
847        Ok(code)
848    }
849
850    /// Generate Go test
851    fn generate_go_test(
852        &self,
853        request: &RecordedRequest,
854        response: &RecordedResponse,
855    ) -> Result<String> {
856        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
857        let url = format!("{}{}", base_url, request.path);
858        let test_name = self
859            .generate_test_name(request)
860            .split('_')
861            .map(|s| {
862                let mut c = s.chars();
863                match c.next() {
864                    None => String::new(),
865                    Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
866                }
867            })
868            .collect::<String>();
869
870        let mut code = String::new();
871        code.push_str(&format!("func {}(t *testing.T) {{\n", test_name));
872
873        // Create request
874        if let Some(body) = &request.body {
875            if !body.is_empty() {
876                code.push_str(&format!("    body := strings.NewReader(`{}`)\n", body));
877                code.push_str(&format!(
878                    "    req, err := http.NewRequest(\"{}\", \"{}\", body)\n",
879                    request.method, url
880                ));
881            } else {
882                code.push_str(&format!(
883                    "    req, err := http.NewRequest(\"{}\", \"{}\", nil)\n",
884                    request.method, url
885                ));
886            }
887        } else {
888            code.push_str(&format!(
889                "    req, err := http.NewRequest(\"{}\", \"{}\", nil)\n",
890                request.method, url
891            ));
892        }
893
894        code.push_str("    if err != nil {\n");
895        code.push_str("        t.Fatal(err)\n");
896        code.push_str("    }\n");
897
898        // Add headers
899        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
900            for (key, value) in headers.iter() {
901                if key.to_lowercase() != "host" {
902                    code.push_str(&format!("    req.Header.Set(\"{}\", \"{}\")\n", key, value));
903                }
904            }
905        }
906
907        // Send request
908        code.push_str("    client := &http.Client{}\n");
909        code.push_str("    resp, err := client.Do(req)\n");
910        code.push_str("    if err != nil {\n");
911        code.push_str("        t.Fatal(err)\n");
912        code.push_str("    }\n");
913        code.push_str("    defer resp.Body.Close()\n\n");
914
915        // Assertions
916        if self.config.validate_status {
917            code.push_str(&format!("    if resp.StatusCode != {} {{\n", response.status_code));
918            code.push_str(&format!(
919                "        t.Errorf(\"Expected status {}, got %d\", resp.StatusCode)\n",
920                response.status_code
921            ));
922            code.push_str("    }\n");
923        }
924
925        code.push_str("}\n");
926        Ok(code)
927    }
928
929    /// Generate Ruby RSpec test
930    fn generate_ruby_test(
931        &self,
932        request: &RecordedRequest,
933        response: &RecordedResponse,
934    ) -> Result<String> {
935        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
936        let url = format!("{}{}", base_url, request.path);
937        let test_name = request
938            .path
939            .trim_start_matches('/')
940            .replace(['/', '-'], " ")
941            .replace("{", "")
942            .replace("}", "");
943
944        let mut code = String::new();
945        code.push_str(&format!("  it \"should {} {}\" do\n", request.method, test_name));
946
947        // Build request parameters
948        let mut request_params = vec![format!("method: :{}", request.method.to_lowercase())];
949
950        // Add headers
951        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
952            let header_items: Vec<String> = headers
953                .iter()
954                .filter(|(k, _)| k.to_lowercase() != "host")
955                .map(|(k, v)| format!("'{}' => '{}'", k, v))
956                .collect();
957            if !header_items.is_empty() {
958                request_params.push(format!("headers: {{ {} }}", header_items.join(", ")));
959            }
960        }
961
962        // Add body
963        if let Some(body) = &request.body {
964            if !body.is_empty() {
965                let escaped_body = body.replace('\'', "\\'").replace('\n', "\\n");
966                request_params.push(format!("body: '{}'", escaped_body));
967            }
968        }
969
970        code.push_str(&format!(
971            "    response = HTTParty.{}('{}', {})\n",
972            request.method.to_lowercase(),
973            url,
974            request_params.join(", ")
975        ));
976
977        // Add assertions
978        if self.config.validate_status {
979            code.push_str(&format!("    expect(response.code).to eq({})\n", response.status_code));
980        }
981
982        if self.config.validate_body && response.body.is_some() {
983            if let Some(body) = &response.body {
984                if serde_json::from_str::<Value>(body).is_ok() {
985                    code.push_str("    expect(response.parsed_response).not_to be_nil\n");
986                }
987            }
988        }
989
990        code.push_str("  end\n");
991        Ok(code)
992    }
993
994    /// Generate Java JUnit test
995    fn generate_java_test(
996        &self,
997        request: &RecordedRequest,
998        response: &RecordedResponse,
999    ) -> Result<String> {
1000        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
1001        let url = format!("{}{}", base_url, request.path);
1002        let test_name = self
1003            .generate_test_name(request)
1004            .split('_')
1005            .enumerate()
1006            .map(|(i, s)| {
1007                if i == 0 {
1008                    s.to_string()
1009                } else {
1010                    let mut c = s.chars();
1011                    match c.next() {
1012                        None => String::new(),
1013                        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1014                    }
1015                }
1016            })
1017            .collect::<String>();
1018
1019        let mut code = String::new();
1020        code.push_str("    @Test\n");
1021        code.push_str(&format!("    public void {}() throws Exception {{\n", test_name));
1022
1023        // Create request
1024        code.push_str("        HttpRequest request = HttpRequest.newBuilder()\n");
1025        code.push_str(&format!("            .uri(URI.create(\"{}\"))\n", url));
1026        code.push_str(&format!("            .method(\"{}\", ", request.method));
1027
1028        if let Some(body) = &request.body {
1029            if !body.is_empty() {
1030                let escaped_body = body.replace('"', "\\\"").replace('\n', "\\n");
1031                code.push_str(&format!(
1032                    "HttpRequest.BodyPublishers.ofString(\"{}\"))\n",
1033                    escaped_body
1034                ));
1035            } else {
1036                code.push_str("HttpRequest.BodyPublishers.noBody())\n");
1037            }
1038        } else {
1039            code.push_str("HttpRequest.BodyPublishers.noBody())\n");
1040        }
1041
1042        // Add headers
1043        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
1044            for (key, value) in headers.iter() {
1045                if key.to_lowercase() != "host" {
1046                    code.push_str(&format!("            .header(\"{}\", \"{}\")\n", key, value));
1047                }
1048            }
1049        }
1050
1051        code.push_str("            .build();\n\n");
1052
1053        // Send request
1054        code.push_str("        HttpClient client = HttpClient.newHttpClient();\n");
1055        code.push_str("        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());\n\n");
1056
1057        // Assertions
1058        if self.config.validate_status {
1059            code.push_str(&format!(
1060                "        assertEquals({}, response.statusCode());\n",
1061                response.status_code
1062            ));
1063        }
1064
1065        if self.config.validate_body && response.body.is_some() {
1066            if let Some(body) = &response.body {
1067                if serde_json::from_str::<Value>(body).is_ok() {
1068                    code.push_str("        assertNotNull(response.body());\n");
1069                }
1070            }
1071        }
1072
1073        code.push_str("    }\n");
1074        Ok(code)
1075    }
1076
1077    /// Generate C# xUnit test
1078    fn generate_csharp_test(
1079        &self,
1080        request: &RecordedRequest,
1081        response: &RecordedResponse,
1082    ) -> Result<String> {
1083        let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
1084        let url = format!("{}{}", base_url, request.path);
1085        let test_name = self
1086            .generate_test_name(request)
1087            .split('_')
1088            .map(|s| {
1089                let mut c = s.chars();
1090                match c.next() {
1091                    None => String::new(),
1092                    Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1093                }
1094            })
1095            .collect::<String>();
1096
1097        let mut code = String::new();
1098        code.push_str("        [Fact]\n");
1099        code.push_str(&format!("        public async Task {}Async()\n", test_name));
1100        code.push_str("        {\n");
1101
1102        // Create client and request
1103        code.push_str("            using var client = new HttpClient();\n");
1104
1105        // Create request message
1106        code.push_str(&format!(
1107            "            var request = new HttpRequestMessage(HttpMethod.{}, \"{}\");\n",
1108            request.method.chars().next().unwrap().to_uppercase().collect::<String>()
1109                + &request.method[1..].to_lowercase(),
1110            url
1111        ));
1112
1113        // Add headers
1114        if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
1115            for (key, value) in headers.iter() {
1116                if key.to_lowercase() != "host" && key.to_lowercase() != "content-type" {
1117                    code.push_str(&format!(
1118                        "            request.Headers.Add(\"{}\", \"{}\");\n",
1119                        key, value
1120                    ));
1121                }
1122            }
1123        }
1124
1125        // Add body
1126        if let Some(body) = &request.body {
1127            if !body.is_empty() {
1128                let escaped_body = body.replace('"', "\\\"").replace('\n', "\\n");
1129                code.push_str(&format!("            request.Content = new StringContent(\"{}\", Encoding.UTF8, \"application/json\");\n",
1130                    escaped_body));
1131            }
1132        }
1133
1134        // Send request
1135        code.push_str("            var response = await client.SendAsync(request);\n\n");
1136
1137        // Assertions
1138        if self.config.validate_status {
1139            code.push_str(&format!(
1140                "            Assert.Equal({}, (int)response.StatusCode);\n",
1141                response.status_code
1142            ));
1143        }
1144
1145        if self.config.validate_body && response.body.is_some() {
1146            code.push_str(
1147                "            var content = await response.Content.ReadAsStringAsync();\n",
1148            );
1149            code.push_str("            Assert.NotNull(content);\n");
1150            code.push_str("            Assert.NotEmpty(content);\n");
1151        }
1152
1153        code.push_str("        }\n");
1154        Ok(code)
1155    }
1156
1157    /// Generate complete test file with imports and structure
1158    fn generate_test_file(&self, tests: &[GeneratedTest]) -> Result<String> {
1159        let mut file = String::new();
1160
1161        match self.config.format {
1162            TestFormat::RustReqwest => {
1163                file.push_str("// Generated test file\n");
1164                file.push_str("// Run with: cargo test\n\n");
1165                if self.config.include_setup_teardown {
1166                    file.push_str("use reqwest;\n");
1167                    file.push_str("use serde_json::Value;\n\n");
1168                }
1169
1170                for test in tests {
1171                    file.push_str(&test.code);
1172                    file.push('\n');
1173                }
1174            }
1175            TestFormat::HttpFile => {
1176                for test in tests {
1177                    file.push_str(&test.code);
1178                    file.push('\n');
1179                }
1180            }
1181            TestFormat::Curl => {
1182                file.push_str("#!/bin/bash\n");
1183                file.push_str("# Generated cURL commands\n\n");
1184                for test in tests {
1185                    file.push_str(&format!("# {} {}\n", test.method, test.endpoint));
1186                    file.push_str(&test.code);
1187                    file.push_str("\n\n");
1188                }
1189            }
1190            TestFormat::Postman => {
1191                let collection = serde_json::json!({
1192                    "info": {
1193                        "name": self.config.suite_name,
1194                        "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
1195                    },
1196                    "item": tests.iter().map(|t| {
1197                        serde_json::from_str::<Value>(&t.code).unwrap_or(Value::Null)
1198                    }).collect::<Vec<_>>()
1199                });
1200                file = serde_json::to_string_pretty(&collection)
1201                    .map_err(RecorderError::Serialization)?;
1202            }
1203            TestFormat::K6 => {
1204                file.push_str("import http from 'k6/http';\n");
1205                file.push_str("import { check, sleep } from 'k6';\n\n");
1206                file.push_str("export const options = {\n");
1207                file.push_str("  vus: 10,\n");
1208                file.push_str("  duration: '30s',\n");
1209                file.push_str("};\n\n");
1210                file.push_str("export default function() {\n");
1211                for test in tests {
1212                    file.push_str(&test.code);
1213                }
1214                file.push_str("  sleep(1);\n");
1215                file.push_str("}\n");
1216            }
1217            TestFormat::PythonPytest => {
1218                file.push_str("# Generated test file\n");
1219                file.push_str("# Run with: pytest\n\n");
1220                file.push_str("import requests\n");
1221                file.push_str("import pytest\n\n");
1222                for test in tests {
1223                    file.push_str(&test.code);
1224                    file.push('\n');
1225                }
1226            }
1227            TestFormat::JavaScriptJest => {
1228                file.push_str("// Generated test file\n");
1229                file.push_str("// Run with: npm test\n\n");
1230                file.push_str(&format!("describe('{}', () => {{\n", self.config.suite_name));
1231                for test in tests {
1232                    file.push_str("  ");
1233                    file.push_str(&test.code.replace("\n", "\n  "));
1234                    file.push('\n');
1235                }
1236                file.push_str("});\n");
1237            }
1238            TestFormat::GoTest => {
1239                file.push_str("package main\n\n");
1240                file.push_str("import (\n");
1241                file.push_str("    \"net/http\"\n");
1242                file.push_str("    \"strings\"\n");
1243                file.push_str("    \"testing\"\n");
1244                file.push_str(")\n\n");
1245                for test in tests {
1246                    file.push_str(&test.code);
1247                    file.push('\n');
1248                }
1249            }
1250            TestFormat::RubyRspec => {
1251                file.push_str("# Generated test file\n");
1252                file.push_str("# Run with: rspec spec/api_spec.rb\n\n");
1253                file.push_str("require 'httparty'\n");
1254                file.push_str("require 'rspec'\n\n");
1255                file.push_str(&format!("RSpec.describe '{}' do\n", self.config.suite_name));
1256                for test in tests {
1257                    file.push_str(&test.code);
1258                    file.push('\n');
1259                }
1260                file.push_str("end\n");
1261            }
1262            TestFormat::JavaJunit => {
1263                file.push_str("// Generated test file\n");
1264                file.push_str("// Run with: mvn test or gradle test\n\n");
1265                file.push_str("import org.junit.jupiter.api.Test;\n");
1266                file.push_str("import static org.junit.jupiter.api.Assertions.*;\n");
1267                file.push_str("import java.net.URI;\n");
1268                file.push_str("import java.net.http.HttpClient;\n");
1269                file.push_str("import java.net.http.HttpRequest;\n");
1270                file.push_str("import java.net.http.HttpResponse;\n\n");
1271                file.push_str(&format!(
1272                    "public class {} {{\n",
1273                    self.config.suite_name.replace("-", "_")
1274                ));
1275                for test in tests {
1276                    file.push_str(&test.code);
1277                    file.push('\n');
1278                }
1279                file.push_str("}\n");
1280            }
1281            TestFormat::CSharpXunit => {
1282                file.push_str("// Generated test file\n");
1283                file.push_str("// Run with: dotnet test\n\n");
1284                file.push_str("using System;\n");
1285                file.push_str("using System.Net.Http;\n");
1286                file.push_str("using System.Text;\n");
1287                file.push_str("using System.Threading.Tasks;\n");
1288                file.push_str("using Xunit;\n\n");
1289                file.push_str(&format!("namespace {}\n", self.config.suite_name.replace("-", "_")));
1290                file.push_str("{\n");
1291                file.push_str("    public class ApiTests\n");
1292                file.push_str("    {\n");
1293                for test in tests {
1294                    file.push_str(&test.code);
1295                    file.push('\n');
1296                }
1297                file.push_str("    }\n");
1298                file.push_str("}\n");
1299            }
1300        }
1301
1302        Ok(file)
1303    }
1304
1305    /// Deduplicate similar tests
1306    fn deduplicate_tests(&self, tests: Vec<GeneratedTest>) -> Vec<GeneratedTest> {
1307        let mut unique_tests = Vec::new();
1308        let mut seen_signatures = std::collections::HashSet::new();
1309
1310        for test in tests {
1311            // Create a signature based on method + endpoint + code structure
1312            let signature = format!("{}:{}:{}", test.method, test.endpoint, test.code.len());
1313
1314            if !seen_signatures.contains(&signature) {
1315                seen_signatures.insert(signature);
1316                unique_tests.push(test);
1317            }
1318        }
1319
1320        unique_tests
1321    }
1322
1323    /// Optimize test execution order
1324    fn optimize_test_order(&self, mut tests: Vec<GeneratedTest>) -> Vec<GeneratedTest> {
1325        // Sort tests by:
1326        // 1. GET requests first (read-only, fast)
1327        // 2. POST/PUT requests (may modify state)
1328        // 3. DELETE requests last
1329        tests.sort_by(|a, b| {
1330            let order_a = match a.method.as_str() {
1331                "GET" | "HEAD" => 0,
1332                "POST" | "PUT" | "PATCH" => 1,
1333                "DELETE" => 2,
1334                _ => 3,
1335            };
1336            let order_b = match b.method.as_str() {
1337                "GET" | "HEAD" => 0,
1338                "POST" | "PUT" | "PATCH" => 1,
1339                "DELETE" => 2,
1340                _ => 3,
1341            };
1342            order_a.cmp(&order_b).then_with(|| a.endpoint.cmp(&b.endpoint))
1343        });
1344
1345        tests
1346    }
1347
1348    /// Generate test data fixtures using AI
1349    async fn generate_test_fixtures(
1350        &self,
1351        exchanges: &[crate::models::RecordedExchange],
1352    ) -> Result<Vec<TestFixture>> {
1353        if self.config.llm_config.is_none() {
1354            return Ok(Vec::new());
1355        }
1356
1357        let llm_config = self.config.llm_config.as_ref().unwrap();
1358        let mut fixtures = Vec::new();
1359
1360        // Group exchanges by endpoint
1361        let mut endpoint_data: HashMap<String, Vec<&crate::models::RecordedExchange>> =
1362            HashMap::new();
1363        for exchange in exchanges {
1364            let endpoint = format!("{} {}", exchange.request.method, exchange.request.path);
1365            endpoint_data.entry(endpoint).or_default().push(exchange);
1366        }
1367
1368        // Generate fixtures for each endpoint
1369        for (endpoint, endpoint_exchanges) in endpoint_data.iter().take(5) {
1370            // Collect sample request bodies
1371            let mut sample_bodies = Vec::new();
1372            for exchange in endpoint_exchanges.iter().take(3) {
1373                if let Some(body) = &exchange.request.body {
1374                    if !body.is_empty() {
1375                        if let Ok(json) = serde_json::from_str::<Value>(body) {
1376                            sample_bodies.push(json);
1377                        }
1378                    }
1379                }
1380            }
1381
1382            if sample_bodies.is_empty() {
1383                continue;
1384            }
1385
1386            let prompt = format!(
1387                "Based on these sample API request bodies for endpoint '{}', generate a reusable test fixture in JSON format:\n{}\n\nProvide a clean JSON object with varied test data including edge cases.",
1388                endpoint,
1389                serde_json::to_string_pretty(&sample_bodies).unwrap_or_default()
1390            );
1391
1392            if let Ok(response) = self.call_llm(llm_config, &prompt).await {
1393                // Try to parse the LLM response as JSON
1394                if let Ok(data) = serde_json::from_str::<Value>(&response) {
1395                    fixtures.push(TestFixture {
1396                        name: format!("fixture_{}", endpoint.replace([' ', '/'], "_")),
1397                        description: format!("Test fixture for {}", endpoint),
1398                        data,
1399                        endpoints: vec![endpoint.clone()],
1400                    });
1401                }
1402            }
1403        }
1404
1405        Ok(fixtures)
1406    }
1407
1408    /// Suggest edge cases using AI
1409    async fn suggest_edge_cases(
1410        &self,
1411        exchanges: &[crate::models::RecordedExchange],
1412    ) -> Result<Vec<EdgeCaseSuggestion>> {
1413        if self.config.llm_config.is_none() {
1414            return Ok(Vec::new());
1415        }
1416
1417        let llm_config = self.config.llm_config.as_ref().unwrap();
1418        let mut edge_cases = Vec::new();
1419
1420        // Group by endpoint
1421        let mut endpoint_data: HashMap<String, Vec<&crate::models::RecordedExchange>> =
1422            HashMap::new();
1423        for exchange in exchanges {
1424            let key = format!("{} {}", exchange.request.method, exchange.request.path);
1425            endpoint_data.entry(key).or_default().push(exchange);
1426        }
1427
1428        for (endpoint_key, endpoint_exchanges) in endpoint_data.iter().take(5) {
1429            let parts: Vec<&str> = endpoint_key.splitn(2, ' ').collect();
1430            if parts.len() != 2 {
1431                continue;
1432            }
1433            let (method, endpoint) = (parts[0], parts[1]);
1434
1435            // Collect sample data
1436            let sample_exchange = endpoint_exchanges.first();
1437            let sample_body = sample_exchange
1438                .and_then(|e| e.request.body.as_ref())
1439                .map(|s| s.as_str())
1440                .unwrap_or("{}");
1441
1442            let prompt = format!(
1443                "Suggest 3 critical edge cases to test for this API endpoint:\n\
1444                Method: {}\n\
1445                Path: {}\n\
1446                Sample Request: {}\n\n\
1447                For each edge case, provide:\n\
1448                1. Type (e.g., 'validation', 'boundary', 'security')\n\
1449                2. Description\n\
1450                3. Expected behavior\n\
1451                4. Priority (1-5)\n\n\
1452                Format: type|description|behavior|priority",
1453                method, endpoint, sample_body
1454            );
1455
1456            if let Ok(response) = self.call_llm(llm_config, &prompt).await {
1457                // Parse LLM response
1458                for line in response.lines().take(3) {
1459                    let parts: Vec<&str> = line.split('|').collect();
1460                    if parts.len() >= 4 {
1461                        let priority = parts[3].trim().parse::<u8>().unwrap_or(3);
1462                        edge_cases.push(EdgeCaseSuggestion {
1463                            endpoint: endpoint.to_string(),
1464                            method: method.to_string(),
1465                            case_type: parts[0].trim().to_string(),
1466                            description: parts[1].trim().to_string(),
1467                            suggested_input: None,
1468                            expected_behavior: parts[2].trim().to_string(),
1469                            priority,
1470                        });
1471                    }
1472                }
1473            }
1474        }
1475
1476        Ok(edge_cases)
1477    }
1478
1479    /// Analyze test gaps
1480    async fn analyze_test_gaps(
1481        &self,
1482        exchanges: &[crate::models::RecordedExchange],
1483        tests: &[GeneratedTest],
1484    ) -> Result<TestGapAnalysis> {
1485        // Collect all unique endpoints from exchanges
1486        let mut all_endpoints = std::collections::HashSet::new();
1487        let mut method_by_endpoint: HashMap<String, std::collections::HashSet<String>> =
1488            HashMap::new();
1489        let mut status_codes_by_endpoint: HashMap<String, std::collections::HashSet<u16>> =
1490            HashMap::new();
1491
1492        for exchange in exchanges {
1493            let endpoint = exchange.request.path.clone();
1494            let method = exchange.request.method.clone();
1495            all_endpoints.insert(endpoint.clone());
1496            method_by_endpoint.entry(endpoint.clone()).or_default().insert(method);
1497
1498            if let Some(response) = &exchange.response {
1499                let status_code = response.status_code as u16;
1500                status_codes_by_endpoint
1501                    .entry(endpoint.clone())
1502                    .or_default()
1503                    .insert(status_code);
1504            }
1505        }
1506
1507        // Collect tested endpoints from generated tests
1508        let mut tested_endpoints = std::collections::HashSet::new();
1509        for test in tests {
1510            tested_endpoints.insert(test.endpoint.clone());
1511        }
1512
1513        // Find gaps
1514        let untested_endpoints: Vec<String> =
1515            all_endpoints.difference(&tested_endpoints).cloned().collect();
1516
1517        let mut missing_methods: HashMap<String, Vec<String>> = HashMap::new();
1518        for (endpoint, methods) in &method_by_endpoint {
1519            let tested_methods: std::collections::HashSet<String> = tests
1520                .iter()
1521                .filter(|t| &t.endpoint == endpoint)
1522                .map(|t| t.method.clone())
1523                .collect();
1524
1525            let missing: Vec<String> = methods.difference(&tested_methods).cloned().collect();
1526
1527            if !missing.is_empty() {
1528                missing_methods.insert(endpoint.clone(), missing);
1529            }
1530        }
1531
1532        let mut missing_status_codes: HashMap<String, Vec<u16>> = HashMap::new();
1533        for (endpoint, codes) in &status_codes_by_endpoint {
1534            // For now, we'll just note if error codes (4xx, 5xx) are missing
1535            let has_error_tests = codes.iter().any(|c| *c >= 400);
1536            if has_error_tests {
1537                missing_status_codes.insert(
1538                    endpoint.clone(),
1539                    codes.iter().filter(|c| **c >= 400).copied().collect(),
1540                );
1541            }
1542        }
1543
1544        let missing_error_scenarios = vec![
1545            "401 Unauthorized scenarios".to_string(),
1546            "403 Forbidden scenarios".to_string(),
1547            "404 Not Found scenarios".to_string(),
1548            "429 Rate Limiting scenarios".to_string(),
1549            "500 Internal Server Error scenarios".to_string(),
1550        ];
1551
1552        let coverage_percentage = if all_endpoints.is_empty() {
1553            100.0
1554        } else {
1555            (tested_endpoints.len() as f64 / all_endpoints.len() as f64) * 100.0
1556        };
1557
1558        let mut recommendations = Vec::new();
1559        if !untested_endpoints.is_empty() {
1560            recommendations
1561                .push(format!("Add tests for {} untested endpoints", untested_endpoints.len()));
1562        }
1563        if !missing_methods.is_empty() {
1564            recommendations.push(format!(
1565                "Add tests for missing HTTP methods on {} endpoints",
1566                missing_methods.len()
1567            ));
1568        }
1569        if coverage_percentage < 80.0 {
1570            recommendations.push("Increase test coverage to at least 80%".to_string());
1571        }
1572
1573        Ok(TestGapAnalysis {
1574            untested_endpoints,
1575            missing_methods,
1576            missing_status_codes,
1577            missing_error_scenarios,
1578            coverage_percentage,
1579            recommendations,
1580        })
1581    }
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586    use super::*;
1587    use crate::models::{Protocol, RecordedRequest, RecordedResponse};
1588
1589    #[tokio::test]
1590    async fn test_generate_test_name() {
1591        let database = RecorderDatabase::new_in_memory().await.unwrap();
1592        let config = TestGenerationConfig::default();
1593        let generator = TestGenerator::new(database, config);
1594
1595        let request = RecordedRequest {
1596            id: "test".to_string(),
1597            protocol: Protocol::Http,
1598            timestamp: chrono::Utc::now(),
1599            method: "GET".to_string(),
1600            path: "/api/users/123".to_string(),
1601            query_params: None,
1602            headers: "{}".to_string(),
1603            body: None,
1604            body_encoding: "utf-8".to_string(),
1605            status_code: Some(200),
1606            duration_ms: Some(50),
1607            client_ip: None,
1608            trace_id: None,
1609            span_id: None,
1610            tags: None,
1611        };
1612
1613        let name = generator.generate_test_name(&request);
1614        assert_eq!(name, "test_get_api_users_123");
1615    }
1616
1617    #[test]
1618    fn test_default_config() {
1619        let config = TestGenerationConfig::default();
1620        assert_eq!(config.format, TestFormat::RustReqwest);
1621        assert!(config.include_assertions);
1622        assert!(config.validate_body);
1623        assert!(config.validate_status);
1624    }
1625
1626    #[tokio::test]
1627    async fn test_generate_rust_test() {
1628        let database = RecorderDatabase::new_in_memory().await.unwrap();
1629        let config = TestGenerationConfig::default();
1630        let generator = TestGenerator::new(database, config);
1631
1632        let request = RecordedRequest {
1633            id: "test-1".to_string(),
1634            protocol: Protocol::Http,
1635            timestamp: chrono::Utc::now(),
1636            method: "GET".to_string(),
1637            path: "/api/users".to_string(),
1638            query_params: None,
1639            headers: r#"{"content-type":"application/json"}"#.to_string(),
1640            body: None,
1641            body_encoding: "utf-8".to_string(),
1642            status_code: Some(200),
1643            duration_ms: Some(45),
1644            client_ip: Some("127.0.0.1".to_string()),
1645            trace_id: None,
1646            span_id: None,
1647            tags: None,
1648        };
1649
1650        let response = RecordedResponse {
1651            request_id: "test-1".to_string(),
1652            status_code: 200,
1653            headers: r#"{"content-type":"application/json"}"#.to_string(),
1654            body: Some(r#"{"users":[]}"#.to_string()),
1655            body_encoding: "utf-8".to_string(),
1656            size_bytes: 12,
1657            timestamp: chrono::Utc::now(),
1658        };
1659
1660        let code = generator.generate_rust_test(&request, &response).unwrap();
1661
1662        assert!(code.contains("#[tokio::test]"));
1663        assert!(code.contains("async fn test_get_api_users()"));
1664        assert!(code.contains("reqwest::Client::new()"));
1665        assert!(code.contains("assert_eq!(response.status().as_u16(), 200)"));
1666    }
1667
1668    #[tokio::test]
1669    async fn test_generate_curl() {
1670        let database = RecorderDatabase::new_in_memory().await.unwrap();
1671        let config = TestGenerationConfig::default();
1672        let generator = TestGenerator::new(database, config);
1673
1674        let request = RecordedRequest {
1675            id: "test-2".to_string(),
1676            protocol: Protocol::Http,
1677            timestamp: chrono::Utc::now(),
1678            method: "POST".to_string(),
1679            path: "/api/users".to_string(),
1680            query_params: None,
1681            headers: r#"{"content-type":"application/json"}"#.to_string(),
1682            body: Some(r#"{"name":"John"}"#.to_string()),
1683            body_encoding: "utf-8".to_string(),
1684            status_code: Some(201),
1685            duration_ms: Some(80),
1686            client_ip: None,
1687            trace_id: None,
1688            span_id: None,
1689            tags: None,
1690        };
1691
1692        let response = RecordedResponse {
1693            request_id: "test-2".to_string(),
1694            status_code: 201,
1695            headers: r#"{}"#.to_string(),
1696            body: None,
1697            body_encoding: "utf-8".to_string(),
1698            size_bytes: 0,
1699            timestamp: chrono::Utc::now(),
1700        };
1701
1702        let code = generator.generate_curl(&request, &response).unwrap();
1703
1704        assert!(code.contains("curl -X POST"));
1705        assert!(code.contains("/api/users"));
1706        assert!(code.contains("-H 'content-type: application/json'"));
1707        assert!(code.contains(r#"-d '{"name":"John"}'"#));
1708    }
1709
1710    #[tokio::test]
1711    async fn test_generate_http_file() {
1712        let database = RecorderDatabase::new_in_memory().await.unwrap();
1713        let config = TestGenerationConfig::default();
1714        let generator = TestGenerator::new(database, config);
1715
1716        let request = RecordedRequest {
1717            id: "test-3".to_string(),
1718            protocol: Protocol::Http,
1719            timestamp: chrono::Utc::now(),
1720            method: "DELETE".to_string(),
1721            path: "/api/users/123".to_string(),
1722            query_params: None,
1723            headers: r#"{}"#.to_string(),
1724            body: None,
1725            body_encoding: "utf-8".to_string(),
1726            status_code: Some(204),
1727            duration_ms: Some(30),
1728            client_ip: None,
1729            trace_id: None,
1730            span_id: None,
1731            tags: None,
1732        };
1733
1734        let response = RecordedResponse {
1735            request_id: "test-3".to_string(),
1736            status_code: 204,
1737            headers: r#"{}"#.to_string(),
1738            body: None,
1739            body_encoding: "utf-8".to_string(),
1740            size_bytes: 0,
1741            timestamp: chrono::Utc::now(),
1742        };
1743
1744        let code = generator.generate_http_file(&request, &response).unwrap();
1745
1746        assert!(code.contains("### DELETE /api/users/123"));
1747        assert!(code.contains("DELETE http://localhost:3000/api/users/123"));
1748    }
1749
1750    #[test]
1751    fn test_llm_config_defaults() {
1752        let config = LlmConfig::default();
1753        assert_eq!(config.provider, "ollama");
1754        assert_eq!(config.model, "llama2");
1755        assert_eq!(config.temperature, 0.3);
1756    }
1757
1758    #[test]
1759    fn test_test_format_variants() {
1760        assert_eq!(TestFormat::RustReqwest, TestFormat::RustReqwest);
1761        assert_ne!(TestFormat::RustReqwest, TestFormat::Curl);
1762        assert_ne!(TestFormat::PythonPytest, TestFormat::JavaScriptJest);
1763    }
1764}