mod common;
use common::{SIMPLE_RULE, rsigma, temp_file};
use predicates::prelude::*;
#[test]
fn eval_output_format_json_emits_compact_object() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
"--output-format",
"json",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let trimmed = stdout.trim();
assert!(
trimmed.starts_with('{') && trimmed.ends_with('}'),
"expected a single JSON object, got: {trimmed}",
);
assert_eq!(trimmed.matches('\n').count(), 0);
assert!(trimmed.contains("\"rule_title\":\"Test Rule\""));
}
#[test]
fn eval_output_format_ndjson_is_line_oriented() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
"--output-format",
"ndjson",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines().filter(|l| !l.trim().is_empty()) {
let _: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("invalid NDJSON line: {line:?}: {e}"));
}
}
#[test]
fn eval_output_format_table_renders_columns() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
"--output-format",
"table",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("LEVEL"), "missing LEVEL header: {stdout}");
assert!(stdout.contains("RULE"), "missing RULE header: {stdout}");
assert!(stdout.contains("TYPE"), "missing TYPE header: {stdout}");
assert!(stdout.contains("DETAIL"), "missing DETAIL header: {stdout}");
assert!(stdout.contains("Test Rule"), "missing rule row: {stdout}");
}
#[test]
fn eval_output_format_csv_has_header_and_row() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
"--output-format",
"csv",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines().filter(|l| !l.trim().is_empty());
assert_eq!(lines.next(), Some("LEVEL,RULE,TYPE,DETAIL"));
let row = lines.next().expect("at least one data row");
assert!(row.starts_with("high,Test Rule,detection,"), "row: {row}");
}
#[test]
fn eval_output_format_tsv_uses_tabs() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
"--output-format",
"tsv",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines().filter(|l| !l.trim().is_empty());
assert_eq!(lines.next(), Some("LEVEL\tRULE\tTYPE\tDETAIL"));
let row = lines.next().expect("data row");
assert_eq!(row.matches('\t').count(), 3);
}
#[test]
fn eval_quiet_suppresses_stats_and_progress() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
"--quiet",
])
.output()
.unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("Loaded"),
"expected no progress lines under --quiet, got: {stderr}",
);
}
#[test]
fn eval_no_stats_keeps_progress_drops_summary() {
let rule = temp_file(".yml", SIMPLE_RULE);
let events = temp_file(
".ndjson",
"{\"CommandLine\": \"malware\"}\n{\"CommandLine\": \"benign\"}\n",
);
let out = rsigma()
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
&format!("@{}", events.path().display()),
"--no-stats",
])
.output()
.unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("Loaded"),
"--no-stats should keep progress, got: {stderr}",
);
assert!(
!stderr.contains("Processed"),
"--no-stats should drop summary, got: {stderr}",
);
}
#[test]
fn lint_color_always_emits_ansi_codes() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"rule",
"lint",
rule.path().to_str().unwrap(),
"--color",
"always",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\x1b["),
"--color always should emit ANSI escapes: {stdout}",
);
}
#[test]
fn lint_color_never_strips_ansi_codes() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"rule",
"lint",
rule.path().to_str().unwrap(),
"--color",
"never",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("\x1b["),
"--color never should strip ANSI escapes: {stdout}",
);
}
#[test]
fn lint_output_format_json_emits_envelope() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"rule",
"lint",
rule.path().to_str().unwrap(),
"--output-format",
"json",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let value: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(value.get("summary").is_some(), "missing summary: {stdout}");
assert!(
value.get("findings").is_some(),
"missing findings: {stdout}"
);
}
#[test]
fn lint_output_format_csv_lists_findings() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"rule",
"lint",
rule.path().to_str().unwrap(),
"--output-format",
"csv",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines().filter(|l| !l.trim().is_empty());
assert_eq!(
lines.next(),
Some("PATH,SEVERITY,RULE,LINE,MESSAGE"),
"header mismatch in {stdout}",
);
}
#[test]
fn fields_legacy_json_alias_emits_envelope() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"rule",
"fields",
"-r",
rule.path().to_str().unwrap(),
"--json",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let value: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(value.get("summary").is_some());
assert!(value.get("fields").is_some());
}
#[test]
fn fields_output_format_csv_writes_field_rows() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"rule",
"fields",
"-r",
rule.path().to_str().unwrap(),
"--output-format",
"csv",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines().filter(|l| !l.trim().is_empty());
assert_eq!(lines.next(), Some("FIELD,RULES,SOURCES"));
assert!(
lines.next().is_some_and(|r| r.starts_with("CommandLine,")),
"expected first CSV row to be CommandLine in {stdout}",
);
}
#[test]
fn env_layer_sets_output_format() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.env("RSIGMA_GLOBAL__OUTPUT_FORMAT", "json")
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let trimmed = stdout.trim();
assert!(
trimmed.starts_with('{'),
"expected env-driven JSON, got: {trimmed}",
);
}
#[test]
fn config_file_sets_output_format() {
let rule = temp_file(".yml", SIMPLE_RULE);
let cfg = temp_file(
".yaml",
"global:\n output_format: json\neval:\n fail_on_detection: false\n",
);
let out = rsigma()
.args([
"engine",
"eval",
"--config",
cfg.path().to_str().unwrap(),
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let trimmed = stdout.trim();
assert!(
trimmed.starts_with('{') && trimmed.contains("\"rule_title\":"),
"expected config-driven JSON, got: {trimmed}",
);
}
#[test]
fn flag_beats_env_for_output_format() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.env("RSIGMA_GLOBAL__OUTPUT_FORMAT", "ndjson")
.args([
"engine",
"eval",
"--rules",
rule.path().to_str().unwrap(),
"--event",
r#"{"CommandLine": "malware"}"#,
"--output-format",
"csv",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("LEVEL,RULE,TYPE,DETAIL"),
"expected CSV (flag) to beat NDJSON (env): {stdout}",
);
}
#[test]
fn convert_output_format_json_wraps_queries() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"backend",
"convert",
rule.path().to_str().unwrap(),
"-t",
"test",
"--output-format",
"json",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let value: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("valid convert envelope");
assert_eq!(value["target"], serde_json::json!("test"));
assert!(value["queries"].as_array().is_some_and(|a| !a.is_empty()));
}
#[test]
fn convert_falls_back_to_text_for_csv() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"backend",
"convert",
rule.path().to_str().unwrap(),
"-t",
"test",
"--output-format",
"csv",
])
.output()
.unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stderr.contains("not supported by `backend convert`"),
"expected fallback warning, got: {stderr}",
);
assert!(
!stdout.starts_with('{'),
"expected raw query text, got: {stdout}"
);
}
#[test]
fn quiet_suppresses_convert_fallback_warning() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"backend",
"convert",
rule.path().to_str().unwrap(),
"-t",
"test",
"--output-format",
"csv",
"--quiet",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("not supported"),
"--quiet should drop the fallback notice, got: {stderr}",
);
let _ = predicate::always();
}
#[test]
fn convert_output_format_ndjson_is_one_query_per_line() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"backend",
"convert",
rule.path().to_str().unwrap(),
"-t",
"test",
"--output-format",
"ndjson",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let mut saw = false;
for line in stdout.lines().filter(|l| !l.trim().is_empty()) {
let value: serde_json::Value =
serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid NDJSON: {line:?}: {e}"));
assert_eq!(value["target"], "test");
assert!(value.get("query").is_some());
saw = true;
}
assert!(saw, "expected at least one NDJSON query record");
}
#[test]
fn targets_output_format_csv_lists_native_rows() {
let out = rsigma()
.env("RSIGMA_SIGMA_CLI", "/nonexistent/rsigma-test-sigma-cli")
.args(["backend", "targets", "--output-format", "csv"])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines().filter(|l| !l.trim().is_empty());
assert_eq!(lines.next(), Some("PROVIDER,NAME,DESCRIPTION"));
assert!(
lines.any(|l| l.starts_with("native,postgres,")),
"missing postgres row in {stdout}"
);
}
#[test]
fn validate_output_format_json_emits_summary_envelope() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("rule.yml"), SIMPLE_RULE).unwrap();
let out = rsigma()
.args([
"rule",
"validate",
dir.path().to_str().unwrap(),
"--output-format",
"json",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let value: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("validate JSON envelope");
assert!(value.get("summary").is_some());
assert_eq!(value["summary"]["detection_rules"], 1);
}
#[test]
fn parse_table_warns_and_falls_back_to_json() {
let rule = temp_file(".yml", SIMPLE_RULE);
let out = rsigma()
.args([
"rule",
"parse",
rule.path().to_str().unwrap(),
"--output-format",
"table",
])
.output()
.unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stderr.contains("not supported by `rule parse`"),
"expected unsupported warning, got: {stderr}"
);
let _: serde_json::Value = serde_json::from_str(stdout.trim()).expect("JSON fallback");
}
#[test]
fn reverse_csv_warns_and_keeps_yaml() {
let out = rsigma()
.args([
"rule",
"reverse",
"--from",
"lucene",
"EventID:1",
"--logsource-product",
"windows",
"--output-format",
"csv",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stderr.contains("not supported by `rule reverse`"),
"expected warning, got: {stderr}"
);
assert!(
stdout.contains("title:") || stdout.contains("detection:"),
"expected Sigma YAML, got: {stdout}"
);
}
#[test]
fn config_path_csv_emits_source_path_rows() {
let cfg = temp_file(".yaml", "version: 1\n");
let out = rsigma()
.args([
"config",
"path",
"-c",
cfg.path().to_str().unwrap(),
"--output-format",
"csv",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("SOURCE,PATH"),
"missing CSV header in {stdout}"
);
assert!(
stdout.contains(cfg.path().file_name().unwrap().to_str().unwrap()),
"missing path row in {stdout}"
);
}
#[test]
fn config_validate_local_format_beats_global_output_format() {
let cfg = temp_file(".yaml", "version: 1\n");
let out = rsigma()
.args([
"config",
"validate",
"-c",
cfg.path().to_str().unwrap(),
"--format",
"json",
"--output-format",
"csv",
])
.output()
.unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stderr.contains("local --format takes precedence"),
"expected precedence warning, got: {stderr}"
);
let value: serde_json::Value = serde_json::from_str(stdout.trim()).expect("JSON from --format");
assert_eq!(value["ok"], true);
}
#[test]
fn config_short_path_supplies_global_output_format() {
let cfg = temp_file(".yaml", "version: 1\nglobal:\n output_format: csv\n");
let out = rsigma()
.args(["config", "show", "-c", cfg.path().to_str().unwrap()])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).starts_with("PATH,VALUE,SOURCE\n"),
"expected CSV selected from -c config, got: {}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn invalid_environment_format_falls_back_to_config_file() {
let cfg = temp_file(".yaml", "version: 1\nglobal:\n output_format: csv\n");
let out = rsigma()
.env("RSIGMA_GLOBAL__OUTPUT_FORMAT", "xml")
.args(["config", "show", "--config", cfg.path().to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success());
assert!(
String::from_utf8_lossy(&out.stdout).starts_with("PATH,VALUE,SOURCE\n"),
"expected file CSV fallback, got: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid global.output_format 'xml'"));
}
#[test]
fn convert_output_path_warns_when_output_format_is_ignored() {
let rule = temp_file(".yml", SIMPLE_RULE);
let output = tempfile::NamedTempFile::new().unwrap();
let out = rsigma()
.args([
"backend",
"convert",
rule.path().to_str().unwrap(),
"-t",
"test",
"--output-format",
"json",
"--output",
output.path().to_str().unwrap(),
])
.output()
.unwrap();
assert!(out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("--output always writes raw query text"),
"expected precedence warning, got: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty());
}
#[test]
fn pipeline_diff_csv_emits_per_rule_rows() {
let rule = temp_file(".yml", SIMPLE_RULE);
let pipe = temp_file(
".yml",
r#"
name: rename
priority: 10
transformations:
- id: rename_cmd
type: field_name_mapping
mapping:
CommandLine: process.command_line
"#,
);
let out = rsigma()
.args([
"pipeline",
"diff",
"-r",
rule.path().to_str().unwrap(),
"-p",
pipe.path().to_str().unwrap(),
"--output-format",
"csv",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let mut reader = csv::ReaderBuilder::new().from_reader(stdout.as_bytes());
let headers: Vec<String> = reader
.headers()
.unwrap()
.iter()
.map(str::to_string)
.collect();
assert_eq!(
headers,
vec![
"RULE_TITLE",
"RULE_ID",
"CHANGED",
"APPLIED_ITEMS",
"BEFORE",
"AFTER"
]
);
assert!(reader.records().next().is_some());
}