use anyhow::{Context, Result};
use chrono::Local;
use clap::ValueEnum;
use colored::Colorize;
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
const TRUSTY_KEY: &str = "trusty-search";
const TRUSTY_DESCRIPTION: &str = "Hybrid code search — BM25 + vector + knowledge graph";
const BACKUP_DIR_NAME: &str = ".mcp-installer-backups";
const CURSOR_RULES_BODY: &str = r#"---
description: |
trusty-search is available as an MCP tool for hybrid code search.
Use it for semantic, lexical, and graph-expanded queries over the indexed codebase.
globs:
- "**/*"
alwaysApply: true
---
# trusty-search Code Search
This project has trusty-search MCP tools available. Prefer them over grep for non-trivial queries.
- `search_code` — hybrid BM25 + vector search with KG expansion (best for most queries)
- `search_similar` — find code similar to a given file/function
- `reindex` — trigger a full reindex of this project
- `index_status` — check chunk count and index health
- `search_health` — confirm the daemon is running
## When to use
- Finding function definitions → `search_code "fn <name>"` with Definition intent
- Exploring callers of a function → `search_code "<name> callers"` with Usage intent
- Conceptual queries ("how does auth work") → `search_code` with Conceptual intent
- Finding similar implementations → `search_similar`
"#;
#[derive(Debug, Clone, ValueEnum)]
pub enum IntegrateTarget {
Cursor,
}
#[derive(Debug, PartialEq, Eq)]
pub enum McpFileStatus {
Written,
AlreadyConfigured,
WouldWrite,
}
#[derive(Debug)]
pub struct McpFileResult {
pub path: PathBuf,
pub status: McpFileStatus,
pub backup: Option<PathBuf>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum RulesStatus {
Written,
AlreadyExists,
WouldWrite,
}
#[derive(Debug)]
pub struct RulesResult {
pub path: PathBuf,
pub status: RulesStatus,
}
pub async fn handle_integrate(
target: IntegrateTarget,
dry_run: bool,
global_only: bool,
project_only: bool,
no_rules: bool,
) -> Result<()> {
match target {
IntegrateTarget::Cursor => {}
}
println!("{} Integrating trusty-search with Cursor…\n", "⟳".cyan());
if dry_run {
println!("{} Dry run — no files will be modified.\n", "·".dimmed());
}
if !project_only {
let path = global_cursor_mcp_path()?;
let result = upsert_cursor_mcp(&path, dry_run)?;
print_mcp_line("Global MCP", "~/.cursor/mcp.json", &result);
}
if !global_only {
let path = project_cursor_mcp_path()?;
let result = upsert_cursor_mcp(&path, dry_run)?;
print_mcp_line("Project MCP", ".cursor/mcp.json", &result);
}
if !global_only && !no_rules {
let rules_dir = project_cursor_rules_dir()?;
let result = write_cursor_rules(&rules_dir, dry_run)?;
print_rules_line(".cursor/rules/trusty-search.mdc", &result);
}
println!();
if dry_run {
println!(
"{} Dry run complete. Re-run without --dry-run to apply.",
"·".dimmed()
);
} else {
println!(
"{} Done. Restart Cursor (or reload MCP servers via Cursor Settings → MCP) to activate.",
"✓".green()
);
}
Ok(())
}
fn global_cursor_mcp_path() -> Result<PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
Ok(home.join(".cursor").join("mcp.json"))
}
fn project_cursor_mcp_path() -> Result<PathBuf> {
let cwd = std::env::current_dir().context("could not determine current directory")?;
Ok(cwd.join(".cursor").join("mcp.json"))
}
fn project_cursor_rules_dir() -> Result<PathBuf> {
let cwd = std::env::current_dir().context("could not determine current directory")?;
Ok(cwd.join(".cursor").join("rules"))
}
fn upsert_cursor_mcp(path: &Path, dry_run: bool) -> Result<McpFileResult> {
let (original, mut root): (Option<String>, Value) = match fs::read_to_string(path) {
Ok(content) => {
let parsed: Value = serde_json::from_str(&content)
.with_context(|| format!("parse {}", path.display()))?;
(Some(content), parsed)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
(None, serde_json::json!({ "mcpServers": {} }))
}
Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())),
};
if !root.is_object() {
return Err(anyhow::anyhow!("{} is not a JSON object", path.display()));
}
let obj = root
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("{} is not a JSON object", path.display()))?;
let servers_entry = obj
.entry("mcpServers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let servers = servers_entry
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("`mcpServers` in {} is not an object", path.display()))?;
if servers.contains_key(TRUSTY_KEY) {
return Ok(McpFileResult {
path: path.to_path_buf(),
status: McpFileStatus::AlreadyConfigured,
backup: None,
});
}
servers.insert(TRUSTY_KEY.to_string(), trusty_server_entry());
if dry_run {
return Ok(McpFileResult {
path: path.to_path_buf(),
status: McpFileStatus::WouldWrite,
backup: None,
});
}
let backup = match &original {
Some(_) => Some(backup_file(path)?),
None => None,
};
write_json_atomic(path, &root)?;
Ok(McpFileResult {
path: path.to_path_buf(),
status: McpFileStatus::Written,
backup,
})
}
fn trusty_server_entry() -> Value {
let mut entry = Map::new();
entry.insert("command".to_string(), Value::String(TRUSTY_KEY.to_string()));
entry.insert(
"args".to_string(),
Value::Array(vec![Value::String("serve".to_string())]),
);
entry.insert(
"description".to_string(),
Value::String(TRUSTY_DESCRIPTION.to_string()),
);
Value::Object(entry)
}
fn write_cursor_rules(dir: &Path, dry_run: bool) -> Result<RulesResult> {
let path = dir.join("trusty-search.mdc");
if path.exists() {
return Ok(RulesResult {
path,
status: RulesStatus::AlreadyExists,
});
}
if dry_run {
return Ok(RulesResult {
path,
status: RulesStatus::WouldWrite,
});
}
fs::create_dir_all(dir).with_context(|| format!("create rules dir {}", dir.display()))?;
fs::write(&path, CURSOR_RULES_BODY)
.with_context(|| format!("write rules file {}", path.display()))?;
Ok(RulesResult {
path,
status: RulesStatus::Written,
})
}
fn backup_file(path: &Path) -> Result<PathBuf> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("path has no parent: {}", path.display()))?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("path has no file name: {}", path.display()))?;
let backup_dir = parent.join(BACKUP_DIR_NAME);
fs::create_dir_all(&backup_dir)
.with_context(|| format!("create backup dir {}", backup_dir.display()))?;
let backup_name = format!("{file_name}.{}.backup", make_timestamp());
let backup_path = backup_dir.join(backup_name);
fs::copy(path, &backup_path)
.with_context(|| format!("copy {} → {}", path.display(), backup_path.display()))?;
Ok(backup_path)
}
fn make_timestamp() -> String {
Local::now().format("%Y%m%d_%H%M%S").to_string()
}
fn write_json_atomic(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create dir {}", parent.display()))?;
}
let pretty = serde_json::to_string_pretty(value).context("serialize Cursor MCP config")?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("path has no file name: {}", path.display()))?;
let tmp = path.with_file_name(format!("{file_name}.tmp"));
fs::write(&tmp, format!("{pretty}\n"))
.with_context(|| format!("write temp {}", tmp.display()))?;
fs::rename(&tmp, path)
.with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?;
Ok(())
}
fn print_mcp_line(label: &str, display_path: &str, r: &McpFileResult) {
tracing::debug!(target: "integrate", path = %r.path.display(), "{label} resolved");
let label_col = format!("{label:<13}");
let path_col = format!("{display_path:<32}");
match &r.status {
McpFileStatus::Written => {
let suffix = match &r.backup {
Some(b) => {
let name = b.file_name().and_then(|n| n.to_str()).unwrap_or("backup");
format!(" (backup: {BACKUP_DIR_NAME}/{name})")
.dimmed()
.to_string()
}
None => String::new(),
};
println!(
" {} {} {}{}",
label_col.cyan(),
path_col,
"✓ written".green(),
suffix
);
}
McpFileStatus::WouldWrite => println!(
" {} {} {}",
label_col.cyan(),
path_col,
"· would write".dimmed()
),
McpFileStatus::AlreadyConfigured => println!(
" {} {} {}",
label_col.cyan(),
path_col,
"· already configured (skipped)".dimmed()
),
}
}
fn print_rules_line(display_path: &str, r: &RulesResult) {
tracing::debug!(target: "integrate", path = %r.path.display(), "rules file resolved");
let label_col = format!("{:<13}", "Rules");
let path_col = format!("{display_path:<32}");
match r.status {
RulesStatus::Written => println!(
" {} {} {}",
label_col.cyan(),
path_col,
"✓ written".green()
),
RulesStatus::WouldWrite => println!(
" {} {} {}",
label_col.cyan(),
path_col,
"· would write".dimmed()
),
RulesStatus::AlreadyExists => println!(
" {} {} {}",
label_col.cyan(),
path_col,
"· already exists (skipped)".dimmed()
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_upsert_creates_fresh_config() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(".cursor").join("mcp.json");
let result = upsert_cursor_mcp(&path, false).expect("upsert");
assert_eq!(result.status, McpFileStatus::Written);
assert!(result.backup.is_none(), "no backup for a fresh file");
assert!(path.exists(), "config file should be written");
let written: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let servers = written["mcpServers"].as_object().unwrap();
assert_eq!(servers["trusty-search"]["command"], "trusty-search");
assert_eq!(servers["trusty-search"]["args"][0], "serve");
assert_eq!(
servers["trusty-search"]["description"], TRUSTY_DESCRIPTION,
"description field should be present"
);
}
#[test]
fn test_upsert_idempotent() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("mcp.json");
let input = serde_json::json!({
"mcpServers": {
"trusty-search": {
"command": "trusty-search",
"args": ["serve"]
}
}
});
let serialized = serde_json::to_string_pretty(&input).unwrap();
fs::write(&path, &serialized).expect("write input");
let result = upsert_cursor_mcp(&path, false).expect("upsert");
assert_eq!(result.status, McpFileStatus::AlreadyConfigured);
assert!(result.backup.is_none(), "no backup for a skipped file");
assert_eq!(
fs::read_to_string(&path).unwrap(),
serialized,
"already-configured file should be untouched"
);
}
#[test]
fn test_upsert_preserves_existing_servers() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("mcp.json");
let input = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": ["run"] },
"another": { "command": "another" }
},
"unrelatedTopLevel": "keep-me"
});
fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
let result = upsert_cursor_mcp(&path, false).expect("upsert");
assert_eq!(result.status, McpFileStatus::Written);
assert!(result.backup.is_some(), "existing file should be backed up");
let written: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let servers = written["mcpServers"].as_object().unwrap();
assert!(
servers.contains_key("other-server"),
"unrelated server dropped"
);
assert!(servers.contains_key("another"), "unrelated server dropped");
assert!(
servers.contains_key("trusty-search"),
"trusty-search entry missing"
);
assert_eq!(
written["unrelatedTopLevel"], "keep-me",
"unrelated top-level key dropped"
);
}
#[test]
fn test_backup_path_format() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("mcp.json");
fs::write(&path, "{}").expect("write input");
let backup = backup_file(&path).expect("backup");
let parent = backup.parent().expect("backup has parent");
assert_eq!(
parent.file_name().and_then(|n| n.to_str()),
Some(BACKUP_DIR_NAME),
"backup should be inside .mcp-installer-backups"
);
assert!(parent.is_dir(), "backup dir should exist");
assert!(backup.exists(), "backup file should exist");
let name = backup
.file_name()
.and_then(|n| n.to_str())
.expect("backup file name");
assert!(
name.starts_with("mcp.json."),
"backup name should start with `mcp.json.`: {name}"
);
assert!(
name.ends_with(".backup"),
"backup name should end with `.backup`: {name}"
);
let ts = name
.strip_prefix("mcp.json.")
.and_then(|s| s.strip_suffix(".backup"))
.expect("timestamp segment");
assert_eq!(ts.len(), 15, "timestamp should be 15 chars: {ts}");
assert_eq!(
ts.chars().nth(8),
Some('_'),
"timestamp should have `_` at index 8: {ts}"
);
assert!(
ts.chars()
.enumerate()
.all(|(i, c)| if i == 8 { c == '_' } else { c.is_ascii_digit() }),
"timestamp should be digits with one `_`: {ts}"
);
}
#[test]
fn test_make_timestamp_format() {
let ts = make_timestamp();
assert_eq!(ts.len(), 15, "expected `YYYYMMDD_HHMMSS`: {ts}");
assert_eq!(ts.chars().nth(8), Some('_'), "separator at index 8: {ts}");
assert!(!ts.contains(':'), "no colons allowed: {ts}");
assert!(!ts.contains(' '), "no spaces allowed: {ts}");
}
#[test]
fn test_write_rules_creates_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let rules_dir = tmp.path().join(".cursor").join("rules");
let result = write_cursor_rules(&rules_dir, false).expect("write rules");
assert_eq!(result.status, RulesStatus::Written);
let rules_path = rules_dir.join("trusty-search.mdc");
assert!(rules_path.exists(), "rules file should be written");
let body = fs::read_to_string(&rules_path).unwrap();
assert!(
body.contains("alwaysApply: true"),
"rules frontmatter missing"
);
assert!(
body.contains("search_code"),
"rules body should mention search_code"
);
let again = write_cursor_rules(&rules_dir, false).expect("write rules again");
assert_eq!(again.status, RulesStatus::AlreadyExists);
assert_eq!(
fs::read_to_string(&rules_path).unwrap(),
body,
"rules file should be untouched on a second run"
);
}
}