use clap::Subcommand;
use mockforge_recorder::{models::Protocol, RecorderDatabase, StubFormat, StubMappingConverter};
use std::path::PathBuf;
use tracing::info;
#[derive(Subcommand)]
pub enum RecorderCommands {
Convert {
#[arg(long)]
recording_id: Option<String>,
#[arg(short, long)]
input: Option<PathBuf>,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long, default_value = "yaml")]
format: String,
#[arg(long, default_value = "true")]
detect_dynamic_values: bool,
#[arg(long)]
deduplicate: bool,
#[arg(long)]
protocol: Option<String>,
#[arg(long)]
method: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(short, long, default_value = "100")]
limit: usize,
},
}
pub async fn handle_recorder_command(command: RecorderCommands) -> anyhow::Result<()> {
match command {
RecorderCommands::Convert {
recording_id,
input,
output,
format,
detect_dynamic_values,
deduplicate,
protocol,
method,
path,
limit,
} => {
handle_convert(
recording_id,
input,
output,
format,
detect_dynamic_values,
deduplicate,
protocol,
method,
path,
limit,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_convert(
recording_id: Option<String>,
input: Option<PathBuf>,
output: PathBuf,
format: String,
detect_dynamic_values: bool,
deduplicate: bool,
protocol: Option<String>,
method: Option<String>,
path: Option<String>,
limit: usize,
) -> anyhow::Result<()> {
let stub_format = match format.to_lowercase().as_str() {
"json" => StubFormat::Json,
_ => StubFormat::Yaml,
};
let converter = StubMappingConverter::new(detect_dynamic_values);
if let Some(id) = recording_id {
println!("🔄 Converting recording {} to stub mapping...", id);
let db_path = input.unwrap_or_else(|| PathBuf::from("./mockforge-recordings.db"));
let db = RecorderDatabase::new(&db_path).await?;
let exchange = db
.get_exchange(&id)
.await?
.ok_or_else(|| anyhow::anyhow!("Recording {} not found", id))?;
let stub = converter.convert(&exchange)?;
let content = converter.to_string(&stub, stub_format)?;
tokio::fs::write(&output, content).await?;
println!("✅ Stub mapping written to: {}", output.display());
} else {
let db_path = input
.ok_or_else(|| anyhow::anyhow!("Either --recording-id or --input must be specified"))?;
println!("🔄 Converting recordings from database: {}", db_path.display());
println!("📁 Output directory: {}", output.display());
let db = RecorderDatabase::new(&db_path).await?;
use mockforge_recorder::{query::execute_query, QueryFilter};
let mut filter = QueryFilter {
limit: Some(limit as i32),
..Default::default()
};
if let Some(ref proto) = protocol {
let protocol_enum = match proto.to_lowercase().as_str() {
"http" => Protocol::Http,
"grpc" => Protocol::Grpc,
"websocket" => Protocol::WebSocket,
"graphql" => Protocol::GraphQL,
_ => {
return Err(anyhow::anyhow!(
"Invalid protocol: {}. Must be one of: http, grpc, websocket, graphql",
proto
));
}
};
filter.protocol = Some(protocol_enum);
}
if let Some(ref m) = method {
filter.method = Some(m.clone());
}
if let Some(ref p) = path {
filter.path = Some(p.clone());
}
let query_result = execute_query(&db, filter).await?;
println!("📊 Found {} recordings to convert", query_result.total);
if output.is_dir() || !output.exists() {
tokio::fs::create_dir_all(&output).await?;
}
let mut converted = 0;
let mut errors = 0;
let mut seen_identifiers = std::collections::HashSet::new();
for exchange in query_result.exchanges {
let exchange_id = exchange.request.id.clone();
match converter.convert(&exchange) {
Ok(stub) => {
if deduplicate && seen_identifiers.contains(&stub.identifier) {
continue;
}
seen_identifiers.insert(stub.identifier.clone());
let content = converter.to_string(&stub, stub_format)?;
let extension = match stub_format {
StubFormat::Yaml => "yaml",
StubFormat::Json => "json",
};
let filename = format!("{}.{}", stub.identifier, extension);
let file_path = if output.is_dir() {
output.join(&filename)
} else {
output.clone()
};
tokio::fs::write(&file_path, content).await?;
converted += 1;
if converted % 10 == 0 {
info!("Converted {} recordings...", converted);
}
}
Err(e) => {
eprintln!("⚠️ Failed to convert {}: {}", exchange_id, e);
errors += 1;
}
}
}
println!("\n✅ Conversion complete!");
println!(" Converted: {}", converted);
println!(" Errors: {}", errors);
println!(" Output: {}", output.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recorder_commands_convert_variant() {
let cmd = RecorderCommands::Convert {
recording_id: Some("abc123".to_string()),
input: Some(PathBuf::from("input.db")),
output: PathBuf::from("output/fixtures"),
format: "yaml".to_string(),
detect_dynamic_values: true,
deduplicate: false,
protocol: Some("http".to_string()),
method: Some("GET".to_string()),
path: Some("/api/users".to_string()),
limit: 100,
};
match cmd {
RecorderCommands::Convert {
recording_id,
input,
output,
format,
detect_dynamic_values,
deduplicate,
protocol,
method,
path,
limit,
} => {
assert_eq!(recording_id, Some("abc123".to_string()));
assert_eq!(input, Some(PathBuf::from("input.db")));
assert_eq!(output, PathBuf::from("output/fixtures"));
assert_eq!(format, "yaml");
assert!(detect_dynamic_values);
assert!(!deduplicate);
assert_eq!(protocol, Some("http".to_string()));
assert_eq!(method, Some("GET".to_string()));
assert_eq!(path, Some("/api/users".to_string()));
assert_eq!(limit, 100);
}
}
}
#[test]
fn test_recorder_commands_convert_minimal() {
let cmd = RecorderCommands::Convert {
recording_id: None,
input: None,
output: PathBuf::from("output"),
format: "json".to_string(),
detect_dynamic_values: false,
deduplicate: true,
protocol: None,
method: None,
path: None,
limit: 50,
};
match cmd {
RecorderCommands::Convert {
recording_id,
input,
format,
detect_dynamic_values,
deduplicate,
protocol,
method,
path,
limit,
..
} => {
assert!(recording_id.is_none());
assert!(input.is_none());
assert_eq!(format, "json");
assert!(!detect_dynamic_values);
assert!(deduplicate);
assert!(protocol.is_none());
assert!(method.is_none());
assert!(path.is_none());
assert_eq!(limit, 50);
}
}
}
#[test]
fn test_format_parsing() {
let formats = vec![
("json", "json"),
("JSON", "json"),
("Json", "json"),
("yaml", "yaml"),
("YAML", "yaml"),
("Yaml", "yaml"),
("other", "yaml"), ];
for (input, expected) in formats {
let result = match input.to_lowercase().as_str() {
"json" => "json",
_ => "yaml",
};
assert_eq!(result, expected, "Format '{}' should map to '{}'", input, expected);
}
}
#[test]
fn test_protocol_parsing() {
let valid_protocols = vec![
("http", Protocol::Http),
("HTTP", Protocol::Http),
("grpc", Protocol::Grpc),
("GRPC", Protocol::Grpc),
("websocket", Protocol::WebSocket),
("WebSocket", Protocol::WebSocket),
("graphql", Protocol::GraphQL),
("GraphQL", Protocol::GraphQL),
];
for (input, expected) in valid_protocols {
let result = match input.to_lowercase().as_str() {
"http" => Some(Protocol::Http),
"grpc" => Some(Protocol::Grpc),
"websocket" => Some(Protocol::WebSocket),
"graphql" => Some(Protocol::GraphQL),
_ => None,
};
assert_eq!(result, Some(expected), "Protocol '{}' should parse correctly", input);
}
}
#[test]
fn test_invalid_protocol_parsing() {
let invalid_protocols = vec!["invalid", "tcp", "udp", "mqtt", "amqp"];
for proto in invalid_protocols {
let result = match proto.to_lowercase().as_str() {
"http" => Some(Protocol::Http),
"grpc" => Some(Protocol::Grpc),
"websocket" => Some(Protocol::WebSocket),
"graphql" => Some(Protocol::GraphQL),
_ => None,
};
assert!(result.is_none(), "Protocol '{}' should be invalid", proto);
}
}
#[test]
fn test_output_path_handling() {
let paths = vec![
PathBuf::from("fixtures/"),
PathBuf::from("./output"),
PathBuf::from("/absolute/path"),
PathBuf::from("relative/path/file.yaml"),
];
for path in paths {
let cmd = RecorderCommands::Convert {
recording_id: None,
input: None,
output: path.clone(),
format: "yaml".to_string(),
detect_dynamic_values: true,
deduplicate: false,
protocol: None,
method: None,
path: None,
limit: 100,
};
match cmd {
RecorderCommands::Convert { output, .. } => {
assert_eq!(output, path);
}
}
}
}
#[test]
fn test_http_methods() {
let methods = vec!["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"];
for method in methods {
let cmd = RecorderCommands::Convert {
recording_id: None,
input: None,
output: PathBuf::from("output"),
format: "yaml".to_string(),
detect_dynamic_values: true,
deduplicate: false,
protocol: None,
method: Some(method.to_string()),
path: None,
limit: 100,
};
match cmd {
RecorderCommands::Convert { method: m, .. } => {
assert_eq!(m, Some(method.to_string()));
}
}
}
}
#[test]
fn test_path_filter_patterns() {
let path_patterns = vec![
"/api/users",
"/api/users/*",
"/api/v1/items/**",
"/health",
"/api/search?q=*",
];
for pattern in path_patterns {
let cmd = RecorderCommands::Convert {
recording_id: None,
input: None,
output: PathBuf::from("output"),
format: "yaml".to_string(),
detect_dynamic_values: true,
deduplicate: false,
protocol: None,
method: None,
path: Some(pattern.to_string()),
limit: 100,
};
match cmd {
RecorderCommands::Convert { path, .. } => {
assert_eq!(path, Some(pattern.to_string()));
}
}
}
}
#[test]
fn test_limit_values() {
let limits = vec![1, 10, 100, 1000, 10000];
for limit_val in limits {
let cmd = RecorderCommands::Convert {
recording_id: None,
input: None,
output: PathBuf::from("output"),
format: "yaml".to_string(),
detect_dynamic_values: true,
deduplicate: false,
protocol: None,
method: None,
path: None,
limit: limit_val,
};
match cmd {
RecorderCommands::Convert { limit, .. } => {
assert_eq!(limit, limit_val);
}
}
}
}
#[test]
fn test_recording_id_formats() {
let ids = vec![
"abc123".to_string(),
"uuid-like-id-12345".to_string(),
"123456".to_string(),
"rec_001".to_string(),
"a".repeat(100), ];
for id in ids {
let cmd = RecorderCommands::Convert {
recording_id: Some(id.clone()),
input: None,
output: PathBuf::from("output.yaml"),
format: "yaml".to_string(),
detect_dynamic_values: true,
deduplicate: false,
protocol: None,
method: None,
path: None,
limit: 100,
};
match cmd {
RecorderCommands::Convert { recording_id, .. } => {
assert_eq!(recording_id, Some(id));
}
}
}
}
#[test]
fn test_input_database_paths() {
let db_paths = vec![
PathBuf::from("recordings.db"),
PathBuf::from("./data/mockforge-recordings.db"),
PathBuf::from("/var/lib/mockforge/recordings.db"),
];
for db_path in db_paths {
let cmd = RecorderCommands::Convert {
recording_id: None,
input: Some(db_path.clone()),
output: PathBuf::from("output"),
format: "yaml".to_string(),
detect_dynamic_values: true,
deduplicate: false,
protocol: None,
method: None,
path: None,
limit: 100,
};
match cmd {
RecorderCommands::Convert { input, .. } => {
assert_eq!(input, Some(db_path));
}
}
}
}
}