allium-cli 3.0.4

CLI for checking Allium specification files
mod generators;
mod test_plan;

use allium_parser::diagnostic::Severity;
use allium_parser::lexer::SourceMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();

    if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
        eprintln!("Usage: allium check <file.allium>...");
        eprintln!("       allium check <directory>");
        eprintln!("       allium parse <file.allium>");
        eprintln!("       allium plan <file.allium>");
        eprintln!("       allium model <file.allium>");
        eprintln!("       allium --version");
        return ExitCode::from(2);
    }

    if args[0] == "--version" || args[0] == "-V" {
        println!("allium {} (language versions: 1, 2, 3)", env!("CARGO_PKG_VERSION"));
        return ExitCode::SUCCESS;
    }

    match args[0].as_str() {
        "check" => cmd_check(&args[1..]),
        "parse" => cmd_parse(&args[1..]),
        "plan" => cmd_plan(&args[1..]),
        "model" => cmd_model(&args[1..]),
        other => {
            eprintln!("Unknown command: {other}");
            eprintln!("Available commands: check, parse, plan, model");
            ExitCode::from(2)
        }
    }
}

fn cmd_parse(args: &[String]) -> ExitCode {
    if args.len() != 1 {
        eprintln!("Usage: allium parse <file.allium>");
        return ExitCode::from(2);
    }

    let path = Path::new(&args[0]);
    let source = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("{}: {e}", path.display());
            return ExitCode::from(1);
        }
    };

    let result = allium_parser::parse(&source);
    match serde_json::to_string_pretty(&result) {
        Ok(json) => {
            println!("{json}");
            ExitCode::SUCCESS
        }
        Err(e) => {
            eprintln!("Failed to serialise AST: {e}");
            ExitCode::from(1)
        }
    }
}

fn cmd_check(args: &[String]) -> ExitCode {
    let files = resolve_files(args);
    if files.is_empty() {
        eprintln!("No .allium files found.");
        return ExitCode::from(2);
    }

    let mut total_errors = 0u32;
    let mut total_warnings = 0u32;

    for path in &files {
        let source = match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("{}: {e}", path.display());
                total_errors += 1;
                continue;
            }
        };

        let result = allium_parser::parse(&source);
        let analysis_diagnostics = allium_parser::analyze(&result.module, &source);
        let source_map = SourceMap::new(&source);

        let all_diagnostics: Vec<&allium_parser::Diagnostic> = result
            .diagnostics
            .iter()
            .chain(analysis_diagnostics.iter())
            .collect();

        for d in all_diagnostics {
            let (line, col) = source_map.line_col(d.span.start);
            let severity = match d.severity {
                Severity::Error => "error",
                Severity::Warning => "warning",
                Severity::Info => "info",
            };
            if let Some(code) = d.code {
                println!(
                    "{}:{}:{}: {severity} {} {}",
                    path.display(),
                    line + 1,
                    col + 1,
                    code,
                    d.message,
                );
            } else {
                println!(
                    "{}:{}:{}: {severity}: {}",
                    path.display(),
                    line + 1,
                    col + 1,
                    d.message,
                );
            }
            print_source_snippet(&source_map, &source, line, col);

            match d.severity {
                Severity::Error => total_errors += 1,
                Severity::Warning => total_warnings += 1,
                Severity::Info => {}
            }
        }
    }

    let file_count = files.len();
    if total_errors == 0 && total_warnings == 0 {
        eprintln!("{file_count} file(s) checked, no issues found.");
        ExitCode::SUCCESS
    } else {
        eprintln!(
            "{file_count} file(s) checked, {total_errors} error(s), {total_warnings} warning(s)."
        );
        ExitCode::from(1)
    }
}

fn cmd_plan(args: &[String]) -> ExitCode {
    if args.len() != 1 {
        eprintln!("Usage: allium plan <file.allium>");
        return ExitCode::from(2);
    }

    let path = Path::new(&args[0]);
    let source = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("{}: {e}", path.display());
            return ExitCode::from(1);
        }
    };

    let result = allium_parser::parse(&source);
    let plan = test_plan::generate_test_plan(&result.module, &source);
    match serde_json::to_string_pretty(&plan) {
        Ok(json) => {
            println!("{json}");
            ExitCode::SUCCESS
        }
        Err(e) => {
            eprintln!("Failed to serialise test plan: {e}");
            ExitCode::from(1)
        }
    }
}

fn cmd_model(args: &[String]) -> ExitCode {
    if args.len() != 1 {
        eprintln!("Usage: allium model <file.allium>");
        return ExitCode::from(2);
    }

    let path = Path::new(&args[0]);
    let source = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("{}: {e}", path.display());
            return ExitCode::from(1);
        }
    };

    let result = allium_parser::parse(&source);
    let spec = generators::generate_generators(&result.module, &source);
    match serde_json::to_string_pretty(&spec) {
        Ok(json) => {
            println!("{json}");
            ExitCode::SUCCESS
        }
        Err(e) => {
            eprintln!("Failed to serialise model: {e}");
            ExitCode::from(1)
        }
    }
}

fn print_source_snippet(source_map: &SourceMap, source: &str, line: u32, col: u32) {
    let line_text = source_map.line_text(source, line);
    let line_num = format!("{}", line + 1);
    let gutter = line_num.len();
    println!("  {} | {}", line_num, line_text);
    println!("  {} | {}^", " ".repeat(gutter), " ".repeat(col as usize));
}

fn resolve_files(args: &[String]) -> Vec<PathBuf> {
    let mut files = Vec::new();
    for arg in args {
        let path = Path::new(arg);
        if path.is_dir() {
            collect_allium_files(path, &mut files);
        } else if path.extension().is_some_and(|e| e == "allium") {
            files.push(path.to_path_buf());
        } else {
            // Try as-is (might be a glob pattern the shell expanded)
            files.push(path.to_path_buf());
        }
    }
    files
}

fn collect_allium_files(dir: &Path, out: &mut Vec<PathBuf>) {
    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return,
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_allium_files(&path, out);
        } else if path.extension().is_some_and(|e| e == "allium") {
            out.push(path);
        }
    }
}