rust-analyzer-cli 0.4.1

A library and CLI tool built on top of rust-analyzer for codebase navigation
Documentation
use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(
    name = "rust-analyzer-cli",
    author,
    version,
    about = "Compiler-accurate Rust codebase navigation powered by rust-analyzer LSP"
)]
pub struct Cli {
    /// Port for the background daemon HTTP server
    #[arg(short, long, env = "RUST_ANALYZER_CLI_PORT", default_value_t = 60094)]
    pub port: u16,

    /// Output results in JSON format
    #[arg(short, long, global = true)]
    pub json: bool,

    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Start the background LSP daemon and HTTP server
    Daemon {
        /// Workspace root directory
        #[arg(short, long, default_value = ".")]
        workspace: PathBuf,
    },
    /// Query daemon and rust-analyzer status
    Status,
    /// Refresh / restart the rust-analyzer session
    Refresh,
    /// Search workspace symbols
    Symbol {
        /// Symbol name to search for
        name: String,
        /// Optional symbol filter: any, struct, enum, fn, trait, type, const, var
        #[arg(default_value = "any")]
        kind: String,
        /// Match exact symbol name
        #[arg(long)]
        exact: bool,
        /// Include source code body/snippet
        #[arg(short, long)]
        body: bool,
        /// Max lines for source code body (0 for unlimited)
        #[arg(long, default_value_t = 100)]
        max_lines: usize,
    },
    /// Extract file document symbols outline
    Outline {
        /// Target file path
        #[arg(short, long)]
        file: Option<PathBuf>,
        /// Comma-separated list of target file paths
        #[arg(long)]
        file_list: Option<String>,
        /// Write outline output to a file
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Include source code body/snippet for symbols
        #[arg(short, long)]
        body: bool,
        /// Max lines for source code body (0 for unlimited)
        #[arg(long, default_value_t = 100)]
        max_lines: usize,
    },
    /// Go to definition at file line and column
    Definition {
        /// File path
        #[arg(short, long)]
        file: PathBuf,
        /// Line number (1-based)
        #[arg(short, long)]
        line: u32,
        /// Column number (1-based)
        #[arg(short, long)]
        col: u32,
        /// Include source code body/snippet of the definition target
        #[arg(short, long)]
        body: bool,
        /// Max lines for source code body (0 for unlimited)
        #[arg(long, default_value_t = 100)]
        max_lines: usize,
        /// Omit line numbers in terminal output
        #[arg(long)]
        no_line_numbers: bool,
    },
    /// Inspect source code body of struct, function, enum, impl, trait, or module
    Body {
        /// File path
        #[arg(short, long)]
        file: PathBuf,
        /// Line number (1-based)
        #[arg(short, long)]
        line: u32,
        /// Column number (1-based)
        #[arg(short, long)]
        col: u32,
        /// Max lines for source code body (0 for unlimited)
        #[arg(long, default_value_t = 100)]
        max_lines: usize,
        /// Omit line numbers in terminal output
        #[arg(long)]
        no_line_numbers: bool,
    },
    /// Find references or call hierarchy at file line and column
    Cursor {
        /// File path
        #[arg(short, long)]
        file: PathBuf,
        /// Line number (1-based)
        #[arg(short, long)]
        line: u32,
        /// Column number (1-based)
        #[arg(short, long)]
        col: u32,
        /// Mode: incoming, outgoing, or references
        #[arg(short, long, default_value = "incoming")]
        mode: String,
        /// Traversal depth (default: 1)
        #[arg(short, long, default_value_t = 1)]
        depth: u32,
    },
    /// Query type hierarchy (supertypes / subtypes)
    TypeHierarchy {
        /// File path
        #[arg(short, long)]
        file: PathBuf,
        /// Line number (1-based)
        #[arg(short, long)]
        line: u32,
        /// Column number (1-based)
        #[arg(short, long)]
        col: u32,
        /// Mode: supertypes or subtypes
        #[arg(short, long, default_value = "supertypes")]
        mode: String,
        /// Traversal depth (default: 1)
        #[arg(short, long, default_value_t = 1)]
        depth: u32,
    },
    /// Run cargo check compilation verification
    Check {
        /// Optional cargo build target
        #[arg(short, long)]
        target: Option<String>,
    },
    /// Initialize/install the rust-codebase-navigation SKILL.md in a workspace
    InitSkill {
        /// Target workspace directory (default: .)
        #[arg(short, long, default_value = ".")]
        workspace: PathBuf,
        /// Custom relative skill path (default: .agents/skills/rust-codebase-navigation/SKILL.md)
        #[arg(short, long)]
        dir: Option<PathBuf>,
    },
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cli_symbol_parsing() {
        let args = vec![
            "rust-analyzer-cli",
            "symbol",
            "ExampleStruct",
            "struct",
            "--exact",
        ];
        let cli = Cli::try_parse_from(args).unwrap();
        assert_eq!(cli.port, 60094);
        assert!(!cli.json);
        match cli.command {
            Commands::Symbol {
                name,
                kind,
                exact,
                body,
                max_lines,
            } => {
                assert_eq!(name, "ExampleStruct");
                assert_eq!(kind, "struct");
                assert!(exact);
                assert!(!body);
                assert_eq!(max_lines, 100);
            }
            _ => panic!("Expected Symbol command"),
        }
    }

    #[test]
    fn test_cli_outline_parsing() {
        let args = vec![
            "rust-analyzer-cli",
            "--json",
            "outline",
            "-f",
            "tests/code_example.rs",
        ];
        let cli = Cli::try_parse_from(args).unwrap();
        assert!(cli.json);
        match cli.command {
            Commands::Outline {
                file,
                file_list,
                output,
                body,
                max_lines,
            } => {
                assert_eq!(file, Some(PathBuf::from("tests/code_example.rs")));
                assert!(file_list.is_none());
                assert!(output.is_none());
                assert!(!body);
                assert_eq!(max_lines, 100);
            }
            _ => panic!("Expected Outline command"),
        }
    }

    #[test]
    fn test_cli_definition_parsing() {
        let args = vec![
            "rust-analyzer-cli",
            "definition",
            "-f",
            "tests/code_example.rs",
            "-l",
            "10",
            "-c",
            "5",
            "--body",
        ];
        let cli = Cli::try_parse_from(args).unwrap();
        match cli.command {
            Commands::Definition {
                file,
                line,
                col,
                body,
                max_lines,
                no_line_numbers,
            } => {
                assert_eq!(file, PathBuf::from("tests/code_example.rs"));
                assert_eq!(line, 10);
                assert_eq!(col, 5);
                assert!(body);
                assert_eq!(max_lines, 100);
                assert!(!no_line_numbers);
            }
            _ => panic!("Expected Definition command"),
        }
    }

    #[test]
    fn test_cli_body_parsing() {
        let args = vec![
            "rust-analyzer-cli",
            "body",
            "-f",
            "tests/code_example.rs",
            "-l",
            "10",
            "-c",
            "5",
            "--max-lines",
            "50",
            "--no-line-numbers",
        ];
        let cli = Cli::try_parse_from(args).unwrap();
        match cli.command {
            Commands::Body {
                file,
                line,
                col,
                max_lines,
                no_line_numbers,
            } => {
                assert_eq!(file, PathBuf::from("tests/code_example.rs"));
                assert_eq!(line, 10);
                assert_eq!(col, 5);
                assert_eq!(max_lines, 50);
                assert!(no_line_numbers);
            }
            _ => panic!("Expected Body command"),
        }
    }

    #[test]
    fn test_cli_init_skill_parsing() {
        let args = vec![
            "rust-analyzer-cli",
            "init-skill",
            "-w",
            "/my/proj",
            "-d",
            "custom/SKILL.md",
        ];
        let cli = Cli::try_parse_from(args).unwrap();
        match cli.command {
            Commands::InitSkill { workspace, dir } => {
                assert_eq!(workspace, PathBuf::from("/my/proj"));
                assert_eq!(dir, Some(PathBuf::from("custom/SKILL.md")));
            }
            _ => panic!("Expected InitSkill command"),
        }
    }
}