1use anyhow::{Context, Result};
7use serde_json::json;
8use std::path::PathBuf;
9use tracing::{debug, info, warn};
10
11use crate::config::RuntimeDaemonConfig;
12
13pub struct AutoGenerator {
15 config: RuntimeDaemonConfig,
17 management_api_url: String,
19}
20
21impl AutoGenerator {
22 pub fn new(config: RuntimeDaemonConfig, management_api_url: String) -> Self {
24 Self {
25 config,
26 management_api_url,
27 }
28 }
29
30 pub async fn generate_mock_from_404(&self, method: &str, path: &str) -> Result<()> {
40 info!("Generating mock for {} {}", method, path);
41
42 let mock_id = self.create_mock_endpoint(method, path).await?;
44 debug!("Created mock endpoint with ID: {}", mock_id);
45
46 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 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 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 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 async fn create_mock_endpoint(&self, method: &str, path: &str) -> Result<String> {
80 let response_body = self.generate_intelligent_response(method, path).await?;
82
83 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 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 async fn generate_intelligent_response(
124 &self,
125 method: &str,
126 path: &str,
127 ) -> Result<serde_json::Value> {
128 #[cfg(feature = "ai")]
130 if self.config.ai_generation {
131 return self.generate_ai_response(method, path).await;
132 }
133
134 let entity_type = self.infer_entity_type(path);
138
139 let response = match method.to_uppercase().as_str() {
141 "GET" => {
142 if path.ends_with('/')
144 || path.split('/').next_back().unwrap_or("").parse::<u64>().is_err()
145 {
146 json!([{
148 "id": "{{uuid}}",
149 "name": format!("Sample {}", entity_type),
150 "created_at": "{{now}}",
151 }])
152 } else {
153 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 json!({
164 "id": "{{uuid}}",
165 "name": format!("New {}", entity_type),
166 "created_at": "{{now}}",
167 "status": "created",
168 })
169 }
170 "PUT" | "PATCH" => {
171 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 json!({
181 "success": true,
182 "message": "Resource deleted",
183 })
184 }
185 _ => {
186 json!({
188 "message": "Auto-generated response",
189 "method": method,
190 "path": path,
191 })
192 }
193 };
194
195 Ok(response)
196 }
197
198 #[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 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 let schema = self.build_schema_for_entity(&entity_type, method);
215
216 let mut ai_config = IntelligentMockConfig::new(ResponseMode::Intelligent)
218 .with_prompt(prompt)
219 .with_schema(schema)
220 .with_count(1);
221
222 if let Ok(rag_config) = self.load_rag_config_from_env() {
224 ai_config = ai_config.with_rag_config(rag_config);
225 }
226
227 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 #[cfg(feature = "ai")]
239 fn load_rag_config_from_env(&self) -> Result<mockforge_data::RagConfig> {
240 use mockforge_data::{EmbeddingProvider, LlmProvider, RagConfig};
241
242 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 config.embedding_provider = match config.provider {
270 LlmProvider::OpenAI => EmbeddingProvider::OpenAI,
271 LlmProvider::Anthropic => EmbeddingProvider::OpenAI, LlmProvider::Ollama => EmbeddingProvider::OpenAI, LlmProvider::OpenAICompatible => EmbeddingProvider::OpenAI,
274 };
275
276 Ok(config)
277 }
278
279 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 match method.to_uppercase().as_str() {
301 "GET" => {
302 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 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 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 fn infer_entity_type(&self, path: &str) -> String {
346 let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
348
349 if let Some(last_part) = parts.last() {
350 let entity = last_part
352 .trim_end_matches('s') .to_lowercase();
354
355 if !entity.is_empty() {
356 return entity;
357 }
358 }
359
360 "resource".to_string()
361 }
362
363 async fn generate_type(&self, method: &str, path: &str) -> Result<()> {
365 use std::path::PathBuf;
366
367 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 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 let entity_type = self.infer_entity_type(path);
382 let type_name = self.sanitize_type_name(&entity_type);
383
384 let schema = self.build_schema_for_entity(&entity_type, method);
386
387 let ts_type = self.generate_typescript_interface(&type_name, &schema, method)?;
389
390 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 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 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 let schema_type = schema.get("type").and_then(|v| v.as_str()).unwrap_or("object");
424
425 if schema_type == "array" {
426 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 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 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 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 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 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 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 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 async fn generate_client_stub(&self, method: &str, path: &str) -> Result<()> {
561 use std::path::PathBuf;
562 use tokio::fs;
563
564 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 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 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 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 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 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 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 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 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 if matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH") {
655 params.push_str(&format!("data?: Partial<{}>", type_name));
656 }
657
658 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 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 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 ¶ms
722 },
723 type_name,
724 function_name,
725 if params.is_empty() {
726 "headers?: Record<string, string>"
727 } else {
728 ¶ms
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 async fn update_openapi_schema(&self, method: &str, path: &str) -> Result<()> {
753 use mockforge_core::openapi::OpenApiSpec;
754
755 let spec_path = self.find_or_create_openapi_spec_path().await?;
757
758 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 self.create_new_openapi_spec().await?
766 };
767
768 self.add_endpoint_to_spec(&mut spec, method, path).await?;
770
771 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 async fn find_or_create_openapi_spec_path(&self) -> Result<PathBuf> {
780 use std::path::PathBuf;
781
782 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 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 for path in &all_paths {
802 if path.exists() {
803 return Ok(path.clone());
804 }
805 }
806
807 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 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 async fn add_endpoint_to_spec(
840 &self,
841 spec: &mut mockforge_core::openapi::OpenApiSpec,
842 method: &str,
843 path: &str,
844 ) -> Result<()> {
845 let mut spec_json = spec
847 .raw_document
848 .clone()
849 .ok_or_else(|| anyhow::anyhow!("OpenAPI spec missing raw document"))?;
850
851 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 let path_entry = paths.entry(path.to_string()).or_insert_with(|| json!({}));
863
864 let method_lower = method.to_lowercase();
866
867 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 path_entry[method_lower] = operation;
886
887 *spec = mockforge_core::openapi::OpenApiSpec::from_json(spec_json)
889 .context("Failed to reload OpenAPI spec after update")?;
890
891 Ok(())
892 }
893
894 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 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 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 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 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 async fn create_scenario(&self, method: &str, path: &str, mock_id: &str) -> Result<()> {
960 use std::path::PathBuf;
961 use tokio::fs;
962
963 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 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 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 if !scenario_dir.exists() {
985 fs::create_dir_all(&scenario_dir)
986 .await
987 .context("Failed to create scenario directory")?;
988 }
989
990 let manifest = self.generate_scenario_manifest(&scenario_name, method, path, mock_id)?;
992
993 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 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 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 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}