From efbf8b74f41271b6544740f869f464e9f0d0aeca Mon Sep 17 00:00:00 2001 From: Jack Schultz Date: Wed, 10 Jun 2026 19:13:43 -0500 Subject: [PATCH] =?UTF-8?q?Plan=20A:=20pgcrate=20brief=20=E2=80=94=20whole?= =?UTF-8?q?=20database=20in=20one=20dense=20screen=20(PGC-100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Built: `pgcrate brief` command (src/commands/brief.rs) — target echo, schemas→tables→estimated rows (reltuples, never count(*); -1 rendered as ?), compact FK graph, migrations line (applied/pending, silent when unconfigured), extensions + server version, catalog-cheap health flags (sequences >75%, XID age). Dense/pretty via PGC-102 Density; --json with pgcrate.brief schema + brief.schema.json. Wired into main.rs + commands/mod.rs; SKILL.md session-start now leads with brief and drops the "coming soon" note. - Validation: cargo fmt --check clean, clippy clean for new files, 780 tests pass (768 baseline + 12 new: 9 brief_integration incl. multi-schema fixture, 3 unit). Live-verified on solitaire_local (multi-schema + migrations line) and the messy postgres instance (88 tables → per-schema cap). ~20ms, 50x under the sub-second bar. - Notes: added a SCHEMA_TABLE_CAP=40 (human output only; JSON uncapped) and size-descending table ordering so a schema full of orphan/partition tables can't bury the headline — the messy-instance case made the cap necessary. Health flags are displayed not scored; brief always exits 0 (orientation, not triage). --- schemas/brief.schema.json | 144 +++++++++ skill/SKILL.md | 19 +- src/commands/brief.rs | 617 +++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 45 +++ tests/brief_integration.rs | 368 ++++++++++++++++++++++ 6 files changed, 1191 insertions(+), 3 deletions(-) create mode 100644 schemas/brief.schema.json create mode 100644 src/commands/brief.rs create mode 100644 tests/brief_integration.rs diff --git a/schemas/brief.schema.json b/schemas/brief.schema.json new file mode 100644 index 0000000..1b91363 --- /dev/null +++ b/schemas/brief.schema.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "pgcrate.brief", + "title": "pgcrate brief output", + "description": "Whole-database orientation: target, schemas/tables/estimated rows, relationships, migrations, extensions, and at-a-glance health flags. Catalog-only, never count(*).", + "allOf": [ + { "$ref": "envelope.schema.json" } + ], + "properties": { + "schema_id": { "const": "pgcrate.brief" }, + "data": { "$ref": "#/$defs/briefData" } + }, + "$defs": { + "briefData": { + "type": "object", + "additionalProperties": false, + "required": ["target", "server", "schemas", "relationships", "extensions", "health"], + "properties": { + "target": { "$ref": "#/$defs/targetInfo" }, + "server": { "$ref": "#/$defs/serverInfo" }, + "schemas": { + "type": "array", + "items": { "$ref": "#/$defs/schemaBrief" }, + "description": "Non-system schemas and the tables they hold" + }, + "relationships": { + "type": "array", + "items": { "$ref": "#/$defs/relationshipBrief" }, + "description": "Foreign-key edges collapsed to child -> {parents}" + }, + "migrations": { + "$ref": "#/$defs/migrationsBrief", + "description": "Migration state; omitted entirely when no migrations directory resolves" + }, + "extensions": { + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { "type": "string", "description": "extension name" }, + { "type": "string", "description": "installed version" } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "description": "Installed extensions as [name, version] pairs, sorted by name" + }, + "health": { + "type": "array", + "items": { "$ref": "#/$defs/healthFlag" }, + "description": "At-a-glance health flags. Displayed, not scored — severity stays healthy." + } + } + }, + "targetInfo": { + "type": "object", + "additionalProperties": false, + "required": ["host", "port", "database", "user", "readonly"], + "properties": { + "host": { "type": "string", "description": "Host (redacted by default)" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "database": { "type": "string" }, + "user": { "type": "string" }, + "readonly": { "type": "boolean" } + } + }, + "serverInfo": { + "type": "object", + "additionalProperties": false, + "required": ["version", "version_num", "version_major", "in_recovery"], + "properties": { + "version": { "type": "string" }, + "version_num": { "type": "integer" }, + "version_major": { "type": "integer" }, + "in_recovery": { "type": "boolean" }, + "data_directory": { "type": "string", "description": "Only with --no-redact" } + } + }, + "schemaBrief": { + "type": "object", + "additionalProperties": false, + "required": ["name", "tables"], + "properties": { + "name": { "type": "string" }, + "tables": { + "type": "array", + "items": { "$ref": "#/$defs/tableBrief" } + } + } + }, + "tableBrief": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "name", "est_rows", "size", "size_bytes"], + "properties": { + "schema": { "type": "string" }, + "name": { "type": "string" }, + "est_rows": { + "type": ["integer", "null"], + "description": "Estimated live rows from reltuples; null when never analyzed (reltuples is -1)" + }, + "size": { "type": "string", "description": "Total relation size, pg_size_pretty form" }, + "size_bytes": { "type": "integer", "description": "Total relation size in bytes" } + } + }, + "relationshipBrief": { + "type": "object", + "additionalProperties": false, + "required": ["child", "parents"], + "properties": { + "child": { "type": "string", "description": "schema.table that holds the FK" }, + "parents": { + "type": "array", + "items": { "type": "string" }, + "description": "Referenced schema.table(s)" + } + } + }, + "migrationsBrief": { + "type": "object", + "additionalProperties": false, + "required": ["applied", "pending"], + "properties": { + "applied": { "type": "integer", "minimum": 0 }, + "pending": { "type": "integer", "minimum": 0 }, + "pending_versions": { + "type": "array", + "items": { "type": "string" }, + "description": "Pending versions, listed only when pending <= 5" + } + } + }, + "healthFlag": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "detail"], + "properties": { + "kind": { "type": "string", "description": "e.g. sequence, xid" }, + "detail": { "type": "string" } + } + } + } +} diff --git a/skill/SKILL.md b/skill/SKILL.md index 1784d25..9a815cf 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -45,6 +45,7 @@ exhaustive reference; this skill is the *when/why*, that flag is the *what*. ## Session start — orient before you act ```bash +pgcrate brief # whole DB in one screen: schemas → tables → est. rows, FKs, migrations, hazards pgcrate context # connection, server version, extensions, privileges, read/write mode pgcrate capabilities # what you're actually allowed to do here pgcrate inspect table # columns, indexes, constraints, stats @@ -53,9 +54,20 @@ pgcrate inspect roles # roles/users; add --describe for one pgcrate inspect extensions # installed extensions (--available for the rest) ``` -Run `context` first on an unfamiliar database — it tells you the PG version, -whether you're on a replica, and whether you even have permission for the -diagnostics below. (A one-shot `brief` summary command is coming; not available yet.) +**Run `brief` first on an unfamiliar database.** It's the one command that +orients you in a single shot: which instance you're on (db/host/port/user/mode — +check this before you touch anything; the worst mistake is the right query on the +wrong instance), every non-system schema with its tables and *estimated* row +counts (`reltuples`, never `count(*)` — so it's sub-second and never scans), the +foreign-key graph in compact `child → parents` form, migration state (applied N / +pending M) when a `pgcrate.toml` resolves, installed extensions + server version, +and any cheap at-a-glance hazards (sequence exhaustion, XID age). It's catalog-only +and read-only; health flags are *shown, not scored* (it always exits `0` — for a +scored health pass use `dba triage`). `--json` gives the full, uncapped structure +(`schema_id: pgcrate.brief`); human output caps long schemas to the largest tables. + +`context` drills into the connection/server/privilege detail when you need it +(e.g. confirming you have permission for the diagnostics below). ## Querying @@ -217,6 +229,7 @@ Every `fix` supports `--dry-run` (preview) and `--verify` (re-check after). Run | Goal | Command | |------|---------| +| Orient on an unfamiliar DB (run first) | `pgcrate brief` | | Connection + server + privileges | `pgcrate context` | | What I'm allowed to do | `pgcrate capabilities` | | Describe a table | `pgcrate inspect table ` | diff --git a/src/commands/brief.rs b/src/commands/brief.rs new file mode 100644 index 0000000..df25592 --- /dev/null +++ b/src/commands/brief.rs @@ -0,0 +1,617 @@ +//! Brief command: the whole database in one dense, sub-second screen. +//! +//! This is the command an agent runs first, every session. It answers the +//! orientation questions that otherwise cost three or four manual pg_catalog +//! round-trips: which instance am I on, which schemas exist, what tables with +//! how many rows, what's hiding outside `public`, what relates to what, is the +//! database migrated, and are there any glance-level hazards. +//! +//! Everything here is catalog/statistics only — `reltuples` estimates, never +//! `count(*)`, no table scans, no `pg_stat_statements` dependency. The target +//! is sub-second on a typical database. +//! +//! Health flags are *displayed*, not scored: brief is orientation, not triage. +//! The exit code stays 0 for any finding; severity lives in `dba triage`. + +use anyhow::Result; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::Path; +use tokio_postgres::Client; + +use crate::commands::context::{ + get_extensions, get_server_info, get_target_info, ServerInfo, TargetInfo, +}; +use crate::config::Config; +use crate::migrations::discover_migrations; +use crate::output::Density; + +/// Sequence usage warning threshold (percent) for the at-a-glance health line. +const SEQ_WARN_PCT: f64 = 75.0; +/// XID age at which wraparound becomes worth flagging at a glance. +const XID_WARN_AGE: i64 = 1_500_000_000; +/// Per-schema table cap for human output. A schema with hundreds of tables +/// (orphan test fixtures, partition children) would bury the headline; brief +/// shows the largest `SCHEMA_TABLE_CAP` and folds the rest into one line. JSON +/// is never capped — automation gets the full set. +const SCHEMA_TABLE_CAP: usize = 40; + +/// One table's headline shape: estimated rows and total on-disk size. +#[derive(Debug, Clone, Serialize)] +pub struct TableBrief { + pub schema: String, + pub name: String, + /// Estimated live rows from `reltuples`. `None` means never analyzed + /// (`reltuples` is -1 on modern PG) — rendered as `?`, never as -1. + pub est_rows: Option, + pub size: String, + pub size_bytes: i64, +} + +/// A schema and the tables it holds, in the order brief prints them. +#[derive(Debug, Clone, Serialize)] +pub struct SchemaBrief { + pub name: String, + pub tables: Vec, +} + +/// A compact foreign-key edge: `child` references one or more `parents`. +#[derive(Debug, Clone, Serialize)] +pub struct RelationshipBrief { + pub child: String, + pub parents: Vec, +} + +/// Migration state, present only when a migrations directory resolves on disk. +#[derive(Debug, Clone, Serialize)] +pub struct MigrationsBrief { + pub applied: usize, + pub pending: usize, + /// Pending versions, listed only when the pending set is small (<= 5). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pending_versions: Vec, +} + +/// A glance-level health note. Cheap catalog reads only; displayed, not scored. +#[derive(Debug, Clone, Serialize)] +pub struct HealthFlag { + pub kind: String, + pub detail: String, +} + +/// Everything `brief` knows about the target, ready to render or serialize. +#[derive(Debug, Serialize)] +pub struct BriefResult { + pub target: TargetInfo, + pub server: ServerInfo, + pub schemas: Vec, + pub relationships: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub migrations: Option, + /// Installed extensions, sorted by name (name=version pairs). + pub extensions: Vec<(String, String)>, + pub health: Vec, +} + +/// Query schemas and their tables in one shot, grouped and ordered. +/// +/// Excludes system schemas (`pg_*`, `information_schema`) and pgcrate's own +/// bookkeeping schema (`pgcrate`) — the latter is covered by the migrations +/// line and is pure noise in the headline. `reltuples` of -1 (never analyzed) +/// becomes `None` so it renders as `?` rather than a misleading -1. +async fn get_schemas(client: &Client) -> Result> { + let rows = client + .query( + r#" + SELECT + n.nspname AS schema, + c.relname AS name, + c.reltuples::bigint AS est_rows, + pg_total_relation_size(c.oid) AS size_bytes, + pg_size_pretty(pg_total_relation_size(c.oid)) AS size + FROM pg_class c + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE c.relkind IN ('r', 'p') + AND n.nspname NOT LIKE 'pg_%' + AND n.nspname <> 'information_schema' + AND n.nspname <> 'pgcrate' + ORDER BY n.nspname, + pg_total_relation_size(c.oid) DESC, + c.relname + "#, + &[], + ) + .await?; + + // Preserve insertion order per schema; BTreeMap keeps schemas sorted, and + // the query already orders tables within each schema. + let mut grouped: BTreeMap> = BTreeMap::new(); + for row in rows { + let schema: String = row.get("schema"); + let est: i64 = row.get("est_rows"); + grouped.entry(schema.clone()).or_default().push(TableBrief { + schema, + name: row.get("name"), + est_rows: if est < 0 { None } else { Some(est) }, + size: row.get("size"), + size_bytes: row.get("size_bytes"), + }); + } + + // Include schemas that exist but hold no tables — an empty `solitaire` + // schema is itself orientation ("the schema is here, it's just empty"). + let schema_rows = client + .query( + r#" + SELECT nspname AS schema + FROM pg_namespace + WHERE nspname NOT LIKE 'pg_%' + AND nspname <> 'information_schema' + AND nspname <> 'pgcrate' + ORDER BY nspname + "#, + &[], + ) + .await?; + + for row in schema_rows { + let name: String = row.get("schema"); + grouped.entry(name).or_default(); + } + + Ok(grouped + .into_iter() + .map(|(name, tables)| SchemaBrief { name, tables }) + .collect()) +} + +/// Query foreign keys, collapsed to `child → {parents}` edges. +/// +/// One row per FK constraint; multiple FKs from the same child collapse into a +/// single edge with a deduplicated, sorted parent set. Self-references are kept +/// (they are real orientation — a tree/graph table). +async fn get_relationships(client: &Client) -> Result> { + let rows = client + .query( + r#" + SELECT + cn.nspname || '.' || cl.relname AS child, + fn.nspname || '.' || fl.relname AS parent + FROM pg_constraint con + JOIN pg_class cl ON con.conrelid = cl.oid + JOIN pg_namespace cn ON cl.relnamespace = cn.oid + JOIN pg_class fl ON con.confrelid = fl.oid + JOIN pg_namespace fn ON fl.relnamespace = fn.oid + WHERE con.contype = 'f' + AND cn.nspname NOT LIKE 'pg_%' + AND cn.nspname <> 'information_schema' + ORDER BY child, parent + "#, + &[], + ) + .await?; + + let mut grouped: BTreeMap> = BTreeMap::new(); + for row in rows { + let child: String = row.get("child"); + let parent: String = row.get("parent"); + let parents = grouped.entry(child).or_default(); + if !parents.contains(&parent) { + parents.push(parent); + } + } + + Ok(grouped + .into_iter() + .map(|(child, parents)| RelationshipBrief { child, parents }) + .collect()) +} + +/// Resolve migration state, or `None` when no migrations exist on disk. +/// +/// Degrades silently: brief runs against arbitrary databases with zero project +/// context, so a missing `pgcrate.toml` or migrations directory is not an +/// error — it just means there's no migration line to print. +async fn get_migrations(client: &Client, config: &Config) -> Result> { + let dir = config.migrations_dir(); + let migrations = discover_migrations(Path::new(dir))?; + if migrations.is_empty() { + return Ok(None); + } + + // Read applied versions without creating the schema_migrations table — + // brief is read-only and must never mutate the target. A database with no + // pgcrate schema simply reports zero applied. + let applied: std::collections::HashSet = client + .query("SELECT version FROM pgcrate.schema_migrations", &[]) + .await + .map(|rows| rows.iter().map(|r| r.get::<_, String>("version")).collect()) + .unwrap_or_default(); + + let pending: Vec = migrations + .iter() + .filter(|m| !applied.contains(&m.version)) + .map(|m| m.version.clone()) + .collect(); + + let applied_count = migrations + .iter() + .filter(|m| applied.contains(&m.version)) + .count(); + + // List pending versions only when the set is small enough to be useful + // inline; beyond that the count alone is the signal. + let pending_versions = if pending.len() <= 5 { + pending.clone() + } else { + Vec::new() + }; + + Ok(Some(MigrationsBrief { + applied: applied_count, + pending: pending.len(), + pending_versions, + })) +} + +/// Gather the cheap, at-a-glance health flags. Catalog reads only. +/// +/// Sequences past `SEQ_WARN_PCT` of their max, and current-database XID age +/// past `XID_WARN_AGE`. Both are nearly free; anything requiring a table scan +/// or `pg_stat_statements` belongs in `dba triage`, not here. +async fn get_health(client: &Client) -> Result> { + let mut flags = Vec::new(); + + // Sequences approaching exhaustion. + let seq_rows = client + .query( + r#" + SELECT + schemaname || '.' || sequencename AS name, + round(100.0 * last_value / max_value, 1)::float8 AS pct + FROM pg_sequences + WHERE last_value IS NOT NULL + AND max_value > 0 + AND increment_by > 0 + AND (100.0 * last_value / max_value) >= $1 + ORDER BY pct DESC + "#, + &[&SEQ_WARN_PCT], + ) + .await + .unwrap_or_default(); + + for row in seq_rows { + let name: String = row.get("name"); + let pct: f64 = row.get("pct"); + flags.push(HealthFlag { + kind: "sequence".to_string(), + detail: format!("{} at {:.1}% of max", name, pct), + }); + } + + // Current-database transaction-ID age (wraparound risk). + if let Ok(row) = client + .query_one( + "SELECT age(datfrozenxid)::bigint AS xid_age + FROM pg_database WHERE datname = current_database()", + &[], + ) + .await + { + let age: i64 = row.get("xid_age"); + if age >= XID_WARN_AGE { + let pct = 100.0 * age as f64 / 2_147_483_647.0; + flags.push(HealthFlag { + kind: "xid".to_string(), + detail: format!("database XID age {} ({:.0}% to wraparound)", age, pct), + }); + } + } + + Ok(flags) +} + +/// Run the full brief: one pass over the catalog, assembled into `BriefResult`. +pub async fn run_brief( + client: &Client, + connection_url: &str, + read_only: bool, + no_redact: bool, + config: &Config, +) -> Result { + let target = get_target_info(client, connection_url, read_only, no_redact).await?; + let server = get_server_info(client, no_redact).await?; + let schemas = get_schemas(client).await?; + let relationships = get_relationships(client).await?; + let migrations = get_migrations(client, config).await?; + let health = get_health(client).await?; + + let mut extensions: Vec<(String, String)> = get_extensions(client).await?.into_iter().collect(); + extensions.sort_by(|a, b| a.0.cmp(&b.0)); + + Ok(BriefResult { + target, + server, + schemas, + relationships, + migrations, + extensions, + health, + }) +} + +/// Format an estimated row count for display: `?` for never-analyzed tables, +/// thousands-grouped otherwise (`12,345`). +fn fmt_rows(est: Option) -> String { + match est { + None => "?".to_string(), + Some(n) => group_thousands(n), + } +} + +/// Group an integer with thousands separators (`1234567` -> `1,234,567`). +fn group_thousands(n: i64) -> String { + let neg = n < 0; + let digits = n.unsigned_abs().to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3 + 1); + let bytes = digits.as_bytes(); + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(*b as char); + } + if neg { + format!("-{}", out) + } else { + out + } +} + +/// Print the brief in human-readable form (dense or pretty). +pub fn print_human(result: &BriefResult, density: Density) { + if density.is_dense() { + print_dense(result); + } else { + print_pretty(result); + } +} + +fn mode_str(read_only: bool) -> &'static str { + if read_only { + "read-only" + } else { + "read-write" + } +} + +fn print_dense(r: &BriefResult) { + // Target — loud and first. The worst session hazard is being on the wrong + // instance while everything looks fine, so this line leads. + println!( + "TARGET: {}@{}:{}/{} ({}) — pg {} {}", + r.target.user, + r.target.host, + r.target.port, + r.target.database, + mode_str(r.target.readonly), + r.server.version_major, + if r.server.in_recovery { + "replica" + } else { + "primary" + }, + ); + + // Schemas → tables → estimated rows. The headline section. + let table_count: usize = r.schemas.iter().map(|s| s.tables.len()).sum(); + println!( + "SCHEMAS ({}), {} table{}:", + r.schemas.len(), + table_count, + if table_count == 1 { "" } else { "s" } + ); + for schema in &r.schemas { + if schema.tables.is_empty() { + println!(" {} (empty)", schema.name); + continue; + } + println!(" {} ({}):", schema.name, schema.tables.len()); + for t in schema.tables.iter().take(SCHEMA_TABLE_CAP) { + println!(" {} ~{} rows, {}", t.name, fmt_rows(t.est_rows), t.size); + } + if schema.tables.len() > SCHEMA_TABLE_CAP { + println!( + " … and {} more (largest shown; `--json` lists all)", + schema.tables.len() - SCHEMA_TABLE_CAP + ); + } + } + + // Relationships — compact FK summary. + if r.relationships.is_empty() { + println!("RELATIONSHIPS: none"); + } else { + println!("RELATIONSHIPS ({}):", r.relationships.len()); + for rel in &r.relationships { + println!(" {} → {}", rel.child, rel.parents.join(", ")); + } + } + + // Migrations — only when configured. + if let Some(m) = &r.migrations { + let mut line = format!("MIGRATIONS: applied {} / pending {}", m.applied, m.pending); + if !m.pending_versions.is_empty() { + line.push_str(&format!(" ({})", m.pending_versions.join(", "))); + } + println!("{}", line); + } + + // Extensions + version (one line). + if r.extensions.is_empty() { + println!("EXTENSIONS (0)"); + } else { + let list: Vec = r + .extensions + .iter() + .map(|(name, ver)| format!("{}={}", name, ver)) + .collect(); + println!("EXTENSIONS ({}): {}", r.extensions.len(), list.join(" ")); + } + println!( + "SERVER: pg {} ({})", + r.server.version_major, r.server.version_num + ); + + // Health flags — displayed, not scored. + if r.health.is_empty() { + println!("HEALTH: no flags"); + } else { + println!("HEALTH ({}):", r.health.len()); + for flag in &r.health { + println!(" {}: {}", flag.kind, flag.detail); + } + } +} + +fn print_pretty(r: &BriefResult) { + println!("TARGET"); + println!(" Database: {}", r.target.database); + println!(" Host: {}:{}", r.target.host, r.target.port); + println!(" User: {}", r.target.user); + println!(" Mode: {}", mode_str(r.target.readonly)); + println!( + " Server: pg {} ({}) {}", + r.server.version_major, + r.server.version_num, + if r.server.in_recovery { + "replica" + } else { + "primary" + } + ); + + println!(); + let table_count: usize = r.schemas.iter().map(|s| s.tables.len()).sum(); + println!( + "SCHEMAS ({}, {} table{})", + r.schemas.len(), + table_count, + if table_count == 1 { "" } else { "s" } + ); + for schema in &r.schemas { + if schema.tables.is_empty() { + println!(" {} (empty)", schema.name); + continue; + } + println!(" {} ({})", schema.name, schema.tables.len()); + for t in schema.tables.iter().take(SCHEMA_TABLE_CAP) { + println!( + " {:30} ~{:>12} rows {:>10}", + t.name, + fmt_rows(t.est_rows), + t.size + ); + } + if schema.tables.len() > SCHEMA_TABLE_CAP { + println!( + " … and {} more (largest shown; `--json` lists all)", + schema.tables.len() - SCHEMA_TABLE_CAP + ); + } + } + + println!(); + if r.relationships.is_empty() { + println!("RELATIONSHIPS"); + println!(" (none)"); + } else { + println!("RELATIONSHIPS ({})", r.relationships.len()); + for rel in &r.relationships { + println!(" {} → {}", rel.child, rel.parents.join(", ")); + } + } + + if let Some(m) = &r.migrations { + println!(); + println!("MIGRATIONS"); + println!(" Applied: {}", m.applied); + println!(" Pending: {}", m.pending); + if !m.pending_versions.is_empty() { + for v in &m.pending_versions { + println!(" · {}", v); + } + } + } + + println!(); + println!("EXTENSIONS ({})", r.extensions.len()); + if r.extensions.is_empty() { + println!(" (none)"); + } else { + for (name, ver) in &r.extensions { + println!(" {} ({})", name, ver); + } + } + + println!(); + if r.health.is_empty() { + println!("HEALTH"); + println!(" No flags. (Run `pgcrate dba triage` for a full health pass.)"); + } else { + println!("HEALTH ({})", r.health.len()); + for flag in &r.health { + println!(" {}: {}", flag.kind, flag.detail); + } + } +} + +/// Print the brief as versioned JSON. +/// +/// Schema id `pgcrate.brief`. Severity is always `Healthy` — brief reports +/// findings without scoring them, so health flags never change the envelope's +/// severity or the exit code. +pub fn print_json( + result: &BriefResult, + timeouts: Option, +) -> Result<()> { + use crate::output::{DiagnosticOutput, Severity}; + + const SCHEMA_ID: &str = "pgcrate.brief"; + + let output = match timeouts { + Some(t) => DiagnosticOutput::with_timeouts(SCHEMA_ID, result, Severity::Healthy, t), + None => DiagnosticOutput::new(SCHEMA_ID, result, Severity::Healthy), + }; + output.print()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fmt_rows_never_analyzed_is_question_mark() { + assert_eq!(fmt_rows(None), "?"); + } + + #[test] + fn fmt_rows_groups_thousands() { + assert_eq!(fmt_rows(Some(0)), "0"); + assert_eq!(fmt_rows(Some(42)), "42"); + assert_eq!(fmt_rows(Some(1_000)), "1,000"); + assert_eq!(fmt_rows(Some(1_234_567)), "1,234,567"); + } + + #[test] + fn group_thousands_handles_boundaries() { + assert_eq!(group_thousands(0), "0"); + assert_eq!(group_thousands(999), "999"); + assert_eq!(group_thousands(1_000), "1,000"); + assert_eq!(group_thousands(10_000), "10,000"); + assert_eq!(group_thousands(100_000), "100,000"); + assert_eq!(group_thousands(1_000_000), "1,000,000"); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9f943fb..4c9ec21 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,6 +6,7 @@ mod anonymize; pub mod autovacuum_progress; pub mod bloat; mod bootstrap; +pub mod brief; pub mod cache; pub mod capabilities; pub mod checkpoints; diff --git a/src/main.rs b/src/main.rs index 438abaf..720788e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,6 +72,7 @@ fn json_supported(command: &Commands) -> bool { // Inspect commands all support JSON Commands::Inspect { .. } => true, // Operations + Commands::Brief => true, Commands::Context => true, Commands::Capabilities => true, Commands::Sql { .. } => true, @@ -261,6 +262,9 @@ enum Commands { }, // ===== Operations ===== + /// Orient on an unfamiliar database: schemas, tables, rows, relationships, + /// migrations, and at-a-glance hazards — the whole DB in one screen. + Brief, /// Show connection context, server info, extensions, and privileges Context, /// Show available capabilities based on privileges and connection mode @@ -2153,6 +2157,46 @@ async fn run(cli: Cli, output: &Output) -> Result<()> { } } } + Commands::Brief => { + let config = + Config::load(cli.config_path.as_deref()).context("Failed to load configuration")?; + let conn_result = connection::resolve_and_validate( + &config, + cli.database_url.as_deref(), + cli.connection.as_deref(), + cli.env_var.as_deref(), + cli.allow_primary, + cli.read_write, + cli.quiet, + )?; + + // Use DiagnosticSession with timeout enforcement + let timeout_config = parse_timeout_config(&cli)?; + let session = DiagnosticSession::connect(&conn_result.url, timeout_config).await?; + + // Set up Ctrl+C handler to cancel queries gracefully + setup_ctrlc_handler(session.cancel_token()); + + // Show effective timeouts unless quiet + if !cli.quiet && !cli.json { + eprintln!("pgcrate: timeouts: {}", session.effective_timeouts()); + } + + let result = commands::brief::run_brief( + session.client(), + &conn_result.url, + !cli.read_write, // read_only is the inverse of read_write flag + cli.no_redact, + &config, + ) + .await?; + + if cli.json { + commands::brief::print_json(&result, Some(session.effective_timeouts()))?; + } else { + commands::brief::print_human(&result, output.density()); + } + } Commands::Context => { let config = Config::load(cli.config_path.as_deref()).context("Failed to load configuration")?; @@ -2509,6 +2553,7 @@ async fn run(cli: Cli, output: &Output) -> Result<()> { | Commands::Init { .. } | Commands::Dba { .. } | Commands::Inspect { .. } + | Commands::Brief | Commands::Context | Commands::Capabilities | Commands::Sql { .. } diff --git a/tests/brief_integration.rs b/tests/brief_integration.rs new file mode 100644 index 0000000..67d670f --- /dev/null +++ b/tests/brief_integration.rs @@ -0,0 +1,368 @@ +//! Integration tests for `pgcrate brief` (PGC-100). +//! +//! brief is the orientation command an agent runs first, every session. These +//! tests prove the headline guarantee: dropped on an unfamiliar database, brief +//! alone surfaces schemas (including non-public ones), tables with estimated +//! rows, foreign-key relationships, and at-a-glance facts — sub-second, catalog +//! only. The multi-schema fixture exists specifically to catch the original +//! dogfooding bug: `public`-only views hiding data that lives in another schema. +//! +//! These tests require a running PostgreSQL instance. +//! Set TEST_DATABASE_URL or use the default postgres://postgres:postgres@localhost:5432/postgres. +//! +//! Run with: cargo test --test brief_integration + +use std::env; +use std::process::Command; +use std::sync::atomic::{AtomicU32, Ordering}; + +static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); + +fn get_test_db_url() -> String { + env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/postgres".to_string()) +} + +fn pgcrate_binary() -> String { + env!("CARGO_BIN_EXE_pgcrate").to_string() +} + +/// Run pgcrate via the compiled binary. stdout is a pipe here, so without an +/// override the binary picks the dense format — exactly the agent-capture case. +fn run_pgcrate(args: &[&str], db_url: &str) -> std::process::Output { + Command::new(pgcrate_binary()) + .args(args) + .env("DATABASE_URL", db_url) + .output() + .expect("Failed to execute pgcrate") +} + +fn run_psql(sql: &str, db_url: &str) -> std::process::Output { + Command::new("psql") + .args([db_url, "-c", sql]) + .output() + .expect("Failed to execute psql") +} + +fn unique_db_name(base: &str) -> String { + let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let pid = std::process::id(); + format!("{}_{pid}_{id}", base) +} + +fn setup_test_db(base_name: &str) -> Option { + let test_db = unique_db_name(base_name); + let db_url = get_test_db_url(); + let test_url = db_url + .rsplit_once('/') + .map(|(base, _)| format!("{}/{}", base, test_db)) + .unwrap_or_else(|| format!("{}/{}", db_url, test_db)); + + let _ = run_psql(&format!("DROP DATABASE IF EXISTS \"{}\"", test_db), &db_url); + let create_result = run_psql(&format!("CREATE DATABASE \"{}\"", test_db), &db_url); + if !create_result.status.success() { + eprintln!("Skipping test: could not create test database"); + return None; + } + Some(test_url) +} + +fn cleanup_test_db(test_url: &str) { + let db_url = get_test_db_url(); + if let Some(db_name) = test_url.rsplit('/').next() { + let _ = run_psql(&format!("DROP DATABASE IF EXISTS \"{}\"", db_name), &db_url); + } +} + +fn out(o: &std::process::Output) -> String { + String::from_utf8_lossy(&o.stdout).to_string() +} + +/// A schema outside `public` with related tables and an FK — the shape that +/// caused the original session's wild-goose chase. +const MULTI_SCHEMA_SETUP: &str = r#" + CREATE SCHEMA app; + CREATE TABLE app.users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE + ); + CREATE TABLE app.orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES app.users(id), + total NUMERIC(12,2) NOT NULL DEFAULT 0 + ); + INSERT INTO app.users (email) + SELECT 'u' || g || '@example.com' FROM generate_series(1, 25) g; + INSERT INTO app.orders (user_id, total) + SELECT (g % 25) + 1, g FROM generate_series(1, 100) g; + -- Force reltuples to be populated so estimated rows are real, not '?'. + ANALYZE app.users; + ANALYZE app.orders; +"#; + +// =========================================================================== +// Headline: schemas, tables, estimated rows +// =========================================================================== + +#[test] +fn brief_surfaces_a_non_public_schema_and_its_tables() { + let Some(test_url) = setup_test_db("pgcrate_brief_multi") else { + return; + }; + assert!( + run_psql(MULTI_SCHEMA_SETUP, &test_url).status.success(), + "multi-schema setup failed" + ); + + let stdout = out(&run_pgcrate(&["brief"], &test_url)); + + // The headline section names the non-public schema and both its tables. + assert!( + stdout.contains("SCHEMAS"), + "brief leads with a SCHEMAS section: {stdout}" + ); + assert!( + stdout.contains("app"), + "brief surfaces the non-public schema 'app': {stdout}" + ); + assert!( + stdout.contains("users") && stdout.contains("orders"), + "brief lists tables in the non-public schema: {stdout}" + ); + // Estimated rows are present (ANALYZE ran, so these are real, not '?'). + assert!( + stdout.contains("~25 rows") && stdout.contains("~100 rows"), + "brief shows estimated row counts per table: {stdout}" + ); + + cleanup_test_db(&test_url); +} + +#[test] +fn brief_renders_never_analyzed_tables_as_question_mark_not_minus_one() { + let Some(test_url) = setup_test_db("pgcrate_brief_unanalyzed") else { + return; + }; + // A fresh table with no ANALYZE has reltuples = -1 on modern PG. + let setup = "CREATE TABLE fresh (id SERIAL PRIMARY KEY, note TEXT);"; + assert!(run_psql(setup, &test_url).status.success(), "setup failed"); + + let stdout = out(&run_pgcrate(&["brief"], &test_url)); + + assert!( + stdout.contains("fresh ~? rows"), + "never-analyzed table shows '?' rows: {stdout}" + ); + assert!( + !stdout.contains("-1 rows"), + "brief must never print the raw -1 reltuples value: {stdout}" + ); + + cleanup_test_db(&test_url); +} + +// =========================================================================== +// Relationships +// =========================================================================== + +#[test] +fn brief_summarizes_foreign_key_relationships() { + let Some(test_url) = setup_test_db("pgcrate_brief_fk") else { + return; + }; + assert!( + run_psql(MULTI_SCHEMA_SETUP, &test_url).status.success(), + "setup failed" + ); + + let stdout = out(&run_pgcrate(&["brief"], &test_url)); + + assert!( + stdout.contains("RELATIONSHIPS"), + "brief has a RELATIONSHIPS section: {stdout}" + ); + // The compact edge form: child → parent. + assert!( + stdout.contains("app.orders → app.users"), + "brief shows the FK edge orders → users: {stdout}" + ); + + cleanup_test_db(&test_url); +} + +// =========================================================================== +// Density: dense default vs pretty override +// =========================================================================== + +#[test] +fn brief_piped_defaults_to_dense() { + let Some(test_url) = setup_test_db("pgcrate_brief_dense") else { + return; + }; + assert!( + run_psql(MULTI_SCHEMA_SETUP, &test_url).status.success(), + "setup failed" + ); + + let dense = out(&run_pgcrate(&["brief"], &test_url)); + // Dense folds the target onto one line beginning "TARGET:". + assert!( + dense.contains("TARGET: ") && dense.contains('@'), + "piped brief is dense (single-line TARGET): {dense}" + ); + // The padded multi-line " Host:" form must not appear when dense. + assert!( + !dense.contains(" Host:"), + "dense brief drops the padded Host: line: {dense}" + ); + + cleanup_test_db(&test_url); +} + +#[test] +fn brief_pretty_and_dense_carry_the_same_facts() { + let Some(test_url) = setup_test_db("pgcrate_brief_parity") else { + return; + }; + assert!( + run_psql(MULTI_SCHEMA_SETUP, &test_url).status.success(), + "setup failed" + ); + + let dense = out(&run_pgcrate(&["brief", "--dense"], &test_url)); + let pretty = out(&run_pgcrate(&["brief", "--pretty"], &test_url)); + + // Both forms report every section — density changes layout, not facts. + for section in ["TARGET", "SCHEMAS", "RELATIONSHIPS", "EXTENSIONS", "HEALTH"] { + assert!(dense.contains(section), "dense missing {section}: {dense}"); + assert!( + pretty.contains(section), + "pretty missing {section}: {pretty}" + ); + } + // Both name the non-public schema and its tables. + for fact in ["app", "users", "orders"] { + assert!(dense.contains(fact), "dense missing {fact}"); + assert!(pretty.contains(fact), "pretty missing {fact}"); + } + + cleanup_test_db(&test_url); +} + +// =========================================================================== +// JSON +// =========================================================================== + +#[test] +fn brief_json_has_brief_schema_and_full_structure() { + let Some(test_url) = setup_test_db("pgcrate_brief_json") else { + return; + }; + assert!( + run_psql(MULTI_SCHEMA_SETUP, &test_url).status.success(), + "setup failed" + ); + + // Use run_pgcrate (not _ok): health flags are valid states, never an error, + // but we still want the raw output regardless of exit code. + let output = run_pgcrate(&["brief", "--json"], &test_url); + let stdout = out(&output); + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("brief --json must be valid JSON"); + + assert_eq!(json.get("ok"), Some(&serde_json::json!(true))); + assert_eq!( + json.get("schema_id"), + Some(&serde_json::json!("pgcrate.brief")), + "schema_id is pgcrate.brief: {stdout}" + ); + // Brief is informational — severity never escalates past healthy. + assert_eq!(json.get("severity"), Some(&serde_json::json!("healthy"))); + + let data = json.get("data").expect("data payload present"); + // Target echo. + assert!(data.get("target").and_then(|t| t.get("database")).is_some()); + // The non-public schema appears in the schemas array. + let schemas = data + .get("schemas") + .and_then(|s| s.as_array()) + .expect("schemas array"); + let app = schemas + .iter() + .find(|s| s.get("name") == Some(&serde_json::json!("app"))) + .expect("app schema present in JSON"); + let tables = app.get("tables").and_then(|t| t.as_array()).unwrap(); + assert_eq!(tables.len(), 2, "app has two tables in JSON"); + // Relationships are structured child/parents. + let rels = data + .get("relationships") + .and_then(|r| r.as_array()) + .expect("relationships array"); + assert!( + rels.iter() + .any(|r| r.get("child") == Some(&serde_json::json!("app.orders"))), + "FK edge present in JSON: {stdout}" + ); + + cleanup_test_db(&test_url); +} + +#[test] +fn brief_json_is_unaffected_by_density_flags() { + let db_url = get_test_db_url(); + // --json wins over --dense/--pretty and emits the structured envelope. + let stdout = out(&run_pgcrate(&["brief", "--json", "--dense"], &db_url)); + let json: serde_json::Value = serde_json::from_str(&stdout) + .expect("brief --json must be valid JSON regardless of density"); + assert_eq!(json.get("ok"), Some(&serde_json::json!(true))); + assert_eq!( + json.get("schema_id"), + Some(&serde_json::json!("pgcrate.brief")) + ); +} + +// =========================================================================== +// Migrations degrade silently with no project context +// =========================================================================== + +#[test] +fn brief_omits_migrations_when_no_project_context() { + let Some(test_url) = setup_test_db("pgcrate_brief_nomig") else { + return; + }; + let setup = "CREATE TABLE thing (id SERIAL PRIMARY KEY);"; + assert!(run_psql(setup, &test_url).status.success(), "setup failed"); + + // No pgcrate.toml, no migrations dir on the test's cwd → no migrations line. + // (The binary's cwd is the crate root, which has no db/migrations either.) + let stdout = out(&run_pgcrate(&["brief"], &test_url)); + assert!( + !stdout.contains("MIGRATIONS"), + "brief must omit the migrations section with no project context: {stdout}" + ); + + cleanup_test_db(&test_url); +} + +// =========================================================================== +// Exit code: informational, always 0 regardless of findings +// =========================================================================== + +#[test] +fn brief_exits_zero_informational() { + let Some(test_url) = setup_test_db("pgcrate_brief_exit") else { + return; + }; + let setup = "CREATE TABLE thing (id SERIAL PRIMARY KEY);"; + assert!(run_psql(setup, &test_url).status.success(), "setup failed"); + + let output = run_pgcrate(&["brief"], &test_url); + assert_eq!( + output.status.code(), + Some(0), + "brief is orientation, not triage — it exits 0: {}", + out(&output) + ); + + cleanup_test_db(&test_url); +}