use clap::Subcommand;
use mockforge_core::{
ai_contract_diff::{
CapturedRequest, ContractDiffAnalyzer, ContractDiffConfig, ContractDiffResult,
},
openapi::OpenApiSpec,
request_capture::{get_global_capture_manager, init_global_capture_manager},
Error, Result,
};
use std::path::PathBuf;
use tracing::{info, warn};
#[derive(Subcommand)]
pub(crate) enum ContractDiffCommands {
#[command(verbatim_doc_comment)]
Analyze {
#[arg(short, long)]
spec: PathBuf,
#[arg(long, conflicts_with = "capture_id")]
request_path: Option<PathBuf>,
#[arg(long, conflicts_with = "request_path")]
capture_id: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
llm_provider: Option<String>,
#[arg(long)]
llm_model: Option<String>,
#[arg(long)]
llm_api_key: Option<String>,
#[arg(long)]
confidence_threshold: Option<f64>,
},
#[command(verbatim_doc_comment)]
Compare {
#[arg(long)]
old_spec: PathBuf,
#[arg(long)]
new_spec: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
#[command(verbatim_doc_comment)]
GeneratePatch {
#[arg(short, long)]
spec: PathBuf,
#[arg(long, conflicts_with = "capture_id")]
request_path: Option<PathBuf>,
#[arg(long, conflicts_with = "request_path")]
capture_id: Option<String>,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
llm_provider: Option<String>,
#[arg(long)]
llm_model: Option<String>,
#[arg(long)]
llm_api_key: Option<String>,
},
#[command(verbatim_doc_comment)]
ApplyPatch {
#[arg(short, long)]
spec: PathBuf,
#[arg(short, long)]
patch: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
}
pub(crate) async fn handle_contract_diff(
diff_command: ContractDiffCommands,
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
match diff_command {
ContractDiffCommands::Analyze {
spec,
request_path,
capture_id,
output,
llm_provider,
llm_model,
llm_api_key,
confidence_threshold,
} => {
let config = if llm_provider.is_some()
|| llm_model.is_some()
|| llm_api_key.is_some()
|| confidence_threshold.is_some()
{
let mut cfg = ContractDiffConfig::default();
if let Some(provider) = llm_provider {
cfg.llm_provider = provider;
}
if let Some(model) = llm_model {
cfg.llm_model = model;
}
if let Some(api_key) = llm_api_key {
cfg.api_key = Some(api_key);
}
if let Some(threshold) = confidence_threshold {
cfg.confidence_threshold = threshold;
}
Some(cfg)
} else {
None
};
handle_contract_diff_analyze(spec, request_path, capture_id, output, config).await?;
}
ContractDiffCommands::Compare {
old_spec,
new_spec,
output,
} => {
handle_contract_diff_compare(old_spec, new_spec, output).await?;
}
ContractDiffCommands::GeneratePatch {
spec,
request_path,
capture_id,
output,
llm_provider,
llm_model,
llm_api_key,
} => {
let config = if llm_provider.is_some() || llm_model.is_some() || llm_api_key.is_some() {
let mut cfg = ContractDiffConfig::default();
if let Some(provider) = llm_provider {
cfg.llm_provider = provider;
}
if let Some(model) = llm_model {
cfg.llm_model = model;
}
if let Some(api_key) = llm_api_key {
cfg.api_key = Some(api_key);
}
Some(cfg)
} else {
None
};
handle_contract_diff_generate_patch(spec, request_path, capture_id, output, config)
.await?;
}
ContractDiffCommands::ApplyPatch {
spec,
patch,
output,
} => {
handle_contract_diff_apply_patch(spec, patch, output).await?;
}
}
Ok(())
}
pub async fn handle_contract_diff_analyze(
spec_path: PathBuf,
request_path: Option<PathBuf>,
capture_id: Option<String>,
output: Option<PathBuf>,
config: Option<ContractDiffConfig>,
) -> Result<()> {
info!("Starting contract diff analysis");
let spec = OpenApiSpec::from_file(&spec_path).await?;
info!("Loaded contract spec from: {:?}", spec_path);
let request = if let Some(req_path) = request_path {
let request_json = std::fs::read_to_string(&req_path)?;
let request: CapturedRequest = serde_json::from_str(&request_json)
.map_err(|e| Error::internal(format!("Failed to parse request file: {}", e)))?;
request
} else if let Some(id) = capture_id {
init_global_capture_manager(1000);
let manager = get_global_capture_manager()
.ok_or_else(|| Error::internal("Capture manager not initialized"))?;
let (request, _) = manager
.get_capture(&id)
.await
.ok_or_else(|| Error::internal(format!("Capture not found: {}", id)))?;
request
} else {
return Err(Error::internal("Either --request-path or --capture-id must be provided"));
};
let analyzer_config = config.unwrap_or_default();
let analyzer = ContractDiffAnalyzer::new(analyzer_config)?;
info!("Analyzing request against contract...");
let result = analyzer.analyze(&request, &spec).await?;
if let Some(output_path) = output {
let output_json = serde_json::to_string_pretty(&result)?;
std::fs::write(&output_path, output_json)?;
info!("Results written to: {:?}", output_path);
} else {
print_analysis_results(&result);
}
if !result.matches {
warn!("Contract mismatches detected!");
std::process::exit(1);
}
info!("Contract analysis completed successfully");
Ok(())
}
pub async fn handle_contract_diff_compare(
old_spec_path: PathBuf,
new_spec_path: PathBuf,
output: Option<PathBuf>,
) -> Result<()> {
info!("Comparing contract specifications");
let old_spec = OpenApiSpec::from_file(&old_spec_path).await?;
let new_spec = OpenApiSpec::from_file(&new_spec_path).await?;
info!("Loaded old spec from: {:?}", old_spec_path);
info!("Loaded new spec from: {:?}", new_spec_path);
let validator = mockforge_core::contract_validation::ContractValidator::new();
let result = validator.compare_specs(&old_spec, &new_spec);
if let Some(output_path) = output {
let report = validator.generate_report(&result);
std::fs::write(&output_path, report)?;
info!("Comparison report written to: {:?}", output_path);
} else {
let report = validator.generate_report(&result);
println!("{}", report);
}
if !result.passed {
warn!("Breaking changes detected!");
std::process::exit(1);
}
info!("Contract comparison completed successfully");
Ok(())
}
pub async fn handle_contract_diff_generate_patch(
spec_path: PathBuf,
request_path: Option<PathBuf>,
capture_id: Option<String>,
output: PathBuf,
config: Option<ContractDiffConfig>,
) -> Result<()> {
info!("Generating correction patch");
let spec = OpenApiSpec::from_file(&spec_path).await?;
let request = if let Some(req_path) = request_path {
let request_json = std::fs::read_to_string(&req_path)?;
let request: CapturedRequest = serde_json::from_str(&request_json)
.map_err(|e| Error::internal(format!("Failed to parse request file: {}", e)))?;
request
} else if let Some(id) = capture_id {
init_global_capture_manager(1000);
let manager = get_global_capture_manager()
.ok_or_else(|| Error::internal("Capture manager not initialized"))?;
let (request, _) = manager
.get_capture(&id)
.await
.ok_or_else(|| Error::internal(format!("Capture not found: {}", id)))?;
request
} else {
return Err(Error::internal("Either --request-path or --capture-id must be provided"));
};
let analyzer_config = config.unwrap_or_default();
let analyzer = ContractDiffAnalyzer::new(analyzer_config)?;
let result = analyzer.analyze(&request, &spec).await?;
if result.corrections.is_empty() {
warn!("No corrections to generate");
return Ok(());
}
let spec_version = if spec.spec.info.version.is_empty() {
"1.0.0".to_string()
} else {
spec.spec.info.version.clone()
};
let patch_file = analyzer.generate_patch_file(&result.corrections, &spec_version);
let patch_json = serde_json::to_string_pretty(&patch_file)?;
std::fs::write(&output, patch_json)?;
info!("Patch file written to: {:?}", output);
info!("Generated {} corrections", result.corrections.len());
Ok(())
}
pub async fn handle_contract_diff_apply_patch(
spec_path: PathBuf,
patch_path: PathBuf,
output: Option<PathBuf>,
) -> Result<()> {
info!("Applying correction patch to contract");
let spec = OpenApiSpec::from_file(&spec_path).await?;
let mut spec_json = spec
.raw_document
.ok_or_else(|| Error::internal("Spec does not have raw document"))?;
let patch_content = std::fs::read_to_string(&patch_path)?;
let patch_file: serde_json::Value = serde_json::from_str(&patch_content)
.map_err(|e| Error::internal(format!("Failed to parse patch file: {}", e)))?;
if let Some(operations) = patch_file.get("operations").and_then(|v| v.as_array()) {
for op in operations {
apply_patch_operation(&mut spec_json, op)?;
}
} else {
return Err(Error::internal("Invalid patch file format"));
}
let output_path = output.unwrap_or(spec_path);
let updated_json = serde_json::to_string_pretty(&spec_json)?;
std::fs::write(&output_path, updated_json)?;
info!("Updated contract spec written to: {:?}", output_path);
Ok(())
}
fn apply_patch_operation(spec: &mut serde_json::Value, op: &serde_json::Value) -> Result<()> {
let op_type = op
.get("op")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::internal("Missing 'op' field in patch operation"))?;
let path = op
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::internal("Missing 'path' field in patch operation"))?;
let path_parts: Vec<String> = path
.trim_start_matches('/')
.split('/')
.map(|p| p.replace("~1", "/").replace("~0", "~"))
.collect();
match op_type {
"add" => {
let value = op
.get("value")
.ok_or_else(|| Error::internal("Missing 'value' for add operation"))?;
add_to_path(spec, &path_parts, value)?;
}
"remove" => {
remove_from_path(spec, &path_parts)?;
}
"replace" => {
let value = op
.get("value")
.ok_or_else(|| Error::internal("Missing 'value' for replace operation"))?;
replace_at_path(spec, &path_parts, value)?;
}
_ => {
return Err(Error::internal(format!("Unsupported patch operation: {}", op_type)));
}
}
Ok(())
}
fn add_to_path(
spec: &mut serde_json::Value,
path_parts: &[String],
value: &serde_json::Value,
) -> Result<()> {
let mut current = spec;
for (idx, part) in path_parts.iter().enumerate() {
if idx == path_parts.len() - 1 {
if let Some(obj) = current.as_object_mut() {
obj.insert(part.clone(), value.clone());
} else {
return Err(Error::internal("Cannot add to non-object"));
}
} else {
current = current
.get_mut(part)
.ok_or_else(|| Error::internal(format!("Path not found: {}", part)))?;
}
}
Ok(())
}
fn remove_from_path(spec: &mut serde_json::Value, path_parts: &[String]) -> Result<()> {
let mut current = spec;
for (idx, part) in path_parts.iter().enumerate() {
if idx == path_parts.len() - 1 {
if let Some(obj) = current.as_object_mut() {
obj.remove(part);
} else {
return Err(Error::internal("Cannot remove from non-object"));
}
} else {
current = current
.get_mut(part)
.ok_or_else(|| Error::internal(format!("Path not found: {}", part)))?;
}
}
Ok(())
}
fn replace_at_path(
spec: &mut serde_json::Value,
path_parts: &[String],
value: &serde_json::Value,
) -> Result<()> {
let mut current = spec;
for (idx, part) in path_parts.iter().enumerate() {
if idx == path_parts.len() - 1 {
if let Some(obj) = current.as_object_mut() {
obj.insert(part.clone(), value.clone());
} else {
return Err(Error::internal("Cannot replace in non-object"));
}
} else {
current = current
.get_mut(part)
.ok_or_else(|| Error::internal(format!("Path not found: {}", part)))?;
}
}
Ok(())
}
fn print_analysis_results(result: &ContractDiffResult) {
println!("Contract Diff Analysis Results");
println!("==============================");
println!();
println!(
"Status: {}",
if result.matches {
"✓ MATCHES"
} else {
"✗ MISMATCHES"
}
);
println!("Confidence: {:.2}%", result.confidence * 100.0);
println!("Mismatches: {}", result.mismatches.len());
println!();
if !result.mismatches.is_empty() {
println!("Mismatches:");
for (idx, mismatch) in result.mismatches.iter().enumerate() {
println!(" {}. {} - {}", idx + 1, mismatch.path, mismatch.description);
println!(" Type: {:?}", mismatch.mismatch_type);
println!(" Severity: {:?}", mismatch.severity);
println!(" Confidence: {:.2}%", mismatch.confidence * 100.0);
if let Some(expected) = &mismatch.expected {
println!(" Expected: {}", expected);
}
if let Some(actual) = &mismatch.actual {
println!(" Actual: {}", actual);
}
println!();
}
}
if !result.recommendations.is_empty() {
println!("Recommendations:");
for (idx, rec) in result.recommendations.iter().enumerate() {
println!(" {}. {}", idx + 1, rec.recommendation);
if let Some(fix) = &rec.suggested_fix {
println!(" Suggested Fix: {}", fix);
}
println!(" Confidence: {:.2}%", rec.confidence * 100.0);
println!();
}
}
if !result.corrections.is_empty() {
println!("Corrections Available: {}", result.corrections.len());
println!(" Use 'contract-diff generate-patch' to create a patch file");
}
}