Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 191 additions & 27 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ mod runner;
mod storage;

use output::OutputFormat;
use rules::RulesFile;
use rules::{RulesFile, Severity};
use runner::run_rule;
use storage::register_data;

Expand Down Expand Up @@ -46,74 +46,238 @@ async fn main() {

let args = Cli::parse();
let verbose = args.verbose;
if let Err(e) = run(args).await {
if verbose {
eprintln!("Error: {e:?}");
} else {
eprintln!("Error: {e}");
match run(args).await {
Ok(code) => std::process::exit(code),
Err(e) => {
// Check if the error carries a specific exit code
let code = if let Some(exit_err) = e.downcast_ref::<ExitCodeError>() {
exit_err.code
} else {
1
};
if verbose {
eprintln!("Error: {e:?}");
} else {
eprintln!("Error: {e}");
}
std::process::exit(code);
}
std::process::exit(1);
}
}

async fn run(args: Cli) -> anyhow::Result<()> {
let content = std::fs::read_to_string(&args.rules).context("Could not read rules file")?;
let rules: RulesFile =
serde_yaml::from_str(&content).context("Could not parse the rules YAML")?;
/// Compute the granular exit code from the collected rule results.
///
/// - `0` — all rules passed
/// - `1` — at least one `error`-severity rule failed
/// - `2` — only `warning`-severity rules triggered (no errors failed)
pub fn compute_exit_code(results: &[RuleResult]) -> i32 {
let has_error_fail = results
.iter()
.any(|r| matches!(r.status, RuleStatus::Fail) && matches!(r.severity, Severity::Error));
if has_error_fail {
return 1;
}
let has_warning_fail = results
.iter()
.any(|r| matches!(r.status, RuleStatus::Fail) && matches!(r.severity, Severity::Warning));
if has_warning_fail {
return 2;
}
0
}

async fn run(args: Cli) -> anyhow::Result<i32> {
// Exit code 3: invalid rules file or schema mismatch
let content = std::fs::read_to_string(&args.rules)
.map_err(|e| anyhow::anyhow!("Could not read rules file: {e}"))
.map_err(|e| ExitCodeError::new(3, e))?;

let rules: RulesFile = serde_yaml::from_str(&content)
.map_err(|e| anyhow::anyhow!("Could not parse the rules YAML: {e}"))
.map_err(|e| ExitCodeError::new(3, e))?;

let format: OutputFormat = args
.format
.context("Could not parse output format. Valid options are json or table")?;
.context("Could not parse output format. Valid options are json or table")
.map_err(|e| ExitCodeError::new(3, e))?;

let ctx = SessionContext::new();
register_data(&ctx, &args.file).await?;

// Exit code 4: data file not found or unreadable
register_data(&ctx, &args.file)
.await
.map_err(|e| ExitCodeError::new(4, e))?;

let schema_cols: Vec<String> = ctx
.table("data")
.await
.context("Could not read the table schema")?
.context("Could not read the table schema")
.map_err(|e| ExitCodeError::new(4, e))?
.schema()
.fields()
.iter()
.map(|c| c.name().clone())
.collect();

let missing_cols: Vec<String> = rules
.rules
.iter()
.map(|c| c.column.clone())
.filter(|c| !schema_cols.contains(c))
.collect();

if !missing_cols.is_empty() {
anyhow::bail!("Invalid columns in rules: {}", missing_cols.join(", "));
return Err(ExitCodeError::new(
3,
anyhow::anyhow!("Invalid columns in rules: {}", missing_cols.join(", ")),
)
.into());
}
runner::validate_threshold(&rules.rules)?;

runner::validate_threshold(&rules.rules).map_err(|e| ExitCodeError::new(3, e))?;

if args.dry_run {
for rule in &rules.rules {
runner::validate_rule(rule)
.with_context(|| format!("Rule '{}' is invalid", rule.name))?;
.with_context(|| format!("Rule '{}' is invalid", rule.name))
.map_err(|e| ExitCodeError::new(3, e))?;
}
println!(
"Rules file is valid. {} rules ready to run.",
rules.rules.len()
);
return Ok(());
return Ok(0);
}
let mut any_failed = false;
let total_rows = run_sql(&ctx, "SELECT COUNT(*) FROM data".into()).await?;

let total_rows = run_sql(&ctx, "SELECT COUNT(*) FROM data".into())
.await
.map_err(|e| ExitCodeError::new(4, e))?;

if total_rows == 0 {
anyhow::bail!("Input file is empty");
return Err(ExitCodeError::new(4, anyhow::anyhow!("Input file is empty")).into());
}

let mut results: Vec<RuleResult> = Vec::new();
for rule in &rules.rules {
let result = run_rule(&ctx, rule, total_rows)
.await
.with_context(|| format!("Rule '{}' failed to execute", rule.name))?;
if matches!(result.status, RuleStatus::Fail) {
any_failed = true;
}
results.push(result);
}

let out = format_results(&results, &format);
println!("{}", out);
if any_failed {
std::process::exit(1);

Ok(compute_exit_code(&results))
}

/// A wrapper error that carries a desired process exit code alongside the anyhow error chain.
#[derive(Debug)]
struct ExitCodeError {
code: i32,
inner: anyhow::Error,
}

impl ExitCodeError {
fn new(code: i32, inner: anyhow::Error) -> Self {
ExitCodeError { code, inner }
}
}

impl std::fmt::Display for ExitCodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.inner)
}
}

impl std::error::Error for ExitCodeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.inner.source()
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{Check, Rule, Severity};
use crate::runner::{RuleResult, RuleStatus};

fn make_result(status: RuleStatus, severity: Severity) -> RuleResult {
RuleResult {
name: "test_rule".to_string(),
status,
severity,
violations: 0,
total_rows: 10,
violation_rate: 0.0,
}
}

#[test]
fn test_all_pass_gives_exit_code_0() {
let results = vec![
make_result(RuleStatus::Pass, Severity::Error),
make_result(RuleStatus::Pass, Severity::Warning),
];
assert_eq!(compute_exit_code(&results), 0);
}

#[test]
fn test_error_fail_gives_exit_code_1() {
let results = vec![
make_result(RuleStatus::Fail, Severity::Error),
make_result(RuleStatus::Pass, Severity::Warning),
];
assert_eq!(compute_exit_code(&results), 1);
}

#[test]
fn test_error_and_warning_fail_gives_exit_code_1() {
let results = vec![
make_result(RuleStatus::Fail, Severity::Error),
make_result(RuleStatus::Fail, Severity::Warning),
];
assert_eq!(compute_exit_code(&results), 1);
}

#[test]
fn test_warning_only_fail_gives_exit_code_2() {
let results = vec![
make_result(RuleStatus::Pass, Severity::Error),
make_result(RuleStatus::Fail, Severity::Warning),
];
assert_eq!(compute_exit_code(&results), 2);
}

#[test]
fn test_empty_results_gives_exit_code_0() {
let results: Vec<RuleResult> = vec![];
assert_eq!(compute_exit_code(&results), 0);
}

#[test]
fn test_multiple_warnings_fail_gives_exit_code_2() {
let results = vec![
make_result(RuleStatus::Fail, Severity::Warning),
make_result(RuleStatus::Fail, Severity::Warning),
];
assert_eq!(compute_exit_code(&results), 2);
}

#[test]
fn test_make_rule_helper_builds_valid_rule() {
// Verify Rule struct fields are accessible (compile-time check for struct completeness)
let rule = Rule {
name: "test".to_string(),
column: "id".to_string(),
check: Check::NotNull,
min: None,
max: None,
pattern: None,
threshold: None,
sql: None,
severity: Severity::Warning,
};
assert_eq!(rule.severity, Severity::Warning);
}
Ok(())
}
3 changes: 2 additions & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ pub fn build_json(results: &[RuleResult]) -> String {

pub fn build_table(results: &[RuleResult]) -> String {
let mut table = Table::new();
table.set_header(["RULE", "STATUS", "VIOLATIONS", "TOTAL", "RATE"]);
table.set_header(["RULE", "STATUS", "SEVERITY", "VIOLATIONS", "TOTAL", "RATE"]);
results.iter().for_each(|res| {
table.add_row([
res.name.clone(),
format!("{}", res.status),
format!("{}", res.severity),
res.violations.to_string(),
res.total_rows.to_string(),
format!("{:.1}%", res.violation_rate * 100.0),
Expand Down
Loading
Loading