Skip to main content

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}