use clap::{Parser, Subcommand};
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "etdl",
version,
about = "ETDL parser, validator, and compiler",
after_help = "Exit codes: 0 = success, 1 = validation/compile failure, 2 = usage error"
)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true)]
quiet: bool,
#[arg(long, global = true)]
verbose: bool,
#[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) or directories")]
files: Vec<PathBuf>,
},
Analyze {
#[arg(help = "Path to .etdl document")]
file: PathBuf,
},
Version,
}
fn main() {
let Cli {
json,
quiet,
verbose,
command,
} = Cli::parse();
let flags = CliFlags {
json,
quiet,
verbose,
};
let code = match command {
Command::Compile {
file,
target,
out_dir,
} => cmd_compile(&flags, &file, &target, &out_dir),
Command::Validate { files } => cmd_validate(&flags, &files),
Command::Analyze { file } => cmd_analyze(&flags, &file),
Command::Version => {
if flags.json {
println!(
"{}",
serde_json::json!({ "name": "etdl", "version": env!("CARGO_PKG_VERSION") })
);
} else {
println!("etdl {}", env!("CARGO_PKG_VERSION"));
}
0
}
};
std::process::exit(code);
}
#[derive(Clone, Copy)]
struct CliFlags {
json: bool,
quiet: bool,
verbose: bool,
}
fn collect_etdl_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
for p in paths {
let meta =
std::fs::metadata(p).map_err(|e| format!("cannot access '{}': {}", p.display(), e))?;
if meta.is_dir() {
let mut entries: Vec<PathBuf> = std::fs::read_dir(p)
.map_err(|e| format!("cannot read directory '{}': {}", p.display(), e))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "etdl"))
.collect();
entries.sort();
out.extend(entries);
} else {
out.push(p.clone());
}
}
Ok(out)
}
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);
}
}
}
}
}
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);
}
}
}
#[derive(Serialize)]
struct DiagnosticJson<'a> {
code: &'a str,
severity: &'a str,
message: &'a str,
line: Option<u32>,
column: Option<u32>,
}
fn diagnostic_line(d: &etdl_compiler::validate::Diagnostic) -> String {
let level = if d.is_error() { "ERROR" } else { "WARNING" };
let position = match (d.line, d.column) {
(Some(l), Some(c)) => format!(" ({}:{})", l + 1, c + 1),
_ => String::new(),
};
format!("[{}] {}{}: {}", level, d.code, position, d.message)
}
fn print_diagnostics(flags: &CliFlags, diagnostics: &[etdl_compiler::validate::Diagnostic]) {
if flags.json {
let items: Vec<DiagnosticJson> = diagnostics
.iter()
.map(|d| DiagnosticJson {
code: &d.code,
severity: if d.is_error() { "error" } else { "warning" },
message: &d.message,
line: d.line,
column: d.column,
})
.collect();
println!("{}", serde_json::to_string(&items).unwrap_or_default());
} else {
for d in diagnostics {
if flags.quiet && !d.is_error() {
continue;
}
println!("{}", diagnostic_line(d));
}
}
}
fn cmd_compile(flags: &CliFlags, file: &Path, target: &str, out_dir: &Path) -> i32 {
if target != "rust" {
eprintln!(
"error: unsupported target language '{}'; supported: rust",
target
);
return 1;
}
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!("error: cannot read file '{}': {}", file.display(), e);
return 1;
}
};
let doc = match etdl_parser::parse_document(&content) {
Ok(doc) => doc,
Err(e) => {
eprintln!("error: {}", e);
return 1;
}
};
let base_dir = file.parent().unwrap_or(Path::new("."));
let registry = match etdl_parser::load_asyncapi_imports(&doc, base_dir) {
Ok(registry) => registry,
Err(e) => {
eprintln!("error: {}", e);
return 1;
}
};
let compiler = etdl_compiler::Compiler::new();
let mut result = compiler.compile(&doc, ®istry);
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();
print_diagnostics(flags, &result.diagnostics);
match result.rust_output {
Some(output) => {
let stem = file.file_stem().unwrap_or_default().to_string_lossy();
let out_path = out_dir.join(format!("{}.rs", stem));
if !out_dir.exists() {
if let Err(e) = std::fs::create_dir_all(out_dir) {
eprintln!("error: cannot create output directory: {}", e);
return 1;
}
}
if let Err(e) = std::fs::write(&out_path, output) {
eprintln!(
"error: cannot write generated code to {}: {}",
out_path.display(),
e
);
return 1;
}
if !flags.quiet {
println!(
"compiled '{}' to '{}' ({} errors, {} warnings)",
file.display(),
out_path.display(),
error_count,
warning_count
);
}
0
}
None => {
if !flags.quiet {
eprintln!(
"compilation failed with {} errors and {} warnings",
error_count, warning_count
);
}
1
}
}
}
fn cmd_validate(flags: &CliFlags, paths: &[PathBuf]) -> i32 {
let files = match collect_etdl_files(paths) {
Ok(f) => f,
Err(e) => {
eprintln!("error: {}", e);
return 1;
}
};
if files.is_empty() {
eprintln!("error: no .etdl files found");
return 1;
}
if flags.verbose {
eprintln!("etdl: validating {} file(s)", files.len());
}
let mut worst_exit = 0;
if flags.json {
let mut results = Vec::new();
for file in &files {
let (diagnostics, ok) = validate_one(flags, file);
if !ok {
worst_exit = 1;
}
let items: Vec<DiagnosticJson> = diagnostics
.iter()
.map(|d| DiagnosticJson {
code: &d.code,
severity: if d.is_error() { "error" } else { "warning" },
message: &d.message,
line: d.line,
column: d.column,
})
.collect();
results.push(serde_json::json!({
"file": file.display().to_string(),
"valid": ok,
"diagnostics": items,
}));
}
println!("{}", serde_json::json!({ "results": results }));
return worst_exit;
}
for file in &files {
let (diagnostics, ok) = validate_one(flags, file);
let error_count = diagnostics.iter().filter(|d| d.is_error()).count();
let warning_count = diagnostics.iter().filter(|d| !d.is_error()).count();
if ok {
if !flags.quiet {
println!(
"document '{}' is valid ({} errors, {} warnings)",
file.display(),
error_count,
warning_count
);
}
} else {
worst_exit = 1;
if !flags.quiet {
eprintln!(
"document '{}' has {} validation errors",
file.display(),
error_count
);
}
}
}
worst_exit
}
fn validate_one(flags: &CliFlags, file: &Path) -> (Vec<etdl_compiler::validate::Diagnostic>, bool) {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!("[ERROR] {}: {}", file.display(), e);
return (Vec::new(), false);
}
};
let doc = match etdl_parser::parse_document(&content) {
Ok(doc) => doc,
Err(e) => {
eprintln!("[ERROR] {}: {}", file.display(), e);
return (Vec::new(), false);
}
};
let base_dir = file.parent().unwrap_or(Path::new("."));
let registry = match etdl_parser::load_asyncapi_imports(&doc, base_dir) {
Ok(registry) => registry,
Err(e) => {
eprintln!("[ERROR] {}: {}", file.display(), e);
return (Vec::new(), false);
}
};
let compiler = etdl_compiler::Compiler::new();
let mut diagnostics = compiler.validate(&doc, ®istry);
append_duplicate_warnings(&mut diagnostics, &content);
resolve_diagnostic_positions(&mut diagnostics, &content);
if !flags.json {
print_diagnostics(flags, &diagnostics);
}
let ok = !diagnostics.iter().any(|d| d.is_error());
(diagnostics, ok)
}
fn cmd_analyze(flags: &CliFlags, file: &Path) -> i32 {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!("error: cannot read file '{}': {}", file.display(), e);
return 1;
}
};
let doc = match etdl_parser::parse_document(&content) {
Ok(doc) => doc,
Err(e) => {
eprintln!("error: {}", e);
return 1;
}
};
let base_dir = file.parent().unwrap_or(Path::new("."));
let registry = match etdl_parser::load_asyncapi_imports(&doc, base_dir) {
Ok(registry) => registry,
Err(e) => {
eprintln!("error: {}", e);
return 1;
}
};
let compiler = etdl_compiler::Compiler::new();
let mut diagnostics = compiler.validate(&doc, ®istry);
append_duplicate_warnings(&mut diagnostics, &content);
resolve_diagnostic_positions(&mut diagnostics, &content);
let errors: Vec<_> = diagnostics.iter().filter(|d| d.is_error()).collect();
if !errors.is_empty() {
print_diagnostics(flags, &diagnostics);
return 1;
}
let probs = etdl_compiler::fault_tree::resolve_fault_trees(&doc, &mut Vec::new());
if flags.json {
let ft_json: Vec<_> = probs
.iter()
.map(|(id, p)| serde_json::json!({ "faultTree": id, "topEventProbability": p }))
.collect();
println!(
"{}",
serde_json::json!({
"document": file.display().to_string(),
"eventTrees": doc.event_trees.len(),
"faultTrees": doc.fault_trees.as_ref().map(|f| f.len()).unwrap_or(0),
"faultTreeProbabilities": ft_json,
})
);
} else {
println!("document: {}", file.display());
println!("event trees: {}", doc.event_trees.len());
println!(
"fault trees: {}",
doc.fault_trees.as_ref().map(|f| f.len()).unwrap_or(0)
);
for (id, p) in &probs {
println!(" {}: topEvent probability = {:.6}", id, p);
}
}
0
}