etdl-cli 0.1.4

ETDL CLI: compile and validate .etdl documents with IEC 61025 fault tree and IEC 62502 event tree analysis, generating Rust for microservices
use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "etdl", version, about = "ETDL parser, validator, and compiler")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Compile {
        #[arg(help = "Path to .etdl document")]
        file: PathBuf,

        #[arg(long, default_value = "rust", help = "Target language for code generation")]
        target: String,

        #[arg(long, default_value = ".", help = "Output directory for generated code")]
        out_dir: PathBuf,
    },
    Validate {
        #[arg(help = "Path(s) to .etdl document(s)")]
        files: Vec<PathBuf>,
    },
    Version,
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Command::Compile {
            file,
            target,
            out_dir,
        } => {
            if let Err(e) = cmd_compile(&file, &target, &out_dir) {
                eprintln!("error: {}", e);
                std::process::exit(1);
            }
        }
        Command::Validate { files } => {
            let exit_code = cmd_validate(&files);
            std::process::exit(exit_code);
        }
        Command::Version => {
            println!("etdl {}", env!("CARGO_PKG_VERSION"));
        }
    }
}

/// Fill `line`/`column` on diagnostics from their structured span keys.
fn resolve_diagnostic_positions(
    diagnostics: &mut [etdl_compiler::validate::Diagnostic],
    content: &str,
) {
    let index = etdl_parser::spanned::build_span_index(content).ok();
    for d in diagnostics.iter_mut() {
        if d.line.is_none() {
            if let (Some(key), Some(index)) = (&d.key, &index) {
                if let Some(el) = index.resolve(key) {
                    let span = el.key_span.unwrap_or(el.span);
                    d.line = Some(span.line);
                    d.column = Some(span.column);
                    d.end_line = Some(span.end_line);
                    d.end_column = Some(span.end_column);
                }
            }
        }
    }
}

/// Append `V-001` warnings for duplicate ids under `nodes`/`gates`/`basicEvents`.
fn append_duplicate_warnings(
    diagnostics: &mut Vec<etdl_compiler::validate::Diagnostic>,
    content: &str,
) {
    if let Ok(duplicates) = etdl_parser::spanned::detect_duplicate_ids(content) {
        for dup in duplicates {
            let mut d = etdl_compiler::validate::Diagnostic::warning(
                "V-001",
                format!(
                    "duplicate {} id '{}' in tree '{}'",
                    dup.kind, dup.id, dup.tree
                ),
            )
            .with_position(dup.span.line, dup.span.column);
            d.end_line = Some(dup.span.end_line);
            d.end_column = Some(dup.span.end_column);
            diagnostics.push(d);
        }
    }
}

fn cmd_compile(file: &PathBuf, target: &str, out_dir: &PathBuf) -> Result<(), String> {
    if target != "rust" {
        return Err(format!("unsupported target language '{}'; supported: rust", target));
    }

    let content =
        std::fs::read_to_string(file).map_err(|e| format!("cannot read file: {}", e))?;

    let doc = etdl_parser::parse_document(&content)?;

    let base_dir = file.parent().unwrap_or(std::path::Path::new("."));

    let registry = etdl_parser::load_asyncapi_imports(&doc, base_dir)?;

        let compiler = etdl_compiler::Compiler::new();
        let mut result = compiler.compile(&doc, &registry);

        append_duplicate_warnings(&mut result.diagnostics, &content);
        resolve_diagnostic_positions(&mut result.diagnostics, &content);

    let error_count = result.diagnostics.iter().filter(|d| d.is_error()).count();
    let warning_count = result.diagnostics.iter().filter(|d| !d.is_error()).count();

    for diag in &result.diagnostics {
        let level = if diag.is_error() { "ERROR" } else { "WARNING" };
        let position = match (diag.line, diag.column) {
            (Some(l), Some(c)) => format!(" ({}:{})", l + 1, c + 1),
            _ => String::new(),
        };
        eprintln!("[{}] {}{}: {}", level, diag.code, position, diag.message);
    }

    if let Some(ref output) = result.rust_output {
        let stem = file.file_stem().unwrap_or_default().to_string_lossy();
        let out_path = out_dir.join(format!("{}.rs", stem));

        let out_dir_exists = out_dir.exists();
        if !out_dir_exists {
            std::fs::create_dir_all(out_dir).map_err(|e| {
                format!("cannot create output directory: {}", e)
            })?;
        }

        std::fs::write(&out_path, output).map_err(|e| {
            format!("cannot write generated code to {}: {}", out_path.display(), e)
        })?;

        println!(
            "compiled '{}' to '{}' ({} errors, {} warnings)",
            file.display(),
            out_path.display(),
            error_count,
            warning_count
        );
    } else {
        eprintln!(
            "compilation failed with {} errors and {} warnings",
            error_count, warning_count
        );
        std::process::exit(1);
    }

    Ok(())
}

fn cmd_validate(files: &[PathBuf]) -> i32 {
    let mut worst_exit = 0;

    for file in files {
        let content = match std::fs::read_to_string(file) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("[ERROR] {}: {}", file.display(), e);
                worst_exit = 1;
                continue;
            }
        };

        let doc = match etdl_parser::parse_document(&content) {
            Ok(doc) => doc,
            Err(e) => {
                eprintln!("[ERROR] {}: {}", file.display(), e);
                worst_exit = 1;
                continue;
            }
        };

        let base_dir = file.parent().unwrap_or(std::path::Path::new("."));
        let registry = match etdl_parser::load_asyncapi_imports(&doc, base_dir) {
            Ok(registry) => registry,
            Err(e) => {
                eprintln!("[ERROR] {}: {}", file.display(), e);
                worst_exit = 1;
                continue;
            }
        };

        let compiler = etdl_compiler::Compiler::new();
        let mut diagnostics = compiler.validate(&doc, &registry);

        append_duplicate_warnings(&mut diagnostics, &content);
        resolve_diagnostic_positions(&mut diagnostics, &content);

        let error_count = diagnostics.iter().filter(|d| d.is_error()).count();
        let warning_count = diagnostics.iter().filter(|d| !d.is_error()).count();

        for diag in &diagnostics {
            let level = if diag.is_error() { "ERROR" } else { "WARNING" };
            let position = match (diag.line, diag.column) {
                (Some(l), Some(c)) => format!(" ({}:{})", l + 1, c + 1),
                _ => String::new(),
            };
            println!("[{}] {}{}: {}", level, diag.code, position, diag.message);
        }

        if error_count == 0 {
            println!(
                "document '{}' is valid ({} errors, {} warnings)",
                file.display(),
                error_count,
                warning_count
            );
        } else {
            eprintln!(
                "document '{}' has {} validation errors",
                file.display(),
                error_count
            );
            worst_exit = 1;
        }
    }

    worst_exit
}