rust-analyzer-cli 0.3.1

A library and CLI tool built on top of rust-analyzer for codebase navigation
Documentation
use anyhow::{anyhow, Result};
use clap::Parser;
use reqwest::Client;
use rust_analyzer_cli::{
    cli::{Cli, Commands},
    daemon,
    lsp::types::*,
};
use std::path::PathBuf;


#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();

    let cli = Cli::parse();
    let port = cli.port;
    let json_output = cli.json;
    let daemon_url = format!("http://127.0.0.1:{}", port);

    match cli.command {
        Commands::Daemon { workspace } => {
            daemon::server::start_daemon_server(workspace, port).await?;
        }
        Commands::Status => {
            let client = Client::new();
            let res = client
                .get(format!("{}/status", daemon_url))
                .send()
                .await
                .map_err(|_| daemon_not_running_error(port))?;
            let res = check_response(res).await?;

            let status: DaemonStatusResponse = res.json().await?;
            if json_output {
                println!("{}", serde_json::to_string_pretty(&status)?);
            } else {
                println!("rust-analyzer-cli Daemon Status:");
                println!("  Status: {}", if status.ready { "READY" } else { "INITIALIZING" });
                println!("  Workspace: {}", status.workspace_root);
                println!("  rust-analyzer PID: {}", status.process_id);
            }
        }
        Commands::Refresh => {
            let client = Client::new();
            let res = client
                .post(format!("{}/refresh", daemon_url))
                .send()
                .await
                .map_err(|_| daemon_not_running_error(port))?;
            let res = check_response(res).await?;

            let body: serde_json::Value = res.json().await?;
            if json_output {
                println!("{}", serde_json::to_string_pretty(&body)?);
            } else {
                println!("rust-analyzer session refreshed successfully.");
            }
        }
        Commands::Symbol { name, kind, exact } => {
            let client = Client::new();
            let req = SymbolQueryRequest { name, kind, exact };
            let res = client
                .post(format!("{}/api/symbol", daemon_url))
                .json(&req)
                .send()
                .await
                .map_err(|_| daemon_not_running_error(port))?;
            let res = check_response(res).await?;

            let symbols: Vec<SymbolItem> = res.json().await?;

            if json_output {
                println!("{}", serde_json::to_string_pretty(&symbols)?);
            } else if symbols.is_empty() {
                println!("No matching symbols found.");
            } else {
                println!("Found {} symbol(s):", symbols.len());
                for s in symbols {
                    let container = s.container_name.map(|c| format!(" ({})", c)).unwrap_or_default();
                    println!("  [{}] {}{} -> {}:{}:{}", s.kind, s.name, container, s.file, s.line, s.col);
                }
            }
        }
        Commands::Outline { file, file_list, output } => {
            let client = Client::new();
            let mut target_files = Vec::new();

            if let Some(f) = file {
                target_files.push(f);
            } else if let Some(fl) = file_list {
                for item in fl.split(',') {
                    let trimmed = item.trim();
                    if !trimmed.is_empty() {
                        target_files.push(PathBuf::from(trimmed));
                    }
                }
            } else {
                return Err(anyhow!("Please specify --file <path> or --file-list <paths>"));
            }

            let mut all_outlines = Vec::new();
            for f in &target_files {
                let req = OutlineQueryRequest { file: f.clone() };
                let res = client
                    .post(format!("{}/api/outline", daemon_url))
                    .json(&req)
                    .send()
                    .await
                    .map_err(|_| daemon_not_running_error(port))?;
                let res = check_response(res).await?;

                let items: Vec<OutlineItem> = res.json().await?;
                all_outlines.push((f.to_string_lossy().to_string(), items));
            }

            let output_str = if json_output {
                serde_json::to_string_pretty(&all_outlines)?
            } else {
                let mut buf = String::new();
                for (path_str, items) in all_outlines {
                    buf.push_str(&format!("File: {}\n", path_str));
                    format_outline_items(&items, 1, &mut buf);
                    buf.push('\n');
                }
                buf
            };

            if let Some(out_path) = output {
                std::fs::write(&out_path, &output_str)?;
                println!("Outline successfully written to {}", out_path.display());
            } else {
                print!("{}", output_str);
            }
        }
        Commands::Definition { file, line, col } => {
            let client = Client::new();
            let req = DefinitionQueryRequest { file, line, col };
            let res = client
                .post(format!("{}/api/definition", daemon_url))
                .json(&req)
                .send()
                .await
                .map_err(|_| daemon_not_running_error(port))?;
            let res = check_response(res).await?;

            let items: Vec<DefinitionItem> = res.json().await?;
            if json_output {
                println!("{}", serde_json::to_string_pretty(&items)?);
            } else if items.is_empty() {
                println!("No definition found at location.");
            } else {
                println!("Definition location(s):");
                for d in items {
                    println!("  {}:{}:{} (end {}:{})", d.file, d.line, d.col, d.end_line, d.end_col);
                }
            }
        }
        Commands::Cursor { file, line, col, mode, depth } => {
            let client = Client::new();
            let req = CursorQueryRequest { file, line, col, mode, depth };
            let res = client
                .post(format!("{}/api/cursor", daemon_url))
                .json(&req)
                .send()
                .await
                .map_err(|_| daemon_not_running_error(port))?;
            let res = check_response(res).await?;

            let items: Vec<CursorItem> = res.json().await?;
            if json_output {
                println!("{}", serde_json::to_string_pretty(&items)?);
            } else if items.is_empty() {
                println!("No cursor references or calls found.");
            } else {
                println!("Cursor query results ({} items):", items.len());
                for c in items {
                    let extra = c.caller_or_callee.map(|x| format!(" [{}]", x)).unwrap_or_default();
                    println!("  [{}] {}{} -> {}:{}:{}", c.kind, c.name, extra, c.file, c.line, c.col);
                }
            }
        }
        Commands::TypeHierarchy { file, line, col, mode, depth } => {
            let client = Client::new();
            let req = TypeHierarchyQueryRequest { file, line, col, mode, depth };
            let res = client
                .post(format!("{}/api/type-hierarchy", daemon_url))
                .json(&req)
                .send()
                .await
                .map_err(|_| daemon_not_running_error(port))?;
            let res = check_response(res).await?;

            let items: Vec<TypeHierarchyItemResult> = res.json().await?;
            if json_output {
                println!("{}", serde_json::to_string_pretty(&items)?);
            } else if items.is_empty() {
                println!("No type hierarchy items found.");
            } else {
                println!("Type hierarchy results:");
                for t in items {
                    println!("  [{}] {} -> {}:{}:{}", t.kind, t.name, t.file, t.line, t.col);
                }
            }
        }

        Commands::Check { target } => {
            let client = Client::new();
            let req = CheckQueryRequest { target };
            let res = client
                .post(format!("{}/api/check", daemon_url))
                .json(&req)
                .send()
                .await
                .map_err(|_| daemon_not_running_error(port))?;
            let res = check_response(res).await?;

            let check_res: CheckResponse = res.json().await?;

            if json_output {
                println!("{}", serde_json::to_string_pretty(&check_res)?);
            } else {
                println!("Cargo Check Result: {}", if check_res.success { "PASSED" } else { "FAILED" });
                for diag in check_res.diagnostics {
                    let loc = match (diag.file, diag.line, diag.col) {
                        (Some(f), Some(l), Some(c)) => format!(" at {}:{}:{}", f, l, c),
                        (Some(f), _, _) => format!(" at {}", f),
                        _ => String::new(),
                    };
                    println!("  [{}] {}{}", diag.level.to_uppercase(), diag.message, loc);
                }
            }
        }
        Commands::InitSkill { workspace, dir } => {

            let skill_rel = dir.unwrap_or_else(|| PathBuf::from(".agents/skills/rust-codebase-navigation/SKILL.md"));
            let target_path = workspace.join(skill_rel);
            if let Some(parent) = target_path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            let skill_content = include_str!("../.agents/skills/rust-codebase-navigation/SKILL.md");
            std::fs::write(&target_path, skill_content)?;
            if json_output {
                println!("{}", serde_json::json!({
                    "success": true,
                    "path": target_path.to_string_lossy()
                }));
            } else {
                println!("Successfully installed skill to: {}", target_path.display());
            }
        }
    }


    Ok(())
}

async fn check_response(res: reqwest::Response) -> Result<reqwest::Response> {
    if !res.status().is_success() {
        let status = res.status();
        let err_text = res.text().await.unwrap_or_else(|_| "Unknown error".to_string());
        return Err(anyhow!("Daemon error ({}): {}", status, err_text));
    }
    Ok(res)
}

fn daemon_not_running_error(port: u16) -> anyhow::Error {
    anyhow!(
        "rust-analyzer-cli daemon is not running on http://127.0.0.1:{}.\nStart the daemon first with:\n  rust-analyzer-cli daemon --workspace .",
        port
    )
}

fn format_outline_items(items: &[OutlineItem], indent_level: usize, buf: &mut String) {
    let indent = "  ".repeat(indent_level);
    for item in items {
        let detail = item.detail.as_ref().map(|d| format!(" ({})", d)).unwrap_or_default();
        buf.push_str(&format!("{}[{}] {}{} at line {}:{}\n", indent, item.kind, item.name, detail, item.line, item.col));
        format_outline_items(&item.children, indent_level + 1, buf);
    }
}