use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use miette::IntoDiagnostic;
use hornet_bind9::{
error::Severity, parse_named_conf_file, parse_zone_file_from_path, validate_named_conf,
validate_zone_file, write_named_conf, write_zone_file, writer::WriteOptions,
};
#[derive(Parser)]
#[command(
name = "hornet",
version,
about = "Parse, validate, and format BIND9 configuration files",
long_about = "A fast Rust-based tool for working with BIND9 named.conf and zone files.\n\nPass --help to any subcommand for detailed usage."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
#[command(visible_alias = "p")]
Parse {
file: PathBuf,
#[arg(short, long, default_value_t = 4)]
indent: usize,
#[arg(long, default_value_t = true)]
modern: bool,
},
#[command(visible_alias = "z")]
Zone {
file: PathBuf,
#[arg(short, long, default_value_t = 4)]
indent: usize,
},
#[command(visible_alias = "c")]
Check {
file: PathBuf,
#[arg(long)]
allow_warnings: bool,
#[arg(long, default_value = "info")]
min_severity: String,
},
CheckZone {
file: PathBuf,
#[arg(long)]
allow_warnings: bool,
},
Fmt {
file: PathBuf,
#[arg(short, long, default_value_t = 4)]
indent: usize,
#[arg(long)]
check: bool,
#[arg(long, default_value_t = true)]
modern: bool,
},
Convert {
file: PathBuf,
#[arg(long)]
in_place: bool,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli.command) {
Ok(code) => code,
Err(e) => {
eprintln!("Error: {e}");
ExitCode::FAILURE
}
}
}
#[allow(clippy::too_many_lines)]
fn run(cmd: Command) -> miette::Result<ExitCode> {
match cmd {
Command::Parse {
file,
indent,
modern,
} => {
let conf = parse_named_conf_file(&file).into_diagnostic()?;
let opts = WriteOptions {
indent,
modern_keywords: modern,
..Default::default()
};
print!("{}", write_named_conf(&conf, &opts));
Ok(ExitCode::SUCCESS)
}
Command::Zone { file, indent } => {
let zone = parse_zone_file_from_path(&file).into_diagnostic()?;
let opts = WriteOptions {
indent,
..Default::default()
};
print!("{}", write_zone_file(&zone, &opts));
Ok(ExitCode::SUCCESS)
}
Command::Check {
file,
allow_warnings,
min_severity,
} => {
let conf = parse_named_conf_file(&file).into_diagnostic()?;
let diags = validate_named_conf(&conf);
let min_sev = parse_severity(&min_severity);
let mut worst = Severity::Info;
for d in &diags {
if d.severity >= min_sev {
let prefix = match d.severity {
Severity::Error => "\x1b[31merror\x1b[0m",
Severity::Warning => "\x1b[33mwarning\x1b[0m",
Severity::Info => "\x1b[36minfo\x1b[0m",
};
eprintln!("{prefix}: {}", d.message);
if d.severity > worst {
worst = d.severity.clone();
}
}
}
if diags.is_empty() {
eprintln!("\x1b[32mOK\x1b[0m {} — no issues found", file.display());
} else {
eprintln!(
"\n{} diagnostic(s) found in {}",
diags.len(),
file.display()
);
}
if worst == Severity::Error || (!allow_warnings && worst == Severity::Warning) {
Ok(ExitCode::FAILURE)
} else {
Ok(ExitCode::SUCCESS)
}
}
Command::CheckZone {
file,
allow_warnings,
} => {
let zone = parse_zone_file_from_path(&file).into_diagnostic()?;
let diags = validate_zone_file(&zone);
let mut has_error = false;
let mut has_warning = false;
for d in &diags {
let prefix = match d.severity {
Severity::Error => {
has_error = true;
"\x1b[31merror\x1b[0m"
}
Severity::Warning => {
has_warning = true;
"\x1b[33mwarning\x1b[0m"
}
Severity::Info => "\x1b[36minfo\x1b[0m",
};
eprintln!("{prefix}: {}", d.message);
}
if diags.is_empty() {
eprintln!("\x1b[32mOK\x1b[0m {} — no issues found", file.display());
}
if has_error || (!allow_warnings && has_warning) {
Ok(ExitCode::FAILURE)
} else {
Ok(ExitCode::SUCCESS)
}
}
Command::Fmt {
file,
indent,
check,
modern,
} => {
let original = std::fs::read_to_string(&file).into_diagnostic()?;
let conf = parse_named_conf_file(&file).into_diagnostic()?;
let opts = WriteOptions {
indent,
modern_keywords: modern,
..Default::default()
};
let formatted = write_named_conf(&conf, &opts);
if check {
if formatted == original {
eprintln!("\x1b[32mOK\x1b[0m {} is already formatted", file.display());
Ok(ExitCode::SUCCESS)
} else {
eprintln!(
"\x1b[31mFAIL\x1b[0m {} would be reformatted",
file.display()
);
Ok(ExitCode::FAILURE)
}
} else {
std::fs::write(&file, &formatted).into_diagnostic()?;
eprintln!("Formatted {}", file.display());
Ok(ExitCode::SUCCESS)
}
}
Command::Convert { file, in_place } => {
let conf = parse_named_conf_file(&file).into_diagnostic()?;
let opts = WriteOptions {
modern_keywords: true,
..Default::default()
};
let output = write_named_conf(&conf, &opts);
if in_place {
std::fs::write(&file, &output).into_diagnostic()?;
eprintln!("Converted {} to modern keywords", file.display());
} else {
print!("{output}");
}
Ok(ExitCode::SUCCESS)
}
}
}
fn parse_severity(s: &str) -> Severity {
match s.to_ascii_lowercase().as_str() {
"error" => Severity::Error,
"warning" | "warn" => Severity::Warning,
_ => Severity::Info,
}
}