use std::io::IsTerminal;
use std::path::Path;
use std::process::ExitCode;
use zhao_core::adapters::warehouse;
use zhao_core::config::Config;
use zhao_core::diff::diff;
use zhao_core::rules::evaluate;
use crate::adapter::ResolvedAdapter;
use crate::cli::{CheckArgs, OutputFormat};
use crate::report::{DeferSettings, Report, render_text};
pub(crate) struct EngineOutput {
pub report: Report,
pub current: zhao_core::model::ParsedProject,
pub log_retention_days: Option<u32>,
pub adapter: ResolvedAdapter,
}
pub(crate) fn build_report(args: &CheckArgs) -> Result<EngineOutput, String> {
let config = Config::load_for_project(&args.project_dir).map_err(|err| err.to_string())?;
let (dbt_command, dbt_passthrough_args) = crate::dbt_target::resolve_dbt_invocation(
args.dbt_command.as_deref(),
args.dbt_passthrough_args()?,
&config,
)?;
let current_manifest =
crate::dbt_target::resolve_target_dir(&args.project_dir, &dbt_passthrough_args)
.join("manifest.json");
if !args.allow_stale_manifest {
check_current_manifest_freshness(&args.project_dir, ¤t_manifest)?;
}
let adapter = ResolvedAdapter::resolve(&args.project_dir, config.tool())
.map_err(|err| err.to_string())?;
let against = args
.against
.clone()
.or_else(|| config.against().map(str::to_string))
.unwrap_or_else(|| "master".to_string());
let current_catalog_available = adapter.catalog_available(¤t_manifest);
let (baseline, use_catalog) = crate::baseline::resolve(
&adapter,
args.state.as_deref(),
&args.project_dir,
&against,
&dbt_command,
&dbt_passthrough_args,
current_catalog_available,
)
.map_err(|err| err.to_string())?;
let current = adapter
.parse_for_comparison(¤t_manifest, use_catalog)
.map_err(|err| format!("{}: {err}", current_manifest.display()))?;
let changes = diff(&baseline, ¤t);
let findings = evaluate(&baseline, ¤t, &changes, &config);
let defer_settings = DeferSettings {
target: args
.defer_target
.clone()
.or_else(|| config.defer_target().map(str::to_string)),
state: args
.defer_state
.as_ref()
.map(|path| path.display().to_string())
.or_else(|| config.defer_state().map(str::to_string)),
};
let mut report = Report::new(&changes, &findings)
.with_staleness_warning(is_stale(&args.project_dir, &against))
.with_impacted_models(adapter.vocabulary())
.with_defer_plan(¤t, adapter.vocabulary(), &defer_settings)
.with_recommended_command(
config.recommended_command_subcommand(),
&dbt_command,
defer_settings.target.as_deref(),
)
.with_schema_evolution_warnings(¤t);
if args.check_relations {
report = apply_check_relations(
report,
&adapter,
args,
¤t_manifest,
&dbt_command,
&dbt_passthrough_args,
);
}
let log_retention_days = args.purge_logs.or_else(|| config.log_retention_days());
Ok(EngineOutput {
report,
current,
log_retention_days,
adapter,
})
}
pub(crate) fn purge_run_logs(args: &CheckArgs, log_retention_days: Option<u32>) {
crate::log::purge(&args.project_dir, log_retention_days);
}
fn apply_check_relations(
report: Report,
adapter: &ResolvedAdapter,
args: &CheckArgs,
current_manifest: &Path,
dbt_command: &str,
dbt_passthrough_args: &[String],
) -> Report {
let adapter_type = match adapter.adapter_type(current_manifest) {
Ok(Some(adapter_type)) => adapter_type,
Ok(None) => {
eprintln!(
"warning: --check-relations: the compiled manifest doesn't record which \
warehouse it targets, so live existence checks aren't available"
);
return report;
}
Err(err) => {
eprintln!("warning: --check-relations: could not read the compiled manifest: {err}");
return report;
}
};
let Some(warehouse_adapter) = warehouse::resolve(&adapter_type) else {
eprintln!(
"warning: --check-relations: {adapter_type:?} isn't a supported warehouse yet, \
so live existence checks aren't available"
);
return report;
};
let relation_identities = match adapter.relation_identities(current_manifest) {
Ok(identities) => identities,
Err(err) => {
eprintln!(
"warning: --check-relations: could not read relation identities from the \
compiled manifest: {err}"
);
return report;
}
};
let executor = adapter.query_executor(&args.project_dir, dbt_command, dbt_passthrough_args);
report.with_live_relation_checks(|node_id| {
let relation = relation_identities.get(node_id)?;
match warehouse_adapter.relation_exists(relation, executor.as_ref()) {
Ok(exists) => Some(exists),
Err(err) => {
eprintln!("warning: --check-relations: could not check {node_id}: {err}");
None
}
}
})
}
pub(crate) fn write_run_metadata(output: &EngineOutput, args: &CheckArgs) {
let metadata = crate::metadata::RunMetadata::new(&output.report, &output.current);
if let Err(message) = crate::metadata::write(&metadata, &args.project_dir) {
eprintln!("warning: could not write run metadata: {message}");
}
}
pub(crate) fn print_report(
report: &Report,
adapter: &ResolvedAdapter,
args: &CheckArgs,
) -> Result<(), String> {
let printed = match args.format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(report)
.map_err(|err| format!("could not serialize report as JSON: {err}"))?;
println!("{json}");
format!("{json}\n")
}
OutputFormat::Text => {
let text = render_text(report, adapter.vocabulary(), use_color(args.no_color));
print!("{text}");
text
}
};
crate::log::mirror(&args.project_dir, &printed);
Ok(())
}
const EXIT_ERROR: u8 = 2;
pub(crate) fn fail(message: &str) -> ExitCode {
eprintln!("error: {message}");
ExitCode::from(EXIT_ERROR)
}
fn is_stale(project_dir: &Path, against: &str) -> bool {
zhao_core::git::repo_root(project_dir)
.and_then(|repo_root| zhao_core::git::merge_base_is_stale(&repo_root, against))
.unwrap_or(false)
}
fn use_color(no_color_flag: bool) -> bool {
use_color_decision(
no_color_flag,
std::env::var_os("NO_COLOR").is_some(),
std::env::var_os("GITHUB_ACTIONS").is_some(),
std::io::stdout().is_terminal(),
)
}
fn use_color_decision(
no_color_flag: bool,
no_color_env_set: bool,
github_actions_env_set: bool,
stdout_is_tty: bool,
) -> bool {
if no_color_flag || no_color_env_set {
return false;
}
github_actions_env_set || stdout_is_tty
}
const DBT_SOURCE_ROOT_FILES: &[&str] = &["dbt_project.yml", "packages.yml", "dependencies.yml"];
const DBT_SOURCE_DIRS: &[&str] = &[
"models",
"macros",
"seeds",
"snapshots",
"analyses",
"tests",
];
fn check_current_manifest_freshness(
project_dir: &Path,
manifest_path: &Path,
) -> Result<(), String> {
let Some(newest_source) = newest_dbt_source_mtime(project_dir) else {
return Ok(());
};
let Ok(manifest_mtime) = std::fs::metadata(manifest_path).and_then(|m| m.modified()) else {
return Ok(());
};
if newest_source > manifest_mtime {
return Err(format!(
"{manifest} looks stale: a dbt source file under {project_dir} was modified more \
recently than the compiled manifest. This usually means the manifest was compiled \
from a different branch or an older commit -- run `dbt compile` in {project_dir} \
and try again. Pass --allow-stale-manifest to skip this check (not recommended).",
manifest = manifest_path.display(),
project_dir = project_dir.display(),
));
}
Ok(())
}
fn newest_dbt_source_mtime(project_dir: &Path) -> Option<std::time::SystemTime> {
let mut newest: Option<std::time::SystemTime> = None;
let mut consider = |path: &Path| {
if let Ok(modified) = std::fs::metadata(path).and_then(|m| m.modified()) {
if newest.is_none_or(|current| modified > current) {
newest = Some(modified);
}
}
};
for file_name in DBT_SOURCE_ROOT_FILES {
consider(&project_dir.join(file_name));
}
for dir_name in DBT_SOURCE_DIRS {
walk_mtimes(&project_dir.join(dir_name), &mut consider);
}
newest
}
fn walk_mtimes(dir: &Path, consider: &mut dyn FnMut(&Path)) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_mtimes(&path, consider);
} else {
consider(&path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn set_mtime(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");
}
#[test]
fn no_dbt_project_present_is_never_flagged_stale() {
let dir = tempfile::tempdir().expect("tempdir should be creatable");
std::fs::create_dir_all(dir.path().join("target")).expect("target dir should be creatable");
let manifest = dir.path().join("target").join("manifest.json");
std::fs::write(&manifest, "{}").expect("manifest should be writable");
assert!(check_current_manifest_freshness(dir.path(), &manifest).is_ok());
}
#[test]
fn a_model_file_newer_than_the_manifest_is_flagged_stale() {
let dir = tempfile::tempdir().expect("tempdir should be creatable");
std::fs::create_dir_all(dir.path().join("target")).expect("target dir should be creatable");
let manifest = dir.path().join("target").join("manifest.json");
std::fs::write(&manifest, "{}").expect("manifest should be writable");
set_mtime(&manifest, 1_000);
std::fs::create_dir_all(dir.path().join("models")).expect("models dir should be creatable");
let model = dir.path().join("models").join("foo.sql");
std::fs::write(&model, "select 1").expect("model should be writable");
set_mtime(&model, 2_000);
let err = check_current_manifest_freshness(dir.path(), &manifest)
.expect_err("a newer model file should be flagged stale");
assert!(err.contains("looks stale"), "{err}");
assert!(err.contains("--allow-stale-manifest"), "{err}");
}
#[test]
fn a_changed_dbt_project_yml_alone_is_flagged_stale() {
let dir = tempfile::tempdir().expect("tempdir should be creatable");
std::fs::create_dir_all(dir.path().join("target")).expect("target dir should be creatable");
let manifest = dir.path().join("target").join("manifest.json");
std::fs::write(&manifest, "{}").expect("manifest should be writable");
set_mtime(&manifest, 1_000);
let dbt_project = dir.path().join("dbt_project.yml");
std::fs::write(&dbt_project, "name: fixture").expect("dbt_project.yml should be writable");
set_mtime(&dbt_project, 2_000);
assert!(check_current_manifest_freshness(dir.path(), &manifest).is_err());
}
#[test]
fn a_manifest_newer_than_every_source_file_is_not_flagged_stale() {
let dir = tempfile::tempdir().expect("tempdir should be creatable");
std::fs::create_dir_all(dir.path().join("models")).expect("models dir should be creatable");
let model = dir.path().join("models").join("foo.sql");
std::fs::write(&model, "select 1").expect("model should be writable");
set_mtime(&model, 1_000);
std::fs::create_dir_all(dir.path().join("target")).expect("target dir should be creatable");
let manifest = dir.path().join("target").join("manifest.json");
std::fs::write(&manifest, "{}").expect("manifest should be writable");
set_mtime(&manifest, 2_000);
assert!(check_current_manifest_freshness(dir.path(), &manifest).is_ok());
}
#[test]
fn an_unrelated_file_outside_dbt_source_conventions_is_ignored() {
let dir = tempfile::tempdir().expect("tempdir should be creatable");
std::fs::create_dir_all(dir.path().join("models")).expect("models dir should be creatable");
let model = dir.path().join("models").join("foo.sql");
std::fs::write(&model, "select 1").expect("model should be writable");
set_mtime(&model, 500);
std::fs::create_dir_all(dir.path().join("target")).expect("target dir should be creatable");
let manifest = dir.path().join("target").join("manifest.json");
std::fs::write(&manifest, "{}").expect("manifest should be writable");
set_mtime(&manifest, 1_000);
let unrelated = dir.path().join("zhao.yml");
std::fs::write(&unrelated, "preset: strict").expect("zhao.yml should be writable");
set_mtime(&unrelated, 2_000);
assert!(check_current_manifest_freshness(dir.path(), &manifest).is_ok());
}
#[test]
fn no_color_flag_wins_over_everything_else() {
assert!(!use_color_decision(true, false, true, true));
}
#[test]
fn no_color_env_var_wins_over_a_real_tty_and_github_actions() {
assert!(!use_color_decision(false, true, true, true));
}
#[test]
fn a_real_tty_enables_color_by_default() {
assert!(use_color_decision(false, false, false, true));
}
#[test]
fn github_actions_enables_color_even_without_a_real_tty() {
assert!(use_color_decision(false, false, true, false));
}
#[test]
fn a_plain_non_tty_non_ci_environment_suppresses_color() {
assert!(!use_color_decision(false, false, false, false));
}
}