djogi_cli/analyze.rs
1//! Partition / vacuum analysis for adopter Postgres tables.
2//! `djogi analyze` inspects `pg_stat_user_tables` and (when the
3//! extension is installed) `pg_partman` metadata to surface vacuum and
4//! partitioning recommendations to operators. The recommendation logic
5//! ([`recommend`]) is pure — no DB, no I/O, no global state — so it
6//! can be unit-tested against synthetic [`TableHealth`] inputs without
7//! a live database. The live-DB query path lives in
8//! [`fetch_table_health`]; the CLI dispatch entry point is [`run`].
9//! # Why a pure substrate
10//! `recommend()` is exposed as a free function taking only
11//! `&TableHealth` plus scalar threshold args. That shape is
12//! deliberately deterministic — the same inputs always produce the
13//! exact same output bytes. Two consequences fall out:
14//! 1. **Byte-stable JSON.** When the serialiser sorts a
15//! `Vec<(table_name, Recommendation)>` to `serde_json`, the result
16//! is reproducible across runs / hosts / Postgres restarts. CI
17//! dashboards that diff yesterday's `analyze --format json` output
18//! against today's see only real changes, never iteration-order
19//! churn.
20//! 2. **Trivial unit-testability.** No `tokio` runtime, no fixture DB,
21//! no temp dirs — every recommendation rule is exercised in-process
22//! against hand-built `TableHealth` values.
23//! # Threshold rationale
24//! Both thresholds are runtime arguments rather than constants because
25//! healthy bloat / partition-row ceilings vary per workload. The
26//! defaults chosen by the CLI (`0.2` and `10_000_000`) are conservative
27//! middle-of-the-road values; OLTP-heavy tables typically tighten the
28//! vacuum threshold, while warehouse-style tables loosen the partition
29//! row count. Adopters override on the command line without recompiling.
30
31use std::io::Write;
32
33use serde::Serialize;
34use tokio_postgres::error::SqlState;
35
36use djogi::__bypass::RawAccessExt as _;
37use djogi::DjogiError;
38use djogi::config::DjogiConfig;
39use djogi::context::DjogiContext;
40use djogi::pg::pool::DjogiPool;
41
42/// Snapshot of a single table's vacuum / partition health.
43/// Field provenance (per the live-DB query plan):
44/// - `table_name` — `pg_stat_user_tables.relname`
45/// - `n_live_tup`, `n_dead_tup` — `pg_stat_user_tables` columns of the
46/// same name; Postgres-maintained per-row visibility counters.
47/// - `last_analyze` — `pg_stat_user_tables.last_analyze`; `None` when
48/// the table has never been analysed (e.g. freshly created).
49/// - `partition_count` — `0` for plain tables, `>= 1` for partitioned
50/// parents (sourced via `pg_partitioned_table` join, with a
51/// `pg_partman` fallback when the extension is installed).
52/// `last_analyze` is intentionally `time::OffsetDateTime`, not
53/// `chrono::DateTime` — djogi forbids `chrono` workspace-wide
54/// (CLAUDE.md "Dependencies excluded").
55#[derive(Debug, Clone, Serialize)]
56pub struct TableHealth {
57 pub table_name: String,
58 pub n_live_tup: i64,
59 pub n_dead_tup: i64,
60 pub last_analyze: Option<time::OffsetDateTime>,
61 pub partition_count: i32,
62}
63
64/// Recommendation produced by [`recommend`] for a single table.
65/// # Precedence
66/// When multiple rules would fire, [`recommend`] returns the
67/// highest-priority match per this strict ordering (highest first):
68/// 1. [`Recommendation::VacuumNeeded`] — bloat dominates everything;
69/// autovacuum lag is the most operationally urgent signal because
70/// dead tuples block index health and inflate disk usage.
71/// 2. [`Recommendation::PartitionRecommended`] — an unpartitioned table
72/// has crossed the row-count threshold; partitioning is structural
73/// work that should land before the table grows further.
74/// 3. [`Recommendation::PartitionCountIncrease`] — partitions exist
75/// but average row count per partition exceeds the threshold;
76/// expanding the partition count is incremental tuning.
77/// 4. [`Recommendation::Healthy`] — no rule fires.
78/// # JSON shape
79/// The `#[serde(tag = "kind", rename_all = "snake_case")]` attribute
80/// produces internally-tagged JSON like
81/// `{"kind":"vacuum_needed","dead_tup_ratio":0.42}`. The
82/// `--format json` path serialises a sorted vector of
83/// `{table, recommendation}` pairs; the snake_case tag keeps the
84/// machine-readable output ergonomic for shell scripts and dashboards.
85#[derive(Debug, Clone, PartialEq, Serialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum Recommendation {
88 /// Dead-tuple ratio exceeded `threshold_vacuum`; operator should
89 /// run `VACUUM` (or tune autovacuum).
90 VacuumNeeded {
91 /// `n_dead_tup / (n_live_tup + n_dead_tup)`. Always strictly
92 /// greater than the threshold that triggered the variant.
93 dead_tup_ratio: f64,
94 },
95 /// Unpartitioned table whose live row count exceeds
96 /// `threshold_partition_rows`; operator should partition.
97 PartitionRecommended {
98 /// Human-readable explanation including the row count and the
99 /// threshold that fired the rule. Stable string format so
100 /// `--format json` consumers can grep for substrings (`"not
101 /// partitioned"`, `"threshold:"`).
102 reason: String,
103 },
104 /// Partitioned table whose average rows-per-partition exceeds
105 /// `threshold_partition_rows`; operator should expand the partition
106 /// count.
107 PartitionCountIncrease {
108 /// Current partition count.
109 current: i32,
110 /// Suggested partition count — currently a simple doubling.
111 /// Bounded by `i32::saturating_mul` so pathological inputs
112 /// (e.g. 1.5B partitions) cap at `i32::MAX` rather than
113 /// overflowing.
114 suggested: i32,
115 },
116 /// No recommendation — table is within all thresholds.
117 Healthy,
118}
119
120/// Pure recommendation function for a single table.
121/// # Determinism
122/// `recommend` takes only the borrowed `TableHealth` and two scalar
123/// thresholds. It performs no I/O, reads no globals, allocates only
124/// the `String` inside `PartitionRecommended::reason` (when that arm
125/// fires), and traverses no unordered collections. Repeated invocation
126/// on byte-identical inputs returns byte-identical outputs — the
127/// `recommend_is_deterministic` test asserts this with 100 repetitions.
128/// # Threshold semantics
129/// - `threshold_vacuum`: dead-tuple ratio strictly above which
130/// [`Recommendation::VacuumNeeded`] fires. Typical: `0.2` (20% bloat).
131/// Higher values mean the operator tolerates more bloat before
132/// flagging.
133/// - `threshold_partition_rows`: live row count strictly above which
134/// an unpartitioned table triggers [`Recommendation::PartitionRecommended`].
135/// Typical: `10_000_000`. The same threshold is reused for the
136/// per-partition row average that drives
137/// [`Recommendation::PartitionCountIncrease`].
138/// # Edge cases
139/// - Empty table (`n_live_tup == 0 && n_dead_tup == 0`): vacuum check
140/// is short-circuited (division-by-zero guard). Partition checks
141/// still run but neither fires for an empty table.
142/// - `partition_count == 0`: treated as "not partitioned" — only the
143/// `PartitionRecommended` rule can fire.
144/// - `partition_count >= 1` but row count below threshold: falls
145/// through to `Healthy`.
146/// # See also
147/// [`Recommendation`] for the precedence ordering.
148pub fn recommend(
149 health: &TableHealth,
150 threshold_vacuum: f64,
151 threshold_partition_rows: i64,
152) -> Recommendation {
153 // 1. VacuumNeeded — highest priority. Skipped on empty tables to
154 // avoid 0/0; an empty table cannot be bloated by definition.
155 // `saturating_add` caps at `i64::MAX` rather than panicking
156 // (debug) or wrapping (release) when both counters approach
157 // `i64::MAX` — pathological stats values still produce a valid
158 // ratio in `[0.0, 1.0]`.
159 let total_tup = health.n_live_tup.saturating_add(health.n_dead_tup);
160 if total_tup > 0 {
161 let ratio = health.n_dead_tup as f64 / total_tup as f64;
162 if ratio > threshold_vacuum {
163 return Recommendation::VacuumNeeded {
164 dead_tup_ratio: ratio,
165 };
166 }
167 }
168
169 // 2. PartitionRecommended — unpartitioned table over the row
170 // threshold. `partition_count == 0` is the unpartitioned signal.
171 if health.partition_count == 0 && health.n_live_tup > threshold_partition_rows {
172 return Recommendation::PartitionRecommended {
173 reason: format!(
174 "table has {} live rows but is not partitioned (threshold: {})",
175 health.n_live_tup, threshold_partition_rows
176 ),
177 };
178 }
179
180 // 3. PartitionCountIncrease — partitioned but undersized partitions.
181 // Average is integer-divided; precision is irrelevant since the
182 // threshold gate is also an integer comparison.
183 if health.partition_count > 0 {
184 let avg_per_partition = health.n_live_tup / health.partition_count as i64;
185 if avg_per_partition > threshold_partition_rows {
186 return Recommendation::PartitionCountIncrease {
187 current: health.partition_count,
188 // Saturating multiplication caps at `i32::MAX` rather
189 // than overflowing — pathological partition counts
190 // (e.g. > i32::MAX/2) still produce a valid suggestion.
191 suggested: health.partition_count.saturating_mul(2),
192 };
193 }
194 }
195
196 // 4. Healthy — no rule fired.
197 Recommendation::Healthy
198}
199
200/// Errors surfaced by [`run`] / [`fetch_table_health`].
201/// Each variant carries operator-actionable context — the goal is that
202/// an `eprintln!("djogi analyze: {e}")` line is enough to diagnose
203/// without grepping source. Mirrors `verify::VerifyError`'s shape.
204#[derive(Debug)]
205pub enum AnalyzeError {
206 /// Loading `Djogi.toml` (and its env overlays) failed.
207 Config(String),
208 /// Could not connect to the application database. URL is included
209 /// for diagnostics; the underlying `DjogiError` is rendered into
210 /// `message` so `AnalyzeError` stays `Send + Sync` regardless of
211 /// the variant we received.
212 Pool {
213 /// The application DB URL we attempted to reach. Echoed in the
214 /// operator-facing message so the resolution path is visible.
215 url: String,
216 /// Underlying error message. The framework's
217 /// `DjogiError::Pool` enum carries trait-object inner values
218 /// that aren't always `Send + Sync`; rendering eagerly keeps
219 /// `AnalyzeError` cheap to clone in test assertions and
220 /// avoids leaking deadpool types to consumers.
221 message: String,
222 },
223 /// `pg_stat_user_tables` query (or the optional pg_partman join)
224 /// surfaced a Postgres error that is NOT one of the "extension
225 /// absent" SQLSTATEs we tolerate. Rendered eagerly for the same
226 /// reasons as `Pool::message`.
227 Db(String),
228 /// Writing the rendered output to stdout failed (broken pipe,
229 /// disk full on a redirected `> file.json`, etc.).
230 Io(std::io::Error),
231 /// `serde_json` encoding failed. Should be unreachable because the
232 /// `Row` projection only contains primitive / `OffsetDateTime` /
233 /// known-`Serialize` types, but we surface it as a structured
234 /// variant rather than `unwrap()` so a future schema extension
235 /// that introduces a non-serialisable field fails loudly.
236 Json(serde_json::Error),
237}
238
239impl std::fmt::Display for AnalyzeError {
240 /// Every variant ships an operator-actionable remediation hint after
241 /// the cause — `eprintln!("djogi analyze: {e}")` should be enough to
242 /// either fix the problem or know what to file. The shape is
243 /// "<cause>. <remediation verb>...". The
244 /// `analyze_error_display_is_operator_actionable` test pins each
245 /// variant's remediation keyword (`Verify` / `Check` / `file an
246 /// issue`) so a future drift trips the test rather than silently
247 /// losing the hint.
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 match self {
250 AnalyzeError::Config(message) => write!(
251 f,
252 "config load: {message}. \
253 Verify the Djogi.toml workspace path and the [database] section.",
254 ),
255 AnalyzeError::Pool { url, message } => write!(
256 f,
257 "application DB at `{url}` unreachable: {message}. \
258 Verify Djogi.toml::database.url is reachable and the \
259 credentials grant CONNECT.",
260 ),
261 AnalyzeError::Db(message) => write!(
262 f,
263 "live-DB query: {message}. \
264 Verify the app DB is reachable and the role has SELECT \
265 privilege on pg_stat_user_tables (and on partman.* if \
266 pg_partman is installed).",
267 ),
268 AnalyzeError::Io(e) => write!(
269 f,
270 "writing analyze output: {e}. \
271 Check stdout/stderr permissions and the workspace path.",
272 ),
273 AnalyzeError::Json(e) => write!(
274 f,
275 "encoding analyze output as JSON: {e}. \
276 This is an internal bug — please file an issue with the \
277 input that triggered it.",
278 ),
279 }
280 }
281}
282
283impl std::error::Error for AnalyzeError {
284 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
285 match self {
286 AnalyzeError::Io(e) => Some(e),
287 AnalyzeError::Json(e) => Some(e),
288 AnalyzeError::Config(_) | AnalyzeError::Pool { .. } | AnalyzeError::Db(_) => None,
289 }
290 }
291}
292
293impl From<std::io::Error> for AnalyzeError {
294 fn from(e: std::io::Error) -> Self {
295 AnalyzeError::Io(e)
296 }
297}
298
299impl From<serde_json::Error> for AnalyzeError {
300 fn from(e: serde_json::Error) -> Self {
301 AnalyzeError::Json(e)
302 }
303}
304
305/// Output format selector — wired up to the CLI's `--format` flag.
306/// `Human` is for direct operator reading; `Json` is for CI dashboards
307/// and other machine consumers. Both paths emit deterministic output
308/// (sorted by `table_name`, no `HashMap` iteration anywhere on the
309/// rendering path) so a `diff` between yesterday's and today's run
310/// shows only real schema-health changes.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub enum AnalyzeFormat {
313 /// One sorted ASCII line per table — `table | live | dead | parts | recommendation`.
314 Human,
315 /// Pretty-printed JSON array of `{table_name, recommendation, ...}`
316 /// rows, sorted by `table_name`. Pretty rather than compact so
317 /// `git diff` between dashboard runs is reviewable.
318 Json,
319}
320
321/// Pull live-DB stats from `pg_stat_user_tables` and (optionally)
322/// `pg_partman.show_partitions(...)` for every user table.
323/// # Query design
324/// The primary query lists schema-qualified table names plus visibility
325/// counters and last-analyze timestamps from `pg_stat_user_tables`.
326/// That catalogue is part of every Postgres install, so this query is
327/// always available and never errors with `UNDEFINED_TABLE`.
328/// The per-row partition lookup uses `partman.show_partitions($1)`
329/// which is part of the optional `pg_partman` extension. Many adopters
330/// will not have it installed; rather than refusing to run analyze on
331/// those clusters, we catch SQLSTATE `UNDEFINED_FUNCTION` (`42883`),
332/// `UNDEFINED_TABLE` (`42P01`), and `INVALID_SCHEMA_NAME` (`3F000`)
333/// from the partman call and fall back to `partition_count = 0`. The
334/// fallback collapses the partman path to "no partitions reported"
335/// rather than producing a hard error — which is the right semantic
336/// because a table that pg_partman doesn't know about is, from
337/// analyze's perspective, indistinguishable from an unpartitioned
338/// table.
339/// # Determinism
340/// `ORDER BY table_name` in the primary query plus a defensive
341/// `Vec::sort_by` after collection (in [`run`]) means the output
342/// ordering does not depend on Postgres planner decisions.
343/// # Read-only
344/// Both queries are `SELECT`-only with positional binds. No DDL, no
345/// DML — analyze never writes.
346pub async fn fetch_table_health(pool: &DjogiPool) -> Result<Vec<TableHealth>, AnalyzeError> {
347 let mut ctx = DjogiContext::from_pool(pool.clone());
348
349 // Probe `partman` schema AND `show_partitions` function existence
350 // ONCE up-front rather than letting every per-table
351 // `partman.show_partitions(...)` call discover the absence at
352 // prepare time.
353 // # Why probe up-front
354 // `prepare_cached` (the path `raw_rows` routes through) maps
355 // tokio-postgres errors via `DbError::other(e.to_string)`
356 // which DROPS the SQLSTATE because the prepare-error path
357 // collapses the `tokio_postgres::Error` into a message-only
358 // `DbError` whose `code()` returns `None`. The original
359 // `is_partman_absent_code` SQLSTATE classifier therefore never
360 // matches against a cluster without pg_partman, and every
361 // analyze run on such a cluster fails with `AnalyzeError::Db`
362 // even though the partman call should be a soft fallback.
363 // # Why probe BOTH schema and function
364 // A schema-only check (`pg_namespace.nspname = 'partman'`) is
365 // insufficient: a partial install (e.g. the extension was
366 // dropped but the schema was preserved by a CASCADE-less
367 // `DROP EXTENSION pg_partman`, or a mid-upgrade where the
368 // schema exists but the new function shape has not been
369 // installed yet) leaves the schema present without
370 // `show_partitions`. The per-table call would then `prepare`
371 // the function reference, fail with `UNDEFINED_FUNCTION`, and
372 // hit the same SQLSTATE-dropping path described above — so the
373 // soft fallback would never engage and analyze would fail.
374 // Joining `pg_proc` against `pg_namespace.oid = pg_proc.pronamespace`
375 // confirms the function we're about to call actually exists in
376 // the partman schema. The probe returns true only when BOTH the
377 // schema and `show_partitions` are present; any partial-install
378 // shape returns false and we skip the per-table partman calls
379 // entirely (`partition_count = 0` for every row — the same
380 // outcome the SQLSTATE-fallback path would have produced for an
381 // entirely-absent extension).
382 // Both `pg_namespace` and `pg_proc` are core system catalogues
383 // the probe is always-prepareable, returns a clean
384 // `Result<bool, _>`, and costs ONE query per analyze invocation
385 // instead of N (one per user table).
386 // The retained `query_partition_count` SQLSTATE classifier is a
387 // belt-and-braces guard for execution-time partman failures: if
388 // `show_partitions` exists at probe time but errors at runtime
389 // (e.g. permission revoked between probe and call, or the
390 // function body itself raises `UNDEFINED_TABLE` because no
391 // partitioned parents are registered), we still soft-degrade
392 // to `partition_count = 0`. That path goes through `query` not
393 // `prepare`, so the SQLSTATE survives and the classifier still
394 // catches it.
395 let partman_present = !ctx
396 .raw_rows(
397 "SELECT 1 \
398 FROM pg_namespace n \
399 JOIN pg_proc p ON p.pronamespace = n.oid \
400 WHERE n.nspname = 'partman' AND p.proname = 'show_partitions'",
401 &[],
402 )
403 .await
404 .map_err(djogi_err_to_analyze)?
405 .is_empty();
406
407 // Primary query — `pg_stat_user_tables` is always available on
408 // every Postgres cluster djogi targets (>= 18). The schema name is
409 // joined into `table_name` so partitioned tables in non-public
410 // schemas show up unambiguously in the operator output.
411 let stats_sql = "SELECT \
412 schemaname || '.' || relname AS table_name, \
413 n_live_tup, \
414 n_dead_tup, \
415 last_analyze \
416 FROM pg_stat_user_tables \
417 ORDER BY schemaname, relname";
418 let stats_rows = ctx
419 .raw_rows(stats_sql, &[])
420 .await
421 .map_err(djogi_err_to_analyze)?;
422
423 // Per-row partition lookup. Errors of the "extension absent" class
424 // are swallowed into `partition_count = 0`; everything else
425 // propagates as `AnalyzeError::Db`. When the up-front probe says
426 // partman is absent, we skip the per-table query entirely and
427 // assign `partition_count = 0` directly — same outcome, no
428 // per-table round-trip.
429 let mut out = Vec::with_capacity(stats_rows.len());
430 for row in stats_rows {
431 // `try_get` (not `get`) per the `raw_rows` contract: the row
432 // accessor that returns `Result<T, _>` rather than panicking on
433 // a type/decode mismatch. A future Postgres upgrade or a
434 // restrictive role granting access to `pg_stat_user_tables` with
435 // unexpected nullability could otherwise crash the CLI; routing
436 // through `AnalyzeError::Db` keeps every failure path
437 // operator-actionable.
438 let table_name: String = row
439 .try_get(0)
440 .map_err(|e| AnalyzeError::Db(format!("decoding table_name: {e}")))?;
441 let n_live_tup: i64 = row
442 .try_get(1)
443 .map_err(|e| AnalyzeError::Db(format!("decoding n_live_tup: {e}")))?;
444 let n_dead_tup: i64 = row
445 .try_get(2)
446 .map_err(|e| AnalyzeError::Db(format!("decoding n_dead_tup: {e}")))?;
447 let last_analyze: Option<time::OffsetDateTime> = row
448 .try_get(3)
449 .map_err(|e| AnalyzeError::Db(format!("decoding last_analyze: {e}")))?;
450
451 let partition_count = if partman_present {
452 match query_partition_count(&mut ctx, &table_name).await {
453 Ok(count) => count,
454 Err(PartmanError::Absent) => 0,
455 Err(PartmanError::Other(message)) => return Err(AnalyzeError::Db(message)),
456 }
457 } else {
458 0
459 };
460
461 out.push(TableHealth {
462 table_name,
463 n_live_tup,
464 n_dead_tup,
465 last_analyze,
466 partition_count,
467 });
468 }
469
470 Ok(out)
471}
472
473/// Internal error type for [`query_partition_count`]. Distinct from
474/// [`AnalyzeError`] so the caller can match on `Absent` (treat as
475/// `partition_count = 0`) without conflating with the broader
476/// `AnalyzeError::Db` channel.
477enum PartmanError {
478 /// `pg_partman` is not installed on this cluster — the
479 /// `partman.show_partitions` function or `partman` schema is
480 /// undefined. Treated as "no partitions" by the caller.
481 Absent,
482 /// Anything else — connection error, syntax error, permission
483 /// denied. Rendered into `AnalyzeError::Db`.
484 Other(String),
485}
486
487/// Query the partition count for `table_name` via
488/// `partman.show_partitions($1)`.
489/// **Parameter binding.** `$1` carries `table_name`; we never
490/// `format!()`-interpolate. The table name comes from
491/// `pg_stat_user_tables` (a system catalogue, so trusted), but the
492/// codebase rule is "always parameterise" — there is no scenario in
493/// which the cost of a parameter bind matters, and a stray code path
494/// that builds the table name from an untrusted source later cannot
495/// regress this query into an injection vector.
496/// Returns `Err(PartmanError::Absent)` for the three SQLSTATEs that
497/// indicate "pg_partman not installed":
498/// - `42883` `UNDEFINED_FUNCTION` — `partman` schema present but
499/// `show_partitions` not (e.g. partial install).
500/// - `42P01` `UNDEFINED_TABLE` — `show_partitions` resolves but the
501/// underlying `part_config` lookup fails because no partitioned
502/// parents are registered.
503/// - `3F000` `INVALID_SCHEMA_NAME` — `partman` schema entirely
504/// absent.
505async fn query_partition_count(
506 ctx: &mut DjogiContext,
507 table_name: &str,
508) -> Result<i32, PartmanError> {
509 let sql = "SELECT count(*)::int FROM partman.show_partitions($1)";
510 match ctx.raw_rows(sql, &[&table_name]).await {
511 Ok(rows) => {
512 if let Some(row) = rows.first() {
513 // `try_get` (not `get`) per the `raw_rows` contract — a
514 // future change to `partman.show_partitions` return shape
515 // would otherwise panic the CLI instead of surfacing as
516 // a structured `Other` variant the caller routes into
517 // `AnalyzeError::Db`.
518 let count: i32 = row
519 .try_get(0)
520 .map_err(|e| PartmanError::Other(format!("decoding partition count: {e}")))?;
521 Ok(count)
522 } else {
523 // `count(*)` always returns one row, but defend against
524 // a future Postgres upgrade that could change the
525 // contract — reporting "0 partitions" is the right
526 // fallback if the row is somehow missing.
527 Ok(0)
528 }
529 }
530 Err(DjogiError::Db(db)) => {
531 if let Some(code) = db.code()
532 && is_partman_absent_code(code)
533 {
534 Err(PartmanError::Absent)
535 } else {
536 Err(PartmanError::Other(db.to_string()))
537 }
538 }
539 Err(other) => Err(PartmanError::Other(other.to_string())),
540 }
541}
542
543/// Map the three SQLSTATE classes that signal "pg_partman not
544/// installed" onto a single boolean. Centralised so both
545/// [`query_partition_count`] and any future caller reuse the same
546/// classification.
547fn is_partman_absent_code(code: &SqlState) -> bool {
548 *code == SqlState::UNDEFINED_FUNCTION
549 || *code == SqlState::UNDEFINED_TABLE
550 || *code == SqlState::INVALID_SCHEMA_NAME
551}
552
553/// Convert a `DjogiError` from the live-DB path into an
554/// `AnalyzeError::Db`. Rendered eagerly (`to_string()`) so the
555/// `AnalyzeError` does not need to carry the heterogeneous inner type.
556fn djogi_err_to_analyze(e: DjogiError) -> AnalyzeError {
557 AnalyzeError::Db(e.to_string())
558}
559
560/// `djogi analyze` entry point — consumed by `main.rs::TopCommand::Analyze`.
561/// Orchestrates the live-DB pull, the pure recommendation pass, and
562/// the rendering. Splitting fetch / recommend / render this way means
563/// every test (unit, integration, regression) targets exactly the
564/// layer that interests it without dragging in the others.
565/// # Workspace + config resolution
566/// `workspace` is `None` by default — we resolve to
567/// `std::env::current_dir()` and then load `Djogi.toml` via
568/// `DjogiConfig::load_from_workspace`. Mirrors `verify::run`'s pattern.
569/// # Pool lifecycle
570/// One pool, one context, every per-table query runs through it.
571/// Built fresh on every invocation — analyze is a one-shot CLI command,
572/// not a long-lived process, so pool reuse across invocations is not a
573/// goal.
574/// # Output destination
575/// Both rendering paths write to a locked `stdout`. Locking once at
576/// the top means we don't pay the `Stdout::lock()` cost per row, and
577/// the renderers themselves take a generic `&mut W: Write` so the
578/// pure render-only tests (`render_human_*` / `render_json_*`)
579/// can target a `Vec<u8>` without going through stdout.
580pub async fn run(
581 workspace: Option<std::path::PathBuf>,
582 format: AnalyzeFormat,
583 threshold_vacuum: f64,
584 threshold_partition_rows: i64,
585) -> Result<(), AnalyzeError> {
586 // Step 1 — resolve workspace, load config.
587 let workspace = workspace.unwrap_or_else(|| {
588 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
589 });
590 let config = DjogiConfig::load_from_workspace(&workspace)
591 .map_err(|e| AnalyzeError::Config(e.to_string()))?;
592
593 // Step 2 — connect to the application DB.
594 let url = config.database.url.clone();
595 let pool = DjogiPool::connect(&url)
596 .await
597 .map_err(|e| AnalyzeError::Pool {
598 url: url.clone(),
599 message: e.to_string(),
600 })?;
601 djogi::pg::preflight::check_postgres_version(&pool)
602 .await
603 .map_err(|e| AnalyzeError::Pool {
604 url: url.clone(),
605 message: format!("support boundary: {e}"),
606 })?;
607
608 // Step 3 — fetch + recommend.
609 let mut health = fetch_table_health(&pool).await?;
610 // Defence-in-depth — the SQL `ORDER BY` already sorts the rows,
611 // but we re-sort by the materialised `table_name` so the
612 // determinism contract is independent of the planner.
613 health.sort_by(|a, b| a.table_name.cmp(&b.table_name));
614
615 let report: Vec<(TableHealth, Recommendation)> = health
616 .into_iter()
617 .map(|h| {
618 let rec = recommend(&h, threshold_vacuum, threshold_partition_rows);
619 (h, rec)
620 })
621 .collect();
622
623 // Step 4 — render. Lock stdout once so the renderers don't pay the
624 // per-row lock cost and a downstream `tee` / pipe sees one
625 // contiguous stream.
626 let stdout = std::io::stdout();
627 let mut handle = stdout.lock();
628 match format {
629 AnalyzeFormat::Human => render_human(&report, &mut handle)?,
630 AnalyzeFormat::Json => render_json(&report, &mut handle)?,
631 }
632 handle.flush()?;
633 Ok(())
634}
635
636/// Render the human-readable ASCII table.
637/// One header line plus one body line per table. Columns are padded so
638/// the visual alignment is stable across runs; the recommendation
639/// column carries a short tag (`vacuum`, `partition`, `parts++`,
640/// `healthy`) plus the structural detail. Empty input prints the
641/// header only — no "no rows" placeholder, so a downstream `wc -l`
642/// sees the row count directly.
643/// # Determinism
644/// The input is already sorted by `table_name` (see [`run`]); this
645/// renderer iterates in the input order. No `HashMap`, no
646/// `BTreeMap` — pure `Vec` iteration.
647fn render_human<W: Write>(
648 report: &[(TableHealth, Recommendation)],
649 out: &mut W,
650) -> Result<(), AnalyzeError> {
651 // Header — clippy's `write_literal` lint flags trailing
652 // `"<lit>"` arguments paired with `{}` placeholders, so we keep
653 // the format string fully width-specified and stage the header
654 // labels through bindings rather than literal slots.
655 let h_table = "TABLE";
656 let h_live = "LIVE";
657 let h_dead = "DEAD";
658 let h_parts = "PARTITIONS";
659 let h_rec = "RECOMMENDATION";
660 writeln!(
661 out,
662 "{h_table:<48} {h_live:>14} {h_dead:>14} {h_parts:>10} {h_rec}",
663 )?;
664 for (h, r) in report {
665 writeln!(
666 out,
667 "{:<48} {:>14} {:>14} {:>10} {}",
668 h.table_name,
669 h.n_live_tup,
670 h.n_dead_tup,
671 h.partition_count,
672 recommendation_human(r),
673 )?;
674 }
675 Ok(())
676}
677
678/// Render a single recommendation as a one-line ASCII tag for the
679/// human renderer. Stable text format — string-substring assertions on
680/// the operator output are part of the contract.
681fn recommendation_human(r: &Recommendation) -> String {
682 match r {
683 Recommendation::VacuumNeeded { dead_tup_ratio } => {
684 format!("vacuum (dead_tup_ratio={dead_tup_ratio:.4})")
685 }
686 Recommendation::PartitionRecommended { reason } => {
687 format!("partition ({reason})")
688 }
689 Recommendation::PartitionCountIncrease { current, suggested } => {
690 format!("parts++ (current={current}, suggested={suggested})")
691 }
692 Recommendation::Healthy => "healthy".to_string(),
693 }
694}
695
696/// Render the JSON array.
697/// # Determinism contract
698/// We project through a named `Row` struct rather than a
699/// `HashMap<String, _>` — `serde`'s default behaviour preserves struct
700/// field declaration order, so the JSON output is byte-stable across
701/// runs. `serde_json::to_writer_pretty` is chosen over the compact
702/// form so dashboards can `git diff` two runs without reformatting
703/// first.
704/// # Field order
705/// `table_name` first (sort key), then the raw counters
706/// (`n_live_tup`, `n_dead_tup`), then `last_analyze`,
707/// `partition_count`, and finally the structured `recommendation`.
708/// Pinned by `render_json_field_order_is_stable`.
709fn render_json<W: Write>(
710 report: &[(TableHealth, Recommendation)],
711 out: &mut W,
712) -> Result<(), AnalyzeError> {
713 /// Wire-format projection of one analyzed table.
714 /// Field declaration order IS the JSON field order — see the
715 /// renderer's determinism contract above. Borrows from the
716 /// in-memory `report` so the projection is allocation-free.
717 #[derive(Serialize)]
718 struct Row<'a> {
719 table_name: &'a str,
720 n_live_tup: i64,
721 n_dead_tup: i64,
722 last_analyze: Option<time::OffsetDateTime>,
723 partition_count: i32,
724 recommendation: &'a Recommendation,
725 }
726
727 let rows: Vec<Row<'_>> = report
728 .iter()
729 .map(|(h, r)| Row {
730 table_name: &h.table_name,
731 n_live_tup: h.n_live_tup,
732 n_dead_tup: h.n_dead_tup,
733 last_analyze: h.last_analyze,
734 partition_count: h.partition_count,
735 recommendation: r,
736 })
737 .collect();
738
739 serde_json::to_writer_pretty(&mut *out, &rows)?;
740 // serde_json::to_writer_pretty does NOT emit a trailing newline;
741 // adding one keeps the output well-behaved for shell pipelines
742 // (e.g. `djogi analyze --format json | jq .`).
743 writeln!(out)?;
744 Ok(())
745}
746
747#[cfg(test)]
748mod tests {
749 //! Pure unit tests covering every arm of `Recommendation` plus
750 //! precedence ordering and determinism. None of these tests touch
751 //! the network, the filesystem, or a database — they construct
752 //! `TableHealth` values directly and assert on the returned
753 //! `Recommendation`.
754
755 use super::*;
756
757 /// Helper: build a `TableHealth` with sensible defaults so tests
758 /// only override the fields they care about. Centralising the
759 /// builder keeps test bodies focused on the rule being exercised.
760 fn health(n_live_tup: i64, n_dead_tup: i64, partition_count: i32) -> TableHealth {
761 TableHealth {
762 table_name: "test_table".to_string(),
763 n_live_tup,
764 n_dead_tup,
765 last_analyze: None,
766 partition_count,
767 }
768 }
769
770 #[test]
771 fn recommend_healthy_when_below_all_thresholds() {
772 // Small table, no dead tuples, plenty of partition headroom.
773 let h = health(1_000, 0, 0);
774 assert_eq!(recommend(&h, 0.2, 10_000_000), Recommendation::Healthy);
775
776 // Same idea but partitioned and well below the per-partition
777 // ceiling — also Healthy.
778 let h = health(100_000, 0, 4);
779 assert_eq!(recommend(&h, 0.2, 10_000_000), Recommendation::Healthy);
780
781 // Genuinely empty table — Healthy (vacuum guard skips the
782 // division, partition checks don't fire below threshold).
783 let h = health(0, 0, 0);
784 assert_eq!(recommend(&h, 0.2, 10_000_000), Recommendation::Healthy);
785 }
786
787 #[test]
788 fn recommend_vacuum_when_dead_tup_ratio_high() {
789 // Just above 0.2 — fires.
790 // 21 dead / (79 live + 21 dead) = 0.21
791 let h = health(79, 21, 0);
792 match recommend(&h, 0.2, 10_000_000) {
793 Recommendation::VacuumNeeded { dead_tup_ratio } => {
794 assert!(
795 dead_tup_ratio > 0.20 && dead_tup_ratio < 0.22,
796 "expected ratio near 0.21, got {dead_tup_ratio}"
797 );
798 }
799 other => panic!("expected VacuumNeeded, got {other:?}"),
800 }
801
802 // Just below 0.2 — does NOT fire.
803 // 19 dead / 100 = 0.19
804 let h = health(81, 19, 0);
805 assert_eq!(recommend(&h, 0.2, 10_000_000), Recommendation::Healthy);
806
807 // Exactly at 0.2 — does NOT fire (strict greater-than).
808 let h = health(80, 20, 0);
809 assert_eq!(recommend(&h, 0.2, 10_000_000), Recommendation::Healthy);
810
811 // High ratio (50%) — fires unambiguously.
812 let h = health(50, 50, 0);
813 match recommend(&h, 0.2, 10_000_000) {
814 Recommendation::VacuumNeeded { dead_tup_ratio } => {
815 assert!((dead_tup_ratio - 0.5).abs() < 1e-9);
816 }
817 other => panic!("expected VacuumNeeded, got {other:?}"),
818 }
819 }
820
821 #[test]
822 fn recommend_partition_when_unpartitioned_and_large() {
823 // Just above 10M rows, no dead tuples, no partitions.
824 let h = health(10_000_001, 0, 0);
825 match recommend(&h, 0.2, 10_000_000) {
826 Recommendation::PartitionRecommended { reason } => {
827 assert!(reason.contains("10000001"), "reason: {reason}");
828 assert!(reason.contains("not partitioned"), "reason: {reason}");
829 assert!(reason.contains("threshold: 10000000"), "reason: {reason}");
830 }
831 other => panic!("expected PartitionRecommended, got {other:?}"),
832 }
833
834 // Exactly at threshold — does NOT fire (strict greater-than).
835 let h = health(10_000_000, 0, 0);
836 assert_eq!(recommend(&h, 0.2, 10_000_000), Recommendation::Healthy);
837
838 // Way over threshold but already partitioned — does NOT fire
839 // the unpartitioned rule (CountIncrease may, see below).
840 let h = health(20_000_000, 0, 100);
841 // 20M / 100 = 200k average → below 10M threshold → Healthy.
842 assert_eq!(recommend(&h, 0.2, 10_000_000), Recommendation::Healthy);
843 }
844
845 #[test]
846 fn recommend_partition_count_increase_when_partitions_undersized() {
847 // 100M rows across 4 partitions = 25M each → exceeds 10M.
848 let h = health(100_000_000, 0, 4);
849 assert_eq!(
850 recommend(&h, 0.2, 10_000_000),
851 Recommendation::PartitionCountIncrease {
852 current: 4,
853 suggested: 8,
854 }
855 );
856
857 // Saturating-mul guard: even pathological partition counts
858 // produce a valid `i32` suggestion.
859 let h = health(i64::MAX / 2, 0, i32::MAX);
860 match recommend(&h, 0.2, 10_000_000) {
861 Recommendation::PartitionCountIncrease { current, suggested } => {
862 assert_eq!(current, i32::MAX);
863 assert_eq!(suggested, i32::MAX); // saturated
864 }
865 other => panic!("expected PartitionCountIncrease, got {other:?}"),
866 }
867 }
868
869 #[test]
870 fn recommend_is_deterministic() {
871 // Build a single TableHealth and run recommend() 100 times;
872 // every result must equal the first. Covers the
873 // concern about HashMap-iteration nondeterminism — there is no
874 // HashMap in `recommend`, but the test cements the contract so
875 // future refactors don't sneak one in.
876 let h = health(50_000_000, 0, 3);
877 let baseline = recommend(&h, 0.2, 10_000_000);
878
879 for i in 0..100 {
880 let result = recommend(&h, 0.2, 10_000_000);
881 assert_eq!(
882 result, baseline,
883 "iteration {i} diverged from baseline {baseline:?}"
884 );
885 }
886
887 // Same shape with the VacuumNeeded arm — float math should
888 // also be bit-stable across repeated invocations on the same
889 // inputs.
890 let h = health(70, 30, 0);
891 let baseline = recommend(&h, 0.2, 10_000_000);
892 for i in 0..100 {
893 assert_eq!(
894 recommend(&h, 0.2, 10_000_000),
895 baseline,
896 "vacuum iteration {i} diverged"
897 );
898 }
899 }
900
901 #[test]
902 fn recommend_vacuum_dominates_partition() {
903 // Both VacuumNeeded AND PartitionRecommended would fire in
904 // isolation — vacuum wins per precedence ordering.
905 // Setup: 100M live + 50M dead → 33% dead ratio AND
906 // unpartitioned over the 10M row threshold.
907 let h = health(100_000_000, 50_000_000, 0);
908 match recommend(&h, 0.2, 10_000_000) {
909 Recommendation::VacuumNeeded { dead_tup_ratio } => {
910 assert!((dead_tup_ratio - (50.0 / 150.0)).abs() < 1e-9);
911 }
912 other => panic!("expected VacuumNeeded (precedence), got {other:?}"),
913 }
914 }
915
916 #[test]
917 fn recommend_partition_dominates_count_increase() {
918 // An unpartitioned table cannot trigger CountIncrease at all
919 // (CountIncrease requires `partition_count > 0`), so this test
920 // pins the precedence boundary the other way: a partitioned
921 // table that crosses both the size *and* the per-partition
922 // ceiling falls into CountIncrease — there is no way for
923 // PartitionRecommended to fire on a partitioned table by
924 // construction.
925 // Rule still under test: when both partition rules *could*
926 // logically apply, PartitionRecommended only matches the
927 // unpartitioned case (`partition_count == 0`).
928 let h = health(100_000_000, 0, 4); // partitioned, 25M/partition
929 match recommend(&h, 0.2, 10_000_000) {
930 Recommendation::PartitionCountIncrease { current, suggested } => {
931 assert_eq!(current, 4);
932 assert_eq!(suggested, 8);
933 }
934 other => panic!("expected PartitionCountIncrease, got {other:?}"),
935 }
936
937 // And the inverse: unpartitioned + over threshold goes to
938 // PartitionRecommended, NOT CountIncrease.
939 let h = health(100_000_000, 0, 0);
940 assert!(matches!(
941 recommend(&h, 0.2, 10_000_000),
942 Recommendation::PartitionRecommended { .. }
943 ));
944 }
945
946 #[test]
947 fn recommend_handles_n_tup_addition_overflow() {
948 // Both counters at `i64::MAX` would panic in debug or silently
949 // wrap in release under unchecked addition. `saturating_add`
950 // caps at `i64::MAX`, so the ratio is `i64::MAX / i64::MAX = 1.0`
951 // well above the default 0.2 threshold, so VacuumNeeded fires.
952 // The test pins the contract: pathological stats values must NOT
953 // panic and must still produce a deterministic recommendation.
954 let h = TableHealth {
955 table_name: "boom".to_string(),
956 n_live_tup: i64::MAX,
957 n_dead_tup: i64::MAX,
958 last_analyze: None,
959 partition_count: 0,
960 };
961 let result = recommend(&h, 0.2, 10_000_000);
962 match result {
963 Recommendation::VacuumNeeded { dead_tup_ratio } => {
964 // i64::MAX / i64::MAX (saturated) = 1.0
965 assert!(
966 (dead_tup_ratio - 1.0).abs() < 1e-9,
967 "expected ratio 1.0, got {dead_tup_ratio}"
968 );
969 }
970 other => panic!("expected VacuumNeeded, got {other:?}"),
971 }
972 }
973
974 /// Helper for the render-only tests: construct a small report with
975 /// every recommendation arm represented. Sorted by table_name so
976 /// the renderer's input mirrors what `run` would produce.
977 fn fixture_report() -> Vec<(TableHealth, Recommendation)> {
978 vec![
979 (health(1_000, 0, 0), Recommendation::Healthy),
980 (
981 TableHealth {
982 table_name: "public.events".to_string(),
983 n_live_tup: 100_000_000,
984 n_dead_tup: 0,
985 last_analyze: None,
986 partition_count: 0,
987 },
988 Recommendation::PartitionRecommended {
989 reason:
990 "table has 100000000 live rows but is not partitioned (threshold: 10000000)"
991 .to_string(),
992 },
993 ),
994 (
995 TableHealth {
996 table_name: "public.orders".to_string(),
997 n_live_tup: 100_000_000,
998 n_dead_tup: 0,
999 last_analyze: None,
1000 partition_count: 4,
1001 },
1002 Recommendation::PartitionCountIncrease {
1003 current: 4,
1004 suggested: 8,
1005 },
1006 ),
1007 (
1008 TableHealth {
1009 table_name: "public.users".to_string(),
1010 n_live_tup: 50,
1011 n_dead_tup: 50,
1012 last_analyze: None,
1013 partition_count: 0,
1014 },
1015 Recommendation::VacuumNeeded {
1016 dead_tup_ratio: 0.5,
1017 },
1018 ),
1019 ]
1020 }
1021
1022 /// Render-only test: human format produces a sorted ASCII table
1023 /// whose body lines appear in input order. Pinning the format
1024 /// stops a future "improve the visuals" refactor from breaking
1025 /// downstream operator scripts that grep for substrings.
1026 #[test]
1027 fn render_human_lists_every_table_in_input_order() {
1028 let report = fixture_report();
1029 let mut buf = Vec::new();
1030 render_human(&report, &mut buf).expect("render");
1031 let s = String::from_utf8(buf).expect("utf8");
1032
1033 // Header is present.
1034 assert!(s.contains("TABLE"), "missing header: {s}");
1035 assert!(s.contains("RECOMMENDATION"), "missing header: {s}");
1036
1037 // Each fixture table appears exactly once in the body.
1038 for table in [
1039 "test_table",
1040 "public.events",
1041 "public.orders",
1042 "public.users",
1043 ] {
1044 assert_eq!(
1045 s.matches(table).count(),
1046 1,
1047 "table `{table}` should appear once: {s}"
1048 );
1049 }
1050
1051 // Every recommendation tag shows up with its expected substring.
1052 assert!(s.contains("healthy"), "healthy tag missing: {s}");
1053 assert!(
1054 s.contains("vacuum (dead_tup_ratio="),
1055 "vacuum tag missing: {s}"
1056 );
1057 assert!(
1058 s.contains("partition (table has 100000000"),
1059 "partition tag missing: {s}"
1060 );
1061 assert!(
1062 s.contains("parts++ (current=4, suggested=8)"),
1063 "parts++ tag missing: {s}"
1064 );
1065
1066 // Body line order matches input order.
1067 let test_idx = s.find("test_table").unwrap();
1068 let events_idx = s.find("public.events").unwrap();
1069 let orders_idx = s.find("public.orders").unwrap();
1070 let users_idx = s.find("public.users").unwrap();
1071 assert!(
1072 test_idx < events_idx && events_idx < orders_idx && orders_idx < users_idx,
1073 "input order not preserved: {s}"
1074 );
1075 }
1076
1077 /// Regression sentinel — same input must produce byte-identical
1078 /// output across repeated invocations. Cements the determinism
1079 /// contract that `run` orchestrates: sort then render.
1080 #[test]
1081 fn render_human_is_byte_stable() {
1082 let report = fixture_report();
1083 let mut a = Vec::new();
1084 let mut b = Vec::new();
1085 render_human(&report, &mut a).unwrap();
1086 render_human(&report, &mut b).unwrap();
1087 assert_eq!(a, b, "render_human is not byte-stable");
1088 }
1089
1090 /// Render-only test: JSON format parses through `serde_json::from_slice`
1091 /// as a non-empty array of well-formed objects, with every recommendation
1092 /// arm represented and the field order matching the projection struct.
1093 #[test]
1094 fn render_json_parses_and_field_order_is_stable() {
1095 let report = fixture_report();
1096 let mut buf = Vec::new();
1097 render_json(&report, &mut buf).expect("render");
1098
1099 // 1. Parses through serde_json::Value (smoke test).
1100 let parsed: serde_json::Value =
1101 serde_json::from_slice(&buf).expect("output must be valid JSON");
1102 let array = parsed.as_array().expect("output must be a JSON array");
1103 assert_eq!(array.len(), 4, "expected 4 fixture rows: {parsed}");
1104
1105 // 2. Every recommendation kind shows up at least once.
1106 let kinds: Vec<&str> = array
1107 .iter()
1108 .filter_map(|row| row.get("recommendation"))
1109 .filter_map(|rec| rec.get("kind"))
1110 .filter_map(|k| k.as_str())
1111 .collect();
1112 assert!(kinds.contains(&"healthy"), "kinds: {kinds:?}");
1113 assert!(kinds.contains(&"vacuum_needed"), "kinds: {kinds:?}");
1114 assert!(kinds.contains(&"partition_recommended"), "kinds: {kinds:?}");
1115 assert!(
1116 kinds.contains(&"partition_count_increase"),
1117 "kinds: {kinds:?}"
1118 );
1119
1120 // 3. Field order on the first row matches the Row struct.
1121 // serde_json with `preserve_order` enabled (workspace dep
1122 // feature) preserves insertion order on Map deserialisation;
1123 // we walk the keys and assert the declared order.
1124 let first = array.first().expect("non-empty");
1125 let obj = first.as_object().expect("row must be an object");
1126 let keys: Vec<&str> = obj.keys().map(String::as_str).collect();
1127 assert_eq!(
1128 keys,
1129 vec![
1130 "table_name",
1131 "n_live_tup",
1132 "n_dead_tup",
1133 "last_analyze",
1134 "partition_count",
1135 "recommendation",
1136 ],
1137 "field order must match Row struct declaration: {keys:?}"
1138 );
1139
1140 // 4. Body is byte-stable across two renders.
1141 let mut buf2 = Vec::new();
1142 render_json(&report, &mut buf2).unwrap();
1143 assert_eq!(buf, buf2, "render_json is not byte-stable");
1144 }
1145
1146 /// Empty input produces an empty JSON array — no crash, no
1147 /// "no rows" placeholder, just `[]\n` so downstream JSON parsers
1148 /// see a valid empty list.
1149 #[test]
1150 fn render_json_handles_empty_input() {
1151 let report: Vec<(TableHealth, Recommendation)> = Vec::new();
1152 let mut buf = Vec::new();
1153 render_json(&report, &mut buf).expect("render");
1154 let s = String::from_utf8(buf).expect("utf8");
1155 // `to_writer_pretty` emits `[]` for an empty Vec; the renderer
1156 // appends a newline.
1157 assert_eq!(s, "[]\n", "empty input should produce `[]\\n`, got: {s:?}");
1158 }
1159
1160 /// Empty input produces just the header line in the human renderer.
1161 /// Pins the no-rows behaviour so a downstream `wc -l` sees the row
1162 /// count directly (header + 0 body lines = 1).
1163 #[test]
1164 fn render_human_handles_empty_input() {
1165 let report: Vec<(TableHealth, Recommendation)> = Vec::new();
1166 let mut buf = Vec::new();
1167 render_human(&report, &mut buf).expect("render");
1168 let s = String::from_utf8(buf).expect("utf8");
1169 // Exactly one line — the header.
1170 assert_eq!(
1171 s.lines().count(),
1172 1,
1173 "expected just a header line, got: {s:?}"
1174 );
1175 assert!(s.contains("TABLE"));
1176 }
1177
1178 /// SQLSTATE classifier covers the three "pg_partman absent"
1179 /// codes and rejects anything else. Pure unit test — no DB.
1180 #[test]
1181 fn is_partman_absent_code_recognises_three_states() {
1182 assert!(is_partman_absent_code(&SqlState::UNDEFINED_FUNCTION));
1183 assert!(is_partman_absent_code(&SqlState::UNDEFINED_TABLE));
1184 assert!(is_partman_absent_code(&SqlState::INVALID_SCHEMA_NAME));
1185 // Non-absence codes must NOT count as partman-absent.
1186 assert!(!is_partman_absent_code(&SqlState::SYNTAX_ERROR));
1187 assert!(!is_partman_absent_code(&SqlState::INSUFFICIENT_PRIVILEGE));
1188 assert!(!is_partman_absent_code(&SqlState::CONNECTION_FAILURE));
1189 }
1190
1191 /// `AnalyzeError::Display` must surface operator-actionable text
1192 /// for every variant — the CLI prints these straight to stderr.
1193 /// Every arm carries a remediation hint with a verb keyword
1194 /// (`Verify` / `Check` / `file an issue`). The keyword assertion is
1195 /// the regression sentinel: a future drift that drops the hint
1196 /// trips the test rather than silently regressing operator UX.
1197 /// All five variants are covered (Config, Pool, Db, Io, Json).
1198 #[test]
1199 fn analyze_error_display_is_operator_actionable() {
1200 // Config — surfaces the cause AND a remediation pointer to the
1201 // workspace + [database] section.
1202 let cfg = AnalyzeError::Config("bad toml".to_string());
1203 let s = format!("{cfg}");
1204 assert!(s.contains("config load"), "config display: {s}");
1205 assert!(s.contains("bad toml"), "config display: {s}");
1206 assert!(
1207 s.contains("Verify"),
1208 "config display must carry a remediation keyword: {s}"
1209 );
1210 assert!(
1211 s.contains("Djogi.toml"),
1212 "config display must point at Djogi.toml: {s}"
1213 );
1214
1215 // Pool — surfaces the URL, the cause, AND a remediation pointer
1216 // to Djogi.toml::database.url.
1217 let pool = AnalyzeError::Pool {
1218 url: "postgres://localhost/x".to_string(),
1219 message: "refused".to_string(),
1220 };
1221 let s = format!("{pool}");
1222 assert!(s.contains("postgres://localhost/x"), "pool display: {s}");
1223 assert!(s.contains("refused"), "pool display: {s}");
1224 assert!(
1225 s.contains("Djogi.toml::database.url"),
1226 "pool display must point at config: {s}"
1227 );
1228 assert!(
1229 s.contains("Verify"),
1230 "pool display must carry a remediation keyword: {s}"
1231 );
1232
1233 // Db — surfaces the cause AND a remediation pointer to
1234 // privileges + reachability.
1235 let db = AnalyzeError::Db("relation \"foo\" does not exist".to_string());
1236 let s = format!("{db}");
1237 assert!(s.contains("live-DB query"), "db display: {s}");
1238 assert!(s.contains("relation \"foo\""), "db display: {s}");
1239 assert!(
1240 s.contains("Verify"),
1241 "db display must carry a remediation keyword: {s}"
1242 );
1243 assert!(
1244 s.contains("pg_stat_user_tables"),
1245 "db display must mention the catalogue we query: {s}"
1246 );
1247
1248 // Io — surfaces the cause AND a remediation pointer to
1249 // stdout/stderr permissions.
1250 let io = AnalyzeError::Io(std::io::Error::other("broken pipe"));
1251 let s = format!("{io}");
1252 assert!(s.contains("writing analyze output"), "io display: {s}");
1253 assert!(s.contains("broken pipe"), "io display: {s}");
1254 assert!(
1255 s.contains("Check"),
1256 "io display must carry a remediation keyword: {s}"
1257 );
1258
1259 // Json — was previously omitted; covered now. Surfaces the cause
1260 // AND a "file an issue" hint because reaching this arm implies
1261 // an internal serde bug rather than operator misconfiguration.
1262 let json_err: serde_json::Error =
1263 serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
1264 let json = AnalyzeError::Json(json_err);
1265 let s = format!("{json}");
1266 assert!(s.contains("encoding analyze output"), "json display: {s}");
1267 assert!(
1268 s.contains("file an issue"),
1269 "json display must point at the issue tracker: {s}"
1270 );
1271 }
1272}