rich-prompt 0.2.0

A Rust CLI tool for rich prompts with file and directory selection.
use crate::core::context_generator::{build_context_output, format_output};
use crate::core::file_selector::select_files;
use crate::domain::models::ContextConfig;
use crate::infra::file_system::{
    generate_file_map, list_code_files, list_code_files_with_gitignore, read_file_contents,
};
use crate::infra::logger::setup_logger;
use crate::infra::output::write_output;
use clap::{Parser, Subcommand};
use log::{debug, info};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "rich-prompt")]
#[command(about = "Flatten files into LLM context block", long_about = None)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,

    #[arg(short, long, action = clap::ArgAction::Count)]
    pub verbose: u8,
}

#[derive(Subcommand)]
pub enum Commands {
    Generate {
        #[arg(long, default_value = ".")]
        path: String,

        #[arg(long)]
        ext: Option<String>,

        #[arg(long)]
        exclude: Option<String>,

        #[arg(long)]
        output: Option<String>,

        #[arg(long)]
        auto: bool,

        #[arg(long)]
        prompt: Option<String>,

        #[arg(long, default_value = ".git")]
        exclude_version_control_dir: String,

        #[arg(long, default_value = "true")]
        apply_dot_git_ignore: bool,

        #[arg(long, help = "Copy the output to the clipboard")]
        clipboard_output: bool,
    },
}

pub fn run() -> anyhow::Result<()> {
    let cli = Cli::parse();

    setup_logger(cli.verbose)?;

    match cli.command {
        Commands::Generate {
            path,
            ext,
            exclude,
            output,
            auto,
            prompt,
            exclude_version_control_dir,
            apply_dot_git_ignore,
            clipboard_output,
        } => {
            info!("Starting generate command");
            debug!(
                "Command parameters: path={}, ext={:?}, exclude={:?}, output={:?}, auto={}, prompt={:?}, exclude_version_control_dir={}, apply_dot_git_ignore={}, clipboard_output={}",
                path, ext, exclude, output, auto, prompt, exclude_version_control_dir, apply_dot_git_ignore, clipboard_output
            );

            let extensions: Vec<&str> = match &ext {
                Some(ext_value) => ext_value.split(',').map(str::trim).collect(),
                None => Vec::new(),
            };

            let excludes: Vec<&str> = match &exclude {
                Some(exclude_value) => exclude_value.split(',').map(str::trim).collect(),
                None => Vec::new(),
            };

            let config = ContextConfig {
                root_path: path.clone(),
                extensions: extensions.iter().map(|&s| s.to_string()).collect(),
                exclude_patterns: excludes.iter().map(|&s| s.to_string()).collect(),
                output_path: output.clone(),
                auto_select: auto,
                user_prompt: prompt,
                exclude_version_control_dir: exclude_version_control_dir,
                apply_dot_git_ignore: apply_dot_git_ignore,
                clipboard_output: clipboard_output,
            };

            generate_context(&config)?;
        }
    }
    Ok(())
}

fn generate_context(config: &ContextConfig) -> anyhow::Result<()> {
    let extensions: Vec<&str> = config.extensions.iter().map(|s| s.as_str()).collect();
    let excludes: Vec<&str> = config.exclude_patterns.iter().map(|s| s.as_str()).collect();

    info!("Scanning for files in {}", config.root_path);
    let available_files = if config.apply_dot_git_ignore {
        list_code_files_with_gitignore(
            &config.root_path,
            &extensions,
            &excludes,
            &config.exclude_version_control_dir,
            config.apply_dot_git_ignore,
        )?
    } else {
        list_code_files(&config.root_path, &extensions, &excludes)?
    };

    if available_files.is_empty() {
        info!("No files found with the specified extensions");
        return Ok(());
    }

    info!("Generating file map");
    let file_map = generate_file_map(
        &config.root_path,
        &excludes,
        &config.exclude_version_control_dir,
        config.apply_dot_git_ignore,
    )?;

    info!("Selecting files");
    let selected_files = select_files(
        available_files,
        |path: &PathBuf| read_file_contents(&path),
        config.auto_select,
    )?;

    info!("Building context output");
    let output = build_context_output(selected_files, file_map, config.user_prompt.clone());
    let formatted_output = format_output(&output);

    info!("Writing output");
    write_output(
        &output,
        &formatted_output,
        config.output_path.clone(),
        config.clipboard_output,
    )
}

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

    #[test]
    fn test_cli_parsing() {
        let cli = Cli::try_parse_from(&[
            "rich-prompt",
            "generate",
            "--path",
            "./src",
            "--ext",
            ".rs",
            "--exclude",
            ".git",
            "--auto",
            "--prompt",
            "Test prompt",
            "--exclude-version-control-dir",
            ".svn",
            "--apply-dot-git-ignore",
            "false",
            "--clipboard-output",
        ])
        .unwrap();

        match cli.command {
            Commands::Generate {
                path,
                ext,
                exclude,
                auto,
                prompt,
                exclude_version_control_dir,
                apply_dot_git_ignore,
                clipboard_output,
                ..
            } => {
                assert_eq!(path, "./src");
                assert_eq!(ext, Some(".rs".to_string()));
                assert_eq!(exclude, Some(".git".to_string()));
                assert!(auto);
                assert_eq!(prompt, Some("Test prompt".to_string()));
                assert_eq!(exclude_version_control_dir, ".svn");
                assert_eq!(apply_dot_git_ignore, false);
                assert_eq!(clipboard_output, true);
            }
        }
    }

    #[test]
    fn test_cli_parsing_with_optional_args() {
        let cli = Cli::try_parse_from(&[
            "rich-prompt",
            "generate",
            "--path",
            "./src",
            "--auto",
            "--exclude-version-control-dir",
            ".svn",
        ])
        .unwrap();

        match cli.command {
            Commands::Generate {
                path,
                ext,
                exclude,
                auto,
                prompt,
                exclude_version_control_dir,
                apply_dot_git_ignore,
                clipboard_output,
                ..
            } => {
                assert_eq!(path, "./src");
                assert_eq!(ext, None);
                assert_eq!(exclude, None);
                assert!(auto);
                assert_eq!(prompt, None);
                assert_eq!(exclude_version_control_dir, ".svn");
                assert_eq!(apply_dot_git_ignore, true);
                assert_eq!(clipboard_output, false);
            }
        }
    }
}