codoc 0.1.0

Unified documentation parser for Ruby and TypeScript codebases
Documentation
//! Codoc CLI - Unified code documentation parser.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use clap::{Parser as ClapParser, Subcommand, ValueEnum};
use colored::Colorize;
use walkdir::WalkDir;

use codoc::parser::ruby::RubyParser;
use codoc::parser::typescript::TypeScriptParser;
use codoc::parser::{ParseContext, Parser, ParserConfig};
use codoc::schema::Language;

#[derive(ClapParser)]
#[command(name = "codoc")]
#[command(about = "Unified code documentation parser for Ruby and TypeScript")]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Parse source files and generate documentation JSON.
    Parse {
        /// Source directory or file to parse.
        #[arg(value_name = "PATH")]
        path: PathBuf,

        /// Output file path (defaults to stdout).
        #[arg(short, long)]
        output: Option<PathBuf>,

        /// Source language.
        #[arg(short, long, value_enum)]
        language: LanguageArg,

        /// Project name.
        #[arg(short, long, default_value = "project")]
        name: String,

        /// Project version.
        #[arg(long)]
        version: Option<String>,

        /// ID prefix for all entities.
        #[arg(long)]
        id_prefix: Option<String>,

        /// Pretty-print JSON output.
        #[arg(long, default_value = "true")]
        pretty: bool,
    },
}

#[derive(Clone, Copy, ValueEnum)]
enum LanguageArg {
    Ruby,
    Typescript,
}

impl From<LanguageArg> for Language {
    fn from(arg: LanguageArg) -> Self {
        match arg {
            LanguageArg::Ruby => Language::Ruby,
            LanguageArg::Typescript => Language::TypeScript,
        }
    }
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Parse {
            path,
            output,
            language,
            name,
            version,
            id_prefix,
            pretty,
        } => {
            let (document, stats) = parse_sources(&path, language, &name, version, id_prefix)?;

            let json = if pretty {
                serde_json::to_string_pretty(&document)?
            } else {
                serde_json::to_string(&document)?
            };

            match output {
                Some(output_path) => {
                    fs::write(&output_path, &json)
                        .with_context(|| format!("Failed to write to {}", output_path.display()))?;
                    print_summary(&stats, Some(&output_path));
                }
                None => {
                    println!("{}", json);
                }
            }
        }
    }

    Ok(())
}

/// Statistics collected during parsing.
struct ParseStats {
    /// Number of files successfully parsed.
    files_parsed: usize,
    /// Number of files that failed to parse.
    files_failed: usize,
    /// Total number of entities extracted.
    entities_found: usize,
    /// Total number of symbols generated.
    symbols_found: usize,
}

fn print_summary(stats: &ParseStats, output_path: Option<&Path>) {
    eprintln!();
    eprintln!("{}", "Parsing complete".green().bold());

    // File statistics
    eprintln!("  {} {} parsed", "Files:".cyan(), stats.files_parsed);
    if stats.files_failed > 0 {
        eprintln!(
            "  {} {} files failed",
            "Warnings:".yellow(),
            stats.files_failed
        );
    }

    // Entity statistics
    eprintln!("  {} {}", "Entities:".cyan(), stats.entities_found);
    eprintln!("  {} {}", "Symbols:".cyan(), stats.symbols_found);

    // Output location
    if let Some(path) = output_path {
        eprintln!("  {} {}", "Output:".cyan(), path.display());
    }
}

fn parse_sources(
    path: &Path,
    language: LanguageArg,
    name: &str,
    version: Option<String>,
    id_prefix: Option<String>,
) -> Result<(codoc::Document, ParseStats)> {
    let source_root = if path.is_dir() {
        path.to_path_buf()
    } else {
        path.parent().unwrap_or(Path::new(".")).to_path_buf()
    };

    let mut config = ParserConfig::new(name, language.into())
        .with_source_root(source_root.to_string_lossy().to_string());

    if let Some(ver) = version {
        config = config.with_version(ver);
    }

    if let Some(prefix) = id_prefix {
        config = config.with_id_prefix(prefix);
    }

    let mut ctx = ParseContext::new(config);

    let files = collect_files(path, language)?;
    let mut parser: Box<dyn Parser> = match language {
        LanguageArg::Ruby => Box::new(RubyParser::new()?),
        LanguageArg::Typescript => Box::new(TypeScriptParser::new()?),
    };

    let mut files_parsed = 0;
    let mut files_failed = 0;

    for file_path in &files {
        let content = fs::read_to_string(file_path)
            .with_context(|| format!("Failed to read {}", file_path.display()))?;

        let relative = file_path
            .strip_prefix(&source_root)
            .unwrap_or(file_path)
            .to_string_lossy()
            .to_string();

        match parser.parse_file(file_path, &content) {
            Ok(entities) => {
                let symbols = parser.generate_symbols(&entities, None);
                ctx.symbols.extend(symbols);
                ctx.entities.extend(entities);
                ctx.files.push(relative);
                files_parsed += 1;
            }
            Err(e) => {
                eprintln!(
                    "{} {} {}",
                    "warning:".yellow().bold(),
                    "Failed to parse".yellow(),
                    file_path.display()
                );
                eprintln!("  {}", e.to_string().dimmed());
                files_failed += 1;
            }
        }
    }

    let stats = ParseStats {
        files_parsed,
        files_failed,
        entities_found: ctx.entities.len(),
        symbols_found: ctx.symbols.len(),
    };

    Ok((ctx.into_document(), stats))
}

fn collect_files(path: &Path, language: LanguageArg) -> Result<Vec<PathBuf>> {
    let extensions: &[&str] = match language {
        LanguageArg::Ruby => &["rb"],
        LanguageArg::Typescript => &["ts", "tsx"],
    };

    let mut files = Vec::new();

    if path.is_file() {
        files.push(path.to_path_buf());
    } else {
        for entry in WalkDir::new(path)
            .follow_links(true)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            let entry_path = entry.path();
            if entry_path.is_file() {
                if let Some(ext) = entry_path.extension() {
                    if extensions.contains(&ext.to_string_lossy().as_ref()) {
                        // Skip node_modules and hidden directories
                        let path_str = entry_path.to_string_lossy();
                        if !path_str.contains("node_modules")
                            && !path_str.contains("/.")
                            && !path_str.contains("\\.")
                        {
                            files.push(entry_path.to_path_buf());
                        }
                    }
                }
            }
        }
    }

    files.sort();
    Ok(files)
}