use assert_cmd::Command;
use predicates::prelude::*;
use std::path::Path;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")).join(name)
}
fn write_dbt_project_marker(dir: &Path) {
let path = dir.join("dbt_project.yml");
std::fs::write(&path, "name: fixture\nversion: '1.0.0'\n")
.expect("should write dbt_project.yml marker");
std::fs::File::options()
.write(true)
.open(&path)
.expect("should reopen dbt_project.yml")
.set_modified(std::time::SystemTime::UNIX_EPOCH)
.expect("should set an old mtime on dbt_project.yml");
}
#[test]
fn default_text_output_produces_the_three_part_human_readable_report() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--no-color")
.output()
.expect("command should run");
assert_eq!(output.status.code(), Some(1));
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Changed:\n"), "{stdout}");
assert!(stdout.contains("Downstream impact:\n"), "{stdout}");
assert!(stdout.contains("Summary:"), "{stdout}");
assert!(
stdout.contains("model model.zhao_dbt_test.stg_customers:"),
"{stdout}"
);
assert!(
stdout.contains("model model.zhao_dbt_test.dim_customers:"),
"{stdout}"
);
assert!(
stdout.contains("~ column type changed: customer_id (bigint -> int)"),
"{stdout}"
);
assert!(
stdout.contains("+ column added: marketing_source"),
"{stdout}"
);
assert!(stdout.contains("- column removed: last_name"), "{stdout}");
assert!(
stdout.contains("~ join changed at position 0: left -> inner"),
"{stdout}"
);
assert!(
stdout.contains("Summary: 2 model(s) changed, 4 column(s) changed, 1 breaking, 1 warning"),
"{stdout}"
);
assert!(!stdout.contains("Node "), "{stdout}");
assert!(!stdout.contains("Origin "), "{stdout}");
}
#[test]
fn downstream_impact_lists_only_nodes_actually_reached_with_reason_and_rule() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--no-color")
.output()
.expect("command should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let downstream_impact = stdout
.split("Downstream impact:\n")
.nth(1)
.expect("Downstream impact section should be present")
.split("\nSummary:")
.next()
.expect("Summary should follow Downstream impact");
assert!(
downstream_impact.contains(
"[BREAKING] last_name removed from model model.zhao_dbt_test.stg_customers \
breaks reference via last_name (column-removed-with-active-references)"
),
"{downstream_impact}"
);
assert!(
downstream_impact
.contains("[WARN] customer_id type narrowed from bigint to int (column-type-narrowed)"),
"{downstream_impact}"
);
assert!(
!downstream_impact.contains("marketing_source"),
"a pass-severity finding must not appear in Downstream impact: {downstream_impact}"
);
}
#[test]
fn unrelated_models_in_the_same_project_do_not_appear_in_either_section() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--no-color")
.output()
.expect("command should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let changed_and_downstream_impact = stdout
.split("\nSummary:")
.next()
.expect("Summary should follow Changed/Downstream impact");
for unrelated in [
"stg_payments",
"stg_orders",
"fct_orders",
"fct_orders_incremental",
] {
assert!(
!changed_and_downstream_impact.contains(unrelated),
"{unrelated} is unrelated to this diff and must not appear in Changed or \
Downstream impact, but it did: {changed_and_downstream_impact}"
);
}
}
#[test]
fn no_color_flag_produces_byte_for_byte_plain_text() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--no-color")
.assert()
.code(1)
.stdout(
"Changed:\n\
\x20 model model.zhao_dbt_test.stg_customers:\n\
\x20 ~ column type changed: customer_id (bigint -> int)\n\
\x20 + column added: marketing_source\n\
\x20 - column removed: last_name\n\
\x20 model model.zhao_dbt_test.dim_customers:\n\
\x20 - column removed: last_name\n\
\x20 ~ join changed at position 0: left -> inner\n\
\n\
Downstream impact:\n\
\x20 model model.zhao_dbt_test.stg_customers:\n\
\x20 [WARN] customer_id type narrowed from bigint to int (column-type-narrowed)\n\
\x20 model model.zhao_dbt_test.dim_customers:\n\
\x20 [BREAKING] last_name removed from model model.zhao_dbt_test.stg_customers \
breaks reference via last_name (column-removed-with-active-references)\n\
\n\
Summary: 2 model(s) changed, 4 column(s) changed, 1 breaking, 1 warning\n\
\n\
Impacted models: stg_customers, dim_customers\n\
\n\
Defer plan:\n\
\x20 Build: stg_customers, dim_customers\n\
\x20 Defer (assumed available): stg_orders\n",
);
}
#[test]
fn auto_detection_suppresses_color_when_stdout_is_not_a_tty() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env_remove("GITHUB_ACTIONS")
.env_remove("NO_COLOR")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.output()
.expect("command should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(
!stdout.contains('\u{1b}'),
"piped (non-TTY) stdout outside any CI environment should auto-suppress color: \
{stdout:?}"
);
}
#[test]
fn color_codes_are_emitted_in_a_color_capable_environment() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("GITHUB_ACTIONS", "true")
.env_remove("NO_COLOR")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.output()
.expect("command should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(
stdout.contains('\u{1b}'),
"a color-capable environment (GITHUB_ACTIONS=true) should emit ANSI escapes: {stdout:?}"
);
}
#[test]
fn exits_non_zero_and_reports_the_breaking_change_as_json() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(
predicate::str::contains("\"rule\": \"column-removed-with-active-references\"")
.and(predicate::str::contains("\"severity\": \"error\""))
.and(predicate::str::contains(
"\"reached\": \"model.zhao_dbt_test.dim_customers\"",
)),
);
}
#[test]
fn all_applicable_rules_fire_together_on_a_fixture_with_simultaneous_changes() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--format")
.arg("json")
.output()
.expect("command should run");
let parsed: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
let changes = parsed["changes"]
.as_array()
.expect("changes should be an array");
let findings = parsed["findings"]
.as_array()
.expect("findings should be an array");
assert_eq!(changes.len(), 5);
let rules: Vec<&str> = findings
.iter()
.map(|f| f["rule"].as_str().unwrap())
.collect();
assert_eq!(
rules.len(),
3,
"expected exactly 3 findings, got: {findings:#?}"
);
assert!(rules.contains(&"column-removed-with-active-references"));
assert!(rules.contains(&"column-type-narrowed"));
assert!(rules.contains(&"column-added"));
assert!(
!rules.contains(&"join-cardinality-loosened"),
"the fixture's join change is LEFT -> INNER, a tightening -- it must not fire"
);
let severities: Vec<&str> = findings
.iter()
.map(|f| f["severity"].as_str().unwrap())
.collect();
assert!(severities.contains(&"error"));
assert!(severities.contains(&"warn"));
assert!(severities.contains(&"pass"));
}
#[test]
fn exits_zero_when_nothing_breaking_is_found() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(fixture("clean_project"))
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains("\"findings\": []"));
}
#[test]
fn a_target_path_override_changes_where_the_current_manifest_is_read_from() {
let dir = tempfile::tempdir().expect("should create temp dir");
let project_dir = dir.path();
write_dbt_project_marker(project_dir);
std::fs::create_dir_all(project_dir.join("custom_target")).expect("should create dir");
std::fs::copy(
fixture("clean_project")
.join("target")
.join("manifest.json"),
project_dir.join("custom_target").join("manifest.json"),
)
.expect("should copy fixture manifest into the custom target-path dir");
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(project_dir)
.arg("--dbt-args")
.arg("--target-path custom_target")
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains("\"findings\": []"));
assert!(
!project_dir.join("target").join("manifest.json").exists(),
"the current manifest should be read from custom_target/, never written to target/"
);
}
#[test]
fn impacted_models_matches_the_downstream_impact_nodes() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(predicate::str::contains(
"\"impacted_models\": [\n \"stg_customers\",\n \"dim_customers\"\n ]",
));
}
#[test]
fn recommended_command_is_built_from_impacted_models_when_configured() {
let dir = tempfile::tempdir().expect("should create temp dir");
let project_dir = dir.path();
write_dbt_project_marker(project_dir);
std::fs::create_dir_all(project_dir.join("target")).expect("should create target dir");
std::fs::copy(
fixture("breaking_project")
.join("target")
.join("manifest.json"),
project_dir.join("target").join("manifest.json"),
)
.expect("should copy fixture manifest");
std::fs::write(
project_dir.join("zhao.yml"),
"tool: dbt\nrecommended-command:\n subcommand: run\n",
)
.expect("should write zhao.yml");
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(project_dir)
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(predicate::str::contains(
"\"recommended_command\": \"dbt run --select stg_customers dim_customers\"",
));
}
#[test]
fn recommended_command_is_absent_from_json_when_not_configured() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(predicate::str::contains("\"recommended_command\"").not());
}
#[test]
fn impacted_models_is_empty_with_no_changes_and_names_only_the_changed_model_for_a_pass_change() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(fixture("clean_project"))
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains("\"impacted_models\": []"));
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(fixture("non_matching_project"))
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains(
"\"impacted_models\": [\n \"stg_orders\"\n ]",
));
}
#[test]
fn defer_plan_identifies_build_and_defer_sets_correctly() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(
predicate::str::contains("\"defer_plan\":")
.and(predicate::str::contains("\"stg_customers\","))
.and(predicate::str::contains("\"dim_customers\""))
.and(predicate::str::contains(
"\"defer\": [\n \"stg_orders\"\n ]",
)),
);
}
#[test]
fn defer_plan_appears_in_human_readable_output() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--no-color")
.assert()
.code(1)
.stdout(
predicate::str::contains("Defer plan:\n")
.and(predicate::str::contains(
"Build: stg_customers, dim_customers",
))
.and(predicate::str::contains(
"Defer (assumed available): stg_orders",
)),
);
}
#[test]
fn defer_target_and_state_flags_surface_the_state_path() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--defer-target")
.arg("prod")
.arg("--defer-state")
.arg("artifacts/prod/manifest.json")
.arg("--no-color")
.assert()
.code(1)
.stdout(
predicate::str::contains("Target: prod").and(predicate::str::contains(
"State: artifacts/prod/manifest.json",
)),
);
}
#[test]
fn defer_target_and_state_flags_appear_in_json_output() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--defer-target")
.arg("prod")
.arg("--defer-state")
.arg("artifacts/prod/manifest.json")
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(
predicate::str::contains("\"target\": \"prod\"").and(predicate::str::contains(
"\"state\": \"artifacts/prod/manifest.json\"",
)),
);
}
#[test]
fn no_defer_flags_produce_no_target_or_state() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("breaking_project"))
.arg("--no-color")
.assert()
.code(1)
.stdout(
predicate::str::contains("Defer plan:\n")
.and(predicate::str::contains("Target:").not())
.and(predicate::str::contains("State:").not()),
);
}
#[test]
fn zhao_yml_defer_config_surfaces_without_any_cli_flag() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("defer_config_project"))
.arg("--no-color")
.assert()
.code(1)
.stdout(
predicate::str::contains("Target: staging").and(predicate::str::contains(
"State: artifacts/staging/manifest.json",
)),
);
}
#[test]
fn defer_flags_override_a_conflicting_zhao_yml_defer_config() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("defer_config_project"))
.arg("--defer-target")
.arg("prod")
.arg("--defer-state")
.arg("artifacts/prod/manifest.json")
.arg("--no-color")
.assert()
.code(1)
.stdout(
predicate::str::contains("Target: prod")
.and(predicate::str::contains(
"State: artifacts/prod/manifest.json",
))
.and(predicate::str::contains("staging").not())
.and(predicate::str::contains("artifacts/staging").not()),
);
}
#[test]
fn no_defer_plan_when_nothing_is_impactful() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(fixture("clean_project"))
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains("defer_plan").not());
}
#[test]
fn exits_zero_when_the_only_finding_is_pass_severity() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(fixture("non_matching_project"))
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(
predicate::str::contains("\"type\": \"column_added\"")
.and(predicate::str::contains("\"rule\": \"column-added\""))
.and(predicate::str::contains("\"severity\": \"pass\"")),
);
}
#[test]
fn type_widening_does_not_fire_while_join_loosening_does() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("rules_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("rules_project"))
.arg("--format")
.arg("json")
.output()
.expect("command should run");
let parsed: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
let findings = parsed["findings"]
.as_array()
.expect("findings should be an array");
assert_eq!(
findings.len(),
1,
"expected only the join-loosening finding, got: {findings:#?}"
);
assert_eq!(findings[0]["rule"], "join-cardinality-loosened");
assert_eq!(findings[0]["from_kind"], "inner");
assert_eq!(findings[0]["to_kind"], "left");
assert!(
!findings.iter().any(|f| f["rule"] == "column-type-narrowed"),
"a type widening (int -> bigint) must not fire column-type-narrowed"
);
}
#[test]
fn no_zhao_yml_behaves_identically_to_v1_defaults() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("rules_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("rules_project"))
.arg("--format")
.arg("json")
.assert()
.code(0) .stdout(
predicate::str::contains("\"rule\": \"join-cardinality-loosened\"")
.and(predicate::str::contains("\"severity\": \"warn\"")),
);
}
#[test]
fn strict_preset_changes_a_warn_rule_to_error() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("rules_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("config_strict_project"))
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(
predicate::str::contains("\"rule\": \"join-cardinality-loosened\"")
.and(predicate::str::contains("\"severity\": \"error\"")),
);
}
#[test]
fn per_rule_override_wins_only_for_that_rule() {
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--project-dir")
.arg(fixture("config_override_project"))
.arg("--format")
.arg("json")
.output()
.expect("command should run");
let parsed: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
let findings = parsed["findings"]
.as_array()
.expect("findings should be an array");
let type_narrowed = findings
.iter()
.find(|f| f["rule"] == "column-type-narrowed")
.expect("column-type-narrowed should fire");
assert_eq!(
type_narrowed["severity"], "error",
"not overridden -- should follow the strict Preset"
);
let column_added = findings
.iter()
.find(|f| f["rule"] == "column-added")
.expect("column-added should fire");
assert_eq!(
column_added["severity"], "pass",
"overridden -- should stay pass despite the strict Preset"
);
}
fn fake_monorepo(project_manifest_fixture: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("should create temp dir");
std::fs::create_dir_all(dir.path().join(".git")).expect("should create .git marker");
let project_dir = dir.path().join("services").join("analytics");
std::fs::create_dir_all(project_dir.join("target")).expect("should create project target dir");
write_dbt_project_marker(&project_dir);
std::fs::copy(
fixture(project_manifest_fixture)
.join("target")
.join("manifest.json"),
project_dir.join("target").join("manifest.json"),
)
.expect("should copy fixture manifest");
(dir, project_dir)
}
#[test]
fn a_root_only_zhao_yml_applies_to_a_nested_dbt_project() {
let (repo, project_dir) = fake_monorepo("rules_project");
std::fs::write(repo.path().join("zhao.yml"), "preset: strict\n")
.expect("should write root zhao.yml");
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("rules_baseline_manifest.json"))
.arg("--project-dir")
.arg(&project_dir)
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(
predicate::str::contains("\"rule\": \"join-cardinality-loosened\"")
.and(predicate::str::contains("\"severity\": \"error\"")),
);
}
#[test]
fn a_project_local_zhao_yml_overrides_the_root_one_per_key() {
let (repo, project_dir) = fake_monorepo("rules_project");
std::fs::write(repo.path().join("zhao.yml"), "preset: strict\n")
.expect("should write root zhao.yml");
std::fs::write(
project_dir.join("zhao.yml"),
"rules:\n join-cardinality-loosened: warn\n",
)
.expect("should write project-local zhao.yml");
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("rules_baseline_manifest.json"))
.arg("--project-dir")
.arg(&project_dir)
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(
predicate::str::contains("\"rule\": \"join-cardinality-loosened\"")
.and(predicate::str::contains("\"severity\": \"warn\"")),
);
}
#[test]
fn a_single_project_repo_behaves_exactly_as_before_monorepo_support() {
let dir = tempfile::tempdir().expect("should create temp dir");
std::fs::create_dir_all(dir.path().join(".git")).expect("should create .git marker");
std::fs::create_dir_all(dir.path().join("target")).expect("should create target dir");
write_dbt_project_marker(dir.path());
std::fs::copy(
fixture("rules_project")
.join("target")
.join("manifest.json"),
dir.path().join("target").join("manifest.json"),
)
.expect("should copy fixture manifest");
std::fs::write(dir.path().join("zhao.yml"), "preset: strict\n").expect("should write zhao.yml");
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("rules_baseline_manifest.json"))
.arg("--project-dir")
.arg(dir.path())
.arg("--format")
.arg("json")
.assert()
.code(1)
.stdout(
predicate::str::contains("\"rule\": \"join-cardinality-loosened\"")
.and(predicate::str::contains("\"severity\": \"error\"")),
);
}
#[test]
fn invalid_zhao_yml_produces_a_clear_error() {
let dir = tempfile::tempdir().expect("should create temp dir");
std::fs::create_dir_all(dir.path().join("target")).expect("should create target dir");
std::fs::copy(
fixture("clean_project")
.join("target")
.join("manifest.json"),
dir.path().join("target").join("manifest.json"),
)
.expect("should copy fixture manifest");
std::fs::write(
dir.path().join("zhao.yml"),
"rules:\n not-a-real-rule: error\n",
)
.expect("should write zhao.yml");
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(dir.path())
.assert()
.code(2)
.stderr(
predicate::str::contains("unknown rule")
.and(predicate::str::contains("not-a-real-rule")),
);
}
#[test]
fn exits_with_error_code_on_a_missing_baseline_path() {
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("does_not_exist.json"))
.arg("--project-dir")
.arg(fixture("clean_project"))
.assert()
.code(2)
.stderr(predicate::str::contains("error:"));
}
#[cfg(unix)]
mod check_relations {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn incremental_manifest(select_sql: &str) -> String {
format!(
r#"{{
"metadata": {{"adapter_type": "duckdb"}},
"sources": {{}},
"nodes": {{
"model.p.m": {{
"unique_id": "model.p.m",
"resource_type": "model",
"name": "m",
"database": "db",
"schema": "public",
"alias": "m",
"compiled_code": "{select_sql}",
"config": {{"materialized": "incremental"}}
}}
}}
}}"#
)
}
fn project_with_a_schema_change() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("should create temp dir");
let baseline_path = dir.path().join("baseline_manifest.json");
std::fs::write(&baseline_path, incremental_manifest("select 1 as id"))
.expect("should write baseline manifest");
write_dbt_project_marker(dir.path());
std::fs::create_dir_all(dir.path().join("target")).expect("should create target dir");
std::fs::write(
dir.path().join("target").join("manifest.json"),
incremental_manifest("select 1 as id, 2 as new_col"),
)
.expect("should write current manifest");
(dir, baseline_path)
}
fn stub_dbt_run_operation(result: &str) -> tempfile::TempDir {
let stub_dir = tempfile::tempdir().expect("should create temp dir");
let path = stub_dir.path().join("dbt");
std::fs::write(
&path,
format!("#!/bin/sh\necho 'ZHAO_RELATION_EXISTS_RESULT:{result}'\n"),
)
.expect("should write stub dbt script");
let mut perms = std::fs::metadata(&path)
.expect("should stat stub script")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("should chmod stub script");
std::thread::sleep(std::time::Duration::from_millis(50));
stub_dir
}
#[test]
fn upgrades_the_flag_to_definitive_when_the_relation_is_confirmed_to_exist() {
let (project, baseline) = project_with_a_schema_change();
let stub_dir = stub_dbt_run_operation("true");
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env(
"PATH",
format!(
"{}:{}",
stub_dir.path().display(),
std::env::var("PATH").unwrap_or_default()
),
)
.arg("check")
.arg("--state")
.arg(&baseline)
.arg("--project-dir")
.arg(project.path())
.arg("--check-relations")
.arg("--no-color")
.output()
.expect("command should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Schema evolution:"), "{stdout}");
assert!(!stdout.contains("if this"), "{stdout}");
assert!(
stdout.contains("exists in your target environment"),
"{stdout}"
);
}
#[test]
fn drops_the_flag_when_the_relation_is_confirmed_absent() {
let (project, baseline) = project_with_a_schema_change();
let stub_dir = stub_dbt_run_operation("false");
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env(
"PATH",
format!(
"{}:{}",
stub_dir.path().display(),
std::env::var("PATH").unwrap_or_default()
),
)
.arg("check")
.arg("--state")
.arg(&baseline)
.arg("--project-dir")
.arg(project.path())
.arg("--check-relations")
.arg("--no-color")
.output()
.expect("command should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(!stdout.contains("Schema evolution:"), "{stdout}");
}
#[test]
fn without_the_flag_the_wording_stays_conditional() {
let (project, baseline) = project_with_a_schema_change();
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(&baseline)
.arg("--project-dir")
.arg(project.path())
.arg("--no-color")
.output()
.expect("command should run");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Schema evolution:"), "{stdout}");
assert!(stdout.contains("if this"), "{stdout}");
}
}
#[cfg(unix)]
mod git_native_baseline {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::process::Command as StdCommand;
struct TestRepo {
_dir: tempfile::TempDir,
path: std::path::PathBuf,
}
impl TestRepo {
fn git(&self, args: &[&str]) {
let output = StdCommand::new("git")
.current_dir(&self.path)
.args(args)
.output()
.expect("git should be runnable in tests");
assert!(
output.status.success(),
"git {args:?} should succeed: {output:?}"
);
}
fn write(&self, relative_path: &str, contents: &str) {
let path = self.path.join(relative_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("should create parent dir");
}
std::fs::write(path, contents).expect("should write file");
}
fn commit(&self, message: &str) {
self.git(&["add", "."]);
self.git(&["commit", "-m", message]);
}
}
fn new_test_repo() -> TestRepo {
let dir = tempfile::tempdir().expect("should create temp dir");
let path = dir.path().to_path_buf();
let repo = TestRepo { _dir: dir, path };
repo.git(&["init", "--initial-branch=master"]);
repo.git(&["config", "user.email", "test@zhao.invalid"]);
repo.git(&["config", "user.name", "zhao test"]);
repo.write("dbt_project.yml", "name: fixture\nversion: '1.0.0'\n");
repo
}
fn stub_dbt_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("should create temp dir");
let path = dir.path().join("dbt");
std::fs::write(
&path,
"#!/bin/sh\nmkdir -p target\ncp dbt_manifest_source.json target/manifest.json\n",
)
.expect("should write stub dbt script");
let mut perms = std::fs::metadata(&path)
.expect("should stat stub script")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("should chmod stub script");
std::thread::sleep(std::time::Duration::from_millis(50));
dir
}
fn path_with_stub_dbt_prepended(stub_dir: &tempfile::TempDir) -> String {
let existing = std::env::var("PATH").unwrap_or_default();
format!("{}:{existing}", stub_dir.path().display())
}
fn logging_stub_dbt_dir(invocation_log: &Path) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("should create temp dir");
let path = dir.path().join("dbt");
std::fs::write(
&path,
format!(
"#!/bin/sh\necho \"$@\" >> {log}\nif [ \"$1\" = \"compile\" ]; then\n mkdir -p target\n cp dbt_manifest_source.json target/manifest.json\nfi\n",
log = invocation_log.display()
),
)
.expect("should write logging stub dbt script");
let mut perms = std::fs::metadata(&path)
.expect("should stat stub script")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("should chmod stub script");
std::thread::sleep(std::time::Duration::from_millis(50));
dir
}
fn repo_with_baseline_and_current(baseline_manifest: &str, current_manifest: &str) -> TestRepo {
let repo = new_test_repo();
repo.write("dbt_manifest_source.json", baseline_manifest);
repo.commit("baseline state");
repo.git(&["checkout", "-b", "feature"]);
repo.write("README.md", "an unrelated change on the feature branch\n");
repo.commit("feature work, ahead of master");
repo.write("target/manifest.json", current_manifest);
repo
}
fn rules_baseline_manifest_json() -> String {
std::fs::read_to_string(fixture("rules_baseline_manifest.json"))
.expect("should read fixture")
}
fn rules_project_current_manifest_json() -> String {
std::fs::read_to_string(
fixture("rules_project")
.join("target")
.join("manifest.json"),
)
.expect("should read fixture")
}
#[test]
fn resolves_and_compiles_the_merge_base_commit_as_the_baseline() {
let stub_dir = stub_dbt_dir();
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--format")
.arg("json")
.output()
.expect("command should run");
assert!(
output.status.success() || output.status.code() == Some(1),
"expected exit 0 or 1, got {:?}; stderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
let findings = parsed["findings"]
.as_array()
.expect("findings should be an array");
assert_eq!(
findings.len(),
1,
"expected only the join-loosening finding, got: {findings:#?}"
);
assert_eq!(findings[0]["rule"], "join-cardinality-loosened");
assert_eq!(findings[0]["from_kind"], "inner");
assert_eq!(findings[0]["to_kind"], "left");
assert!(
!findings.iter().any(|f| f["rule"] == "column-type-narrowed"),
"a type widening (int -> bigint) must not fire column-type-narrowed"
);
}
fn target_path_aware_stub_dbt_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("should create temp dir");
let path = dir.path().join("dbt");
std::fs::write(
&path,
"#!/bin/sh\n\
target_dir=target\n\
prev=\"\"\n\
for arg in \"$@\"; do\n\
\x20\x20if [ \"$prev\" = \"--target-path\" ]; then target_dir=\"$arg\"; fi\n\
\x20\x20prev=\"$arg\"\n\
done\n\
if [ \"$1\" = \"compile\" ]; then\n\
\x20\x20mkdir -p \"$target_dir\"\n\
\x20\x20cp dbt_manifest_source.json \"$target_dir/manifest.json\"\n\
fi\n",
)
.expect("should write stub dbt script");
let mut perms = std::fs::metadata(&path)
.expect("should stat stub script")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("should chmod stub script");
std::thread::sleep(std::time::Duration::from_millis(50));
dir
}
#[test]
fn a_target_path_override_isolates_the_baseline_compile_too_and_is_read_back_from_there() {
let stub_dir = target_path_aware_stub_dbt_dir();
let repo = new_test_repo();
repo.write("dbt_manifest_source.json", &rules_baseline_manifest_json());
repo.commit("baseline state");
repo.git(&["checkout", "-b", "feature"]);
repo.write("README.md", "an unrelated change on the feature branch\n");
repo.commit("feature work, ahead of master");
let current_target_path = tempfile::tempdir().expect("should create temp dir");
std::fs::write(
current_target_path.path().join("manifest.json"),
rules_project_current_manifest_json(),
)
.expect("should write current manifest");
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--format")
.arg("json")
.arg("--dbt-arg")
.arg("--target-path")
.arg("--dbt-arg")
.arg(current_target_path.path())
.output()
.expect("command should run");
assert!(
output.status.success() || output.status.code() == Some(1),
"expected exit 0 or 1 (Baseline resolution should succeed), got {:?}; stderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
assert_eq!(
parsed["findings"]
.as_array()
.expect("findings should be an array")
.len(),
1,
"expected the same single finding resolves_and_compiles_the_merge_base_commit_as_the_baseline asserts: {parsed:#?}"
);
assert!(
!repo.path.join("target").join("manifest.json").exists(),
"the project's real target/manifest.json should never be written"
);
}
fn repo_with_main_branch_and_current(
baseline_manifest: &str,
current_manifest: &str,
) -> TestRepo {
let dir = tempfile::tempdir().expect("should create temp dir");
let path = dir.path().to_path_buf();
let repo = TestRepo { _dir: dir, path };
repo.git(&["init", "--initial-branch=main"]);
repo.git(&["config", "user.email", "test@zhao.invalid"]);
repo.git(&["config", "user.name", "zhao test"]);
repo.write("dbt_project.yml", "name: fixture\nversion: '1.0.0'\n");
repo.write("dbt_manifest_source.json", baseline_manifest);
repo.commit("baseline state");
repo.git(&["checkout", "-b", "feature"]);
repo.write("README.md", "an unrelated change on the feature branch\n");
repo.commit("feature work, ahead of main");
repo.write("target/manifest.json", current_manifest);
repo
}
#[test]
fn zhao_yml_against_is_honored_with_no_cli_flag() {
let stub_dir = stub_dbt_dir();
let repo = repo_with_main_branch_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
repo.write("zhao.yml", "against: main\n");
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--format")
.arg("json")
.output()
.expect("command should run");
assert!(
output.status.success() || output.status.code() == Some(1),
"expected exit 0 or 1 (a real merge-base was found and diffed), got {:?}; stderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn cli_against_flag_overrides_a_conflicting_zhao_yml_value() {
let stub_dir = stub_dbt_dir();
let repo = repo_with_main_branch_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
repo.write("zhao.yml", "against: does-not-exist\n");
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--against")
.arg("main")
.arg("--format")
.arg("json")
.output()
.expect("command should run");
assert!(
output.status.success() || output.status.code() == Some(1),
"expected exit 0 or 1 (--against main should win over zhao.yml's bogus value), got {:?}; stderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn captures_the_baseline_manifest_after_git_native_resolution() {
let stub_dir = stub_dbt_dir();
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--format")
.arg("json")
.output()
.expect("command should run");
assert!(
output.status.success() || output.status.code() == Some(1),
"expected exit 0 or 1, got {:?}; stderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let captured_path = repo.path.join("target/zhao/baseline_manifest.json");
assert!(
captured_path.exists(),
"expected {} to exist after a git-native Baseline run",
captured_path.display()
);
let captured =
std::fs::read_to_string(&captured_path).expect("should read captured manifest");
assert_eq!(
captured,
rules_baseline_manifest_json(),
"the captured file should match exactly what the Baseline actually compiled to"
);
}
#[test]
fn state_flag_does_not_write_a_baseline_manifest_capture() {
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--state")
.arg(fixture("diff_baseline_manifest.json"))
.arg("--format")
.arg("json")
.output()
.expect("command should run");
assert!(
output.status.success() || output.status.code() == Some(1),
"expected exit 0 or 1, got {:?}; stderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let captured_path = repo.path.join("target/zhao/baseline_manifest.json");
assert!(
!captured_path.exists(),
"a --state run should never write baseline_manifest.json (nothing was compiled)"
);
}
#[test]
fn produces_a_clear_error_when_dbt_is_not_invokable() {
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
let git_path = String::from_utf8(
StdCommand::new("sh")
.arg("-c")
.arg("command -v git")
.output()
.expect("should locate git")
.stdout,
)
.expect("git path should be utf8")
.trim()
.to_string();
let git_only_dir = tempfile::tempdir().expect("should create temp dir");
std::os::unix::fs::symlink(&git_path, git_only_dir.path().join("git"))
.expect("should symlink git");
Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", git_only_dir.path())
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.assert()
.code(2)
.stderr(predicate::str::contains("dbt").and(predicate::str::contains("PATH")));
}
#[test]
fn produces_a_clear_error_when_a_merge_base_cannot_be_determined() {
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--against")
.arg("this-branch-does-not-exist")
.assert()
.code(2)
.stderr(predicate::str::contains("merge-base"));
}
#[test]
fn dbt_deps_runs_before_compile_when_packages_yml_is_present_at_the_baseline() {
let log_dir = tempfile::tempdir().expect("should create temp dir");
let invocation_log = log_dir.path().join("invocations.log");
let repo = new_test_repo();
repo.write("packages.yml", "packages: []\n");
repo.write("dbt_manifest_source.json", &rules_baseline_manifest_json());
repo.commit("baseline state, with packages.yml");
repo.git(&["checkout", "-b", "feature"]);
repo.write("README.md", "an unrelated change on the feature branch\n");
repo.commit("feature work, ahead of master");
repo.write(
"target/manifest.json",
&rules_project_current_manifest_json(),
);
let stub_dir = logging_stub_dbt_dir(&invocation_log);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.output()
.expect("command should run");
assert!(
output.status.code() == Some(0) || output.status.code() == Some(1),
"zhao itself should run to completion: {output:?}"
);
let log = std::fs::read_to_string(&invocation_log).expect("should read invocation log");
let subcommands: Vec<&str> = log
.lines()
.map(|line| line.split(' ').next().unwrap_or(""))
.collect();
assert_eq!(
subcommands,
vec!["deps", "--version", "compile"],
"dbt deps should run, before dbt compile, when packages.yml is present: {log}"
);
}
#[test]
fn dbt_deps_is_skipped_entirely_when_no_packages_yml_exists() {
let log_dir = tempfile::tempdir().expect("should create temp dir");
let invocation_log = log_dir.path().join("invocations.log");
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
let stub_dir = logging_stub_dbt_dir(&invocation_log);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.output()
.expect("command should run");
assert!(
output.status.code() == Some(0) || output.status.code() == Some(1),
"zhao itself should run to completion: {output:?}"
);
let log = std::fs::read_to_string(&invocation_log).expect("should read invocation log");
let subcommands: Vec<&str> = log
.lines()
.map(|line| line.split(' ').next().unwrap_or(""))
.collect();
assert_eq!(
subcommands,
vec!["--version", "compile"],
"dbt deps should not run at all when no packages.yml exists: {log}"
);
}
#[test]
fn dbt_deps_runs_before_compile_when_dependencies_yml_is_present_at_the_baseline() {
let log_dir = tempfile::tempdir().expect("should create temp dir");
let invocation_log = log_dir.path().join("invocations.log");
let repo = new_test_repo();
repo.write("dependencies.yml", "packages: []\n");
repo.write("dbt_manifest_source.json", &rules_baseline_manifest_json());
repo.commit("baseline state, with dependencies.yml");
repo.git(&["checkout", "-b", "feature"]);
repo.write("README.md", "an unrelated change on the feature branch\n");
repo.commit("feature work, ahead of master");
repo.write(
"target/manifest.json",
&rules_project_current_manifest_json(),
);
let stub_dir = logging_stub_dbt_dir(&invocation_log);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.output()
.expect("command should run");
assert!(
output.status.code() == Some(0) || output.status.code() == Some(1),
"zhao itself should run to completion: {output:?}"
);
let log = std::fs::read_to_string(&invocation_log).expect("should read invocation log");
let subcommands: Vec<&str> = log
.lines()
.map(|line| line.split(' ').next().unwrap_or(""))
.collect();
assert_eq!(
subcommands,
vec!["deps", "--version", "compile"],
"dbt deps should run, before dbt compile, when dependencies.yml is present: {log}"
);
}
#[test]
fn dbt_arg_values_are_appended_to_both_deps_and_compile_invocations() {
let log_dir = tempfile::tempdir().expect("should create temp dir");
let invocation_log = log_dir.path().join("invocations.log");
let repo = new_test_repo();
repo.write("packages.yml", "packages: []\n");
repo.write("dbt_manifest_source.json", &rules_baseline_manifest_json());
repo.commit("baseline state, with packages.yml");
repo.git(&["checkout", "-b", "feature"]);
repo.write("README.md", "an unrelated change on the feature branch\n");
repo.commit("feature work, ahead of master");
repo.write(
"target/manifest.json",
&rules_project_current_manifest_json(),
);
let stub_dir = logging_stub_dbt_dir(&invocation_log);
Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--dbt-arg")
.arg("--target=ci")
.assert()
.code(predicate::in_iter([0, 1]));
let log = std::fs::read_to_string(&invocation_log).expect("should read invocation log");
assert_eq!(
log.lines().collect::<Vec<_>>(),
vec!["deps --target=ci", "--version", "compile --target=ci"],
"--dbt-arg values should be appended to both the deps and compile invocations: {log}"
);
}
#[test]
fn dbt_arg_values_are_appended_in_order_to_the_compile_invocation() {
let log_dir = tempfile::tempdir().expect("should create temp dir");
let invocation_log = log_dir.path().join("invocations.log");
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
let stub_dir = logging_stub_dbt_dir(&invocation_log);
Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--dbt-arg")
.arg("--target=ci")
.arg("--dbt-arg")
.arg("--vars={\"foo\": \"bar\"}")
.assert()
.code(predicate::in_iter([0, 1]));
let log = std::fs::read_to_string(&invocation_log).expect("should read invocation log");
assert_eq!(
log.lines().collect::<Vec<_>>(),
vec!["--version", "compile --target=ci --vars={\"foo\": \"bar\"}"],
"both --dbt-arg values should be appended, in order, to the compile invocation: {log}"
);
}
#[test]
fn dbt_args_shell_splits_into_the_identical_result_as_dbt_arg() {
let log_dir = tempfile::tempdir().expect("should create temp dir");
let invocation_log = log_dir.path().join("invocations.log");
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
let stub_dir = logging_stub_dbt_dir(&invocation_log);
Command::cargo_bin("zhao")
.expect("binary should build")
.env("PATH", path_with_stub_dbt_prepended(&stub_dir))
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--dbt-args")
.arg("--target ci --vars '{\"foo\": \"bar\"}'")
.assert()
.code(predicate::in_iter([0, 1]));
let log = std::fs::read_to_string(&invocation_log).expect("should read invocation log");
assert_eq!(
log.lines().collect::<Vec<_>>(),
vec!["--version", "compile --target ci --vars {\"foo\": \"bar\"}"],
"--dbt-args should shell-word-split into the same argument boundaries \
--dbt-arg would have produced by hand: {log}"
);
}
#[test]
fn using_both_dbt_arg_and_dbt_args_together_is_a_clear_cli_error() {
let repo = repo_with_baseline_and_current(
&rules_baseline_manifest_json(),
&rules_project_current_manifest_json(),
);
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--project-dir")
.arg(&repo.path)
.arg("--dbt-arg")
.arg("--target=ci")
.arg("--dbt-args")
.arg("--target ci")
.assert()
.code(2)
.stderr(predicate::str::contains("cannot be used with"));
}
}
#[cfg(unix)]
mod staleness_warning {
use super::*;
use std::process::Command as StdCommand;
struct TestRepo {
_dir: tempfile::TempDir,
path: std::path::PathBuf,
}
impl TestRepo {
fn git(&self, args: &[&str]) {
let output = StdCommand::new("git")
.current_dir(&self.path)
.args(args)
.output()
.expect("git should be runnable in tests");
assert!(
output.status.success(),
"git {args:?} should succeed: {output:?}"
);
}
fn commit(&self, relative_path: &str, contents: &str, message: &str) {
std::fs::write(self.path.join(relative_path), contents).expect("should write file");
self.git(&["add", "."]);
self.git(&["commit", "-m", message]);
}
}
fn up_to_date_repo() -> TestRepo {
let dir = tempfile::tempdir().expect("should create temp dir");
let path = dir.path().to_path_buf();
let repo = TestRepo { _dir: dir, path };
repo.git(&["init", "--initial-branch=master"]);
repo.git(&["config", "user.email", "test@zhao.invalid"]);
repo.git(&["config", "user.name", "zhao test"]);
repo.commit(".gitignore", "target/\n", "ignore target/");
repo.commit(
"dbt_project.yml",
"name: fixture\nversion: '1.0.0'\n",
"add dbt_project.yml",
);
std::fs::File::options()
.write(true)
.open(repo.path.join("dbt_project.yml"))
.expect("should reopen dbt_project.yml")
.set_modified(std::time::SystemTime::UNIX_EPOCH)
.expect("should set an old mtime on dbt_project.yml");
repo.commit("README.md", "on master\n", "on master");
repo.git(&["checkout", "-b", "feature"]);
repo.commit("README.md", "on feature\n", "feature work, ahead of master");
std::fs::create_dir_all(repo.path.join("target")).expect("should create target dir");
std::fs::copy(
fixture("clean_project")
.join("target")
.join("manifest.json"),
repo.path.join("target").join("manifest.json"),
)
.expect("should copy fixture manifest");
repo
}
fn stale_repo() -> TestRepo {
let repo = up_to_date_repo();
repo.git(&["checkout", "master"]);
repo.commit(
"README.md",
"on master, updated after feature branched off\n",
"a new commit landed on master after feature branched off",
);
repo.git(&["checkout", "feature"]);
repo
}
fn check_command(repo: &TestRepo) -> Command {
let mut cmd = Command::cargo_bin("zhao").expect("binary should build");
cmd.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(&repo.path)
.arg("--no-color");
cmd
}
#[test]
fn no_warning_when_the_merge_base_matches_the_target_branchs_tip() {
let repo = up_to_date_repo();
check_command(&repo)
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains("staleness_warning").not());
check_command(&repo)
.assert()
.code(0)
.stdout(predicate::str::contains("warning:").not());
}
#[test]
fn warns_when_the_merge_base_is_behind_the_target_branchs_tip() {
let repo = stale_repo();
check_command(&repo)
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains(
"\"staleness_warning\": \"analysis may be stale, consider rebasing\"",
));
check_command(&repo)
.assert()
.code(0)
.stdout(predicate::str::contains(
"warning: analysis may be stale, consider rebasing",
));
}
#[test]
fn the_warning_never_changes_the_exit_code_even_under_a_strict_preset() {
let repo = stale_repo();
std::fs::create_dir_all(repo.path.join("target")).expect("should create target dir");
std::fs::copy(
fixture("rules_project")
.join("target")
.join("manifest.json"),
repo.path.join("target").join("manifest.json"),
)
.expect("should overwrite with a fixture that has a real breaking change");
std::fs::write(repo.path.join("zhao.yml"), "preset: strict\n")
.expect("should write zhao.yml");
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("rules_baseline_manifest.json"))
.arg("--project-dir")
.arg(&repo.path)
.arg("--format")
.arg("json")
.output()
.expect("command should run");
assert_eq!(
output.status.code(),
Some(1),
"join-cardinality-loosened should be escalated to error by the strict Preset, \
regardless of the simultaneous staleness warning; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
assert_eq!(
parsed["staleness_warning"], "analysis may be stale, consider rebasing",
"the staleness warning should still be present alongside the breaking finding"
);
assert_eq!(
parsed["findings"][0]["rule"], "join-cardinality-loosened",
"the exit code should come from this finding's severity, not the staleness warning"
);
assert_eq!(parsed["findings"][0]["severity"], "error");
}
}
mod stale_current_manifest {
use super::*;
struct TestProject {
_dir: tempfile::TempDir,
path: std::path::PathBuf,
}
fn set_mtime(path: &std::path::Path, seconds_since_epoch: u64) {
let time =
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds_since_epoch);
std::fs::File::options()
.write(true)
.open(path)
.expect("file should be openable for writing")
.set_modified(time)
.expect("mtime should be settable");
}
fn project_with_dbt_project_yml_mtime(
manifest_seconds: u64,
dbt_project_seconds: u64,
) -> TestProject {
let dir = tempfile::tempdir().expect("should create temp dir");
let path = dir.path().to_path_buf();
std::fs::create_dir_all(path.join("target")).expect("should create target dir");
std::fs::copy(
fixture("clean_project")
.join("target")
.join("manifest.json"),
path.join("target").join("manifest.json"),
)
.expect("should copy fixture manifest");
set_mtime(&path.join("target").join("manifest.json"), manifest_seconds);
std::fs::write(
path.join("dbt_project.yml"),
"name: fixture\nversion: '1.0.0'\n",
)
.expect("should write dbt_project.yml");
set_mtime(&path.join("dbt_project.yml"), dbt_project_seconds);
TestProject { _dir: dir, path }
}
#[test]
fn a_stale_manifest_fails_with_a_clear_error_by_default() {
let project = project_with_dbt_project_yml_mtime(1_000, 2_000);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(&project.path)
.output()
.expect("command should run");
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("looks stale"), "{stderr}");
assert!(stderr.contains("dbt compile"), "{stderr}");
assert!(stderr.contains("--allow-stale-manifest"), "{stderr}");
}
#[test]
fn a_fresh_manifest_is_not_flagged() {
let project = project_with_dbt_project_yml_mtime(2_000, 1_000);
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(&project.path)
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains("\"findings\": []"));
}
#[test]
fn allow_stale_manifest_bypasses_the_check() {
let project = project_with_dbt_project_yml_mtime(1_000, 2_000);
Command::cargo_bin("zhao")
.expect("binary should build")
.arg("check")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(&project.path)
.arg("--allow-stale-manifest")
.arg("--format")
.arg("json")
.assert()
.code(0)
.stdout(predicate::str::contains("\"findings\": []"));
}
#[test]
fn zhao_diff_is_also_gated_by_the_same_check() {
let project = project_with_dbt_project_yml_mtime(1_000, 2_000);
let output = Command::cargo_bin("zhao")
.expect("binary should build")
.arg("diff")
.arg("--state")
.arg(fixture("diff_baseline_manifest_clean.json"))
.arg("--project-dir")
.arg(&project.path)
.output()
.expect("command should run");
assert_eq!(
output.status.code(),
Some(2),
"zhao diff shares engine.rs::build_report with zhao check, so it must be gated too"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("looks stale"), "{stderr}");
}
}