use super::daemon_utils::daemon_base_url;
use anyhow::Result;
use clap::ValueEnum;
use colored::Colorize;
#[derive(Debug, Clone, ValueEnum)]
pub enum ConvertTarget {
Project,
All,
}
#[derive(Debug, serde::Deserialize)]
struct MvsConfig {
project_root: std::path::PathBuf,
}
fn find_mvs_config(start: &std::path::Path) -> Option<std::path::PathBuf> {
let mut dir = start.to_path_buf();
loop {
let candidate = dir.join(".mcp-vector-search").join("config.json");
if candidate.exists() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn find_all_mvs_configs() -> Vec<std::path::PathBuf> {
let home = match dirs::home_dir() {
Some(h) => h,
None => return Vec::new(),
};
let mut configs = Vec::new();
for entry in walkdir::WalkDir::new(&home)
.max_depth(6)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!matches!(
name.as_ref(),
"node_modules"
| ".git"
| "target"
| "Library"
| ".cache"
| ".cargo"
| ".rustup"
| ".npm"
| ".pnpm"
| ".pyenv"
| ".nvm"
| "venv"
| ".venv"
| "__pycache__"
)
})
.filter_map(|e| e.ok())
{
if entry.file_name() == "config.json"
&& entry
.path()
.parent()
.and_then(|p| p.file_name())
.map(|n| n == ".mcp-vector-search")
.unwrap_or(false)
{
configs.push(entry.path().to_path_buf());
}
}
configs
}
fn parse_mvs_config(config_path: &std::path::Path) -> Result<(std::path::PathBuf, String)> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| anyhow::anyhow!("read {}: {e}", config_path.display()))?;
let config: MvsConfig = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("parse {}: {e}", config_path.display()))?;
let name = config
.project_root
.file_name()
.map(|n| n.to_string_lossy().to_lowercase().replace(' ', "-"))
.unwrap_or_else(|| "project".to_string());
Ok((config.project_root, name))
}
#[derive(Debug)]
enum ConvertStatus {
Queued,
AlreadyRegistered,
DryRun,
Failed(String),
}
#[derive(Debug)]
struct ConvertResult {
name: String,
path: std::path::PathBuf,
status: ConvertStatus,
}
async fn convert_one(
project_root: std::path::PathBuf,
index_name: String,
base_url: &str,
dry_run: bool,
) -> ConvertResult {
if dry_run {
return ConvertResult {
name: index_name,
path: project_root,
status: ConvertStatus::DryRun,
};
}
let client = match trusty_common::server::daemon_http_client() {
Ok(c) => c,
Err(e) => {
return ConvertResult {
name: index_name,
path: project_root,
status: ConvertStatus::Failed(format!("failed to build HTTP client: {e}")),
};
}
};
let create_url = format!("{base_url}/indexes");
let create_resp = client
.post(&create_url)
.json(&serde_json::json!({
"id": index_name,
"root_path": project_root,
}))
.send()
.await;
let already_existed = match create_resp {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value =
resp.json().await.unwrap_or_else(|_| serde_json::json!({}));
!body
.get("created")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
Ok(resp) => {
return ConvertResult {
name: index_name,
path: project_root,
status: ConvertStatus::Failed(format!("create returned {}", resp.status())),
};
}
Err(e) => {
return ConvertResult {
name: index_name,
path: project_root,
status: ConvertStatus::Failed(format!("create error: {e}")),
};
}
};
let reindex_url = format!("{base_url}/indexes/{index_name}/reindex");
let reindex_resp = client
.post(&reindex_url)
.json(&serde_json::json!({ "root_path": project_root }))
.send()
.await;
match reindex_resp {
Ok(resp) if resp.status().is_success() => ConvertResult {
name: index_name,
path: project_root,
status: if already_existed {
ConvertStatus::AlreadyRegistered
} else {
ConvertStatus::Queued
},
},
Ok(resp) => ConvertResult {
name: index_name,
path: project_root,
status: ConvertStatus::Failed(format!("reindex returned {}", resp.status())),
},
Err(e) => ConvertResult {
name: index_name,
path: project_root,
status: ConvertStatus::Failed(format!("reindex error: {e}")),
},
}
}
fn print_convert_line(idx: usize, total: usize, r: &ConvertResult) {
let prefix = format!("[{}/{}]", idx, total);
let path = r.path.display().to_string();
match &r.status {
ConvertStatus::Queued => {
println!(
" {} {} {:<24} → {}",
prefix.dimmed(),
"✓".green(),
r.name,
path.dimmed()
);
}
ConvertStatus::AlreadyRegistered => {
println!(
" {} {} {:<24} → {} {}",
prefix.dimmed(),
"↻".cyan(),
r.name,
path.dimmed(),
"(already registered, reindexing)".dimmed()
);
}
ConvertStatus::DryRun => {
println!(" {} {:<24} {}", prefix.dimmed(), r.name, path.dimmed());
}
ConvertStatus::Failed(msg) => {
println!(
" {} {} {:<24} → {} {}",
prefix.dimmed(),
"✗".red(),
r.name,
path.dimmed(),
format!("({})", msg).red()
);
}
}
}
pub async fn handle_convert(
target: ConvertTarget,
dry_run: bool,
concurrency: usize,
) -> Result<()> {
let base = daemon_base_url();
crate::commands::daemon_guard::ensure_daemon_running_or_exit(&base).await?;
match target {
ConvertTarget::Project => handle_convert_project(dry_run, &base).await,
ConvertTarget::All => handle_convert_all(dry_run, concurrency, base).await,
}
}
async fn handle_convert_project(dry_run: bool, base: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let config_path = find_mvs_config(&cwd).ok_or_else(|| {
anyhow::anyhow!(
"No .mcp-vector-search/config.json found in {} or any parent directory",
cwd.display()
)
})?;
let (root, name) = parse_mvs_config(&config_path)?;
if dry_run {
println!(
"{} Dry run — would convert '{}' ({})",
"·".dimmed(),
name.bold(),
root.display()
);
return Ok(());
}
println!(
"{} Converting '{}' ({})…",
"⟳".cyan(),
name.bold(),
root.display()
);
let result = convert_one(root, name, base, false).await;
match &result.status {
ConvertStatus::Queued => {
println!(
"{} Queued for reindex — watch progress with: {}",
"✓".green(),
"trusty-search status".cyan()
);
}
ConvertStatus::AlreadyRegistered => {
println!("{} Already registered — reindex queued", "↻".cyan());
}
ConvertStatus::Failed(msg) => {
anyhow::bail!("Conversion failed: {}", msg);
}
ConvertStatus::DryRun => unreachable!(),
}
Ok(())
}
async fn handle_convert_all(dry_run: bool, concurrency: usize, base: String) -> Result<()> {
let home_display = dirs::home_dir()
.map(|h| h.display().to_string())
.unwrap_or_else(|| "$HOME".to_string());
println!(
"🔍 Scanning for mcp-vector-search projects under {}…",
home_display
);
let configs = find_all_mvs_configs();
if configs.is_empty() {
println!("{} No mcp-vector-search projects found.", "·".dimmed());
return Ok(());
}
if dry_run {
println!(
"{} Dry run — would convert {} projects:\n",
"·".dimmed(),
configs.len()
);
} else {
println!(
"{} Found {} projects. Converting (max {} concurrent)…\n",
"·".dimmed(),
configs.len(),
concurrency
);
}
let total = configs.len();
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency.max(1)));
let base = std::sync::Arc::new(base);
let mut tasks = tokio::task::JoinSet::new();
for (i, config_path) in configs.into_iter().enumerate() {
let sem = sem.clone();
let base = base.clone();
tasks.spawn(async move {
let _permit = sem.acquire_owned().await.ok();
let parsed = parse_mvs_config(&config_path);
let result = match parsed {
Ok((root, name)) => convert_one(root, name, &base, dry_run).await,
Err(e) => ConvertResult {
name: config_path.display().to_string(),
path: config_path.clone(),
status: ConvertStatus::Failed(format!("parse: {e}")),
},
};
(i + 1, result)
});
}
let mut queued = 0usize;
let mut already = 0usize;
let mut dry = 0usize;
let mut failed = 0usize;
let mut results: Vec<(usize, ConvertResult)> = Vec::with_capacity(total);
while let Some(joined) = tasks.join_next().await {
match joined {
Ok((i, r)) => results.push((i, r)),
Err(e) => eprintln!("{} task panicked: {e}", "✗".red()),
}
}
results.sort_by_key(|(i, _)| *i);
for (i, r) in &results {
print_convert_line(*i, total, r);
match r.status {
ConvertStatus::Queued => queued += 1,
ConvertStatus::AlreadyRegistered => already += 1,
ConvertStatus::DryRun => dry += 1,
ConvertStatus::Failed(_) => failed += 1,
}
}
println!();
if dry_run {
println!("{} Dry run complete: {} projects", "·".dimmed(), dry);
} else {
println!(
"{} Summary: {} queued, {} already registered (reindexing), {} failed",
"✓".green(),
queued,
already,
failed
);
println!(
" Reindexing in background. Run {} to see progress.",
"trusty-search list".cyan()
);
}
Ok(())
}