use clap::Args;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Args)]
pub struct DevSetupArgs {
pub framework: String,
#[arg(long, default_value = "http://localhost:3000")]
pub base_url: String,
#[arg(long, default_value = "moderate")]
pub reality_level: String,
#[arg(short, long)]
pub spec: Option<PathBuf>,
#[arg(short, long, default_value = "./src/mockforge")]
pub output: PathBuf,
#[arg(long)]
pub force: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
React,
Vue,
Angular,
Svelte,
Next,
Nuxt,
}
impl Framework {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"react" => Some(Self::React),
"vue" => Some(Self::Vue),
"angular" => Some(Self::Angular),
"svelte" => Some(Self::Svelte),
"next" => Some(Self::Next),
"nuxt" => Some(Self::Nuxt),
_ => None,
}
}
fn name(&self) -> &'static str {
match self {
Self::React => "react",
Self::Vue => "vue",
Self::Angular => "angular",
Self::Svelte => "svelte",
Self::Next => "next",
Self::Nuxt => "nuxt",
}
}
fn sdk_package(&self) -> &'static str {
match self {
Self::React | Self::Next => "@mockforge-dev/sdk",
Self::Vue | Self::Nuxt => "@mockforge-dev/sdk",
Self::Angular => "@mockforge-dev/sdk",
Self::Svelte => "@mockforge-dev/sdk",
}
}
}
pub async fn execute_dev_setup(args: DevSetupArgs) -> anyhow::Result<()> {
let framework = Framework::from_str(&args.framework).ok_or_else(|| {
anyhow::anyhow!(
"Unsupported framework: {}. Supported: react, vue, angular, svelte, next, nuxt",
args.framework
)
})?;
println!("🚀 Setting up MockForge for {}...", framework.name());
let project_root = detect_project_root()?;
println!(" ✓ Detected project root: {}", project_root.display());
let (detected_base_url, detected_reality_level) = detect_mockforge_workspace(&project_root)?;
let base_url = match (&detected_base_url, args.base_url.as_str()) {
(Some(detected), "http://localhost:3000") => detected.clone(),
_ => args.base_url.clone(),
};
let reality_level = match (&detected_reality_level, args.reality_level.as_str()) {
(Some(detected), "moderate") => detected.clone(),
_ => args.reality_level.clone(),
};
if detected_base_url.is_some() || detected_reality_level.is_some() {
println!(" ✓ Detected existing MockForge workspace configuration");
if let Some(ref url) = detected_base_url {
println!(" Base URL: {}", url);
}
if let Some(ref level) = detected_reality_level {
println!(" Reality level: {}", level);
}
}
let blueprint_spec = detect_blueprint_origin(&project_root)?;
let spec_path = if args.spec.is_some() {
args.spec.clone()
} else if let Some(ref blueprint_spec_path) = blueprint_spec {
println!(" ✓ Using OpenAPI spec from blueprint: {}", blueprint_spec_path.display());
Some(blueprint_spec_path.clone())
} else {
let auto_detected = auto_detect_openapi_spec(&project_root)?;
if let Some(ref spec) = auto_detected {
println!(" ✓ Auto-detected OpenAPI spec: {}", spec.display());
}
auto_detected
};
let output_dir = project_root.join(&args.output);
if output_dir.exists() && !args.force {
let existing_files = check_existing_client_code(&output_dir)?;
if !existing_files.is_empty() {
println!(" ⚠️ Found existing client code in {}", output_dir.display());
println!(" Files: {}", existing_files.join(", "));
println!(" Use --force to overwrite");
return Ok(());
}
}
fs::create_dir_all(&output_dir)?;
println!(" ✓ Created output directory: {}", output_dir.display());
if let Some(spec_path) = &spec_path {
println!(" 📦 Generating typed client from OpenAPI spec...");
generate_client(framework, spec_path, &output_dir, &base_url).await?;
println!(" ✓ Client generated");
} else {
println!(" ⚠️ No OpenAPI spec found, skipping client generation");
println!(" Use --spec <path> to generate typed client");
}
println!(" 📝 Generating {} examples...", framework.name());
generate_framework_examples(framework, &output_dir, &base_url)?;
println!(" ✓ Examples generated");
println!(" 🔧 Creating environment configuration...");
create_env_example(&project_root, &base_url, &reality_level)?;
println!(" ✓ Environment configuration created");
if let Some(package_json_path) = find_package_json(&project_root) {
println!(" 📦 Updating package.json...");
update_package_json(&package_json_path, framework)?;
println!(" ✓ package.json updated");
}
if let Some(tsconfig_path) = find_tsconfig(&project_root) {
println!(" 🔍 Verifying TypeScript compilation...");
if verify_typescript_compilation(&tsconfig_path).is_ok() {
println!(" ✓ TypeScript compilation verified");
} else {
println!(" ⚠️ TypeScript compilation check skipped (tsc not found)");
}
}
println!("\n✅ MockForge setup complete!");
println!("\nNext steps:");
println!(" 1. Copy .env.mockforge.example to .env.mockforge");
println!(" 2. Review generated files in {}", output_dir.display());
println!(" 3. Import and use the generated hooks/composables in your app");
println!(
" 4. Check out the example component: {}",
output_dir.join("UserList.example.tsx").display()
);
println!(" 5. Start MockForge server: mockforge serve");
Ok(())
}
fn detect_project_root() -> anyhow::Result<PathBuf> {
let current_dir = std::env::current_dir()?;
let indicators = [
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle",
];
let mut dir = current_dir.clone();
loop {
for indicator in &indicators {
if dir.join(indicator).exists() {
return Ok(dir);
}
}
if let Some(parent) = dir.parent() {
dir = parent.to_path_buf();
} else {
break;
}
}
Ok(current_dir)
}
fn find_package_json(project_root: &Path) -> Option<PathBuf> {
let path = project_root.join("package.json");
if path.exists() {
Some(path)
} else {
None
}
}
fn detect_mockforge_workspace(
project_root: &Path,
) -> anyhow::Result<(Option<String>, Option<String>)> {
let config_paths = [
project_root.join("mockforge.yaml"),
project_root.join("mockforge.yml"),
project_root.join(".mockforge.yaml"),
project_root.join(".mockforge.yml"),
];
for config_path in &config_paths {
if config_path.exists() {
return load_config_values(config_path);
}
}
Ok((None, None))
}
fn load_config_values(config_path: &Path) -> anyhow::Result<(Option<String>, Option<String>)> {
use serde_yaml::Value;
let content = fs::read_to_string(config_path)?;
let config: Value = serde_yaml::from_str(&content)?;
let base_url = if let Some(http) = config.get("http") {
let port = http.get("port").and_then(|v| v.as_u64()).unwrap_or(3000);
let host = http.get("host").and_then(|v| v.as_str()).unwrap_or("localhost");
let host_str = if host == "0.0.0.0" { "localhost" } else { host };
Some(format!("http://{}:{}", host_str, port))
} else {
None
};
let reality_level = config
.get("reality")
.and_then(|r| r.get("level"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok((base_url, reality_level))
}
fn auto_detect_openapi_spec(project_root: &Path) -> anyhow::Result<Option<PathBuf>> {
let spec_candidates = [
project_root.join("openapi.json"),
project_root.join("openapi.yaml"),
project_root.join("openapi.yml"),
project_root.join("api-spec.json"),
project_root.join("api-spec.yaml"),
project_root.join("api.json"),
project_root.join("api.yaml"),
project_root.join("examples").join("openapi.json"),
project_root.join("examples").join("openapi.yaml"),
project_root.join("docs").join("openapi.json"),
project_root.join("docs").join("openapi.yaml"),
project_root.join("api").join("openapi.json"),
project_root.join("api").join("openapi.yaml"),
project_root.join("spec").join("openapi.json"),
project_root.join("spec").join("openapi.yaml"),
];
for candidate in &spec_candidates {
if candidate.exists() {
if is_valid_openapi_spec(candidate)? {
return Ok(Some(candidate.clone()));
}
}
}
let config_paths = [
project_root.join("mockforge.yaml"),
project_root.join("mockforge.yml"),
];
for config_path in &config_paths {
if config_path.exists() {
if let Ok(Some(spec_path)) = extract_spec_from_config(config_path, project_root) {
if spec_path.exists() && is_valid_openapi_spec(&spec_path)? {
return Ok(Some(spec_path));
}
}
}
}
Ok(None)
}
fn is_valid_openapi_spec(path: &Path) -> anyhow::Result<bool> {
let content = fs::read_to_string(path)?;
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if json.get("openapi").is_some() || json.get("swagger").is_some() {
return Ok(true);
}
}
if let Ok(yaml) = serde_yaml::from_str::<serde_json::Value>(&content) {
if yaml.get("openapi").is_some() || yaml.get("swagger").is_some() {
return Ok(true);
}
}
Ok(false)
}
fn extract_spec_from_config(
config_path: &Path,
project_root: &Path,
) -> anyhow::Result<Option<PathBuf>> {
use serde_yaml::Value;
let content = fs::read_to_string(config_path)?;
let config: Value = serde_yaml::from_str(&content)?;
if let Some(spec_path_str) =
config.get("http").and_then(|h| h.get("openapi_spec")).and_then(|v| v.as_str())
{
let spec_path = if spec_path_str.starts_with('/') {
PathBuf::from(spec_path_str)
} else {
project_root.join(spec_path_str)
};
return Ok(Some(spec_path));
}
Ok(None)
}
fn check_existing_client_code(output_dir: &Path) -> anyhow::Result<Vec<String>> {
let mut existing_files = Vec::new();
if !output_dir.exists() {
return Ok(existing_files);
}
let common_files = [
"client.ts",
"client.js",
"hooks.ts",
"hooks.js",
"composables.ts",
"composables.js",
"types.ts",
"types.js",
"index.ts",
"index.js",
];
for file in &common_files {
let file_path = output_dir.join(file);
if file_path.exists() {
existing_files.push(file.to_string());
}
}
Ok(existing_files)
}
fn detect_blueprint_origin(project_root: &Path) -> anyhow::Result<Option<PathBuf>> {
let has_scenarios = project_root.join("scenarios").exists();
let has_contracts = project_root.join("contracts").exists();
if has_scenarios || has_contracts {
let config_paths = [
project_root.join("mockforge.yaml"),
project_root.join("mockforge.yml"),
];
for config_path in &config_paths {
if config_path.exists() {
if let Ok(Some(spec_path)) = extract_spec_from_config(config_path, project_root) {
if spec_path.exists() {
return Ok(Some(spec_path));
}
}
}
}
let openapi_candidates = [
project_root.join("openapi.yaml"),
project_root.join("openapi.json"),
project_root.join("openapi.yml"),
];
for candidate in &openapi_candidates {
if candidate.exists() && is_valid_openapi_spec(candidate)? {
return Ok(Some(candidate.clone()));
}
}
}
Ok(None)
}
async fn generate_client(
framework: Framework,
spec_path: &Path,
output_dir: &Path,
base_url: &str,
) -> anyhow::Result<()> {
use crate::client_generator::{ClientGeneratorManager, GenerateArgs};
let manager = ClientGeneratorManager::new()?;
let framework_name = match framework {
Framework::React | Framework::Next => "react",
Framework::Vue | Framework::Nuxt => "vue",
Framework::Angular => "angular",
Framework::Svelte => "svelte",
};
let args = GenerateArgs {
spec: spec_path.to_string_lossy().to_string(),
framework: framework_name.to_string(),
output: output_dir.to_string_lossy().to_string(),
base_url: Some(base_url.to_string()),
include_types: true,
include_mocks: false,
template_dir: None,
options: None,
};
manager.generate_client(&args).await?;
Ok(())
}
fn generate_framework_examples(
framework: Framework,
output_dir: &Path,
base_url: &str,
) -> anyhow::Result<()> {
match framework {
Framework::React | Framework::Next => generate_react_examples(output_dir, base_url)?,
Framework::Vue | Framework::Nuxt => generate_vue_examples(output_dir, base_url)?,
Framework::Angular => generate_angular_examples(output_dir, base_url)?,
Framework::Svelte => generate_svelte_examples(output_dir, base_url)?,
}
Ok(())
}
fn generate_react_examples(output_dir: &Path, base_url: &str) -> anyhow::Result<()> {
let react_query_hooks = format!(
r#"// React Query hooks for MockForge API
// Generated with comprehensive error handling and TypeScript types
import {{ useQuery, useMutation, useQueryClient }} from '@tanstack/react-query';
import type {{ ApiError }} from './client';
const MOCKFORGE_BASE_URL = '{}';
// ============================================================================
// Error Handling Utilities
// ============================================================================
/**
* Format API error for display to users
*/
export function formatApiError(error: unknown): string {{
if (error instanceof Error) {{
// Check if it's an ApiError with detailed information
if ('status' in error && 'statusText' in error) {{
const apiError = error as ApiError;
if (apiError.body && typeof apiError.body === 'object') {{
// Extract user-friendly error message
if ('message' in apiError.body) {{
return String(apiError.body.message);
}}
if ('error' in apiError.body) {{
return String(apiError.body.error);
}}
}}
return `${{apiError.status}} ${{apiError.statusText}}`;
}}
return error.message;
}}
return 'An unexpected error occurred';
}}
/**
* Check if error is a network error (can be retried)
*/
export function isNetworkError(error: unknown): boolean {{
if (error instanceof Error) {{
return error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('Failed to fetch');
}}
return false;
}}
/**
* Check if error is a client error (4xx - user input issue)
*/
export function isClientError(error: unknown): boolean {{
if (error && typeof error === 'object' && 'status' in error) {{
const status = (error as {{ status: number }}).status;
return status >= 400 && status < 500;
}}
return false;
}}
/**
* Check if error is a server error (5xx - server issue)
*/
export function isServerError(error: unknown): boolean {{
if (error && typeof error === 'object' && 'status' in error) {{
const status = (error as {{ status: number }}).status;
return status >= 500;
}}
return false;
}}
// ============================================================================
// Type Definitions
// ============================================================================
export interface User {{
id: string;
name: string;
email: string;
createdAt?: string;
}}
export interface CreateUserRequest {{
name: string;
email: string;
}}
export interface UpdateUserRequest {{
name?: string;
email?: string;
}}
// ============================================================================
// React Query Hooks
// ============================================================================
/**
* Hook to fetch all users
*
* @example
* ```tsx
* function UserList() {{
* const {{ data: users, isLoading, error }} = useUsers();
*
* if (isLoading) return <div>Loading...</div>;
* if (error) return <div>Error: {{formatApiError(error)}}</div>;
*
* return (
* <ul>
* {{users?.map(user => (
* <li key={{user.id}}>{{user.name}}</li>
* ))}}
* </ul>
* );
* }}
* ```
*/
export function useUsers() {{
return useQuery<User[], ApiError>({{
queryKey: ['users'],
queryFn: async () => {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users`);
if (!response.ok) {{
const errorBody = await response.json().catch(() => ({{}}));
throw new Error(`Failed to fetch users: ${{response.status}} ${{response.statusText}}`);
}}
const data = await response.json();
return Array.isArray(data) ? data : (data.users || []);
}},
retry: (failureCount, error) => {{
// Retry network errors up to 3 times
if (isNetworkError(error)) {{
return failureCount < 3;
}}
// Don't retry client errors (4xx)
if (isClientError(error)) {{
return false;
}}
// Retry server errors (5xx) up to 2 times
return failureCount < 2;
}},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
}});
}}
/**
* Hook to fetch a single user by ID
*
* @param id - User ID
* @example
* ```tsx
* function UserProfile({{ userId }}: {{ userId: string }}) {{
* const {{ data: user, isLoading, error }} = useUser(userId);
*
* if (isLoading) return <div>Loading user...</div>;
* if (error) return <div>Error: {{formatApiError(error)}}</div>;
* if (!user) return <div>User not found</div>;
*
* return (
* <div>
* <h1>{{user.name}}</h1>
* <p>{{user.email}}</p>
* </div>
* );
* }}
* ```
*/
export function useUser(id: string) {{
return useQuery<User, ApiError>({{
queryKey: ['users', id],
queryFn: async () => {{
if (!id) throw new Error('User ID is required');
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users/${{id}}`);
if (!response.ok) {{
if (response.status === 404) {{
throw new Error('User not found');
}}
const errorBody = await response.json().catch(() => ({{}}));
throw new Error(`Failed to fetch user: ${{response.status}} ${{response.statusText}}`);
}}
return response.json();
}},
enabled: !!id,
retry: (failureCount, error) => {{
if (isNetworkError(error)) return failureCount < 3;
if (isClientError(error)) return false;
return failureCount < 2;
}},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
}});
}}
/**
* Hook to create a new user
*
* @example
* ```tsx
* function CreateUserForm() {{
* const createUser = useCreateUser();
*
* const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {{
* e.preventDefault();
* const formData = new FormData(e.currentTarget);
*
* try {{
* await createUser.mutateAsync({{
* name: formData.get('name') as string,
* email: formData.get('email') as string,
* }});
* alert('User created successfully!');
* }} catch (error) {{
* alert(`Failed to create user: ${{formatApiError(error)}}`);
* }}
* }};
*
* return (
* <form onSubmit={{handleSubmit}}>
* <input name="name" placeholder="Name" required />
* <input name="email" type="email" placeholder="Email" required />
* <button type="submit" disabled={{createUser.isPending}}>
* {{createUser.isPending ? 'Creating...' : 'Create User'}}
* </button>
* {{createUser.error && (
* <div style={{color: 'red'}}>
* Error: {{formatApiError(createUser.error)}}
* </div>
* )}}
* </form>
* );
* }}
* ```
*/
export function useCreateUser() {{
const queryClient = useQueryClient();
return useMutation<User, ApiError, CreateUserRequest>({{
mutationFn: async (userData) => {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users`, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(userData),
}});
if (!response.ok) {{
const errorBody = await response.json().catch(() => ({{}}));
throw new Error(`Failed to create user: ${{response.status}} ${{response.statusText}}`);
}}
return response.json();
}},
onSuccess: () => {{
// Invalidate and refetch users list
queryClient.invalidateQueries({{ queryKey: ['users'] }});
}},
onError: (error) => {{
// Log error for debugging
console.error('Failed to create user:', error);
}},
}});
}}
/**
* Hook to update an existing user
*
* @example
* ```tsx
* function EditUserForm({{ userId, initialData }}: {{
* userId: string;
* initialData: User
* }}) {{
* const updateUser = useUpdateUser();
*
* const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {{
* e.preventDefault();
* const formData = new FormData(e.currentTarget);
*
* try {{
* await updateUser.mutateAsync({{
* id: userId,
* name: formData.get('name') as string,
* email: formData.get('email') as string,
* }});
* alert('User updated successfully!');
* }} catch (error) {{
* alert(`Failed to update user: ${{formatApiError(error)}}`);
* }}
* }};
*
* return (
* <form onSubmit={{handleSubmit}}>
* <input name="name" defaultValue={{initialData.name}} required />
* <input name="email" type="email" defaultValue={{initialData.email}} required />
* <button type="submit" disabled={{updateUser.isPending}}>
* {{updateUser.isPending ? 'Updating...' : 'Update User'}}
* </button>
* </form>
* );
* }}
* ```
*/
export function useUpdateUser() {{
const queryClient = useQueryClient();
return useMutation<User, ApiError, {{ id: string }} & UpdateUserRequest>({{
mutationFn: async ({{ id, ...userData }}) => {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users/${{id}}`, {{
method: 'PATCH',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(userData),
}});
if (!response.ok) {{
const errorBody = await response.json().catch(() => ({{}}));
throw new Error(`Failed to update user: ${{response.status}} ${{response.statusText}}`);
}}
return response.json();
}},
onSuccess: (_, variables) => {{
// Invalidate both the specific user and the users list
queryClient.invalidateQueries({{ queryKey: ['users', variables.id] }});
queryClient.invalidateQueries({{ queryKey: ['users'] }});
}},
onError: (error) => {{
console.error('Failed to update user:', error);
}},
}});
}}
/**
* Hook to delete a user
*/
export function useDeleteUser() {{
const queryClient = useQueryClient();
return useMutation<void, ApiError, string>({{
mutationFn: async (id) => {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users/${{id}}`, {{
method: 'DELETE',
}});
if (!response.ok) {{
const errorBody = await response.json().catch(() => ({{}}));
throw new Error(`Failed to delete user: ${{response.status}} ${{response.statusText}}`);
}}
}},
onSuccess: () => {{
queryClient.invalidateQueries({{ queryKey: ['users'] }});
}},
}});
}}
"#,
base_url
);
fs::write(output_dir.join("hooks.ts"), react_query_hooks)?;
let swr_hooks = format!(
r#"// SWR hooks for MockForge API
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
const MOCKFORGE_BASE_URL = '{}';
const fetcher = (url: string) => fetch(url).then(res => res.json());
// Example: Get users with SWR
export function useUsersSWR() {{
const {{ data, error, isLoading }} = useSWR(
`${{MOCKFORGE_BASE_URL}}/api/users`,
fetcher
);
return {{ users: data, error, isLoading }};
}}
// Example: Get user by ID with SWR
export function useUserSWR(id: string) {{
const {{ data, error, isLoading }} = useSWR(
id ? `${{MOCKFORGE_BASE_URL}}/api/users/${{id}}` : null,
fetcher
);
return {{ user: data, error, isLoading }};
}}
// Example: Create user mutation with SWR
export function useCreateUserSWR() {{
const {{ trigger, isMutating }} = useSWRMutation(
`${{MOCKFORGE_BASE_URL}}/api/users`,
async (url, {{ arg }}: {{ name: string; email: string }}) => {{
const response = await fetch(url, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(arg),
}});
if (!response.ok) throw new Error('Failed to create user');
return response.json();
}}
);
return {{ createUser: trigger, isCreating: isMutating }};
}}
"#,
base_url
);
fs::write(output_dir.join("hooks-swr.ts"), swr_hooks)?;
let example_component = r#"// Example React component using MockForge hooks
// This demonstrates how to use the generated hooks in a real component
import React from 'react';
import { useUsers, useCreateUser, formatApiError } from './hooks';
/**
* Example UserList component
*
* This component demonstrates:
* - Fetching data with useUsers hook
* - Creating new users with useCreateUser hook
* - Error handling and loading states
* - TypeScript type safety
*/
export function UserList() {
const { data: users, isLoading, error, refetch } = useUsers();
const createUser = useCreateUser();
const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
try {
await createUser.mutateAsync({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
// Form will be reset by the form's reset() method
e.currentTarget.reset();
} catch (error) {
// Error is already handled by the mutation's onError
console.error('Failed to create user:', error);
}
};
if (isLoading) {
return (
<div style={ padding: '20px' }>
<div>Loading users...</div>
</div>
);
}
if (error) {
return (
<div style={ padding: '20px', color: 'red' }>
<h2>Error loading users</h2>
<p>{formatApiError(error)}</p>
<button onClick={() => refetch()}>Retry</button>
</div>
);
}
return (
<div style={ padding: '20px' }>
<h1>Users</h1>
<form onSubmit={handleCreateUser} style={ marginBottom: '20px', padding: '10px', border: '1px solid #ccc' }>
<h3>Create New User</h3>
<div style={ marginBottom: '10px' }>
<input
name="name"
placeholder="Name"
required
style={ padding: '8px', width: '200px', marginRight: '10px' }
/>
<input
name="email"
type="email"
placeholder="Email"
required
style={ padding: '8px', width: '200px', marginRight: '10px' }
/>
<button
type="submit"
disabled={createUser.isPending}
style={ padding: '8px 16px' }
>
{createUser.isPending ? 'Creating...' : 'Create User'}
</button>
</div>
{createUser.error && (
<div style={ color: 'red', fontSize: '14px' }>
Error: {formatApiError(createUser.error)}
</div>
)}
</form>
<div>
<h3>User List</h3>
{users && users.length > 0 ? (
<ul style={ listStyle: 'none', padding: 0 }>
{{users.map((user) => (
<li
key={{user.id}}
style={{ padding: '10px', marginBottom: '10px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<strong>{{user.name}}</strong> - {{user.email}}
{{user.createdAt && (
<span style={{ color: '#666', fontSize: '12px', marginLeft: '10px' }}>
(Created: {{new Date(user.createdAt).toLocaleDateString()}})
</span>
)}}
</li>
)}}
</ul>
) : (
<p>No users found. Create one above!</p>
)}
</div>
</div>
);
}
export default UserList;
"#.to_string();
fs::write(output_dir.join("UserList.example.tsx"), example_component)?;
let types_file = r#"// TypeScript type definitions for MockForge API
// These types are generated from your OpenAPI specification
// Update this file if your API schema changes
/**
* Base API error response
*/
export interface ApiError {
status: number;
statusText: string;
body?: any;
message?: string;
}
/**
* User entity
*/
export interface User {
id: string;
name: string;
email: string;
createdAt?: string;
updatedAt?: string;
}
/**
* Request to create a new user
*/
export interface CreateUserRequest {
name: string;
email: string;
}
/**
* Request to update an existing user
*/
export interface UpdateUserRequest {
name?: string;
email?: string;
}
/**
* API response wrapper (if your API uses this format)
*/
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: any;
};
}
/**
* Paginated response (if your API supports pagination)
*/
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
"#
.to_string();
fs::write(output_dir.join("types.ts"), types_file)?;
let index_file = r#"// MockForge API Client - Main Export
// Import hooks and utilities from this file
export * from './hooks';
export * from './types';
// Re-export error utilities for convenience
export { formatApiError, isNetworkError, isClientError, isServerError } from './hooks';
"#;
fs::write(output_dir.join("index.ts"), index_file)?;
Ok(())
}
fn generate_vue_examples(output_dir: &Path, base_url: &str) -> anyhow::Result<()> {
let composables = format!(
r#"// Vue composables for MockForge API
import {{ ref, computed }} from 'vue';
import type {{ Ref }} from 'vue';
const MOCKFORGE_BASE_URL = '{}';
// Example: Get users composable
export function useUsers() {{
const users = ref([]);
const loading = ref(false);
const error = ref<Error | null>(null);
const fetchUsers = async () => {{
loading.value = true;
error.value = null;
try {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users`);
if (!response.ok) throw new Error('Failed to fetch users');
users.value = await response.json();
}} catch (e) {{
error.value = e as Error;
}} finally {{
loading.value = false;
}}
}};
// Auto-fetch on first use
fetchUsers();
return {{
users: computed(() => users.value),
loading: computed(() => loading.value),
error: computed(() => error.value),
refresh: fetchUsers,
}};
}}
// Example: Get user by ID composable
export function useUser(id: Ref<string> | string) {{
const userId = typeof id === 'string' ? ref(id) : id;
const user = ref(null);
const loading = ref(false);
const error = ref<Error | null>(null);
const fetchUser = async () => {{
if (!userId.value) return;
loading.value = true;
error.value = null;
try {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users/${{userId.value}}`);
if (!response.ok) throw new Error('Failed to fetch user');
user.value = await response.json();
}} catch (e) {{
error.value = e as Error;
}} finally {{
loading.value = false;
}}
}};
// Auto-fetch when id changes (requires watch import)
// import {{ watch }} from 'vue';
// watch(userId, fetchUser, {{ immediate: true }});
// For now, manually call fetchUser() when needed
fetchUser();
return {{
user: computed(() => user.value),
loading: computed(() => loading.value),
error: computed(() => error.value),
refresh: fetchUser,
}};
}}
// Example: Create user composable
export function useCreateUser() {{
const creating = ref(false);
const error = ref<Error | null>(null);
const createUser = async (userData: {{ name: string; email: string }}) => {{
creating.value = true;
error.value = null;
try {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users`, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(userData),
}});
if (!response.ok) throw new Error('Failed to create user');
return await response.json();
}} catch (e) {{
error.value = e as Error;
throw e;
}} finally {{
creating.value = false;
}}
}};
return {{
createUser,
creating: computed(() => creating.value),
error: computed(() => error.value),
}};
}}
"#,
base_url
);
fs::write(output_dir.join("composables.ts"), composables)?;
let nuxt_composables = format!(
r#"// Nuxt 3 composables for MockForge API (using useFetch)
// Note: This file is for Nuxt 3 projects. For Vue 3, use composables.ts
import {{ ref, computed }} from 'vue';
import type {{ Ref }} from 'vue';
const MOCKFORGE_BASE_URL = '{}';
// Example: Get users composable (Nuxt 3)
export function useUsersNuxt() {{
const {{ data, error, pending, refresh }} = useLazyFetch(
`${{MOCKFORGE_BASE_URL}}/api/users`
);
return {{
users: computed(() => data.value),
error,
loading: pending,
refresh,
}};
}}
// Example: Get user by ID composable (Nuxt 3)
export function useUserNuxt(id: Ref<string> | string) {{
const userId = typeof id === 'string' ? ref(id) : id;
const {{ data, error, pending, refresh }} = useLazyFetch(
computed(() => `${{MOCKFORGE_BASE_URL}}/api/users/${{userId.value}}`)
);
return {{
user: computed(() => data.value),
error,
loading: pending,
refresh,
}};
}}
// Example: Create user composable (Nuxt 3)
export function useCreateUserNuxt() {{
const creating = ref(false);
const error = ref<Error | null>(null);
const createUser = async (userData: {{ name: string; email: string }}) => {{
creating.value = true;
error.value = null;
try {{
const response = await $fetch(`${{MOCKFORGE_BASE_URL}}/api/users`, {{
method: 'POST',
body: userData,
}});
return response;
}} catch (e) {{
error.value = e as Error;
throw e;
}} finally {{
creating.value = false;
}}
}};
return {{
createUser,
creating: computed(() => creating.value),
error: computed(() => error.value),
}};
}}
"#,
base_url
);
fs::write(output_dir.join("composables-nuxt.ts"), nuxt_composables)?;
Ok(())
}
fn generate_angular_examples(output_dir: &Path, base_url: &str) -> anyhow::Result<()> {
let service = format!(
r#"// Angular service for MockForge API
import {{ Injectable }} from '@angular/core';
import {{ HttpClient, HttpParams }} from '@angular/common/http';
import {{ Observable }} from 'rxjs';
import {{ map }} from 'rxjs/operators';
const MOCKFORGE_BASE_URL = '{}';
export interface User {{
id: string;
name: string;
email: string;
}}
@Injectable({{
providedIn: 'root'
}})
export class MockForgeService {{
constructor(private http: HttpClient) {{}}
// Example: Get users
getUsers(): Observable<User[]> {{
return this.http.get<User[]>(`${{MOCKFORGE_BASE_URL}}/api/users`);
}}
// Example: Get user by ID
getUser(id: string): Observable<User> {{
return this.http.get<User>(`${{MOCKFORGE_BASE_URL}}/api/users/${{id}}`);
}}
// Example: Create user
createUser(userData: {{ name: string; email: string }}): Observable<User> {{
return this.http.post<User>(`${{MOCKFORGE_BASE_URL}}/api/users`, userData);
}}
// Example: Update user
updateUser(id: string, userData: Partial<User>): Observable<User> {{
return this.http.patch<User>(`${{MOCKFORGE_BASE_URL}}/api/users/${{id}}`, userData);
}}
// Example: Delete user
deleteUser(id: string): Observable<void> {{
return this.http.delete<void>(`${{MOCKFORGE_BASE_URL}}/api/users/${{id}}`);
}}
}}
"#,
base_url
);
fs::write(output_dir.join("mockforge.service.ts"), service)?;
Ok(())
}
fn generate_svelte_examples(output_dir: &Path, base_url: &str) -> anyhow::Result<()> {
let stores = format!(
r#"// Svelte stores for MockForge API
import {{ writable, derived }} from 'svelte/store';
const MOCKFORGE_BASE_URL = '{}';
// Example: Users store
export const users = writable([]);
export const usersLoading = writable(false);
export const usersError = writable(null);
export async function fetchUsers() {{
usersLoading.set(true);
usersError.set(null);
try {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users`);
if (!response.ok) throw new Error('Failed to fetch users');
const data = await response.json();
users.set(data);
}} catch (error) {{
usersError.set(error);
}} finally {{
usersLoading.set(false);
}}
}}
// Example: User store
export const user = writable(null);
export const userLoading = writable(false);
export const userError = writable(null);
export async function fetchUser(id: string) {{
userLoading.set(true);
userError.set(null);
try {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users/${{id}}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
user.set(data);
}} catch (error) {{
userError.set(error);
}} finally {{
userLoading.set(false);
}}
}}
// Example: Create user function
export async function createUser(userData: {{ name: string; email: string }}) {{
try {{
const response = await fetch(`${{MOCKFORGE_BASE_URL}}/api/users`, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(userData),
}});
if (!response.ok) throw new Error('Failed to create user');
const data = await response.json();
// Refresh users list
await fetchUsers();
return data;
}} catch (error) {{
console.error('Error creating user:', error);
throw error;
}}
}}
"#,
base_url
);
fs::write(output_dir.join("stores.ts"), stores)?;
Ok(())
}
fn create_env_example(
project_root: &Path,
base_url: &str,
reality_level: &str,
) -> anyhow::Result<()> {
let env_content = format!(
r#"# MockForge Configuration
# Copy this file to .env.mockforge and customize as needed
# Base URL for MockForge server
MOCKFORGE_BASE_URL={}
# Reality level: static, light, moderate, high, chaos
MOCKFORGE_REALITY_LEVEL={}
# Optional: API key if authentication is enabled
# MOCKFORGE_API_KEY=your-api-key-here
# Optional: Workspace ID
# MOCKFORGE_WORKSPACE_ID=default
"#,
base_url, reality_level
);
let env_path = project_root.join(".env.mockforge.example");
fs::write(&env_path, env_content)?;
Ok(())
}
fn update_package_json(package_json_path: &Path, framework: Framework) -> anyhow::Result<()> {
let content = fs::read_to_string(package_json_path)?;
let mut package_json: serde_json::Value = serde_json::from_str(&content)?;
if let Some(deps) = package_json.get_mut("dependencies").and_then(|d| d.as_object_mut()) {
deps.insert(
framework.sdk_package().to_string(),
serde_json::Value::String("latest".to_string()),
);
} else {
let mut deps = serde_json::Map::new();
deps.insert(
framework.sdk_package().to_string(),
serde_json::Value::String("latest".to_string()),
);
package_json["dependencies"] = serde_json::Value::Object(deps);
}
if matches!(framework, Framework::React | Framework::Next) {
if let Some(deps) = package_json.get_mut("dependencies").and_then(|d| d.as_object_mut()) {
deps.insert(
"@tanstack/react-query".to_string(),
serde_json::Value::String("^5.0.0".to_string()),
);
}
}
if matches!(framework, Framework::React | Framework::Next) {
if let Some(deps) = package_json.get_mut("dependencies").and_then(|d| d.as_object_mut()) {
deps.insert("swr".to_string(), serde_json::Value::String("^2.0.0".to_string()));
}
}
let updated_content = serde_json::to_string_pretty(&package_json)?;
fs::write(package_json_path, updated_content)?;
Ok(())
}
fn find_tsconfig(project_root: &Path) -> Option<PathBuf> {
let paths = [
project_root.join("tsconfig.json"),
project_root.join("tsconfig.app.json"),
project_root.join("tsconfig.base.json"),
];
for path in &paths {
if path.exists() {
return Some(path.clone());
}
}
None
}
fn verify_typescript_compilation(tsconfig_path: &Path) -> anyhow::Result<()> {
use std::process::Command;
let tsc_check = Command::new("tsc").arg("--version").output();
if tsc_check.is_err() {
return Err(anyhow::anyhow!("tsc not found"));
}
let project_dir = tsconfig_path.parent().unwrap_or(Path::new("."));
let compile_result = Command::new("tsc")
.arg("--noEmit")
.arg("--project")
.arg(tsconfig_path)
.current_dir(project_dir)
.output();
match compile_result {
Ok(output) => {
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
println!(" ⚠️ TypeScript compilation warnings:");
println!(" {}", stderr.lines().take(5).collect::<Vec<_>>().join("\n "));
Ok(())
}
}
Err(_) => Err(anyhow::anyhow!("Failed to run tsc")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_framework_from_str_react() {
assert_eq!(Framework::from_str("react"), Some(Framework::React));
assert_eq!(Framework::from_str("React"), Some(Framework::React));
assert_eq!(Framework::from_str("REACT"), Some(Framework::React));
}
#[test]
fn test_framework_from_str_vue() {
assert_eq!(Framework::from_str("vue"), Some(Framework::Vue));
assert_eq!(Framework::from_str("Vue"), Some(Framework::Vue));
}
#[test]
fn test_framework_from_str_angular() {
assert_eq!(Framework::from_str("angular"), Some(Framework::Angular));
assert_eq!(Framework::from_str("Angular"), Some(Framework::Angular));
}
#[test]
fn test_framework_from_str_svelte() {
assert_eq!(Framework::from_str("svelte"), Some(Framework::Svelte));
assert_eq!(Framework::from_str("Svelte"), Some(Framework::Svelte));
}
#[test]
fn test_framework_from_str_next() {
assert_eq!(Framework::from_str("next"), Some(Framework::Next));
assert_eq!(Framework::from_str("Next"), Some(Framework::Next));
}
#[test]
fn test_framework_from_str_nuxt() {
assert_eq!(Framework::from_str("nuxt"), Some(Framework::Nuxt));
assert_eq!(Framework::from_str("Nuxt"), Some(Framework::Nuxt));
}
#[test]
fn test_framework_from_str_invalid() {
assert_eq!(Framework::from_str("invalid"), None);
assert_eq!(Framework::from_str(""), None);
assert_eq!(Framework::from_str("reactjs"), None);
}
#[test]
fn test_framework_name() {
assert_eq!(Framework::React.name(), "react");
assert_eq!(Framework::Vue.name(), "vue");
assert_eq!(Framework::Angular.name(), "angular");
assert_eq!(Framework::Svelte.name(), "svelte");
assert_eq!(Framework::Next.name(), "next");
assert_eq!(Framework::Nuxt.name(), "nuxt");
}
#[test]
fn test_framework_sdk_package() {
assert_eq!(Framework::React.sdk_package(), "@mockforge-dev/sdk");
assert_eq!(Framework::Vue.sdk_package(), "@mockforge-dev/sdk");
assert_eq!(Framework::Angular.sdk_package(), "@mockforge-dev/sdk");
assert_eq!(Framework::Svelte.sdk_package(), "@mockforge-dev/sdk");
assert_eq!(Framework::Next.sdk_package(), "@mockforge-dev/sdk");
assert_eq!(Framework::Nuxt.sdk_package(), "@mockforge-dev/sdk");
}
#[test]
fn test_detect_project_root_with_package_json() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("package.json"), "{}").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(temp_dir.path()).ok();
let result = detect_project_root();
std::env::set_current_dir(original_dir).ok();
assert!(result.is_ok());
let root = result.unwrap();
assert!(root.join("package.json").exists());
}
#[test]
fn test_detect_project_root_returns_pathbuf() {
let result = detect_project_root();
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.is_absolute() || path.is_relative());
}
#[test]
fn test_find_package_json_exists() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("package.json"), "{}").unwrap();
let result = find_package_json(temp_dir.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), temp_dir.path().join("package.json"));
}
#[test]
fn test_find_package_json_not_exists() {
let temp_dir = TempDir::new().unwrap();
let result = find_package_json(temp_dir.path());
assert!(result.is_none());
}
#[test]
fn test_find_tsconfig_exists() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("tsconfig.json"), "{}").unwrap();
let result = find_tsconfig(temp_dir.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), temp_dir.path().join("tsconfig.json"));
}
#[test]
fn test_find_tsconfig_app_json() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("tsconfig.app.json"), "{}").unwrap();
let result = find_tsconfig(temp_dir.path());
assert!(result.is_some());
}
#[test]
fn test_find_tsconfig_base_json() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("tsconfig.base.json"), "{}").unwrap();
let result = find_tsconfig(temp_dir.path());
assert!(result.is_some());
}
#[test]
fn test_find_tsconfig_not_exists() {
let temp_dir = TempDir::new().unwrap();
let result = find_tsconfig(temp_dir.path());
assert!(result.is_none());
}
#[test]
fn test_is_valid_openapi_spec_json() {
let temp_dir = TempDir::new().unwrap();
let spec_path = temp_dir.path().join("openapi.json");
fs::write(
&spec_path,
r#"{"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#,
)
.unwrap();
let result = is_valid_openapi_spec(&spec_path);
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_is_valid_openapi_spec_swagger() {
let temp_dir = TempDir::new().unwrap();
let spec_path = temp_dir.path().join("swagger.json");
fs::write(
&spec_path,
r#"{"swagger": "2.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#,
)
.unwrap();
let result = is_valid_openapi_spec(&spec_path);
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_is_valid_openapi_spec_yaml() {
let temp_dir = TempDir::new().unwrap();
let spec_path = temp_dir.path().join("openapi.yaml");
fs::write(&spec_path, "openapi: 3.0.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}")
.unwrap();
let result = is_valid_openapi_spec(&spec_path);
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_is_valid_openapi_spec_invalid_json() {
let temp_dir = TempDir::new().unwrap();
let spec_path = temp_dir.path().join("invalid.json");
fs::write(&spec_path, r#"{"not": "openapi"}"#).unwrap();
let result = is_valid_openapi_spec(&spec_path);
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_is_valid_openapi_spec_file_not_found() {
let path = PathBuf::from("/nonexistent/spec.yaml");
let result = is_valid_openapi_spec(&path);
assert!(result.is_err());
}
#[test]
fn test_check_existing_client_code_none() {
let temp_dir = TempDir::new().unwrap();
let result = check_existing_client_code(temp_dir.path());
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_check_existing_client_code_with_files() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("client.ts"), "// client code").unwrap();
fs::write(temp_dir.path().join("hooks.ts"), "// hooks code").unwrap();
let result = check_existing_client_code(temp_dir.path());
assert!(result.is_ok());
let files = result.unwrap();
assert!(files.contains(&"client.ts".to_string()));
assert!(files.contains(&"hooks.ts".to_string()));
}
#[test]
fn test_check_existing_client_code_directory_not_exists() {
let path = PathBuf::from("/nonexistent/directory");
let result = check_existing_client_code(&path);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_dev_setup_args_default_values() {
let args = DevSetupArgs {
framework: "react".to_string(),
base_url: "http://localhost:3000".to_string(),
reality_level: "moderate".to_string(),
spec: None,
output: PathBuf::from("./src/mockforge"),
force: false,
};
assert_eq!(args.framework, "react");
assert_eq!(args.base_url, "http://localhost:3000");
assert_eq!(args.reality_level, "moderate");
assert!(!args.force);
}
#[test]
fn test_dev_setup_args_with_spec() {
let args = DevSetupArgs {
framework: "vue".to_string(),
base_url: "http://localhost:3000".to_string(),
reality_level: "moderate".to_string(),
spec: Some(PathBuf::from("./openapi.yaml")),
output: PathBuf::from("./src/mockforge"),
force: false,
};
assert!(args.spec.is_some());
assert_eq!(args.spec.unwrap(), PathBuf::from("./openapi.yaml"));
}
#[test]
fn test_dev_setup_args_with_force() {
let args = DevSetupArgs {
framework: "angular".to_string(),
base_url: "http://localhost:3000".to_string(),
reality_level: "moderate".to_string(),
spec: None,
output: PathBuf::from("./src/mockforge"),
force: true,
};
assert!(args.force);
}
#[test]
fn test_framework_debug() {
let framework = Framework::React;
let debug = format!("{:?}", framework);
assert!(debug.contains("React"));
}
#[test]
fn test_framework_clone() {
let framework = Framework::Vue;
let cloned = framework;
assert_eq!(framework as i32, cloned as i32);
}
#[test]
fn test_framework_copy() {
let framework = Framework::Angular;
let copied = framework;
assert_eq!(framework.name(), "angular");
assert_eq!(copied.name(), "angular");
}
#[test]
fn test_framework_partial_eq() {
assert_eq!(Framework::React, Framework::React);
assert_ne!(Framework::React, Framework::Vue);
assert_ne!(Framework::Vue, Framework::Angular);
}
#[test]
fn test_framework_eq() {
let f1 = Framework::Svelte;
let f2 = Framework::Svelte;
assert_eq!(f1, f2);
}
#[test]
fn test_detect_mockforge_workspace_no_config() {
let temp_dir = TempDir::new().unwrap();
let result = detect_mockforge_workspace(temp_dir.path());
assert!(result.is_ok());
let (base_url, reality_level) = result.unwrap();
assert!(base_url.is_none());
assert!(reality_level.is_none());
}
#[test]
fn test_detect_mockforge_workspace_with_config() {
let temp_dir = TempDir::new().unwrap();
let config_content = r#"
http:
host: localhost
port: 8080
reality:
level: high
"#;
fs::write(temp_dir.path().join("mockforge.yaml"), config_content).unwrap();
let result = detect_mockforge_workspace(temp_dir.path());
assert!(result.is_ok());
let (base_url, reality_level) = result.unwrap();
assert!(base_url.is_some());
assert_eq!(base_url.unwrap(), "http://localhost:8080");
assert!(reality_level.is_some());
assert_eq!(reality_level.unwrap(), "high");
}
#[test]
fn test_detect_mockforge_workspace_converts_0_0_0_0() {
let temp_dir = TempDir::new().unwrap();
let config_content = r#"
http:
host: 0.0.0.0
port: 3000
"#;
fs::write(temp_dir.path().join("mockforge.yaml"), config_content).unwrap();
let result = detect_mockforge_workspace(temp_dir.path());
assert!(result.is_ok());
let (base_url, _) = result.unwrap();
assert!(base_url.is_some());
assert_eq!(base_url.unwrap(), "http://localhost:3000");
}
}