use crate::output::{ExitCode, Outputable, colors, header};
use crate::telemetry;
use crate::tools::spec::generate_tool_index;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SearchResult {
pub name: String,
pub command: String,
pub platforms: Vec<String>,
pub has_custom_installer: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub relevance: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchResults {
pub query: String,
pub count: usize,
pub results: Vec<SearchResult>,
}
impl Outputable for SearchResults {
fn to_human(&self) -> String {
if self.results.is_empty() {
return format!(
"No tools found matching \"{}\"\n\nTip: Try 'jarvy search --all' to list all available tools",
self.query
);
}
let mut output = String::new();
if self.query.is_empty() {
output.push_str(&header(&format!("Available Tools ({} tools)", self.count)));
} else {
output.push_str(&header(&format!(
"Tools matching \"{}\" ({} results)",
self.query, self.count
)));
}
output.push('\n');
for result in &self.results {
output.push_str(&format!(
"\n{}{}{}\n",
colors::BOLD,
result.name,
colors::RESET
));
let platforms_str = result.platforms.join(", ");
output.push_str(&format!(
" {}Platforms:{} {}\n",
colors::DIM,
colors::RESET,
platforms_str
));
if result.has_custom_installer {
output.push_str(&format!(
" {}Installation:{} Custom installer\n",
colors::DIM,
colors::RESET
));
}
output.push_str(&format!(
" {}Command:{} {}\n",
colors::DIM,
colors::RESET,
result.command
));
}
output.push_str(&format!(
"\n{}Tip:{} Add to jarvy.toml:\n [tools]\n {} = \"latest\"\n",
colors::DIM,
colors::RESET,
self.results
.first()
.map(|r| r.name.as_str())
.unwrap_or("tool")
));
output
}
fn exit_code(&self) -> ExitCode {
if self.results.is_empty() {
ExitCode::Warning
} else {
ExitCode::Ok
}
}
}
pub fn search_tools(query: &str, show_all: bool) -> SearchResults {
let index = generate_tool_index();
let results: Vec<SearchResult> = if show_all || query.is_empty() {
index
.tools
.iter()
.map(|tool| SearchResult {
name: tool.name.clone(),
command: tool.command.clone(),
platforms: get_platforms(tool),
has_custom_installer: tool.custom_install.has_custom_installer,
relevance: None,
})
.collect()
} else {
let query_lower = query.to_lowercase();
let mut scored_results: Vec<(SearchResult, f64)> = index
.tools
.iter()
.filter_map(|tool| {
let score = calculate_relevance(&tool.name, &query_lower);
if score > 0.4 {
Some((
SearchResult {
name: tool.name.clone(),
command: tool.command.clone(),
platforms: get_platforms(tool),
has_custom_installer: tool.custom_install.has_custom_installer,
relevance: Some(score),
},
score,
))
} else {
None
}
})
.collect();
scored_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored_results.into_iter().map(|(r, _)| r).collect()
};
let count = results.len();
telemetry::search_executed(query, count);
SearchResults {
query: query.to_string(),
count,
results,
}
}
fn calculate_relevance(name: &str, query: &str) -> f64 {
let name_lower = name.to_lowercase();
let query_lower = query.to_lowercase();
if name_lower == query_lower {
return 1.0;
}
if name_lower.starts_with(&query_lower) {
return 0.9;
}
if name_lower.contains(&query_lower) {
return 0.7;
}
let jaro = strsim::jaro_winkler(&name_lower, &query_lower);
let boost = if name_lower
.split(['-', '_'])
.any(|part| part.starts_with(&query_lower))
{
0.1
} else {
0.0
};
(jaro + boost).min(1.0)
}
fn get_platforms(tool: &crate::tools::spec::ToolIndexEntry) -> Vec<String> {
let mut platforms = Vec::new();
if tool.macos.is_some() {
platforms.push("macOS".to_string());
}
if tool.linux.is_some() {
platforms.push("Linux".to_string());
}
if tool.windows.is_some() {
platforms.push("Windows".to_string());
}
if platforms.is_empty() && tool.custom_install.has_custom_installer {
platforms.push("macOS".to_string());
platforms.push("Linux".to_string());
}
platforms
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_exact_match() {
let results = search_tools("git", false);
assert!(!results.results.is_empty());
assert!(results.results.iter().any(|r| r.name == "git"));
}
#[test]
fn test_search_partial_match() {
let results = search_tools("doc", false);
assert!(results.results.iter().any(|r| r.name.contains("docker")));
}
#[test]
fn test_search_all() {
let results = search_tools("", true);
assert!(results.count > 0);
let index = generate_tool_index();
assert_eq!(results.count, index.count);
}
#[test]
fn test_search_poor_match() {
let results = search_tools("qqqqqqqqqqqqqqqqqqq", false);
let all_results = search_tools("", true);
assert!(
results.count < all_results.count / 10,
"Expected very few results but got {} out of {}",
results.count,
all_results.count
);
}
#[test]
fn test_calculate_relevance_exact() {
assert_eq!(calculate_relevance("git", "git"), 1.0);
}
#[test]
fn test_calculate_relevance_prefix() {
let score = calculate_relevance("docker", "doc");
assert!(score >= 0.9);
}
#[test]
fn test_calculate_relevance_contains() {
let score = calculate_relevance("lazydocker", "docker");
assert!(score >= 0.7);
}
#[test]
fn test_search_results_to_human() {
let results = SearchResults {
query: "test".to_string(),
count: 1,
results: vec![SearchResult {
name: "test-tool".to_string(),
command: "test".to_string(),
platforms: vec!["macOS".to_string(), "Linux".to_string()],
has_custom_installer: false,
relevance: Some(0.8),
}],
};
let output = results.to_human();
assert!(output.contains("test-tool"));
assert!(output.contains("macOS"));
}
#[test]
fn test_search_results_exit_code() {
let empty_results = SearchResults {
query: "xyz".to_string(),
count: 0,
results: vec![],
};
assert_eq!(empty_results.exit_code(), ExitCode::Warning);
let results = SearchResults {
query: "git".to_string(),
count: 1,
results: vec![SearchResult {
name: "git".to_string(),
command: "git".to_string(),
platforms: vec!["macOS".to_string()],
has_custom_installer: false,
relevance: Some(1.0),
}],
};
assert_eq!(results.exit_code(), ExitCode::Ok);
}
}