mockforge_runtime_daemon/
auto_generator.rs

1//! Auto-generator for creating mocks from 404 responses
2//!
3//! This module handles the automatic generation of mocks, types, client stubs,
4//! OpenAPI schema entries, and scenarios when a 404 is detected.
5
6use anyhow::{Context, Result};
7use serde_json::json;
8use std::path::PathBuf;
9use tracing::{debug, info, warn};
10
11use crate::config::RuntimeDaemonConfig;
12
13/// Auto-generator for creating mocks from 404s
14pub struct AutoGenerator {
15    /// Configuration
16    config: RuntimeDaemonConfig,
17    /// Base URL for the MockForge management API
18    management_api_url: String,
19}
20
21impl AutoGenerator {
22    /// Create a new auto-generator
23    pub fn new(config: RuntimeDaemonConfig, management_api_url: String) -> Self {
24        Self {
25            config,
26            management_api_url,
27        }
28    }
29
30    /// Generate a mock from a 404 response
31    ///
32    /// This is the main entry point that orchestrates all the generation steps:
33    /// 1. Create mock endpoint with intelligent response
34    /// 2. Generate type (TypeScript/JSON schema)
35    /// 3. Generate client stub code
36    /// 4. Add to OpenAPI schema
37    /// 5. Add example response
38    /// 6. Set up basic scenario
39    pub async fn generate_mock_from_404(&self, method: &str, path: &str) -> Result<()> {
40        info!("Generating mock for {} {}", method, path);
41
42        // Step 1: Create basic mock endpoint with intelligent response
43        let mock_id = self.create_mock_endpoint(method, path).await?;
44        debug!("Created mock endpoint with ID: {}", mock_id);
45
46        // Step 2: Generate type (if enabled)
47        if self.config.generate_types {
48            if let Err(e) = self.generate_type(method, path).await {
49                warn!("Failed to generate type: {}", e);
50            }
51        }
52
53        // Step 3: Generate client stub (if enabled)
54        if self.config.generate_client_stubs {
55            if let Err(e) = self.generate_client_stub(method, path).await {
56                warn!("Failed to generate client stub: {}", e);
57            }
58        }
59
60        // Step 4: Update OpenAPI schema (if enabled)
61        if self.config.update_openapi {
62            if let Err(e) = self.update_openapi_schema(method, path).await {
63                warn!("Failed to update OpenAPI schema: {}", e);
64            }
65        }
66
67        // Step 5: Create scenario (if enabled)
68        if self.config.create_scenario {
69            if let Err(e) = self.create_scenario(method, path, &mock_id).await {
70                warn!("Failed to create scenario: {}", e);
71            }
72        }
73
74        info!("Completed mock generation for {} {}", method, path);
75        Ok(())
76    }
77
78    /// Create a mock endpoint with a basic intelligent response
79    async fn create_mock_endpoint(&self, method: &str, path: &str) -> Result<String> {
80        // Generate a basic response based on the path
81        let response_body = self.generate_intelligent_response(method, path).await?;
82
83        // Create mock configuration
84        let mock_config = json!({
85            "method": method,
86            "path": path,
87            "status_code": 200,
88            "body": response_body,
89            "name": format!("Auto-generated: {} {}", method, path),
90            "enabled": true,
91        });
92
93        // Call the management API to create the mock
94        let client = reqwest::Client::new();
95        let url = format!("{}/__mockforge/api/mocks", self.management_api_url);
96
97        let response = client
98            .post(&url)
99            .json(&mock_config)
100            .send()
101            .await
102            .context("Failed to send request to management API")?;
103
104        if !response.status().is_success() {
105            let status = response.status();
106            let text = response.text().await.unwrap_or_default();
107            anyhow::bail!("Management API returned {}: {}", status, text);
108        }
109
110        let created_mock: serde_json::Value =
111            response.json().await.context("Failed to parse response from management API")?;
112
113        let mock_id = created_mock
114            .get("id")
115            .and_then(|v| v.as_str())
116            .ok_or_else(|| anyhow::anyhow!("Response missing 'id' field"))?
117            .to_string();
118
119        Ok(mock_id)
120    }
121
122    /// Generate an intelligent response based on the method and path
123    async fn generate_intelligent_response(
124        &self,
125        method: &str,
126        path: &str,
127    ) -> Result<serde_json::Value> {
128        // Use AI generation if enabled
129        #[cfg(feature = "ai")]
130        if self.config.ai_generation {
131            return self.generate_ai_response(method, path).await;
132        }
133
134        // Fallback to pattern-based generation
135
136        // Infer entity type from path
137        let entity_type = self.infer_entity_type(path);
138
139        // Generate a basic response structure
140        let response = match method.to_uppercase().as_str() {
141            "GET" => {
142                // For GET, return a single object or array based on path
143                if path.ends_with('/')
144                    || path.split('/').next_back().unwrap_or("").parse::<u64>().is_err()
145                {
146                    // Looks like a collection endpoint
147                    json!([{
148                        "id": "{{uuid}}",
149                        "name": format!("Sample {}", entity_type),
150                        "created_at": "{{now}}",
151                    }])
152                } else {
153                    // Looks like a single resource endpoint
154                    json!({
155                        "id": path.split('/').next_back().unwrap_or("123"),
156                        "name": format!("Sample {}", entity_type),
157                        "created_at": "{{now}}",
158                    })
159                }
160            }
161            "POST" => {
162                // For POST, return the created resource
163                json!({
164                    "id": "{{uuid}}",
165                    "name": format!("New {}", entity_type),
166                    "created_at": "{{now}}",
167                    "status": "created",
168                })
169            }
170            "PUT" | "PATCH" => {
171                // For PUT/PATCH, return the updated resource
172                json!({
173                    "id": path.split('/').next_back().unwrap_or("123"),
174                    "name": format!("Updated {}", entity_type),
175                    "updated_at": "{{now}}",
176                })
177            }
178            "DELETE" => {
179                // For DELETE, return success status
180                json!({
181                    "success": true,
182                    "message": "Resource deleted",
183                })
184            }
185            _ => {
186                // Default response
187                json!({
188                    "message": "Auto-generated response",
189                    "method": method,
190                    "path": path,
191                })
192            }
193        };
194
195        Ok(response)
196    }
197
198    /// Generate AI-powered response using IntelligentMockGenerator
199    #[cfg(feature = "ai")]
200    async fn generate_ai_response(&self, method: &str, path: &str) -> Result<serde_json::Value> {
201        use mockforge_data::{IntelligentMockConfig, IntelligentMockGenerator, ResponseMode};
202        use std::collections::HashMap;
203
204        // Infer entity type and build a prompt
205        let entity_type = self.infer_entity_type(path);
206        let prompt = format!(
207            "Generate a realistic {} API response for {} {} endpoint. \
208            The response should be appropriate for a {} operation and include realistic data \
209            for a {} entity.",
210            entity_type, method, path, method, entity_type
211        );
212
213        // Build schema based on inferred entity type
214        let schema = self.build_schema_for_entity(&entity_type, method);
215
216        // Create intelligent mock config
217        let mut ai_config = IntelligentMockConfig::new(ResponseMode::Intelligent)
218            .with_prompt(prompt)
219            .with_schema(schema)
220            .with_count(1);
221
222        // Try to load RAG config from environment
223        if let Ok(rag_config) = self.load_rag_config_from_env() {
224            ai_config = ai_config.with_rag_config(rag_config);
225        }
226
227        // Create generator and generate response
228        let mut generator = IntelligentMockGenerator::new(ai_config)
229            .context("Failed to create intelligent mock generator")?;
230
231        let response = generator.generate().await.context("Failed to generate AI response")?;
232
233        info!("Generated AI-powered response for {} {}", method, path);
234        Ok(response)
235    }
236
237    /// Load RAG configuration from environment variables
238    #[cfg(feature = "ai")]
239    fn load_rag_config_from_env(&self) -> Result<mockforge_data::RagConfig> {
240        use mockforge_data::{EmbeddingProvider, LlmProvider, RagConfig};
241
242        // Try to determine provider from environment
243        let provider = std::env::var("MOCKFORGE_RAG_PROVIDER")
244            .unwrap_or_else(|_| "openai".to_string())
245            .to_lowercase();
246
247        let provider = match provider.as_str() {
248            "openai" => LlmProvider::OpenAI,
249            "anthropic" => LlmProvider::Anthropic,
250            "ollama" => LlmProvider::Ollama,
251            _ => LlmProvider::OpenAI,
252        };
253
254        let model =
255            std::env::var("MOCKFORGE_RAG_MODEL").unwrap_or_else(|_| "gpt-3.5-turbo".to_string());
256
257        let api_key = std::env::var("MOCKFORGE_RAG_API_KEY").ok();
258
259        let api_endpoint = std::env::var("MOCKFORGE_RAG_API_ENDPOINT")
260            .unwrap_or_else(|_| "https://api.openai.com/v1/chat/completions".to_string());
261
262        let mut config = RagConfig::default();
263        config.provider = provider;
264        config.model = model;
265        config.api_key = api_key;
266        config.api_endpoint = api_endpoint;
267
268        // Set embedding provider to match LLM provider
269        config.embedding_provider = match config.provider {
270            LlmProvider::OpenAI => EmbeddingProvider::OpenAI,
271            LlmProvider::Anthropic => EmbeddingProvider::OpenAI, // Anthropic doesn't have embeddings, use OpenAI
272            LlmProvider::Ollama => EmbeddingProvider::OpenAI, // Ollama embedding not yet supported, fallback to OpenAI
273            LlmProvider::OpenAICompatible => EmbeddingProvider::OpenAI,
274        };
275
276        Ok(config)
277    }
278
279    /// Build a basic JSON schema for an entity type
280    fn build_schema_for_entity(&self, entity_type: &str, method: &str) -> serde_json::Value {
281        let base_schema = json!({
282            "type": "object",
283            "properties": {
284                "id": {
285                    "type": "string",
286                    "format": "uuid"
287                },
288                "name": {
289                    "type": "string"
290                },
291                "created_at": {
292                    "type": "string",
293                    "format": "date-time"
294                }
295            },
296            "required": ["id", "name"]
297        });
298
299        // Adjust schema based on HTTP method
300        match method.to_uppercase().as_str() {
301            "GET" => {
302                // For GET, might be array or single object
303                if entity_type.ends_with('s') {
304                    json!({
305                        "type": "array",
306                        "items": base_schema
307                    })
308                } else {
309                    base_schema
310                }
311            }
312            "POST" => {
313                // For POST, add status field
314                let mut schema = base_schema.clone();
315                if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
316                    props.insert(
317                        "status".to_string(),
318                        json!({
319                            "type": "string",
320                            "enum": ["created", "pending", "active"]
321                        }),
322                    );
323                }
324                schema
325            }
326            "PUT" | "PATCH" => {
327                // For PUT/PATCH, add updated_at
328                let mut schema = base_schema.clone();
329                if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
330                    props.insert(
331                        "updated_at".to_string(),
332                        json!({
333                            "type": "string",
334                            "format": "date-time"
335                        }),
336                    );
337                }
338                schema
339            }
340            _ => base_schema,
341        }
342    }
343
344    /// Infer entity type from path
345    fn infer_entity_type(&self, path: &str) -> String {
346        // Extract entity type from path (e.g., /api/users -> "user")
347        let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
348
349        if let Some(last_part) = parts.last() {
350            // Remove common prefixes and pluralization
351            let entity = last_part
352                .trim_end_matches('s') // Remove plural 's'
353                .to_lowercase();
354
355            if !entity.is_empty() {
356                return entity;
357            }
358        }
359
360        "resource".to_string()
361    }
362
363    /// Generate a type (TypeScript/JSON schema) for the endpoint
364    async fn generate_type(&self, method: &str, path: &str) -> Result<()> {
365        use std::path::PathBuf;
366
367        // Determine output directory (use workspace_dir if configured, otherwise current dir)
368        let output_dir = if let Some(ref workspace_dir) = self.config.workspace_dir {
369            PathBuf::from(workspace_dir)
370        } else {
371            PathBuf::from(".")
372        };
373
374        // Create types directory if it doesn't exist
375        let types_dir = output_dir.join("types");
376        if !types_dir.exists() {
377            std::fs::create_dir_all(&types_dir).context("Failed to create types directory")?;
378        }
379
380        // Generate TypeScript type from the response schema
381        let entity_type = self.infer_entity_type(path);
382        let type_name = self.sanitize_type_name(&entity_type);
383
384        // Get the response schema we built earlier
385        let schema = self.build_schema_for_entity(&entity_type, method);
386
387        // Generate TypeScript interface
388        let ts_type = self.generate_typescript_interface(&type_name, &schema, method)?;
389
390        // Write TypeScript type file
391        let ts_file = types_dir.join(format!("{}.ts", type_name.to_lowercase()));
392        std::fs::write(&ts_file, ts_type).context("Failed to write TypeScript type file")?;
393
394        // Also generate JSON schema
395        let json_schema = self.generate_json_schema(&type_name, &schema)?;
396        let json_file = types_dir.join(format!("{}.schema.json", type_name.to_lowercase()));
397        std::fs::write(&json_file, serde_json::to_string_pretty(&json_schema)?)
398            .context("Failed to write JSON schema file")?;
399
400        info!(
401            "Generated types for {} {}: {} and {}.schema.json",
402            method,
403            path,
404            ts_file.display(),
405            json_file.display()
406        );
407
408        Ok(())
409    }
410
411    /// Generate TypeScript interface from schema
412    fn generate_typescript_interface(
413        &self,
414        type_name: &str,
415        schema: &serde_json::Value,
416        method: &str,
417    ) -> Result<String> {
418        let mut code = String::new();
419        code.push_str(&format!("// Generated TypeScript type for {} {}\n", method, type_name));
420        code.push_str("// Auto-generated by MockForge Runtime Daemon\n\n");
421
422        // Determine if it's an array or object
423        let schema_type = schema.get("type").and_then(|v| v.as_str()).unwrap_or("object");
424
425        if schema_type == "array" {
426            // Generate array type
427            if let Some(items) = schema.get("items") {
428                let item_type_name = format!("{}Item", type_name);
429                code.push_str(&self.generate_typescript_interface(
430                    &item_type_name,
431                    items,
432                    method,
433                )?);
434                code.push_str(&format!("export type {} = {}[];\n", type_name, item_type_name));
435            } else {
436                code.push_str(&format!("export type {} = any[];\n", type_name));
437            }
438        } else {
439            // Generate interface
440            code.push_str(&format!("export interface {} {{\n", type_name));
441
442            if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
443                let required = schema
444                    .get("required")
445                    .and_then(|r| r.as_array())
446                    .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
447                    .unwrap_or_default();
448
449                for (prop_name, prop_schema) in properties {
450                    let prop_type = self.schema_value_to_typescript_type(prop_schema)?;
451                    let is_optional = !required.contains(&prop_name.as_str());
452                    let optional_marker = if is_optional { "?" } else { "" };
453
454                    code.push_str(&format!("  {}{}: {};\n", prop_name, optional_marker, prop_type));
455                }
456            }
457
458            code.push_str("}\n");
459        }
460
461        Ok(code)
462    }
463
464    /// Convert a JSON schema value to TypeScript type string
465    fn schema_value_to_typescript_type(&self, schema: &serde_json::Value) -> Result<String> {
466        let schema_type = schema.get("type").and_then(|v| v.as_str()).unwrap_or("any");
467
468        match schema_type {
469            "string" => {
470                // Check format
471                if let Some(format) = schema.get("format").and_then(|f| f.as_str()) {
472                    match format {
473                        "date-time" | "date" => Ok("string".to_string()),
474                        "uuid" => Ok("string".to_string()),
475                        _ => Ok("string".to_string()),
476                    }
477                } else {
478                    Ok("string".to_string())
479                }
480            }
481            "integer" | "number" => Ok("number".to_string()),
482            "boolean" => Ok("boolean".to_string()),
483            "array" => {
484                if let Some(items) = schema.get("items") {
485                    let item_type = self.schema_value_to_typescript_type(items)?;
486                    Ok(format!("{}[]", item_type))
487                } else {
488                    Ok("any[]".to_string())
489                }
490            }
491            "object" => {
492                if schema.get("properties").is_some() {
493                    // Inline object type
494                    Ok("Record<string, any>".to_string())
495                } else {
496                    Ok("Record<string, any>".to_string())
497                }
498            }
499            _ => Ok("any".to_string()),
500        }
501    }
502
503    /// Generate JSON schema from the type
504    fn generate_json_schema(
505        &self,
506        type_name: &str,
507        schema: &serde_json::Value,
508    ) -> Result<serde_json::Value> {
509        let mut json_schema = json!({
510            "$schema": "http://json-schema.org/draft-07/schema#",
511            "title": type_name,
512            "type": schema.get("type").unwrap_or(&json!("object")),
513        });
514
515        if let Some(properties) = schema.get("properties") {
516            json_schema["properties"] = properties.clone();
517        }
518
519        if let Some(required) = schema.get("required") {
520            json_schema["required"] = required.clone();
521        }
522
523        Ok(json_schema)
524    }
525
526    /// Sanitize a name to be a valid TypeScript type name
527    fn sanitize_type_name(&self, name: &str) -> String {
528        let mut result = String::new();
529        let mut capitalize_next = true;
530
531        for ch in name.chars() {
532            match ch {
533                '-' | '_' | ' ' => capitalize_next = true,
534                ch if ch.is_alphanumeric() => {
535                    if capitalize_next {
536                        result.push(ch.to_uppercase().next().unwrap_or(ch));
537                        capitalize_next = false;
538                    } else {
539                        result.push(ch);
540                    }
541                }
542                _ => {}
543            }
544        }
545
546        if result.is_empty() {
547            "Resource".to_string()
548        } else {
549            // Ensure first character is uppercase
550            let mut chars = result.chars();
551            if let Some(first) = chars.next() {
552                format!("{}{}", first.to_uppercase(), chars.as_str())
553            } else {
554                "Resource".to_string()
555            }
556        }
557    }
558
559    /// Generate a client stub for the endpoint
560    async fn generate_client_stub(&self, method: &str, path: &str) -> Result<()> {
561        use std::path::PathBuf;
562        use tokio::fs;
563
564        // Determine output directory
565        let output_dir = if let Some(ref workspace_dir) = self.config.workspace_dir {
566            PathBuf::from(workspace_dir)
567        } else {
568            PathBuf::from(".")
569        };
570
571        // Create client-stubs directory if it doesn't exist
572        let stubs_dir = output_dir.join("client-stubs");
573        if !stubs_dir.exists() {
574            fs::create_dir_all(&stubs_dir)
575                .await
576                .context("Failed to create client-stubs directory")?;
577        }
578
579        // Generate client stub code
580        let entity_type = self.infer_entity_type(path);
581        let function_name = self.generate_function_name(method, path);
582        let stub_code =
583            self.generate_client_stub_code(method, path, &function_name, &entity_type)?;
584
585        // Write TypeScript client stub file
586        let stub_file = stubs_dir.join(format!("{}.ts", function_name.to_lowercase()));
587        fs::write(&stub_file, stub_code)
588            .await
589            .context("Failed to write client stub file")?;
590
591        info!("Generated client stub for {} {}: {}", method, path, stub_file.display());
592        Ok(())
593    }
594
595    /// Generate function name from method and path
596    fn generate_function_name(&self, method: &str, path: &str) -> String {
597        let entity_type = self.infer_entity_type(path);
598        let method_prefix = match method.to_uppercase().as_str() {
599            "GET" => "get",
600            "POST" => "create",
601            "PUT" => "update",
602            "PATCH" => "patch",
603            "DELETE" => "delete",
604            _ => "call",
605        };
606
607        // Check if path has an ID parameter (single resource)
608        let has_id = path
609            .split('/')
610            .any(|segment| segment.starts_with('{') && segment.ends_with('}'));
611
612        if has_id && method.to_uppercase() == "GET" {
613            format!("{}{}", method_prefix, self.sanitize_type_name(&entity_type))
614        } else if method.to_uppercase() == "GET" {
615            format!("list{}s", self.sanitize_type_name(&entity_type))
616        } else {
617            format!("{}{}", method_prefix, self.sanitize_type_name(&entity_type))
618        }
619    }
620
621    /// Generate client stub TypeScript code
622    fn generate_client_stub_code(
623        &self,
624        method: &str,
625        path: &str,
626        function_name: &str,
627        entity_type: &str,
628    ) -> Result<String> {
629        let method_upper = method.to_uppercase();
630        let type_name = self.sanitize_type_name(entity_type);
631
632        // Extract path parameters
633        let path_params: Vec<String> = path
634            .split('/')
635            .filter_map(|segment| {
636                if segment.starts_with('{') && segment.ends_with('}') {
637                    Some(segment.trim_matches(|c| c == '{' || c == '}').to_string())
638                } else {
639                    None
640                }
641            })
642            .collect();
643
644        // Build function parameters
645        let mut params = String::new();
646        if !path_params.is_empty() {
647            for param in &path_params {
648                params.push_str(&format!("{}: string", param));
649                params.push_str(", ");
650            }
651        }
652
653        // Add request body parameter for POST/PUT/PATCH
654        if matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH") {
655            params.push_str(&format!("data?: Partial<{}>", type_name));
656        }
657
658        // Add query parameters for GET
659        if method_upper == "GET" {
660            if !params.is_empty() {
661                params.push_str(", ");
662            }
663            params.push_str("queryParams?: Record<string, any>");
664        }
665
666        // Build endpoint path with template literals
667        let mut endpoint_path = path.to_string();
668        for param in &path_params {
669            endpoint_path =
670                endpoint_path.replace(&format!("{{{}}}", param), &format!("${{{}}}", param));
671        }
672
673        // Generate the stub code
674        let stub = format!(
675            r#"// Auto-generated client stub for {} {}
676// Generated by MockForge Runtime Daemon
677
678import type {{ {} }} from '../types/{}';
679
680/**
681 * {} {} endpoint
682 *
683 * @param {} - Request parameters
684 * @returns Promise resolving to {} response
685 */
686export async function {}({}): Promise<{}> {{
687  const endpoint = `{}`;
688  const url = `${{baseUrl}}${{endpoint}}`;
689
690  const response = await fetch(url, {{
691    method: '{}',
692    headers: {{
693      'Content-Type': 'application/json',
694      ...(headers || {{}}),
695    }},
696    {}{}
697  }});
698
699  if (!response.ok) {{
700    throw new Error(`Request failed: ${{response.status}} ${{response.statusText}}`);
701  }}
702
703  return response.json();
704}}
705
706/**
707 * Base URL configuration
708 * Override this to point to your API server
709 */
710export let baseUrl = 'http://localhost:3000';
711"#,
712            method,
713            path,
714            type_name,
715            entity_type.to_lowercase(),
716            method,
717            path,
718            if params.is_empty() {
719                "headers?: Record<string, string>"
720            } else {
721                &params
722            },
723            type_name,
724            function_name,
725            if params.is_empty() {
726                "headers?: Record<string, string>"
727            } else {
728                &params
729            },
730            type_name,
731            endpoint_path,
732            method_upper,
733            if matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH") {
734                "body: JSON.stringify(data || {}),\n    ".to_string()
735            } else if method_upper == "GET" && !path_params.is_empty() {
736                format!("{}const queryString = queryParams ? '?' + new URLSearchParams(queryParams).toString() : '';\n  const urlWithQuery = url + queryString;\n  ",
737                    if !path_params.is_empty() { "" } else { "" })
738            } else {
739                String::new()
740            },
741            if method_upper == "GET" && !path_params.is_empty() {
742                "url: urlWithQuery,\n    ".to_string()
743            } else {
744                String::new()
745            }
746        );
747
748        Ok(stub)
749    }
750
751    /// Update the OpenAPI schema with the new endpoint
752    async fn update_openapi_schema(&self, method: &str, path: &str) -> Result<()> {
753        use mockforge_core::openapi::OpenApiSpec;
754
755        // Determine OpenAPI spec file path
756        let spec_path = self.find_or_create_openapi_spec_path().await?;
757
758        // Load existing spec or create new one
759        let mut spec = if spec_path.exists() {
760            OpenApiSpec::from_file(&spec_path)
761                .await
762                .context("Failed to load existing OpenAPI spec")?
763        } else {
764            // Create a new OpenAPI spec
765            self.create_new_openapi_spec().await?
766        };
767
768        // Add the new endpoint to the spec
769        self.add_endpoint_to_spec(&mut spec, method, path).await?;
770
771        // Save the updated spec
772        self.save_openapi_spec(&spec, &spec_path).await?;
773
774        info!("Updated OpenAPI schema at {} with {} {}", spec_path.display(), method, path);
775        Ok(())
776    }
777
778    /// Find existing OpenAPI spec file or determine where to create one
779    async fn find_or_create_openapi_spec_path(&self) -> Result<PathBuf> {
780        use std::path::PathBuf;
781
782        // Check common locations
783        let possible_paths = vec![
784            PathBuf::from("openapi.yaml"),
785            PathBuf::from("openapi.yml"),
786            PathBuf::from("openapi.json"),
787            PathBuf::from("api.yaml"),
788            PathBuf::from("api.yml"),
789            PathBuf::from("api.json"),
790        ];
791
792        // Also check in workspace directory if configured
793        let mut all_paths = possible_paths.clone();
794        if let Some(ref workspace_dir) = self.config.workspace_dir {
795            for path in possible_paths {
796                all_paths.push(PathBuf::from(workspace_dir).join(path));
797            }
798        }
799
800        // Find first existing spec file
801        for path in &all_paths {
802            if path.exists() {
803                return Ok(path.clone());
804            }
805        }
806
807        // If none found, use default location (workspace_dir or current dir)
808        let default_path = if let Some(ref workspace_dir) = self.config.workspace_dir {
809            PathBuf::from(workspace_dir).join("openapi.yaml")
810        } else {
811            PathBuf::from("openapi.yaml")
812        };
813
814        Ok(default_path)
815    }
816
817    /// Create a new OpenAPI spec
818    async fn create_new_openapi_spec(&self) -> Result<mockforge_core::openapi::OpenApiSpec> {
819        use mockforge_core::openapi::OpenApiSpec;
820        use serde_json::json;
821
822        let spec_json = json!({
823            "openapi": "3.0.3",
824            "info": {
825                "title": "Auto-generated API",
826                "version": "1.0.0",
827                "description": "API specification auto-generated by MockForge Runtime Daemon"
828            },
829            "paths": {},
830            "components": {
831                "schemas": {}
832            }
833        });
834
835        OpenApiSpec::from_json(spec_json).context("Failed to create new OpenAPI spec")
836    }
837
838    /// Add an endpoint to the OpenAPI spec
839    async fn add_endpoint_to_spec(
840        &self,
841        spec: &mut mockforge_core::openapi::OpenApiSpec,
842        method: &str,
843        path: &str,
844    ) -> Result<()> {
845        // Get the raw document to modify
846        let mut spec_json = spec
847            .raw_document
848            .clone()
849            .ok_or_else(|| anyhow::anyhow!("OpenAPI spec missing raw document"))?;
850
851        // Ensure paths object exists
852        if spec_json.get("paths").is_none() {
853            spec_json["paths"] = json!({});
854        }
855
856        let paths = spec_json
857            .get_mut("paths")
858            .and_then(|p| p.as_object_mut())
859            .ok_or_else(|| anyhow::anyhow!("Failed to get paths object"))?;
860
861        // Get or create path item
862        let path_entry = paths.entry(path.to_string()).or_insert_with(|| json!({}));
863
864        // Convert method to lowercase for OpenAPI
865        let method_lower = method.to_lowercase();
866
867        // Create operation
868        let operation = json!({
869            "summary": format!("Auto-generated {} endpoint", method),
870            "description": format!("Endpoint auto-generated by MockForge Runtime Daemon for {} {}", method, path),
871            "operationId": self.generate_operation_id(method, path),
872            "responses": {
873                "200": {
874                    "description": "Successful response",
875                    "content": {
876                        "application/json": {
877                            "schema": self.build_schema_for_entity(&self.infer_entity_type(path), method)
878                        }
879                    }
880                }
881            }
882        });
883
884        // Add the operation to the path
885        path_entry[method_lower] = operation;
886
887        // Reload the spec from the updated JSON
888        *spec = mockforge_core::openapi::OpenApiSpec::from_json(spec_json)
889            .context("Failed to reload OpenAPI spec after update")?;
890
891        Ok(())
892    }
893
894    /// Generate an operation ID from method and path
895    fn generate_operation_id(&self, method: &str, path: &str) -> String {
896        let entity_type = self.infer_entity_type(path);
897        let method_lower = method.to_lowercase();
898
899        // Convert path segments to camelCase
900        let path_parts: Vec<&str> =
901            path.split('/').filter(|s| !s.is_empty() && !s.starts_with('{')).collect();
902
903        if path_parts.is_empty() {
904            format!("{}_{}", method_lower, entity_type)
905        } else {
906            let mut op_id = String::new();
907            op_id.push_str(&method_lower);
908            for part in path_parts {
909                let mut chars = part.chars();
910                if let Some(first) = chars.next() {
911                    op_id.push(first.to_uppercase().next().unwrap_or(first));
912                    op_id.push_str(chars.as_str());
913                }
914            }
915            op_id
916        }
917    }
918
919    /// Save OpenAPI spec to file
920    async fn save_openapi_spec(
921        &self,
922        spec: &mockforge_core::openapi::OpenApiSpec,
923        path: &PathBuf,
924    ) -> Result<()> {
925        use tokio::fs;
926
927        let spec_json = spec
928            .raw_document
929            .clone()
930            .ok_or_else(|| anyhow::anyhow!("OpenAPI spec missing raw document"))?;
931
932        // Determine format based on file extension
933        let is_yaml = path
934            .extension()
935            .and_then(|s| s.to_str())
936            .map(|s| s == "yaml" || s == "yml")
937            .unwrap_or(false);
938
939        let content = if is_yaml {
940            serde_yaml::to_string(&spec_json).context("Failed to serialize OpenAPI spec to YAML")?
941        } else {
942            serde_json::to_string_pretty(&spec_json)
943                .context("Failed to serialize OpenAPI spec to JSON")?
944        };
945
946        // Create parent directory if it doesn't exist
947        if let Some(parent) = path.parent() {
948            fs::create_dir_all(parent)
949                .await
950                .context("Failed to create OpenAPI spec directory")?;
951        }
952
953        fs::write(path, content).await.context("Failed to write OpenAPI spec file")?;
954
955        Ok(())
956    }
957
958    /// Create a basic scenario for the endpoint
959    async fn create_scenario(&self, method: &str, path: &str, mock_id: &str) -> Result<()> {
960        use std::path::PathBuf;
961        use tokio::fs;
962
963        // Determine output directory
964        let output_dir = if let Some(ref workspace_dir) = self.config.workspace_dir {
965            PathBuf::from(workspace_dir)
966        } else {
967            PathBuf::from(".")
968        };
969
970        // Create scenarios directory if it doesn't exist
971        let scenarios_dir = output_dir.join("scenarios");
972        if !scenarios_dir.exists() {
973            fs::create_dir_all(&scenarios_dir)
974                .await
975                .context("Failed to create scenarios directory")?;
976        }
977
978        // Generate scenario name from endpoint
979        let entity_type = self.infer_entity_type(path);
980        let scenario_name = format!("auto-{}-{}", entity_type, method.to_lowercase());
981        let scenario_dir = scenarios_dir.join(&scenario_name);
982
983        // Create scenario directory
984        if !scenario_dir.exists() {
985            fs::create_dir_all(&scenario_dir)
986                .await
987                .context("Failed to create scenario directory")?;
988        }
989
990        // Generate scenario manifest
991        let manifest = self.generate_scenario_manifest(&scenario_name, method, path, mock_id)?;
992
993        // Write scenario.yaml
994        let manifest_path = scenario_dir.join("scenario.yaml");
995        let manifest_yaml =
996            serde_yaml::to_string(&manifest).context("Failed to serialize scenario manifest")?;
997        fs::write(&manifest_path, manifest_yaml)
998            .await
999            .context("Failed to write scenario manifest")?;
1000
1001        // Create a basic config.yaml for the scenario
1002        let config = self.generate_scenario_config(method, path, mock_id)?;
1003        let config_path = scenario_dir.join("config.yaml");
1004        let config_yaml =
1005            serde_yaml::to_string(&config).context("Failed to serialize scenario config")?;
1006        fs::write(&config_path, config_yaml)
1007            .await
1008            .context("Failed to write scenario config")?;
1009
1010        info!("Created scenario '{}' at {}", scenario_name, scenario_dir.display());
1011        Ok(())
1012    }
1013
1014    /// Generate scenario manifest YAML structure
1015    fn generate_scenario_manifest(
1016        &self,
1017        scenario_name: &str,
1018        method: &str,
1019        path: &str,
1020        _mock_id: &str,
1021    ) -> Result<serde_json::Value> {
1022        use chrono::Utc;
1023
1024        let entity_type = self.infer_entity_type(path);
1025        let title = format!("Auto-generated {} {} Scenario", method, entity_type);
1026
1027        let manifest = json!({
1028            "manifest_version": "1.0",
1029            "name": scenario_name,
1030            "version": "1.0.0",
1031            "title": title,
1032            "description": format!(
1033                "Auto-generated scenario for {} {} endpoint. Created by MockForge Runtime Daemon.",
1034                method, path
1035            ),
1036            "author": "MockForge Runtime Daemon",
1037            "author_email": None::<String>,
1038            "category": "other",
1039            "tags": ["auto-generated", "runtime-daemon", entity_type],
1040            "compatibility": {
1041                "min_version": "0.3.0",
1042                "max_version": null,
1043                "required_features": [],
1044                "protocols": ["http"]
1045            },
1046            "files": [
1047                "scenario.yaml",
1048                "config.yaml"
1049            ],
1050            "readme": None::<String>,
1051            "example_usage": format!(
1052                "# Use this scenario\nmockforge scenario use {}\n\n# Start server\nmockforge serve --config config.yaml",
1053                scenario_name
1054            ),
1055            "required_features": [],
1056            "plugin_dependencies": [],
1057            "metadata": {
1058                "auto_generated": true,
1059                "endpoint": path,
1060                "method": method,
1061                "entity_type": entity_type
1062            },
1063            "created_at": Utc::now().to_rfc3339(),
1064            "updated_at": Utc::now().to_rfc3339()
1065        });
1066
1067        Ok(manifest)
1068    }
1069
1070    /// Generate scenario config YAML structure
1071    fn generate_scenario_config(
1072        &self,
1073        method: &str,
1074        path: &str,
1075        mock_id: &str,
1076    ) -> Result<serde_json::Value> {
1077        let entity_type = self.infer_entity_type(path);
1078        let response_body =
1079            serde_json::to_value(self.build_schema_for_entity(&entity_type, method))?;
1080
1081        let config = json!({
1082            "http": {
1083                "enabled": true,
1084                "port": 3000,
1085                "mocks": [
1086                    {
1087                        "id": mock_id,
1088                        "method": method,
1089                        "path": path,
1090                        "status_code": 200,
1091                        "body": response_body,
1092                        "name": format!("Auto-generated: {} {}", method, path),
1093                        "enabled": true
1094                    }
1095                ]
1096            }
1097        });
1098
1099        Ok(config)
1100    }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106
1107    #[test]
1108    fn test_infer_entity_type() {
1109        let config = RuntimeDaemonConfig::default();
1110        let generator = AutoGenerator::new(config, "http://localhost:3000".to_string());
1111
1112        assert_eq!(generator.infer_entity_type("/api/users"), "user");
1113        assert_eq!(generator.infer_entity_type("/api/products"), "product");
1114        assert_eq!(generator.infer_entity_type("/api/orders/123"), "order");
1115        assert_eq!(generator.infer_entity_type("/api"), "resource");
1116    }
1117}