use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use clap::{Args, Parser, Subcommand};
#[derive(Args, Debug, Clone)]
struct ToolDbArgs {
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
}
use clap::builder::TypedValueParser as _;
use rmcp::ServiceExt;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use scryer_db::{
CodeGraphEdge, DependencyPackage, Project, ProjectDependency, ProjectEvent, ProjectRegistry,
Scope, ScryerDb, SourceFile, Symbol, SymbolReference, is_registrable_root,
};
use scryer_engine::scanner::WorkspaceScanner;
use scryer_engine::{EngineService, parsers};
use scryer_mcp::{AlwaysLoad, ResponseFormat, ScryerMcpServer, SessionOptions, ToolProfile};
#[cfg(unix)]
mod daemon_cli;
mod hook;
mod installer;
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 600;
#[derive(Parser, Debug)]
#[command(
name = "scryer",
about = "Scryer: High-performance AST Code Intelligence MCP Server & CLI",
version
)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Commands,
}
static JSON_OUTPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
const TEXT_TOOLS: &[&str] = &[
"find_references",
"search_symbols",
"get_file_outline",
"trace_call_hierarchy",
"get_crate_outline",
"get_enclosing_scope",
"calculate_blast_radius",
"inspect_symbol",
];
fn json_output() -> bool {
JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed)
}
fn with_cli_format(tool: &str, mut args: serde_json::Value) -> serde_json::Value {
if !json_output()
&& TEXT_TOOLS.contains(&tool)
&& let Some(object) = args.as_object_mut()
{
object
.entry("format")
.or_insert_with(|| serde_json::json!("text"));
}
args
}
fn print_tool_output(text: &str) {
match serde_json::from_str::<serde_json::Value>(text) {
Ok(parsed) if json_output() => println!(
"{}",
serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| text.to_string())
),
Ok(parsed) => println!(
"{}",
serde_json::to_string(&parsed).unwrap_or_else(|_| text.to_string())
),
Err(_) => println!("{text}"),
}
}
#[derive(Subcommand, Debug)]
enum HookAction {
PreGrep {
#[command(flatten)]
db: ToolDbArgs,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum AgentArg {
Claude,
Codex,
Cursor,
All,
}
#[derive(Subcommand, Debug)]
enum Commands {
Serve {
#[arg(long, default_value = "stdio")]
transport: String,
#[arg(long, default_value_t = 8080)]
port: u16,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, hide = true)]
watch: bool,
#[arg(long, env = "SCRYER_NO_WATCH")]
no_watch: bool,
#[arg(long, env = "SCRYER_NO_DAEMON")]
no_daemon: bool,
#[arg(long, default_value_t = DEFAULT_IDLE_TIMEOUT_SECS)]
idle_timeout: u64,
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
#[arg(long, env = "SCRYER_MAX_TOKENS")]
max_tokens: Option<usize>,
#[arg(long, env = "SCRYER_PROJECT")]
project: Option<String>,
#[arg(long, env = "SCRYER_TOOLS", default_value = "full",
value_parser = clap::builder::PossibleValuesParser::new(["core", "full"]).map(|v| v.parse::<ToolProfile>().expect("validated by clap")))]
tools: ToolProfile,
#[arg(long, default_value = "core",
value_parser = clap::builder::PossibleValuesParser::new(["core", "none"]).map(|v| v.parse::<AlwaysLoad>().expect("validated by clap")))]
always_load: AlwaysLoad,
#[arg(long, env = "SCRYER_FORMAT", default_value = "json",
value_parser = clap::builder::PossibleValuesParser::new(["json", "text"]).map(|v| v.parse::<ResponseFormat>().expect("validated by clap")))]
format: ResponseFormat,
#[arg(long, env = "SCRYER_NO_TRUNCATE")]
no_truncate: bool,
#[arg(long, env = "SCRYER_PAGE_SIZE")]
page_size: Option<usize>,
#[arg(long, env = "SCRYER_STRUCTURED")]
structured: bool,
},
Hook {
#[command(subcommand)]
action: HookAction,
},
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
#[command(alias = "index_workspace", alias = "index-workspace")]
Index {
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long)]
slug: Option<String>,
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
},
#[command(alias = "get_indexing_status", alias = "get-indexing-status")]
Status {
#[arg(long)]
project: Option<String>,
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
},
Doctor {
#[arg(long)]
project: Option<String>,
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
},
#[command(alias = "delete_project", alias = "delete-project")]
Delete {
project: String,
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
},
Benchmark {
#[arg(long, default_value = ".")]
target: PathBuf,
#[arg(long)]
dependencies: bool,
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 5)]
limit: usize,
#[arg(long)]
package: Option<String>,
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
},
#[command(alias = "install-instructions", alias = "init-agents")]
Init {
#[arg(default_value = ".")]
target: PathBuf,
#[arg(long)]
print: bool,
#[arg(long)]
check: bool,
#[arg(long)]
print_codex_config: bool,
#[arg(long)]
print_skill: bool,
#[arg(long, value_name = "DIR")]
skill_dir: Option<PathBuf>,
#[arg(long, value_enum)]
agent: Vec<AgentArg>,
#[arg(long)]
no_index: bool,
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, env = "SCRYER_AUTH_TOKEN")]
auth_token: Option<String>,
},
#[command(alias = "list_projects")]
ListProjects {
#[arg(long)]
query: Option<String>,
#[arg(long)]
offset: Option<usize>,
#[arg(long)]
limit: Option<usize>,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "switch_active_project")]
SwitchActiveProject {
project: String,
#[command(flatten)]
db: ToolDbArgs,
},
Refs {
symbol: String,
#[arg(long)]
file_filter: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[command(flatten)]
db: ToolDbArgs,
},
Callers {
symbol: String,
#[arg(long)]
file_filter: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[command(flatten)]
db: ToolDbArgs,
},
Files {
symbol: String,
#[arg(long)]
file_filter: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[command(flatten)]
db: ToolDbArgs,
},
Def {
symbol: String,
#[arg(long)]
file_path: Option<String>,
#[command(flatten)]
db: ToolDbArgs,
},
At {
location: String,
#[command(flatten)]
db: ToolDbArgs,
},
Find {
#[arg(required = true, num_args = 1..)]
query: Vec<String>,
#[arg(long)]
limit: Option<usize>,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "get_file_outline", alias = "outline")]
GetFileOutline {
file_path: PathBuf,
#[arg(long)]
query: Option<String>,
#[arg(long, value_delimiter = ',')]
kinds: Option<Vec<String>>,
#[arg(long)]
start_line: Option<usize>,
#[arg(long)]
end_line: Option<usize>,
#[arg(long)]
compact: bool,
#[arg(long)]
offset: Option<usize>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "get_type_contract", alias = "contract")]
GetTypeContract {
type_name: String,
#[arg(long)]
file_path: Option<String>,
#[arg(long)]
member_query: Option<String>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "get_enclosing_scope")]
GetEnclosingScope {
file_path: PathBuf,
#[arg(long)]
line: u32,
#[arg(long)]
column: Option<u32>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "resolve_definition", alias = "definition")]
ResolveDefinition {
file_path: PathBuf,
line: u32,
column: u32,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "find_references", alias = "references")]
FindReferences {
symbol: String,
#[arg(long)]
file_filter: Option<String>,
#[arg(long)]
role: Option<String>,
#[arg(long)]
scope_level: Option<String>,
#[arg(long)]
view: Option<String>,
#[arg(long)]
offset: Option<usize>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "inspect_symbol")]
InspectSymbol {
symbol: String,
#[arg(long)]
file_path: Option<String>,
#[arg(long, value_delimiter = ',')]
include: Option<Vec<String>>,
#[arg(long)]
snippet_lines: Option<usize>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "get_crate_outline")]
GetCrateOutline {
crate_name: String,
#[arg(long)]
module_path: Option<String>,
#[arg(long)]
version: Option<String>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "search_symbols")]
SearchSymbols {
query: Option<String>,
#[arg(long)]
like_symbol: Option<String>,
#[arg(long, value_delimiter = ',')]
kinds: Option<Vec<String>>,
#[arg(long)]
file_filter: Option<String>,
#[arg(long)]
scope_level: Option<String>,
#[arg(long)]
crate_name: Option<String>,
#[arg(long)]
offset: Option<usize>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
compact: bool,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "trace_call_hierarchy")]
TraceCallHierarchy {
symbol: String,
#[arg(long, default_value = "inbound")]
direction: String,
#[arg(long, default_value_t = 3)]
depth: usize,
#[arg(long)]
file_path: Option<String>,
#[arg(long)]
file_filter: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "calculate_blast_radius")]
CalculateBlastRadius {
symbol: String,
#[arg(long, default_value_t = 3)]
depth: usize,
#[arg(long)]
files_only: bool,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "query_adrs")]
QueryAdrs {
query: String,
#[arg(long)]
status: Option<String>,
#[arg(long)]
compact: bool,
#[arg(long)]
offset: Option<usize>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_truncate: bool,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "record_adr")]
RecordAdr {
title: String,
#[arg(long)]
context: String,
#[arg(long)]
decision: String,
#[arg(long)]
consequences: String,
#[arg(long, default_value = "accepted")]
status: String,
#[arg(long)]
affected_paths: Option<Vec<String>>,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "delete_adr")]
DeleteAdr {
number: u32,
#[command(flatten)]
db: ToolDbArgs,
},
#[command(alias = "get_token_savings_metrics")]
GetTokenSavingsMetrics {
#[arg(long)]
session: Option<String>,
#[command(flatten)]
db: ToolDbArgs,
},
Report {
#[arg(long)]
session: Option<String>,
#[command(flatten)]
db: ToolDbArgs,
},
Call {
tool: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
#[command(flatten)]
db: ToolDbArgs,
},
}
#[derive(Subcommand, Debug)]
enum DaemonAction {
Run {
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
#[arg(long, default_value_t = DEFAULT_IDLE_TIMEOUT_SECS)]
idle_timeout: u64,
},
Status {
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
},
Stop {
#[arg(long, env = "SCRYER_DB_URL")]
db_url: Option<String>,
},
}
fn resolve_db_path(db_url: Option<String>) -> String {
if let Some(url) = db_url {
url
} else if let Ok(env_url) = std::env::var("SCRYER_DB_URL") {
env_url
} else if let Some(home) = std::env::var_os("HOME") {
let scryer_dir = PathBuf::from(home).join(".scryer");
let _ = std::fs::create_dir_all(&scryer_dir);
scryer_dir.join("scryer.db").to_string_lossy().to_string()
} else {
"scryer.db".to_string()
}
}
fn init_tracing(to_stderr: bool, default_filter: &str) {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| default_filter.into());
if to_stderr {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(std::io::stderr().is_terminal())
.with_env_filter(filter)
.init();
} else {
tracing_subscriber::fmt().with_env_filter(filter).init();
}
}
async fn open_services(
db_path: &str,
auth_token: Option<&str>,
) -> anyhow::Result<(ScryerDb, Arc<ProjectRegistry>, EngineService)> {
let db = ScryerDb::connect_with_auth(db_path, auth_token).await?;
let registry = Arc::new(ProjectRegistry::new(db.inner()).await?);
let engine = EngineService::new(db.clone());
Ok((db, registry, engine))
}
fn watch_or_warn(watcher: &scryer_engine::WorkspaceWatcher, project: &Project) {
if let Err(e) = watcher.add_project(project.id, Path::new(&project.root_path)) {
tracing::warn!("Not watching {}: {e}", project.root_path);
}
}
fn print_index_report(report: &serde_json::Value, elapsed: std::time::Duration) {
let count = |key: &str| report[key].as_u64().unwrap_or(0);
println!("\n✅ Indexing complete in {:.2?}", elapsed);
println!(" ├─ Files scanned: {}", count("scanned_files"));
println!(" ├─ Files added: {}", count("added_files"));
println!(" ├─ Files modified: {}", count("modified_files"));
println!(" ├─ Files unchanged: {}", count("unchanged_files"));
println!(" ├─ Files deleted: {}", count("deleted_files"));
println!(" ├─ Scopes indexed: {}", count("total_scopes"));
println!(" ├─ Symbols indexed: {}", count("total_symbols"));
println!(" ├─ References resolved: {}", count("total_references"));
println!(" └─ Graph edges: {}", count("total_edges"));
}
async fn run_pre_grep(db: ToolDbArgs) -> anyhow::Result<()> {
use std::io::Read as _;
let mode = hook::Mode::from_env();
if mode == hook::Mode::Off {
return Ok(());
}
let mut input = String::new();
if std::io::stdin().read_to_string(&mut input).is_err() {
return Ok(());
}
let Ok(event) = serde_json::from_str::<serde_json::Value>(&input) else {
return Ok(());
};
let Some(call) = hook::classify(&event) else {
return Ok(());
};
let store = hook::SeenStore::for_session(event["session_id"].as_str().unwrap_or("default"));
if store.seen(&call.pattern) {
return Ok(());
}
if let Some(cwd) = event["cwd"].as_str() {
let _ = std::env::set_current_dir(cwd);
}
let Some(answer) = hook_answer(&call.pattern, &db).await else {
return Ok(());
};
store.record(&call.pattern);
match mode {
hook::Mode::On => println!("{}", hook::deny_output(&call.pattern, &answer)),
hook::Mode::Context => println!("{}", hook::context_output(&call.pattern, &answer)),
hook::Mode::Off => {}
}
Ok(())
}
async fn hook_answer(pattern: &str, db: &ToolDbArgs) -> Option<String> {
let call = |tool: &'static str, args: serde_json::Value| {
let (url, token) = (db.db_url.clone(), db.auth_token.clone());
async move {
let (text, is_err) = call_tool_text(tool, args, url, token).await.ok()?;
(!is_err).then_some(text)
}
};
let status: serde_json::Value =
serde_json::from_str(&call("get_indexing_status", serde_json::json!({})).await?).ok()?;
if status["index_state"] != "ready" || status["file_count"].as_u64().unwrap_or(0) == 0 {
return None;
}
let inspected: serde_json::Value = serde_json::from_str(
&call("inspect_symbol", serde_json::json!({ "symbol": pattern })).await?,
)
.ok()?;
if !hook::names_one_definition(&inspected) {
return None;
}
let answer = call(
"find_references",
with_cli_format(
"find_references",
serde_json::json!({ "symbol": pattern, "limit": 20 }),
),
)
.await?;
let header = answer.lines().next()?;
if header.contains("AMBIGUOUS") || header.contains(": 0 usages") || header.contains("INDEXING")
{
return None;
}
Some(answer)
}
async fn dispatch_refs(
view: &str,
symbol: String,
file_filter: Option<String>,
limit: Option<usize>,
db: ToolDbArgs,
) -> anyhow::Result<()> {
let mut map = serde_json::Map::new();
map.insert("symbol".into(), serde_json::json!(symbol));
map.insert("view".into(), serde_json::json!(view));
if let Some(f) = file_filter {
map.insert("file_filter".into(), serde_json::json!(f));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
dispatch_tool_via_cli(
"find_references",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await
}
fn parse_location(location: &str) -> anyhow::Result<(String, u32)> {
let mut rest = location;
if let Some((head, tail)) = rest.rsplit_once(':')
&& tail.bytes().all(|b| b.is_ascii_digit())
&& let Some((_, mid)) = head.rsplit_once(':')
&& mid.bytes().all(|b| b.is_ascii_digit())
{
rest = head;
}
let (file, line) = rest
.rsplit_once(':')
.filter(|(f, l)| !f.is_empty() && !l.is_empty() && l.bytes().all(|b| b.is_ascii_digit()))
.ok_or_else(|| anyhow::anyhow!("expected `path:line`, got `{location}`"))?;
Ok((file.to_string(), line.parse()?))
}
fn print_install_outcome(outcome: installer::InstallOutcome, path: &Path, what: &str) {
use installer::InstallOutcome::*;
match outcome {
Created => println!("✨ Created `{}` ({what}).", path.display()),
Appended => println!("✨ Added {what} to `{}`.", path.display()),
Updated => println!("🔄 Updated {what} in `{}`.", path.display()),
AlreadyUpToDate => println!("✓ {what} in `{}` is already up to date.", path.display()),
UserEdited => println!(
"⚠ `{}` already has its own Scryer setup; left as you wrote it.",
path.display()
),
}
}
async fn run_index(
path: &Path,
slug: Option<&str>,
db_path: &str,
auth_token: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
#[cfg(unix)]
if let Some(client) = daemon_cli::connect_client(db_path).await? {
println!(" (via the running Scryer daemon)");
let report = daemon_cli::call_tool(
&client,
"index_workspace",
serde_json::json!({
"path": path.to_string_lossy(),
"project": slug,
}),
)
.await;
let _ = client.cancel().await;
return report;
}
let (_db, registry, engine) = open_services(db_path, auth_token).await?;
let project = registry.register_or_update(path, slug).await?;
let report = engine.index_project(project.id, path).await?;
Ok(serde_json::json!({
"scanned_files": report.scanned_files,
"added_files": report.added_files,
"modified_files": report.modified_files,
"unchanged_files": report.unchanged_files,
"deleted_files": report.deleted_files,
"total_scopes": report.total_scopes,
"total_symbols": report.total_symbols,
"total_references": report.total_references,
"total_edges": report.total_edges,
}))
}
async fn run_doctor(
db_path: &str,
auth_token: Option<&str>,
project: Option<&str>,
only_root: Option<&Path>,
) -> anyhow::Result<Vec<String>> {
let mut reports: Vec<(String, u64, String, scryer_engine::IndexHealth)> = Vec::new();
let wanted = |slug: &str, id: u64, root: &str| {
project.is_none_or(|p| p == slug || p == id.to_string())
&& only_root.is_none_or(|r| dunce::canonicalize(root).is_ok_and(|c| c == r))
};
#[cfg(unix)]
let client = daemon_cli::connect_client(db_path).await?;
#[cfg(not(unix))]
let client: Option<daemon_cli::DaemonClient> = None;
if let Some(client) = client {
let listed = daemon_cli::call_tool(&client, "list_projects", serde_json::json!({})).await?;
for proj in listed.as_array().cloned().unwrap_or_default() {
let slug = proj["slug"].as_str().unwrap_or_default().to_string();
let id = proj["id"].as_u64().unwrap_or_default();
let root = proj["root_path"].as_str().unwrap_or_default().to_string();
if !wanted(&slug, id, &root) {
continue;
}
let status = daemon_cli::call_tool(
&client,
"get_indexing_status",
serde_json::json!({ "project": id.to_string() }),
)
.await?;
let health: scryer_engine::IndexHealth = serde_json::from_value(status.clone())?;
reports.push((
slug,
id,
status["root_path"].as_str().unwrap_or_default().to_string(),
health,
));
}
let _ = client.cancel().await;
} else {
let db = ScryerDb::connect_with_auth(db_path, auth_token).await?;
let projects = {
let mut guard = db.lock().await;
Project::all().exec(&mut *guard).await?
};
for proj in projects {
if !wanted(&proj.slug, proj.id, &proj.root_path) {
continue;
}
let health = scryer_engine::index_health(&db, proj.id).await?;
reports.push((proj.slug, proj.id, proj.root_path, health));
}
}
println!("Scryer doctor (DB: {db_path})");
println!("{:=<75}", "");
if reports.is_empty() {
println!("No registered projects found. Run `scryer index` to register a workspace.");
}
let mut problems = Vec::new();
for (slug, id, root, health) in &reports {
println!("Project: {slug} (ID: {id})");
println!("Path: {root}");
print!("{}", health.render());
println!("{:-<75}", "");
problems.extend(health.problems());
}
Ok(problems)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
match cli.command {
Commands::Serve {
transport,
port,
host,
watch: _,
no_watch,
no_daemon,
idle_timeout,
db_url,
auth_token,
max_tokens,
project,
tools,
always_load,
format,
no_truncate,
page_size,
structured,
} => {
let watch = !no_watch;
let is_stdio = transport.eq_ignore_ascii_case("stdio");
let options = SessionOptions {
tools,
max_tokens,
project,
always_load,
format,
no_truncate,
page_size,
structured,
};
init_tracing(is_stdio, "scryer=info,scryer_mcp=info");
let db_path = resolve_db_path(db_url);
#[cfg(unix)]
if is_stdio && !no_daemon && scryer_mcp::daemon::is_local_db(&db_path) {
daemon_cli::serve_stdio(&db_path, watch, idle_timeout, options).await?;
std::process::exit(0);
}
#[cfg(not(unix))]
let _ = (no_daemon, idle_timeout);
tracing::info!("Connecting to database: {db_path}");
let (db, registry, engine) = open_services(&db_path, auth_token.as_deref()).await?;
let mut _watcher = None;
if watch {
let current_dir = std::env::current_dir()?;
let canonical =
dunce::canonicalize(¤t_dir).unwrap_or_else(|_| current_dir.clone());
scryer_mcp::ensure_cwd_registered(®istry, &engine, &canonical).await?;
let watcher = Arc::new(engine.watcher()?);
let mut events = registry.subscribe();
for project in registry.list_projects().await {
watch_or_warn(&watcher, &project);
}
let follower = Arc::clone(&watcher);
tokio::spawn(async move {
loop {
match events.recv().await {
Ok(ProjectEvent::Registered(project)) => {
watch_or_warn(&follower, &project)
}
Ok(ProjectEvent::Removed(id)) => follower.remove_project(id),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
_watcher = Some(watcher);
}
if is_stdio {
let server =
ScryerMcpServer::new(db, engine, registry, None)?.with_options(options);
if let Ok(cwd) = std::env::current_dir() {
server
.set_session_cwd(dunce::canonicalize(&cwd).unwrap_or(cwd))
.await;
}
server.apply_session_options().await?;
tracing::info!("Starting Scryer MCP server over stdio...");
let running = server
.serve((tokio::io::stdin(), tokio::io::stdout()))
.await?;
let _ = running.waiting().await;
} else {
if options.project.is_some() {
tracing::warn!(
"--project applies to stdio sessions only; ignoring it for HTTP"
);
}
let service = StreamableHttpService::new(
move || {
ScryerMcpServer::new(db.clone(), engine.clone(), registry.clone(), None)
.map(|server| server.with_options(options.clone()))
.map_err(std::io::Error::other)
},
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let app = axum::Router::new().nest_service("/mcp", service);
let addr = format!("{host}:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("Scryer MCP server listening on http://{addr}/mcp");
axum::serve(listener, app).await?;
}
}
Commands::Daemon { action } => {
#[cfg(unix)]
match action {
DaemonAction::Run {
db_url,
idle_timeout,
} => {
init_tracing(true, "scryer=info,scryer_mcp=info");
daemon_cli::run(&resolve_db_path(db_url), idle_timeout).await?;
}
DaemonAction::Status { db_url } => {
daemon_cli::status(&resolve_db_path(db_url)).await?;
}
DaemonAction::Stop { db_url } => {
daemon_cli::stop(&resolve_db_path(db_url)).await?;
}
}
#[cfg(not(unix))]
{
let _ = action;
anyhow::bail!(
"The Scryer daemon requires Unix sockets; use `scryer serve --no-daemon`"
);
}
}
Commands::Index {
path,
slug,
db_url,
auth_token,
} => {
init_tracing(false, "scryer=info");
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
println!("🔍 Indexing workspace: {}", canonical.display());
let start = Instant::now();
let db_path = resolve_db_path(db_url);
let report =
run_index(&canonical, slug.as_deref(), &db_path, auth_token.as_deref()).await?;
print_index_report(&report, start.elapsed());
}
Commands::Status {
project,
db_url,
auth_token,
} => {
let db_path = resolve_db_path(db_url);
#[cfg(unix)]
if let Some(client) = daemon_cli::connect_client(&db_path).await? {
let result = print_status_via_daemon(&client, &db_path, project.as_deref()).await;
let _ = client.cancel().await;
return result;
}
let db = ScryerDb::connect_with_auth(&db_path, auth_token.as_deref()).await?;
let mut guard = db.lock().await;
let projects = Project::all().exec(&mut *guard).await?;
println!("📊 Scryer Workspace Status (DB: {db_path})");
println!("{:=<75}", "");
let filter_slug = project.as_deref();
let is_deps_filter = filter_slug.is_some_and(|s| {
let t = s.trim();
(t == "0"
|| t.eq_ignore_ascii_case("dependencies")
|| t.eq_ignore_ascii_case("cargo")
|| t.eq_ignore_ascii_case("cargo-dependencies"))
&& !projects.iter().any(|p| p.slug == s)
});
if is_deps_filter {
let dep_packages = DependencyPackage::all().exec(&mut *guard).await?;
let dep_file_count = SourceFile::filter(SourceFile::fields().project_id().eq(0))
.exec(&mut *guard)
.await?
.len();
let dep_sym_count = Symbol::filter(Symbol::fields().project_id().eq(0))
.exec(&mut *guard)
.await?
.len();
let cargo_home = std::env::var("CARGO_HOME")
.or_else(|_| std::env::var("HOME").map(|h| format!("{h}/.cargo")))
.unwrap_or_else(|_| "~/.cargo".to_string());
println!("Project: cargo-dependencies (ID: 0) [Global Cache]");
println!("Path: {}", cargo_home);
println!(
"Entities: {} packages | {} files | {} public symbols",
dep_packages.len(),
dep_file_count,
dep_sym_count
);
println!("{:-<75}", "");
return Ok(());
}
if projects.is_empty() {
println!(
"No registered projects found. Run `scryer index` to register a workspace."
);
return Ok(());
}
for proj in &projects {
if filter_slug.is_some_and(|s| proj.slug != s && proj.id.to_string() != s) {
continue;
}
let file_count = SourceFile::filter(SourceFile::fields().project_id().eq(proj.id))
.exec(&mut *guard)
.await?
.len();
let symbol_count = Symbol::filter(Symbol::fields().project_id().eq(proj.id))
.exec(&mut *guard)
.await?
.len();
let scope_count = Scope::filter(Scope::fields().project_id().eq(proj.id))
.exec(&mut *guard)
.await?
.len();
let edge_count =
CodeGraphEdge::filter(CodeGraphEdge::fields().project_id().eq(proj.id))
.exec(&mut *guard)
.await?
.len();
let ref_count =
SymbolReference::filter(SymbolReference::fields().project_id().eq(proj.id))
.exec(&mut *guard)
.await?
.len();
let linked_deps =
ProjectDependency::filter(ProjectDependency::fields().project_id().eq(proj.id))
.exec(&mut *guard)
.await?
.len();
println!("Project: {} (ID: {})", proj.slug, proj.id);
println!("Path: {}", proj.root_path);
println!("Updated: {}", proj.updated_at);
println!(
"Entities: {} files | {} symbols | {} scopes | {} refs | {} edges ({} linked deps)",
file_count, symbol_count, scope_count, ref_count, edge_count, linked_deps
);
println!("{:-<75}", "");
}
if filter_slug.is_none() {
let dep_packages = DependencyPackage::all().exec(&mut *guard).await?;
let dep_file_count = SourceFile::filter(SourceFile::fields().project_id().eq(0))
.exec(&mut *guard)
.await?
.len();
let dep_sym_count = Symbol::filter(Symbol::fields().project_id().eq(0))
.exec(&mut *guard)
.await?
.len();
let cargo_home = std::env::var("CARGO_HOME")
.or_else(|_| std::env::var("HOME").map(|h| format!("{h}/.cargo")))
.unwrap_or_else(|_| "~/.cargo".to_string());
println!("Project: cargo-dependencies (ID: 0) [Global Cache]");
println!("Path: {}", cargo_home);
println!(
"Entities: {} packages | {} files | {} public symbols",
dep_packages.len(),
dep_file_count,
dep_sym_count
);
println!("{:-<75}", "");
}
}
Commands::Doctor {
project,
db_url,
auth_token,
} => {
let db_path = resolve_db_path(db_url);
run_doctor(&db_path, auth_token.as_deref(), project.as_deref(), None).await?;
}
Commands::Delete {
project,
db_url,
auth_token,
} => {
let project = match dunce::canonicalize(&project) {
Ok(path) => path.to_string_lossy().to_string(),
Err(_) => project,
};
let db_path = resolve_db_path(db_url);
#[cfg(unix)]
if let Some(client) = daemon_cli::connect_client(&db_path).await? {
let result = daemon_cli::call_tool(
&client,
"delete_project",
serde_json::json!({ "project": project }),
)
.await;
let _ = client.cancel().await;
let result = result?;
println!(
"🗑️ Deleted project '{}' ({}) and {} indexed files",
result["slug"].as_str().unwrap_or_default(),
result["root_path"].as_str().unwrap_or_default(),
result["deleted_files"].as_u64().unwrap_or(0)
);
return Ok(());
}
let (_db, registry, engine) = open_services(&db_path, auth_token.as_deref()).await?;
let Some(found) = registry.find(&project).await else {
anyhow::bail!(
"No registered project matches '{project}'. Run `scryer status` to list projects."
);
};
registry.remove(found.id).await?;
engine.purge_project(found.id).await?;
println!(
"🗑️ Deleted project '{}' ({}) and its index data",
found.slug, found.root_path
);
}
Commands::Benchmark {
target,
dependencies,
project,
limit,
package,
db_url,
auth_token,
} => {
let bpe = tiktoken_rs::cl100k_base()?;
let is_deps = dependencies
|| project.as_deref().is_some_and(|p| {
let t = p.trim();
t == "0"
|| t.eq_ignore_ascii_case("dependencies")
|| t.eq_ignore_ascii_case("cargo")
|| t.eq_ignore_ascii_case("cargo-dependencies")
});
if is_deps {
let db_path = resolve_db_path(db_url);
let db = ScryerDb::connect_with_auth(&db_path, auth_token.as_deref()).await?;
let mut guard = db.lock().await;
let mut packages = DependencyPackage::all().exec(&mut *guard).await?;
if let Some(filter_pkg) = package.as_deref() {
packages.retain(|p| p.name == filter_pkg);
}
if packages.is_empty() {
println!("No indexed Cargo dependencies found in DB ({db_path}).");
return Ok(());
}
let max_pkgs = limit.max(1);
let total_available = packages.len();
let selected_packages: Vec<_> = packages.into_iter().take(max_pkgs).collect();
println!(
"⚡ Running Token & Latency Benchmark on Cargo Dependencies (Project 0, {}/{} crates)",
selected_packages.len(),
total_available
);
println!(
"\n{:<45} | {:>10} | {:>10} | {:>8} | {:>10}",
"File", "Naive Tok", "AST Tok", "Savings", "Latency"
);
println!("{:-<95}", "");
let mut total_naive_tokens = 0;
let mut total_ast_tokens = 0;
let mut total_latency_micros = 0;
let mut files_benchmarked_count = 0;
for pkg in &selected_packages {
let root_dir = PathBuf::from(&pkg.root_path);
let target_dir = if root_dir.join("src").is_dir() {
root_dir.join("src")
} else {
root_dir.clone()
};
let mut pkg_files = Vec::new();
let scanner = WorkspaceScanner::new(&target_dir);
for rel in scanner.scan() {
let path = target_dir.join(rel);
if path.extension().and_then(|s| s.to_str()) == Some("rs") {
pkg_files.push(path);
}
}
for file_path in pkg_files {
let content = match std::fs::read(&file_path) {
Ok(c) => c,
Err(_) => continue,
};
let rel = file_path
.strip_prefix(&root_dir)
.unwrap_or(&file_path)
.display()
.to_string();
let display_raw = format!("{}::{}", pkg.name, rel);
let display_name = if display_raw.len() > 43 {
format!("...{}", &display_raw[display_raw.len() - 40..])
} else {
display_raw
};
let content_str = String::from_utf8_lossy(&content);
let naive_tokens = bpe.encode_with_special_tokens(&content_str).len();
let start_parse = Instant::now();
let hash = scryer_engine::hasher::hash_bytes(&content);
let parsed = match parsers::parse_file(
&file_path,
&content,
&hash,
parsers::ParseMode::DependencyPublicSurface {
crate_name: pkg.name.clone(),
},
) {
Ok(p) => p,
Err(_) => continue,
};
let parse_dur = start_parse.elapsed();
let mut ast_outline = String::new();
for s in &parsed.symbols {
ast_outline.push_str(&format!("{} {}\n", s.kind, s.signature));
}
let ast_tokens = bpe.encode_with_special_tokens(&ast_outline).len();
let savings = if naive_tokens > 0 {
(naive_tokens.saturating_sub(ast_tokens) as f64 / naive_tokens as f64)
* 100.0
} else {
0.0
};
total_naive_tokens += naive_tokens;
total_ast_tokens += ast_tokens;
total_latency_micros += parse_dur.as_micros();
files_benchmarked_count += 1;
println!(
"{:<45} | {:>10} | {:>10} | {:>7.1}% | {:>8.2}ms",
display_name,
naive_tokens,
ast_tokens,
savings,
parse_dur.as_secs_f64() * 1000.0
);
}
}
println!("{:=<95}", "");
let overall_savings = if total_naive_tokens > 0 {
(total_naive_tokens.saturating_sub(total_ast_tokens) as f64
/ total_naive_tokens as f64)
* 100.0
} else {
0.0
};
println!(
"{:<45} | {:>10} | {:>10} | {:>7.1}% | {:>8.2}ms",
format!(
"TOTAL ({} files in {} crates)",
files_benchmarked_count,
selected_packages.len()
),
total_naive_tokens,
total_ast_tokens,
overall_savings,
total_latency_micros as f64 / 1000.0
);
println!(
"\nSize of the public surface outline against the whole files ({} tokens fewer, {:.1}x smaller).",
total_naive_tokens.saturating_sub(total_ast_tokens),
if total_ast_tokens > 0 {
total_naive_tokens as f64 / total_ast_tokens as f64
} else {
1.0
}
);
println!(
"This compares sizes only. Agents rarely read whole files, so it is not a measure of what Scryer saves in a session; `scryer report` shows measured usage."
);
return Ok(());
}
println!(
"⚡ Running Token & Latency Benchmark on: {}",
target.display()
);
let target_canonical = dunce::canonicalize(&target).unwrap_or_else(|_| target.clone());
let mut files_to_benchmark = Vec::new();
if target_canonical.is_file() {
files_to_benchmark.push(target_canonical.clone());
} else if target_canonical.is_dir() {
let scanner = WorkspaceScanner::new(&target_canonical);
for rel in scanner.scan() {
files_to_benchmark.push(target_canonical.join(rel));
}
}
if files_to_benchmark.is_empty() {
println!("No supported source files found in target.");
return Ok(());
}
println!(
"\n{:<45} | {:>10} | {:>10} | {:>8} | {:>10}",
"File", "Naive Tok", "AST Tok", "Savings", "Latency"
);
println!("{:-<95}", "");
let mut total_naive_tokens = 0;
let mut total_ast_tokens = 0;
let mut total_latency_micros = 0;
for file_path in &files_to_benchmark {
let content = match std::fs::read(file_path) {
Ok(c) => c,
Err(_) => continue,
};
let rel_display = file_path
.strip_prefix(&target_canonical)
.unwrap_or(file_path)
.display()
.to_string();
let display_name = if rel_display.len() > 43 {
format!("...{}", &rel_display[rel_display.len() - 40..])
} else {
rel_display
};
let content_str = String::from_utf8_lossy(&content);
let naive_tokens = bpe.encode_with_special_tokens(&content_str).len();
let start_parse = Instant::now();
let hash = scryer_engine::hasher::hash_bytes(&content);
let parsed = match parsers::parse_file(
file_path,
&content,
&hash,
parsers::ParseMode::Workspace,
) {
Ok(p) => p,
Err(_) => continue,
};
let parse_dur = start_parse.elapsed();
let mut ast_outline = String::new();
for s in &parsed.symbols {
ast_outline.push_str(&format!("{} {}\n", s.kind, s.signature));
}
let ast_tokens = bpe.encode_with_special_tokens(&ast_outline).len();
let savings = if naive_tokens > 0 {
(naive_tokens.saturating_sub(ast_tokens) as f64 / naive_tokens as f64) * 100.0
} else {
0.0
};
total_naive_tokens += naive_tokens;
total_ast_tokens += ast_tokens;
total_latency_micros += parse_dur.as_micros();
println!(
"{:<45} | {:>10} | {:>10} | {:>7.1}% | {:>8.2}ms",
display_name,
naive_tokens,
ast_tokens,
savings,
parse_dur.as_secs_f64() * 1000.0
);
}
println!("{:=<95}", "");
let overall_savings = if total_naive_tokens > 0 {
(total_naive_tokens.saturating_sub(total_ast_tokens) as f64
/ total_naive_tokens as f64)
* 100.0
} else {
0.0
};
println!(
"{:<45} | {:>10} | {:>10} | {:>7.1}% | {:>8.2}ms",
format!("TOTAL ({} files)", files_to_benchmark.len()),
total_naive_tokens,
total_ast_tokens,
overall_savings,
total_latency_micros as f64 / 1000.0
);
println!(
"\nSize of the outlines against the whole files ({} tokens fewer, {:.1}x smaller).",
total_naive_tokens.saturating_sub(total_ast_tokens),
if total_ast_tokens > 0 {
total_naive_tokens as f64 / total_ast_tokens as f64
} else {
1.0
}
);
println!(
"This compares sizes only. Agents rarely read whole files, so it is not a measure of what Scryer saves in a session; `scryer report` shows measured usage."
);
}
Commands::Init {
target,
print,
check,
print_codex_config,
print_skill,
skill_dir,
agent,
no_index,
db_url,
auth_token,
} => {
if print_codex_config {
print!("{}", installer::codex_config_snippet());
return Ok(());
}
if print {
println!("{}", installer::generate_agent_instructions());
return Ok(());
}
if print_skill {
print!("{}", installer::generate_agent_skill());
return Ok(());
}
if let Some(dir) = skill_dir {
let outcome = installer::install_skill_in(&dir, check)?;
print_install_outcome(outcome, &dir, "Scryer agent skill");
return Ok(());
}
let fail = |err: anyhow::Error| -> anyhow::Result<()> {
if check {
eprintln!("❌ Check failed: {err}");
std::process::exit(1);
}
Err(err)
};
let project_dir = if target.is_dir() {
target.clone()
} else {
target.parent().unwrap_or(Path::new(".")).to_path_buf()
};
let agents_path = installer::resolve_agents_file(&target);
match installer::install_instructions(&target, check) {
Ok(outcome) => {
print_install_outcome(outcome, &agents_path, "Scryer agent instructions")
}
Err(err) => fail(err)?,
}
let skill_path = project_dir.join(installer::SKILL_RELATIVE_PATH);
match installer::install_skill(&project_dir, check) {
Ok(outcome) => print_install_outcome(outcome, &skill_path, "Scryer agent skill"),
Err(err) => fail(err)?,
}
let mut agents: Vec<installer::Agent> = Vec::new();
for arg in &agent {
match arg {
AgentArg::Claude => agents.push(installer::Agent::Claude),
AgentArg::Codex => agents.push(installer::Agent::Codex),
AgentArg::Cursor => agents.push(installer::Agent::Cursor),
AgentArg::All => agents.extend([
installer::Agent::Claude,
installer::Agent::Codex,
installer::Agent::Cursor,
]),
}
}
if agent.is_empty() {
agents = installer::detect_agents(&project_dir);
}
agents.sort();
agents.dedup();
for agent in &agents {
match installer::install_agent(&project_dir, *agent, check) {
Ok(steps) => {
for step in steps {
print_install_outcome(step.outcome, &step.path, step.what);
}
}
Err(err) => fail(err)?,
}
}
if agent.is_empty() && agents.is_empty() {
println!(
"ℹ No agent configuration found. Run `scryer init --agent claude|codex|cursor` to set one up."
);
}
if check || no_index {
return Ok(());
}
let canonical = dunce::canonicalize(&project_dir).unwrap_or(project_dir);
if !is_registrable_root(&canonical) {
println!(
"ℹ Not indexing `{}`: it has no project marker (.git, Cargo.toml, ...) or is too broad a directory.",
canonical.display()
);
return Ok(());
}
init_tracing(false, "scryer=warn");
let db_path = resolve_db_path(db_url);
println!("🔍 Indexing {} ...", canonical.display());
let start = Instant::now();
match run_index(&canonical, None, &db_path, auth_token.as_deref()).await {
Ok(report) => print_index_report(&report, start.elapsed()),
Err(err) => {
eprintln!("⚠ Could not index the project: {err:#}");
eprintln!(" The server indexes it on first use; run `scryer index` to retry.");
return Ok(());
}
}
match run_doctor(&db_path, auth_token.as_deref(), None, Some(&canonical)).await {
Ok(problems) if problems.is_empty() => println!("✓ Scryer is set up."),
Ok(problems) => {
for problem in problems {
println!("⚠ {problem}");
}
}
Err(err) => eprintln!("⚠ Could not run the health check: {err:#}"),
}
}
Commands::ListProjects {
query,
offset,
limit,
db,
} => {
let mut map = serde_json::Map::new();
if let Some(q) = query {
map.insert("query".into(), serde_json::json!(q));
}
if let Some(o) = offset {
map.insert("offset".into(), serde_json::json!(o));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
dispatch_tool_via_cli(
"list_projects",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::SwitchActiveProject { project, db } => {
let mut map = serde_json::Map::new();
map.insert("project".into(), serde_json::json!(project));
dispatch_tool_via_cli(
"switch_active_project",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::Hook {
action: HookAction::PreGrep { db },
} => run_pre_grep(db).await?,
Commands::Refs {
symbol,
file_filter,
limit,
db,
} => dispatch_refs("sites", symbol, file_filter, limit, db).await?,
Commands::Callers {
symbol,
file_filter,
limit,
db,
} => dispatch_refs("callers", symbol, file_filter, limit, db).await?,
Commands::Files {
symbol,
file_filter,
limit,
db,
} => dispatch_refs("files", symbol, file_filter, limit, db).await?,
Commands::Def {
symbol,
file_path,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("symbol".into(), serde_json::json!(symbol));
if let Some(f) = file_path {
map.insert("file_path".into(), serde_json::json!(f));
}
dispatch_tool_via_cli(
"inspect_symbol",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::At { location, db } => {
let (file, line) = parse_location(&location)?;
dispatch_tool_via_cli(
"get_enclosing_scope",
serde_json::json!({ "file_path": file, "line": line }),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::Find { query, limit, db } => {
let mut map = serde_json::Map::new();
map.insert("query".into(), serde_json::json!(query.join(" ")));
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
dispatch_tool_via_cli(
"search_symbols",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::GetFileOutline {
file_path,
query,
kinds,
start_line,
end_line,
compact,
offset,
limit,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert(
"file_path".into(),
serde_json::json!(file_path.to_string_lossy().to_string()),
);
if let Some(q) = query {
map.insert("query".into(), serde_json::json!(q));
}
if let Some(k) = kinds {
map.insert("kinds".into(), serde_json::json!(k));
}
if let Some(s) = start_line {
map.insert("start_line".into(), serde_json::json!(s));
}
if let Some(e) = end_line {
map.insert("end_line".into(), serde_json::json!(e));
}
if compact {
map.insert("compact".into(), serde_json::json!(true));
}
if let Some(o) = offset {
map.insert("offset".into(), serde_json::json!(o));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"get_file_outline",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::GetTypeContract {
type_name,
file_path,
member_query,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("type_name".into(), serde_json::json!(type_name));
if let Some(f) = file_path {
map.insert("file_path".into(), serde_json::json!(f));
}
if let Some(m) = member_query {
map.insert("member_query".into(), serde_json::json!(m));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"get_type_contract",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::GetEnclosingScope {
file_path,
line,
column,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert(
"file_path".into(),
serde_json::json!(file_path.to_string_lossy().to_string()),
);
map.insert("line".into(), serde_json::json!(line));
if let Some(c) = column {
map.insert("column".into(), serde_json::json!(c));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"get_enclosing_scope",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::ResolveDefinition {
file_path,
line,
column,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert(
"file_path".into(),
serde_json::json!(file_path.to_string_lossy().to_string()),
);
map.insert("line".into(), serde_json::json!(line));
map.insert("col".into(), serde_json::json!(column));
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"resolve_definition",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::FindReferences {
symbol,
file_filter,
role,
scope_level,
view,
offset,
limit,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("symbol".into(), serde_json::json!(symbol));
if let Some(f) = file_filter {
map.insert("file_filter".into(), serde_json::json!(f));
}
if let Some(r) = role {
map.insert("role".into(), serde_json::json!(r));
}
if let Some(s) = scope_level {
map.insert("scope_level".into(), serde_json::json!(s));
}
if let Some(v) = view {
map.insert("view".into(), serde_json::json!(v));
}
if let Some(o) = offset {
map.insert("offset".into(), serde_json::json!(o));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"find_references",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::InspectSymbol {
symbol,
file_path,
include,
snippet_lines,
limit,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("symbol".into(), serde_json::json!(symbol));
if let Some(f) = file_path {
map.insert("file_path".into(), serde_json::json!(f));
}
if let Some(inc) = include {
map.insert("include".into(), serde_json::json!(inc));
}
if let Some(n) = snippet_lines {
map.insert("snippet_lines".into(), serde_json::json!(n));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"inspect_symbol",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::GetCrateOutline {
crate_name,
module_path,
version,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("crate_name".into(), serde_json::json!(crate_name));
if let Some(m) = module_path {
map.insert("module_path".into(), serde_json::json!(m));
}
if let Some(v) = version {
map.insert("version".into(), serde_json::json!(v));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"get_crate_outline",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::SearchSymbols {
query,
like_symbol,
kinds,
file_filter,
scope_level,
crate_name,
offset,
limit,
compact,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
if let Some(q) = query {
map.insert("query".into(), serde_json::json!(q));
}
if let Some(l) = like_symbol {
map.insert("like_symbol".into(), serde_json::json!(l));
}
if let Some(k) = kinds {
map.insert("kinds".into(), serde_json::json!(k));
}
if let Some(f) = file_filter {
map.insert("file_filter".into(), serde_json::json!(f));
}
if let Some(s) = scope_level {
map.insert("scope_level".into(), serde_json::json!(s));
}
if let Some(c) = crate_name {
map.insert("crate_name".into(), serde_json::json!(c));
}
if let Some(o) = offset {
map.insert("offset".into(), serde_json::json!(o));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
if compact {
map.insert("compact".into(), serde_json::json!(true));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"search_symbols",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::TraceCallHierarchy {
symbol,
direction,
depth,
file_path,
file_filter,
limit,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("symbol".into(), serde_json::json!(symbol));
map.insert("direction".into(), serde_json::json!(direction));
map.insert("max_depth".into(), serde_json::json!(depth));
if let Some(f) = file_path {
map.insert("file_path".into(), serde_json::json!(f));
}
if let Some(f) = file_filter {
map.insert("file_filter".into(), serde_json::json!(f));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"trace_call_hierarchy",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::CalculateBlastRadius {
symbol,
depth,
files_only,
limit,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("symbol".into(), serde_json::json!(symbol));
map.insert("depth".into(), serde_json::json!(depth));
if files_only {
map.insert("include_symbols".into(), serde_json::json!(false));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"calculate_blast_radius",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::QueryAdrs {
query,
status,
compact,
offset,
limit,
max_tokens,
no_truncate,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("query".into(), serde_json::json!(query));
if let Some(s) = status {
map.insert("status".into(), serde_json::json!(s));
}
if compact {
map.insert("compact".into(), serde_json::json!(true));
}
if let Some(o) = offset {
map.insert("offset".into(), serde_json::json!(o));
}
if let Some(l) = limit {
map.insert("limit".into(), serde_json::json!(l));
}
if let Some(m) = max_tokens {
map.insert("max_tokens".into(), serde_json::json!(m));
}
if no_truncate {
map.insert("no_truncate".into(), serde_json::json!(true));
}
dispatch_tool_via_cli(
"query_adrs",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::RecordAdr {
title,
context,
decision,
consequences,
status,
affected_paths,
db,
} => {
let mut map = serde_json::Map::new();
map.insert("title".into(), serde_json::json!(title));
map.insert("context".into(), serde_json::json!(context));
map.insert("decision".into(), serde_json::json!(decision));
map.insert("consequences".into(), serde_json::json!(consequences));
map.insert("status".into(), serde_json::json!(status));
if let Some(p) = affected_paths {
map.insert("affected_paths".into(), serde_json::json!(p));
}
dispatch_tool_via_cli(
"record_adr",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::DeleteAdr { number, db } => {
let mut map = serde_json::Map::new();
map.insert("number".into(), serde_json::json!(number));
dispatch_tool_via_cli(
"delete_adr",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::Report { session, db } => {
let mut map = serde_json::Map::new();
if let Some(s) = session {
map.insert("session_id".into(), serde_json::json!(s));
}
let (text, is_err) = call_tool_text(
"get_token_savings_metrics",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
if is_err {
eprintln!("{text}");
std::process::exit(1);
}
if json_output() {
print_tool_output(&text);
} else {
let summary: scryer_mcp::UsageSummary = serde_json::from_str(&text)?;
print!("{}", render_usage_report(&summary));
}
}
Commands::GetTokenSavingsMetrics { session, db } => {
let mut map = serde_json::Map::new();
if let Some(s) = session {
map.insert("session_id".into(), serde_json::json!(s));
}
dispatch_tool_via_cli(
"get_token_savings_metrics",
serde_json::Value::Object(map),
db.db_url,
db.auth_token,
)
.await?;
}
Commands::Call { tool, args, db } => {
let raw_val = parse_cli_flags_to_json(&args)?;
let normalized_tool = tool.replace('-', "_");
let val = normalize_tool_call_value(&normalized_tool, raw_val);
dispatch_tool_via_cli(&normalized_tool, val, db.db_url, db.auth_token).await?;
}
}
Ok(())
}
fn render_usage_report(summary: &scryer_mcp::UsageSummary) -> String {
use std::fmt::Write as _;
if summary.total_calls == 0 {
return "No Scryer calls recorded yet in this database.\n\nUsage is recorded locally (tool name, \
response size, truncation, latency; never arguments or answers) unless \
SCRYER_TELEMETRY=off was set. Use Scryer from an agent, then run `scryer report` again.\n"
.to_string();
}
let pct = |rate: f64| (rate * 100.0).round() as u64;
let mut out = format!(
"{} calls, {} response tokens ({:.0} per call), {}% followed by another Scryer call, {} truncated, {:.0} ms mean\n\n",
summary.total_calls,
summary.total_response_tokens,
summary.total_response_tokens as f64 / summary.total_calls as f64,
pct(summary.follow_up_rate),
summary.total_truncated,
summary.mean_duration_ms,
);
let mut tools: Vec<_> = summary.by_tool.iter().collect();
tools.sort_by(|a, b| b.1.calls.cmp(&a.1.calls).then(a.0.cmp(b.0)));
for (name, tool) in tools {
let _ = writeln!(
out,
"{name}: {} calls, followed by another Scryer call {}% of the time, {:.0} response tokens per call, truncated {} of {}",
tool.calls,
pct(tool.follow_up_rate),
tool.mean_response_tokens,
tool.truncated,
tool.calls,
);
}
out.push_str(
"\nA follow-up is any Scryer call right after in the same session. Scryer cannot see a grep or file read after an answer, so the true rate is higher.\n",
);
out
}
fn parse_cli_flags_to_json(args: &[String]) -> anyhow::Result<serde_json::Value> {
if args.is_empty() {
return Ok(serde_json::json!({}));
}
if args.len() == 1 && args[0].trim().starts_with('{') {
return Ok(serde_json::from_str(&args[0])?);
}
let mut map = serde_json::Map::new();
let mut i = 0;
let mut pos_idx = 0;
while i < args.len() {
let arg = &args[i];
if let Some(flag) = arg.strip_prefix("--") {
let (key, value) = if let Some((k, v)) = flag.split_once('=') {
(k.replace('-', "_"), Some(v.to_string()))
} else if i + 1 < args.len() && !args[i + 1].starts_with("--") {
i += 1;
(flag.replace('-', "_"), Some(args[i].clone()))
} else {
(flag.replace('-', "_"), None)
};
let json_val = match value {
Some(v) => {
if v.eq_ignore_ascii_case("true") {
serde_json::Value::Bool(true)
} else if v.eq_ignore_ascii_case("false") {
serde_json::Value::Bool(false)
} else if let Ok(n) = v.parse::<i64>() {
serde_json::json!(n)
} else if let Ok(f) = v.parse::<f64>() {
serde_json::json!(f)
} else {
serde_json::Value::String(v)
}
}
None => serde_json::Value::Bool(true),
};
map.insert(key, json_val);
} else {
map.insert(
format!("_pos_{pos_idx}"),
serde_json::Value::String(arg.clone()),
);
pos_idx += 1;
}
i += 1;
}
Ok(serde_json::Value::Object(map))
}
fn normalize_tool_call_value(tool_name: &str, mut value: serde_json::Value) -> serde_json::Value {
if let serde_json::Value::Object(ref mut map) = value {
let pos0 = map
.remove("_pos_0")
.and_then(|v| v.as_str().map(|s| s.to_string()));
let pos1 = map
.remove("_pos_1")
.and_then(|v| v.as_str().map(|s| s.to_string()));
let pos2 = map
.remove("_pos_2")
.and_then(|v| v.as_str().map(|s| s.to_string()));
match tool_name {
"get_file_outline" | "get-file-outline" => {
if let Some(p) = pos0 {
map.entry("file_path").or_insert(serde_json::json!(p));
}
}
"get_enclosing_scope" | "get-enclosing-scope" => {
if let Some(p) = pos0 {
map.entry("file_path").or_insert(serde_json::json!(p));
}
if let Some(p) = pos1
&& let Ok(l) = p.parse::<u32>()
{
map.entry("line").or_insert(serde_json::json!(l));
}
}
"resolve_definition" | "resolve-definition" => {
if let Some(p) = pos0 {
map.entry("file_path").or_insert(serde_json::json!(p));
}
if let Some(p) = pos1
&& let Ok(l) = p.parse::<u32>()
{
map.entry("line").or_insert(serde_json::json!(l));
}
if let Some(p) = pos2
&& let Ok(c) = p.parse::<u32>()
{
map.entry("col").or_insert(serde_json::json!(c));
}
}
"get_type_contract" | "get-type-contract" => {
if let Some(p) = pos0 {
map.entry("type_name").or_insert(serde_json::json!(p));
}
}
"find_references"
| "find-references"
| "trace_call_hierarchy"
| "trace-call-hierarchy"
| "calculate_blast_radius"
| "calculate-blast-radius"
| "inspect_symbol"
| "inspect-symbol" => {
if let Some(p) = pos0 {
map.entry("symbol").or_insert(serde_json::json!(p));
}
}
"get_crate_outline" | "get-crate-outline" => {
if let Some(p) = pos0 {
map.entry("crate_name").or_insert(serde_json::json!(p));
}
}
"search_symbols" | "search-symbols" | "query_adrs" | "query-adrs" => {
if let Some(p) = pos0 {
map.entry("query").or_insert(serde_json::json!(p));
}
}
"delete_adr" | "delete-adr" => {
if let Some(p) = pos0
&& let Ok(n) = p.parse::<u32>()
{
map.entry("number").or_insert(serde_json::json!(n));
}
}
"switch_active_project"
| "switch-active-project"
| "delete_project"
| "delete-project" => {
if let Some(p) = pos0 {
map.entry("project").or_insert(serde_json::json!(p));
}
}
"index_workspace" | "index-workspace" => {
if let Some(p) = pos0 {
map.entry("path").or_insert(serde_json::json!(p));
}
}
_ => {}
}
}
value
}
async fn dispatch_tool_via_cli(
tool_name: &str,
args: serde_json::Value,
db_url: Option<String>,
auth_token: Option<String>,
) -> anyhow::Result<()> {
let args = with_cli_format(tool_name, args);
let (text, is_err) = call_tool_text(tool_name, args, db_url, auth_token).await?;
if is_err {
eprintln!("{text}");
std::process::exit(1);
}
print_tool_output(&text);
Ok(())
}
async fn call_tool_text(
tool_name: &str,
args: serde_json::Value,
db_url: Option<String>,
auth_token: Option<String>,
) -> anyhow::Result<(String, bool)> {
let db_path = resolve_db_path(db_url);
#[cfg(unix)]
if scryer_mcp::daemon::is_local_db(&db_path) && std::env::var("SCRYER_NO_DAEMON").is_err() {
match daemon_cli::connect_or_spawn_client(&db_path, DEFAULT_IDLE_TIMEOUT_SECS).await {
Ok(client) => return daemon_cli::call_tool_raw(&client, tool_name, args).await,
Err(e) => {
tracing::warn!(
"Failed to dispatch via daemon ({e}); falling back to in-process execution"
);
}
}
}
let (db, registry, engine) = open_services(&db_path, auth_token.as_deref()).await?;
let server = ScryerMcpServer::new(db, engine, registry, None)?;
match server.dispatch_tool(tool_name, args).await {
Ok(rmcp::model::CallToolResponse::Complete(call_result)) => {
let text = call_result
.content
.iter()
.find_map(|b| b.as_text())
.map(|t| t.text.clone())
.unwrap_or_default();
Ok((text, call_result.is_error == Some(true)))
}
Ok(_) => anyhow::bail!("Unexpected task response from tool '{tool_name}'"),
Err(e) => anyhow::bail!("Error executing tool '{tool_name}': {e}"),
}
}
#[cfg(unix)]
async fn print_status_via_daemon(
client: &daemon_cli::DaemonClient,
db_path: &str,
filter_slug: Option<&str>,
) -> anyhow::Result<()> {
let projects = daemon_cli::call_tool(client, "list_projects", serde_json::json!({})).await?;
let projects = projects.as_array().cloned().unwrap_or_default();
let is_deps_filter = filter_slug.is_some_and(|s| {
let t = s.trim();
(t == "0"
|| t.eq_ignore_ascii_case("dependencies")
|| t.eq_ignore_ascii_case("cargo")
|| t.eq_ignore_ascii_case("cargo-dependencies"))
&& !projects.iter().any(|p| p["slug"].as_str() == Some(s))
});
println!("📊 Scryer Workspace Status (DB: {db_path}, via the running Scryer daemon)");
println!("{:=<75}", "");
if is_deps_filter {
let stats = daemon_cli::call_tool(
client,
"get_indexing_status",
serde_json::json!({ "project": "0" }),
)
.await?;
let crate_count = stats["crate_count"].as_u64().unwrap_or(0);
let file_count = stats["file_count"].as_u64().unwrap_or(0);
let sym_count = stats["symbol_count"].as_u64().unwrap_or(0);
println!("Project: cargo-dependencies (ID: 0) [Global Cache]");
println!(
"Path: {}",
stats["root_path"].as_str().unwrap_or_default()
);
println!(
"Entities: {} packages | {} files | {} public symbols",
crate_count, file_count, sym_count
);
println!("{:-<75}", "");
return Ok(());
}
if projects.is_empty() {
println!("No registered projects found. Run `scryer index` to register a workspace.");
return Ok(());
}
for proj in &projects {
let slug = proj["slug"].as_str().unwrap_or_default();
let id = proj["id"].as_u64().unwrap_or_default();
if filter_slug.is_some_and(|s| slug != s && id.to_string() != s) {
continue;
}
let stats = daemon_cli::call_tool(
client,
"get_indexing_status",
serde_json::json!({ "project": id.to_string() }),
)
.await?;
println!("Project: {slug} (ID: {id})");
println!(
"Path: {}",
proj["root_path"].as_str().unwrap_or_default()
);
println!(
"Updated: {}",
proj["updated_at"].as_str().unwrap_or_default()
);
println!(
"Entities: {} files | {} symbols",
stats["file_count"].as_u64().unwrap_or_default(),
stats["symbol_count"].as_u64().unwrap_or_default(),
);
println!("{:-<75}", "");
}
if filter_slug.is_none()
&& let Ok(stats) = daemon_cli::call_tool(
client,
"get_indexing_status",
serde_json::json!({ "project": "0" }),
)
.await
{
let crate_count = stats["crate_count"].as_u64().unwrap_or(0);
let file_count = stats["file_count"].as_u64().unwrap_or(0);
let sym_count = stats["symbol_count"].as_u64().unwrap_or(0);
println!("Project: cargo-dependencies (ID: 0) [Global Cache]");
println!(
"Path: {}",
stats["root_path"].as_str().unwrap_or_default()
);
println!(
"Entities: {} packages | {} files | {} public symbols",
crate_count, file_count, sym_count
);
println!("{:-<75}", "");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn call_args(tool: &str, items: &[&str]) -> serde_json::Value {
let raw = parse_cli_flags_to_json(&args(items)).unwrap();
normalize_tool_call_value(&tool.replace('-', "_"), raw)
}
#[test]
fn json_argument_passes_through() {
let v = call_args("inspect-symbol", &[r#"{"symbol":"Foo","limit":3}"#]);
assert_eq!(v, json!({ "symbol": "Foo", "limit": 3 }));
}
#[test]
fn no_arguments_is_an_empty_object() {
assert_eq!(call_args("list-projects", &[]), json!({}));
}
#[test]
fn resolve_definition_positionals_are_file_line_col() {
let v = call_args("resolve-definition", &["src/main.rs", "42", "10"]);
assert_eq!(
v,
json!({ "file_path": "src/main.rs", "line": 42, "col": 10 })
);
}
#[test]
fn symbol_tools_take_the_symbol_positionally() {
for tool in [
"inspect-symbol",
"find-references",
"trace-call-hierarchy",
"calculate-blast-radius",
] {
assert_eq!(
call_args(tool, &["MySymbol"]),
json!({ "symbol": "MySymbol" })
);
}
assert_eq!(
call_args("get-type-contract", &["MyType"]),
json!({ "type_name": "MyType" })
);
}
#[test]
fn file_outline_takes_only_a_file_and_enclosing_scope_a_line_too() {
assert_eq!(
call_args("get-file-outline", &["src/main.rs"]),
json!({ "file_path": "src/main.rs" })
);
assert_eq!(
call_args("get-file-outline", &["src/main.rs", "7"]),
json!({ "file_path": "src/main.rs" })
);
assert_eq!(
call_args("get-enclosing-scope", &["src/main.rs", "7"]),
json!({ "file_path": "src/main.rs", "line": 7 })
);
}
#[test]
fn flags_are_typed_and_snake_cased() {
let v = call_args(
"get-file-outline",
&[
"--file-path",
"src/main.rs",
"--compact",
"--limit=5",
"--query",
"new",
],
);
assert_eq!(
v,
json!({ "file_path": "src/main.rs", "compact": true, "limit": 5, "query": "new" })
);
}
#[test]
fn explicit_flags_win_over_positionals() {
let v = call_args("inspect-symbol", &["Positional", "--symbol", "Flag"]);
assert_eq!(v["symbol"], "Flag");
}
#[test]
fn invalid_json_is_an_error() {
assert!(parse_cli_flags_to_json(&args(&["{not json"])).is_err());
}
#[test]
fn locations_parse_as_grep_and_compilers_print_them() {
assert_eq!(
parse_location("src/a.rs:12").unwrap(),
("src/a.rs".to_string(), 12)
);
assert_eq!(
parse_location("src/a.rs:12:5").unwrap(),
("src/a.rs".to_string(), 12)
);
assert_eq!(
parse_location("C:/repo/a.rs:7").unwrap(),
("C:/repo/a.rs".to_string(), 7)
);
assert!(parse_location("src/a.rs").is_err());
assert!(parse_location(":12").is_err());
assert!(parse_location("src/a.rs:x").is_err());
}
#[test]
fn tools_with_a_text_form_get_it_unless_the_call_chose_a_format() {
let args = with_cli_format("find_references", serde_json::json!({ "symbol": "x" }));
assert_eq!(args["format"], "text");
let args = with_cli_format(
"find_references",
serde_json::json!({ "symbol": "x", "format": "json" }),
);
assert_eq!(args["format"], "json");
let args = with_cli_format("get_type_contract", serde_json::json!({ "type_name": "T" }));
assert!(args.get("format").is_none());
}
}