---
title: "The dqcheckr workflow: generate, edit, validate, run"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{The dqcheckr workflow: generate, edit, validate, run}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(echo = TRUE, eval = TRUE, collapse = TRUE, comment = "#>")
# The whole demo lives in a throwaway deployment root under tempdir(); every
# chunk below runs from there, exactly as a real deployment runs from its
# own root.
demo_root <- file.path(tempdir(), "dq-demo")
dir.create(demo_root, showWarnings = FALSE)
knitr::opts_knit$set(root.dir = demo_root)
```

dqcheckr checks recurring data deliveries: each time a file arrives, one
function call verifies it against a per-dataset configuration, records a
snapshot, and renders an HTML report. The workflow is deliberately plain:

* **configs are files** — generated once, then owned by your hand edits;
* **running is code** — one R call per action, no application to operate;
* **outputs are files** — self-contained HTML reports you open from disk,
  plus a SQLite snapshot history.

This vignette walks the whole loop on a small example: generate the configs,
read what was generated, edit a rule, validate, run, list the history, and
compare two deliveries.

## A delivery arrives

Our example delivery has a wrinkle that real files often have: the header
repeats a column name (`Amount` appears twice).

```{r delivery}
writeLines(c(
  "Date,Amount,Currency,Amount,Status",
  "2026-07-01,100.00,AUD,15.00,settled",
  "2026-07-02,250.50,AUD,0.00,settled",
  "2026-07-03,80.25,NZD,8.10,pending"
), "orders.csv")
```

## Bootstrap: two generator calls

A brand-new deployment needs exactly two calls: one for the shared global
options, one per dataset. Both are **create-only** — they will never
overwrite a config that already exists.

```{r generate}
library(dqcheckr)
generate_global_config(config_dir = "config")
generate_dataset_config("orders.csv", config_dir = "config")
```

(The detection itself is also available standalone: `sniff_dataset("orders.csv")`
returns everything the generator is about to write — format, delimiter,
encoding, column names and types, key-column candidates — without touching
disk, if you want to inspect a delivery before committing to a config.)

## Read what was generated

The dataset config is fully-optioned and self-documenting: every key the
package understands appears exactly once — detected values live, optional
settings commented out with their defaults — each with the same description
the validator uses. Note three things as you read it:

* the duplicated `Amount` arrived **renamed positionally** (`Amount_2`, with a
  `# was "Amount"` note), and `csv_skip: 1` drops the file's own header so the
  renamed list replaces it;
* the `col_names` list sits under a warning: it is **positional**, so you must
  never comment out a single entry — every entry below it would shift onto the
  wrong column;
* the sniffer proposes `key_columns` candidates **commented out** — columns
  unique in the sample, for you to confirm, never enforced silently.

```{r show-config}
writeLines(readLines(file.path("config", "orders.yml")))
```

## Edit a rule

Editing a config is editing a text file. Here we do with code what you would
do in an editor: uncomment the `rule_overrides` block and require at least
one data row.

```{r edit}
cfg_path <- file.path("config", "orders.yml")
lines <- readLines(cfg_path)
lines <- sub("^# rule_overrides:", "rule_overrides:", lines)
lines <- sub("^#   min_row_count: 1", "  min_row_count: 1", lines)
writeLines(lines, cfg_path)
```

## Validate after every edit

`validate_config()` reports **all** findings in one pass — vocabulary,
types and ranges, positional-list consistency — and, when the delivery file
is reachable, cross-checks the config against the file's header (never its
body, so this is cheap even for very large files).

```{r validate-clean}
validate_config("orders", config_dir = "config")
```

Typos get a suggestion rather than a silent no-op. Watch what happens if we
misspell a key:

```{r validate-typo}
writeLines(c(lines, 'delimitter: ";"'), cfg_path)
validate_config("orders", config_dir = "config")
writeLines(lines, cfg_path)   # undo
```

Findings follow one rule worth understanding, because it decides what can
stop a run:

* **Config mistakes are errors, and errors block.** A wrong value type, a
  broken positional list, a missing file source — things only an edit can
  cause — abort `run_dq_check()` before any snapshot is written. You cannot
  accidentally run an unrunnable config.
* **Delivery-facing findings are warnings, and warnings are recorded.**
  A key column absent from the file, a `col_names` count that no longer
  matches — these can mean a config typo *or* the supplier changed the
  delivery, and drift must be *recorded*, not crash the run. The run
  proceeds, and each warning is persisted into the run's results as a
  `VC-01` check — visible in the report, the WARN counts, and the history —
  as well as printed to the console. Unknown keys (your own annotations
  round-trip safely) are warnings too.

## Run the check

```{r run}
result <- run_dq_check("orders", config_dir = "config", open_report = FALSE)
result$status
```

Each run appends a snapshot to the SQLite database and renders a
self-contained HTML report into `reports/` (when the Quarto CLI is
available; `open_report = TRUE`, the default, opens it for you in
interactive use). The report is a plain file — open it from the file
manager, mail it, archive it.

## The run history

The history is one call away, newest first. The `id` column is what the
drift comparison takes.

```{r list-runs}
runs <- list_runs("orders", config_dir = "config")
runs[, c("id", "run_timestamp", "overall_status",
         "check_pass_count", "check_warn_count", "check_fail_count")]
```

`list_runs()` is per-dataset and resolves the right database from the configs
(honouring a per-dataset `snapshot_db` override). For the whole deployment at
once — every dataset sharing a database — drop to the primitive and name the
file yourself:

```{r list-snapshots}
list_snapshots(db_path = file.path("data", "snapshots.sqlite"))[
  , c("id", "dataset_name", "run_timestamp", "overall_status")]
```

## A second delivery, and drift

The next delivery arrives — with a suspiciously changed `Amount` profile and
a missing status. Run the check again, then compare the two snapshots.

```{r second-delivery}
writeLines(c(
  "Date,Amount,Currency,Amount,Status",
  "2026-08-01,900.00,AUD,15.00,settled",
  "2026-08-02,1250.50,AUD,0.00,",
  "2026-08-03,880.25,NZD,8.10,pending"
), "orders.csv")
result2 <- run_dq_check("orders", config_dir = "config", open_report = FALSE)
```

`compare_snapshots()` defaults to the two most recent runs; pass two `id`s
from `list_runs()` to compare any historical pair. With `report = TRUE` (the
default) it renders a drift report HTML like the run report; here we just
compute.

```{r drift}
cmp <- compare_snapshots("orders", config_dir = "config",
                         report = FALSE, open_report = FALSE)
names(cmp)
```

### Judging spread changes

The drift result also carries each numeric column's **spread** (min, max,
standard deviation). These are always *reported* — but never *judged* until
you set a threshold, so a config that predates the rule gains information
without gaining new failure modes. The `Amount` column's spread more than
doubled with the second delivery, yet `exceeds` stays `FALSE`:

```{r spread-unset}
cmp$spread_changes[, c("Column", "numeric_sd_prev", "numeric_sd_curr",
                       "numeric_sd_shift_pct", "numeric_sd_exceeds")]
```

Activating the judgement is the usual edit — uncomment the rule in the
global config (one default for every dataset; a per-column value in
`column_rules` would win for its column):

```{r spread-rule}
gcfg_path <- file.path("config", "dqcheckr.yml")
glines <- readLines(gcfg_path)
glines <- sub("^# default_rules:", "default_rules:", glines)
glines <- sub("^#   max_numeric_sd_shift_pct:.*",
              "  max_numeric_sd_shift_pct: 0.2", glines)
writeLines(glines, gcfg_path)

cmp2 <- compare_snapshots("orders", config_dir = "config",
                          report = FALSE, open_report = FALSE)
cmp2$spread_changes[, c("Column", "numeric_sd_shift_pct", "numeric_sd_exceeds")]
```

## Special cases worth knowing

**Fixed-width files.** The sniffer guesses column boundaries from blank
gutters and always emits a character-position **ruler comment** above
`fwf_widths` so you can correct a mis-split by counting. A *packed* file —
columns touching, no gutters — gets explicit `TODO` widths that
`validate_config()` flags as an error, so a run refuses to start until you
fill them in against the ruler:

```{r fwf-packed}
writeLines(c("AB12XY0099QQWW2026",
             "CD34ZW0100RRTT2026"), "ledger.txt")
generate_dataset_config("ledger.txt", config_dir = "config")
validate_config("ledger", config_dir = "config")
```

**The never-overwrite rule.** Generators create; humans edit. Re-running a
generator against an existing config is refused, and the file is untouched —
your hand-tuning can never be silently clobbered:

```{r never-overwrite, error=TRUE}
generate_dataset_config("orders.csv", config_dir = "config")
```

To re-sniff a structurally changed delivery, generate under another name (or
into a scratch directory) and diff.

## Where everything lives

```
deployment-root/
├── config/            # one YAML per dataset + dqcheckr.yml -- the dataset list
├── data/
│   └── snapshots.sqlite
├── reports/           # one self-contained HTML per run
└── orders.csv         # deliveries (or point configs at their real location)
```

The `config/` folder **is** the dataset list: add a dataset by generating a
config, remove one by deleting its file, inspect one by opening it. The whole
workflow is these calls:

| Action        | Call |
|---------------|------|
| New deployment | `generate_global_config("config")` |
| New dataset    | `generate_dataset_config("file.csv", config_dir = "config")` |
| Check an edit  | `validate_config("name", config_dir = "config")` |
| Run            | `run_dq_check("name", config_dir = "config")` |
| History        | `list_runs("name", config_dir = "config")` |
| Drift          | `compare_snapshots("name", config_dir = "config")` |

```{r cleanup, include=FALSE}
# Remove the demo's contents but keep the directory itself: it is knitr's
# working directory for this document, and the session tempdir is removed on
# exit anyway.
unlink(list.files(demo_root, full.names = TRUE), recursive = TRUE)
```
