1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Command-line argument parsing for `zhao`.
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
/// zhao: a change-review and CI engine for data transformation projects.
#[derive(Debug, Parser)]
#[command(name = "zhao", version)]
pub struct Cli {
/// The command to run.
#[command(subcommand)]
pub command: Command,
}
/// A `zhao` subcommand.
#[derive(Debug, Subcommand)]
pub enum Command {
/// Runs the breaking-change gate: diffs the current project against a
/// Baseline and exits non-zero if any Rule fires at `error` Severity.
Check(CheckArgs),
/// Runs the identical engine as `check` -- Baseline resolution, diff,
/// Rule evaluation, report rendering -- but always exits zero,
/// regardless of what Severity outcomes are present. For local
/// inspection during development; use `check` for CI gating.
Diff(CheckArgs),
/// Answers "what's upstream/downstream of this model?" -- a
/// structural query over the current project's compiled state, not a
/// Baseline-vs-current diff. No `--state`, no git, no `dbt compile`.
Lineage(LineageArgs),
/// Replaces the current `zhao` binary with a release fetched from
/// GitHub Releases -- the only command that reaches the network at
/// all, and only to download the binary itself; it never sends
/// anything from your project (see issue #28; every other command
/// stays fully offline).
Update(UpdateArgs),
}
/// Arguments for `zhao update`.
#[derive(Debug, clap::Args)]
pub struct UpdateArgs {
/// A specific release tag to install (e.g. `v0.1.1`), pinning to
/// that exact release instead of the latest stable one. Mutually
/// exclusive with `--nightly` (which is really just a shorthand for
/// the tag `"nightly"`, always the current moving nightly build).
#[arg(conflicts_with = "nightly")]
pub version: Option<String>,
/// Installs the latest nightly build instead of the latest stable
/// release.
#[arg(long)]
pub nightly: bool,
}
/// Arguments for `zhao lineage`.
#[derive(Debug, clap::Args)]
pub struct LineageArgs {
/// The lineage target, in dbt's own selector syntax: a bare model
/// name (or `model.column` for column-level lineage) shows both
/// upstream and downstream; a `+` prefix shows only upstream
/// (ancestors); a `+` suffix shows only downstream (descendants).
/// Required for `--text`; optional for the default HTML output,
/// where omitting it embeds the whole project's lineage graph
/// instead of scoping to one target.
pub target: Option<String>,
/// The dbt project directory to query. Its current compiled manifest
/// is read from `<project-dir>/target/manifest.json`, as-is -- run
/// `dbt compile` in the project before invoking `zhao lineage` (or
/// pass `--compile`).
#[arg(long, default_value = ".")]
pub project_dir: PathBuf,
/// Writes the self-contained, interactive HTML lineage graph to this
/// explicit path instead of the computed default under
/// `target/zhao/lineage_graphs/`. HTML is already the default output
/// mode -- this only overrides *where* it's written, not whether it's
/// produced; pass `--text` for the old plain-text report instead.
#[arg(long, conflicts_with = "text")]
pub html: Option<PathBuf>,
/// Prints the old plain-text report to stdout instead of the default
/// HTML export. A target is still required for `--text`, same as
/// text output always required one.
#[arg(long, conflicts_with = "html")]
pub text: bool,
/// Runs `dbt compile` in `--project-dir` before generating the
/// export, for a guaranteed-fresh view. Without it, the existing
/// `target/manifest.json` is read as-is, same as the default for
/// text output.
#[arg(long)]
pub compile: bool,
/// Disambiguates `target` when its bare model name matches more
/// than one model across different dbt packages (a real but
/// uncommon project shape -- multiple internal packages, dbt Mesh,
/// etc.) -- narrows resolution to the model in this package only.
/// The error for an ambiguous target without this flag lists every
/// matching model's full ID (`model.<package>.<name>`), which names
/// exactly what to pass here. Has no effect (and no error) when the
/// bare name is already unambiguous.
#[arg(long)]
pub package: Option<String>,
/// The intended CLI override for `zhao.yml`'s `log.level` (see issue
/// #35). Accepted and parsed now -- neither this flag nor `zhao.yml`'s
/// own `log.level` actually changes anything about the daily run log
/// yet, since its only defined content today (`Mirror`) is a literal,
/// unmodified copy of stdout, and `Debug` has no defined content of
/// its own to switch to -- reserved so a later ticket adding real
/// debug-level content doesn't also need another config-shape change.
#[arg(long = "log-level", value_enum)]
pub log_level: Option<LogLevelArg>,
/// One-off override for `zhao.yml`'s `log.retention_days` (see
/// issue #37): purges `target/zhao/logs/` files older than this
/// many days for this run only, without changing `zhao.yml`. With
/// neither this nor `zhao.yml`'s `log.retention_days` set, no
/// purging happens at all -- the default is "keep everything."
#[arg(long = "purge-logs")]
pub purge_logs: Option<u32>,
}
impl LineageArgs {
/// Splits `target` into the bare model name, an optional column name
/// (present for a `model.column` target), and the requested
/// [`zhao_core::lineage::Direction`], per dbt's own `+`-prefix/suffix
/// selector convention. `None` when no target was given at all
/// (only valid alongside `--html`, for the whole-project graph).
pub fn parse_target(&self) -> Option<(&str, Option<&str>, zhao_core::lineage::Direction)> {
let target = self.target.as_deref()?;
let has_prefix = target.starts_with('+');
let after_prefix = target.strip_prefix('+').unwrap_or(target);
let has_suffix = after_prefix.ends_with('+');
let name = after_prefix.strip_suffix('+').unwrap_or(after_prefix);
// `+target+` (both sides) means both directions in dbt's own
// selector syntax too, same as a bare target -- handled
// explicitly here rather than falling out of checking the prefix
// alone and leaving a trailing `+` stuck to the name.
let direction = match (has_prefix, has_suffix) {
(true, true) | (false, false) => zhao_core::lineage::Direction::Both,
(true, false) => zhao_core::lineage::Direction::Upstream,
(false, true) => zhao_core::lineage::Direction::Downstream,
};
// dbt model names never contain `.`, so the first `.` (if any)
// unambiguously separates the model from a column-level target.
let (model, column) = match name.split_once('.') {
Some((model, column)) => (model, Some(column)),
None => (name, None),
};
Some((model, column, direction))
}
}
/// Arguments shared by `zhao check` and `zhao diff` -- both run the
/// identical engine and accept identical inputs; they differ only in
/// what they do with the result (a gate's exit code vs. always zero).
#[derive(Debug, clap::Args)]
pub struct CheckArgs {
/// Path to the Baseline's compiled dbt manifest (`manifest.json`). If
/// omitted, zhao resolves its own Baseline: it finds the merge-base
/// commit between `HEAD` and `--against`, checks it out into a
/// temporary git worktree, compiles it with `dbt`, and uses that as
/// the Baseline instead.
#[arg(long)]
pub state: Option<PathBuf>,
/// The dbt project directory to check. Its current compiled manifest
/// is read from `<project-dir>/target/manifest.json` -- run `dbt
/// compile` in the project before invoking `zhao check`.
#[arg(long, default_value = ".")]
pub project_dir: PathBuf,
/// The ref to resolve a git-native Baseline's merge-base against.
/// Ignored when `--state` is given. Overrides `zhao.yml`'s `against`
/// when given; with neither set, defaults to `"master"`. No
/// `default_value` here (unlike most flags) specifically so the
/// engine can tell "the user explicitly passed --against" apart from
/// "nothing was passed at all" -- otherwise a `zhao.yml`-configured
/// value could never be distinguished from clap's own default and
/// would always lose to it.
#[arg(long)]
pub against: Option<String>,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
/// Disables ANSI color codes in text output, regardless of what
/// auto-detection would otherwise decide. Has no effect on `--format
/// json`, which never contains color codes.
#[arg(long)]
pub no_color: bool,
/// An extra argument to append to every `dbt deps`/`dbt compile`
/// invocation zhao runs internally (git-native Baseline resolution
/// only) -- repeat for multiple arguments, e.g. `--dbt-arg --target
/// --dbt-arg ci`. Appended verbatim, in the order given; zhao never
/// parses or validates these, dbt does. Mutually exclusive with
/// `--dbt-args`.
#[arg(
long = "dbt-arg",
conflicts_with = "dbt_args",
allow_hyphen_values = true
)]
pub dbt_arg: Vec<String>,
/// A single dbt-invocation-shaped string to split (shell-word-style)
/// into individual arguments and append to every `dbt deps`/`dbt
/// compile` invocation zhao runs internally -- a convenience
/// alternative to repeating `--dbt-arg`, e.g. `--dbt-args "--target ci
/// --vars '{\"key\": \"value\"}'"`. Mutually exclusive with
/// `--dbt-arg`.
#[arg(
long = "dbt-args",
conflicts_with = "dbt_arg",
allow_hyphen_values = true
)]
pub dbt_args: Option<String>,
/// The executable/prefix zhao invokes for every `dbt` subprocess call
/// it makes internally (`dbt deps`/`dbt compile` for Baseline
/// resolution, `dbt run-operation` for `--check-relations`) --
/// ordinarily just `"dbt"`, resolved via `PATH`. Accepts a multi-word
/// prefix (shell-word-split, same as `--dbt-args`), so a project
/// already using its own wrapper instead of invoking `dbt` directly
/// (e.g. `"uv run dbt"`, or a custom wrapper like `"myshell custom-flag"`)
/// can point zhao at that instead. Overrides `zhao.yml`'s
/// `dbt-command` when given; with neither set, defaults to `"dbt"`.
#[arg(long = "dbt-command", allow_hyphen_values = true)]
pub dbt_command: Option<String>,
/// Upgrades the conditional schema-evolution flag (see the "Schema
/// evolution" report section) into a definitive one, or drops it
/// entirely, by actually checking whether each flagged model exists
/// in the configured target -- via the same connection `dbt run`
/// already needs, never a connection zhao holds itself. Opt-in,
/// since (unlike every other check zhao runs) this requires a real
/// connection; without it, the flag stays conditionally worded.
/// Silently unavailable (not an error) for any warehouse zhao
/// doesn't yet support checking against.
#[arg(long = "check-relations")]
pub check_relations: bool,
/// The name of the dbt target the `--defer` plan should defer to
/// (e.g. `"prod"`) -- purely a human-readable label shown alongside
/// the generated command, not passed to dbt as `--target`. Overrides
/// `zhao.yml`'s `defer.target` when given. Has no effect unless a
/// state path is also available (from `--defer-state` or
/// `zhao.yml`'s `defer.state`).
#[arg(long = "defer-target")]
pub defer_target: Option<String>,
/// The path to a compiled manifest to defer to -- when set (here or
/// via `zhao.yml`'s `defer.state`), the report's `--defer` plan
/// includes a ready-to-run `dbt ... --defer --state <path>` command
/// alongside the plan's build/defer Node lists, not just the lists
/// themselves. Overrides `zhao.yml`'s `defer.state` when given.
#[arg(long = "defer-state")]
pub defer_state: Option<PathBuf>,
/// The intended CLI override for `zhao.yml`'s `log.level` (see issue
/// #35). Accepted and parsed now -- neither this flag nor `zhao.yml`'s
/// own `log.level` actually changes anything about the daily run log
/// yet, since its only defined content today (`Mirror`) is a literal,
/// unmodified copy of stdout, and `Debug` has no defined content of
/// its own to switch to -- reserved so a later ticket adding real
/// debug-level content doesn't also need another config-shape change.
#[arg(long = "log-level", value_enum)]
pub log_level: Option<LogLevelArg>,
/// One-off override for `zhao.yml`'s `log.retention_days` (see
/// issue #37): purges `target/zhao/logs/` files older than this
/// many days for this run only, without changing `zhao.yml`. With
/// neither this nor `zhao.yml`'s `log.retention_days` set, no
/// purging happens at all -- the default is "keep everything."
#[arg(long = "purge-logs")]
pub purge_logs: Option<u32>,
/// Skips the check that the current project's compiled manifest
/// (`<project-dir>/target/manifest.json`) is newer than its own dbt
/// source files. Without this flag, zhao refuses to run against a
/// stale manifest -- e.g. one left over from a different branch after
/// a checkout, with `dbt compile` never rerun -- since diffing against
/// it would silently produce an incorrect report. Not recommended;
/// exists for cases like a manually supplied test fixture with no
/// real dbt project alongside it.
#[arg(long = "allow-stale-manifest")]
pub allow_stale_manifest: bool,
}
impl CheckArgs {
/// Resolves the final, ordered list of extra arguments to append to
/// every `dbt deps`/`dbt compile` invocation, from whichever of
/// `--dbt-arg`/`--dbt-args` was given (clap's `conflicts_with` on both
/// fields already guarantees at most one was) -- empty if neither was.
pub fn dbt_passthrough_args(&self) -> Result<Vec<String>, String> {
if !self.dbt_arg.is_empty() {
return Ok(self.dbt_arg.clone());
}
if let Some(raw) = &self.dbt_args {
return shell_words::split(raw)
.map_err(|err| format!("could not parse --dbt-args {raw:?}: {err}"));
}
Ok(Vec::new())
}
}
/// `zhao check`'s output format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
/// A brief human-readable summary.
Text,
/// Machine-readable JSON.
Json,
}
/// The CLI-facing spelling of [`zhao_core::config::LogLevel`] -- a
/// separate type (rather than deriving `ValueEnum` on the `zhao-core`
/// type directly) so `zhao-core` doesn't need a `clap` dependency just
/// for this one flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum LogLevelArg {
/// A literal mirror of whatever was already printed to stdout.
Mirror,
/// Reserved for a later ticket's richer, internal-only content.
Debug,
}
impl From<LogLevelArg> for zhao_core::config::LogLevel {
fn from(value: LogLevelArg) -> Self {
match value {
LogLevelArg::Mirror => zhao_core::config::LogLevel::Mirror,
LogLevelArg::Debug => zhao_core::config::LogLevel::Debug,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zhao_core::lineage::Direction;
fn args(target: &str) -> LineageArgs {
LineageArgs {
target: Some(target.to_string()),
project_dir: PathBuf::from("."),
html: None,
text: false,
compile: false,
package: None,
log_level: None,
purge_logs: None,
}
}
#[test]
fn bare_target_parses_as_both_directions() {
let a = args("dim_customers");
let (name, column, direction) = a.parse_target().expect("target should be present");
assert_eq!(name, "dim_customers");
assert_eq!(column, None);
assert_eq!(direction, Direction::Both);
}
#[test]
fn a_plus_prefix_parses_as_upstream_only() {
let a = args("+dim_customers");
let (name, column, direction) = a.parse_target().expect("target should be present");
assert_eq!(name, "dim_customers");
assert_eq!(column, None);
assert_eq!(direction, Direction::Upstream);
}
#[test]
fn a_plus_suffix_parses_as_downstream_only() {
let a = args("dim_customers+");
let (name, column, direction) = a.parse_target().expect("target should be present");
assert_eq!(name, "dim_customers");
assert_eq!(column, None);
assert_eq!(direction, Direction::Downstream);
}
/// `+target+` (both sides), same as dbt's own selector syntax, means
/// both directions -- same as a bare target, and critically not a
/// garbled name with a stray trailing `+` still attached.
#[test]
fn a_plus_prefix_and_suffix_together_parses_as_both_directions() {
let a = args("+dim_customers+");
let (name, column, direction) = a.parse_target().expect("target should be present");
assert_eq!(name, "dim_customers");
assert_eq!(column, None);
assert_eq!(direction, Direction::Both);
}
#[test]
fn a_dotted_target_splits_into_model_and_column() {
let a = args("dim_customers.customer_id");
let (name, column, direction) = a.parse_target().expect("target should be present");
assert_eq!(name, "dim_customers");
assert_eq!(column, Some("customer_id"));
assert_eq!(direction, Direction::Both);
}
#[test]
fn a_dotted_target_still_honors_plus_prefix_and_suffix() {
let a = args("+dim_customers.customer_id+");
let (name, column, direction) = a.parse_target().expect("target should be present");
assert_eq!(name, "dim_customers");
assert_eq!(column, Some("customer_id"));
assert_eq!(direction, Direction::Both);
}
}