use crate::cli::{ExploreCommand, ExploreFormat};
use crate::core::output::is_json_mode;
use crate::core::parser::{parse_spec, UnifiedOperation, UnifiedSpec};
use anyhow::Result;
use colored::*;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub fn explore_command(cmd: ExploreCommand) -> Result<()> {
let spec_path = cmd.spec.unwrap_or_else(|| {
if Path::new("specs/api.yaml").exists() {
PathBuf::from("specs/api.yaml")
} else if Path::new("specs/api.yml").exists() {
PathBuf::from("specs/api.yml")
} else if Path::new("specs/api.json").exists() {
PathBuf::from("specs/api.json")
} else if Path::new("api.yaml").exists() {
PathBuf::from("api.yaml")
} else {
PathBuf::from("openapi.yaml")
}
});
let results = explore_operations(&spec_path, &cmd.keyword)?;
let format = if is_json_mode() {
ExploreFormat::Json
} else {
cmd.format
};
match format {
ExploreFormat::Pretty => {
display_explore_results(&results, &cmd.keyword, cmd.limit);
}
ExploreFormat::Simple => {
display_simple_results(&results, &cmd.keyword);
}
ExploreFormat::Json => {
display_json_results(&results)?;
}
}
Ok(())
}
pub struct ExploreResult {
pub operation: UnifiedOperation,
pub relevance_score: f32,
pub matched_fields: Vec<String>,
}
pub fn explore_operations(
spec_path: &std::path::Path,
keyword: &str,
) -> Result<Vec<ExploreResult>> {
let spec_content = std::fs::read_to_string(spec_path)?;
let spec = parse_spec(&spec_content)?;
let results = search_operations(&spec, keyword);
Ok(results)
}
fn search_operations(spec: &UnifiedSpec, keyword: &str) -> Vec<ExploreResult> {
let keyword_lower = keyword.to_lowercase();
let mut results = Vec::new();
for operation in &spec.operations {
let mut score = 0.0;
let mut matched_fields = Vec::new();
if operation
.operation_id
.to_lowercase()
.contains(&keyword_lower)
{
score += 3.0;
matched_fields.push("operation_id".to_string());
}
if operation.path.to_lowercase().contains(&keyword_lower) {
score += 2.5;
matched_fields.push("path".to_string());
}
if operation.method.to_lowercase().contains(&keyword_lower) {
score += 1.5;
matched_fields.push("method".to_string());
}
if let Some(summary) = &operation.summary {
if summary.to_lowercase().contains(&keyword_lower) {
score += 2.0;
matched_fields.push("summary".to_string());
}
}
if let Some(desc) = &operation.description {
if desc.to_lowercase().contains(&keyword_lower) {
score += 1.0;
matched_fields.push("description".to_string());
}
}
for param in &operation.parameters {
if param.name.to_lowercase().contains(&keyword_lower) {
score += 0.5;
matched_fields.push(format!("parameter:{}", param.name));
}
}
if score > 0.0 {
results.push(ExploreResult {
operation: operation.clone(),
relevance_score: score,
matched_fields,
});
}
}
results.sort_by(|a, b| b.relevance_score.partial_cmp(&a.relevance_score).unwrap());
results
}
pub fn display_explore_results(results: &[ExploreResult], keyword: &str, limit: usize) {
if results.is_empty() {
println!("❌ No operations found matching '{}'", keyword.red());
return;
}
println!(
"\n🔍 Found {} operations matching '{}':\n",
results.len().to_string().green(),
keyword.cyan()
);
let grouped = group_by_category(results);
for (category, ops) in grouped {
if !category.is_empty() {
println!("{}", format!("📁 {}", category).bright_blue().bold());
}
for (idx, result) in ops.iter().take(limit).enumerate() {
display_single_result(idx + 1, result, keyword);
}
if ops.len() > limit {
println!(" ... and {} more in this category", ops.len() - limit);
}
println!();
}
println!(
"{}",
"💡 Use 'mrapids show <operation>' to see details".dimmed()
);
}
fn display_single_result(num: usize, result: &ExploreResult, keyword: &str) {
let op = &result.operation;
let highlighted_id = highlight_keyword(&op.operation_id, keyword);
let highlighted_path = highlight_keyword(&op.path, keyword);
println!(
" {} {} {}",
format!("{}.", num).dimmed(),
format!("{} {}", op.method.bright_green(), highlighted_path).bold(),
format!("[{}]", highlighted_id).bright_cyan()
);
if let Some(summary) = &op.summary {
if summary.to_lowercase().contains(&keyword.to_lowercase()) {
let highlighted_summary = highlight_keyword(summary, keyword);
println!(" {}", highlighted_summary.dimmed());
} else {
let truncated = if summary.len() > 60 {
format!("{}...", &summary[..60])
} else {
summary.clone()
};
println!(" {}", truncated.dimmed());
}
}
println!(
" {} {}",
"Matched:".bright_black(),
result.matched_fields.join(", ").bright_black()
);
}
fn highlight_keyword(text: &str, keyword: &str) -> String {
let lower_text = text.to_lowercase();
let lower_keyword = keyword.to_lowercase();
if let Some(pos) = lower_text.find(&lower_keyword) {
let (before, rest) = text.split_at(pos);
let (matched, after) = rest.split_at(keyword.len());
format!("{}{}{}", before, matched.bright_yellow().bold(), after)
} else {
text.to_string()
}
}
fn group_by_category(results: &[ExploreResult]) -> Vec<(String, Vec<&ExploreResult>)> {
let mut groups: HashMap<String, Vec<&ExploreResult>> = HashMap::new();
for result in results {
let category = extract_category(&result.operation);
groups.entry(category).or_insert_with(Vec::new).push(result);
}
let mut sorted_groups: Vec<_> = groups.into_iter().collect();
sorted_groups.sort_by(|a, b| {
let a_score: f32 = a.1.iter().map(|r| r.relevance_score).sum();
let b_score: f32 = b.1.iter().map(|r| r.relevance_score).sum();
b_score.partial_cmp(&a_score).unwrap()
});
sorted_groups
}
fn extract_category(operation: &UnifiedOperation) -> String {
let path_parts: Vec<&str> = operation
.path
.split('/')
.filter(|s| !s.is_empty())
.collect();
if let Some(first_part) = path_parts.first() {
if !first_part.starts_with('{') {
return capitalize_first(first_part);
}
}
let op_id_lower = operation.operation_id.to_lowercase();
let categories = [
"user", "pet", "order", "product", "payment", "customer", "account",
];
for cat in &categories {
if op_id_lower.contains(cat) {
return capitalize_first(cat);
}
}
"General".to_string()
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
fn display_simple_results(results: &[ExploreResult], keyword: &str) {
if results.is_empty() {
println!("No operations found matching '{}'", keyword);
return;
}
for result in results {
let op = &result.operation;
println!("{} {} [{}]", op.method, op.path, op.operation_id);
}
}
fn display_json_results(results: &[ExploreResult]) -> Result<()> {
let json_results: Vec<_> = results
.iter()
.map(|r| {
serde_json::json!({
"operation_id": r.operation.operation_id,
"method": r.operation.method,
"path": r.operation.path,
"summary": r.operation.summary,
"relevance_score": r.relevance_score,
"matched_fields": r.matched_fields,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_results)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::parser::{
ApiInfo, ParameterLocation, SchemaType, UnifiedParameter, UnifiedSchema,
};
fn create_test_operation(
id: &str,
method: &str,
path: &str,
summary: Option<&str>,
) -> UnifiedOperation {
UnifiedOperation {
operation_id: id.to_string(),
method: method.to_string(),
path: path.to_string(),
summary: summary.map(|s| s.to_string()),
description: None,
tags: vec![],
parameters: vec![],
request_body: None,
responses: HashMap::new(),
security: None,
}
}
fn create_test_spec(operations: Vec<UnifiedOperation>) -> UnifiedSpec {
UnifiedSpec {
info: ApiInfo {
title: "Test API".to_string(),
version: "1.0.0".to_string(),
description: None,
},
base_url: "https://api.test.com".to_string(),
operations,
security_schemes: HashMap::new(),
}
}
#[test]
fn test_capitalize_first_simple() {
assert_eq!(capitalize_first("hello"), "Hello");
assert_eq!(capitalize_first("world"), "World");
}
#[test]
fn test_capitalize_first_empty() {
assert_eq!(capitalize_first(""), "");
}
#[test]
fn test_capitalize_first_already_uppercase() {
assert_eq!(capitalize_first("Hello"), "Hello");
}
#[test]
fn test_capitalize_first_single_char() {
assert_eq!(capitalize_first("a"), "A");
}
#[test]
fn test_highlight_keyword_found() {
let result = highlight_keyword("getUserById", "user");
assert!(result.contains("User") || result.contains("user"));
}
#[test]
fn test_highlight_keyword_not_found() {
let result = highlight_keyword("getUserById", "pet");
assert_eq!(result, "getUserById");
}
#[test]
fn test_highlight_keyword_case_insensitive() {
let result = highlight_keyword("getUserById", "USER");
assert!(result.len() > "getUserById".len() || result.contains("User"));
}
#[test]
fn test_extract_category_from_path() {
let op = create_test_operation("getUser", "GET", "/users/{id}", None);
assert_eq!(extract_category(&op), "Users");
}
#[test]
fn test_extract_category_from_operation_id() {
let op = create_test_operation("getPetById", "GET", "/{id}", None);
assert_eq!(extract_category(&op), "Pet");
}
#[test]
fn test_extract_category_general() {
let op = create_test_operation("healthCheck", "GET", "/health", None);
assert_eq!(extract_category(&op), "Health");
}
#[test]
fn test_extract_category_products() {
let op = create_test_operation("listProducts", "GET", "/products", None);
assert_eq!(extract_category(&op), "Products");
}
#[test]
fn test_search_operations_by_operation_id() {
let ops = vec![
create_test_operation("getUser", "GET", "/users/{id}", None),
create_test_operation("listPets", "GET", "/pets", None),
];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "user");
assert_eq!(results.len(), 1);
assert_eq!(results[0].operation.operation_id, "getUser");
assert!(results[0]
.matched_fields
.contains(&"operation_id".to_string()));
}
#[test]
fn test_search_operations_by_path() {
let ops = vec![
create_test_operation("getItem", "GET", "/users/{id}", None),
create_test_operation("getPet", "GET", "/pets/{id}", None),
];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "user");
assert_eq!(results.len(), 1);
assert!(results[0].matched_fields.contains(&"path".to_string()));
}
#[test]
fn test_search_operations_by_method() {
let ops = vec![
create_test_operation("createUser", "POST", "/users", None),
create_test_operation("getUser", "GET", "/users/{id}", None),
];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "post");
assert_eq!(results.len(), 1);
assert_eq!(results[0].operation.method, "POST");
}
#[test]
fn test_search_operations_by_summary() {
let ops = vec![
create_test_operation("op1", "GET", "/a", Some("Create a new user")),
create_test_operation("op2", "GET", "/b", Some("List pets")),
];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "user");
assert_eq!(results.len(), 1);
assert!(results[0].matched_fields.contains(&"summary".to_string()));
}
#[test]
fn test_search_operations_multiple_matches() {
let ops = vec![
create_test_operation("getUser", "GET", "/users/{id}", Some("Get a user")),
create_test_operation("createUser", "POST", "/users", Some("Create user")),
create_test_operation("listPets", "GET", "/pets", None),
];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "user");
assert_eq!(results.len(), 2);
}
#[test]
fn test_search_operations_no_matches() {
let ops = vec![create_test_operation("listPets", "GET", "/pets", None)];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "user");
assert!(results.is_empty());
}
#[test]
fn test_search_operations_sorted_by_relevance() {
let ops = vec![
create_test_operation("op1", "GET", "/users", None),
create_test_operation("getUser", "GET", "/other", None),
];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "user");
assert_eq!(results.len(), 2);
assert_eq!(results[0].operation.operation_id, "getUser");
}
#[test]
fn test_search_operations_case_insensitive() {
let ops = vec![create_test_operation(
"GetUserById",
"GET",
"/Users/{id}",
None,
)];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "USER");
assert_eq!(results.len(), 1);
}
#[test]
fn test_search_operations_with_parameters() {
let mut op = create_test_operation("getItem", "GET", "/items/{id}", None);
op.parameters = vec![UnifiedParameter {
name: "userId".to_string(),
location: ParameterLocation::Query,
required: false,
schema: UnifiedSchema {
schema_type: SchemaType::String,
..Default::default()
},
description: None,
example: None,
}];
let spec = create_test_spec(vec![op]);
let results = search_operations(&spec, "user");
assert_eq!(results.len(), 1);
assert!(results[0]
.matched_fields
.iter()
.any(|f| f.contains("parameter")));
}
#[test]
fn test_group_by_category_groups_correctly() {
let ops = vec![
create_test_operation("getUser", "GET", "/users/{id}", None),
create_test_operation("listUsers", "GET", "/users", None),
create_test_operation("getPet", "GET", "/pets/{id}", None),
];
let spec = create_test_spec(ops);
let results = search_operations(&spec, "get");
let groups = group_by_category(&results);
assert_eq!(groups.len(), 2);
}
#[test]
fn test_explore_result_structure() {
let op = create_test_operation("test", "GET", "/test", Some("Test summary"));
let result = ExploreResult {
operation: op,
relevance_score: 2.5,
matched_fields: vec!["operation_id".to_string(), "path".to_string()],
};
assert_eq!(result.relevance_score, 2.5);
assert_eq!(result.matched_fields.len(), 2);
}
}