axon-frontend 1.3.0

AXON compiler frontend — lexer, parser, AST, epistemic type system, type checker, IR generator. Zero runtime dependencies. v1.3.0 lowers requires_capabilities into IRAxonEndpoint (the PCC capability-containment property, §Fase 51.x.1). v1.2.0 added PRIMITIVE_REGISTRY — closed catalogue of every named language construct (45 entries) with PrimitiveInfo / DocStatus / CoverageSummary types + helpers. v1.1.0 introduced session-types + multiparty projection. See https://github.com/Bemarking/axon-lang.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
//! §Fase 38.h (D10) — pure manifest-building logic for the
//! `axon store introspect <store>` CLI.
//!
//! This module is the first runtime CONSUMER of the §Fase 38.c
//! manifest format. It takes the rows a live `pg_catalog`
//! introspection produces (the impure half lives in
//! `axon-rs/src/store/introspect_cli.rs` because it needs `sqlx`)
//! and builds a canonical [`ManifestStore`] from them — mapping
//! Postgres UDTs to the closed 15-type axon catalog, capturing
//! constraints, tracking columns OMITTED because their type is
//! outside the catalog.
//!
//! Architectural split:
//!
//!   - **Pure (here)** — UDT → axon type mapping, manifest building,
//!     diff. Zero crate deps beyond `serde`/`serde_json` (already in
//!     axon-frontend). Fully unit-testable without a database.
//!
//!   - **Impure (axon-rs)** — `sqlx`-based introspection query that
//!     produces [`IntrospectionRow`] values; calls into this module
//!     to build the manifest.
//!
//! Honest scope:
//!
//!   - The 37.x runtime `introspect_conn` query captures only
//!     `(schema, column_name, type_name)`. THIS module needs more
//!     (constraints + nullable + default). The 38.h impure side
//!     ships a deeper introspection query — independent of the 37.x
//!     runtime cache (no Hot-path bloat). Documented in
//!     `axon-rs/src/store/introspect_cli.rs`.
//!
//!   - Postgres types OUTSIDE the closed 15-type catalog (`enum`,
//!     `domain`, array, `citext`, PostGIS, custom composites) are
//!     **honestly omitted** from the manifest with a comment-side
//!     note — NEVER silently lossily mapped (`tier_enum` ≠ `Text`,
//!     even though they look alike at the wire).

use std::collections::BTreeMap;

use crate::store_schema::StoreColumnType;
use crate::store_schema_manifest::{
    Manifest, ManifestColumn, ManifestStore,
};

// ════════════════════════════════════════════════════════════════════
//  Postgres UDT → axon StoreColumnType reverse mapping
// ════════════════════════════════════════════════════════════════════

/// Map a live Postgres UDT name (the `pg_type.typname` value) to its
/// canonical axon [`StoreColumnType`]. Returns `None` when the UDT is
/// outside the closed 15-type v1.30.0 catalog — the caller surfaces
/// that as a typed [`OmittedColumn`] in the manifest output.
///
/// Case-insensitive — Postgres typname is conventionally lowercase
/// but a hand-crafted manifest snapshot might use mixed case.
///
/// Mirror (inverse) of `axon-rs::store::registry::pg_udt_matches_catalog_type`:
/// every UDT this function recognises is one the registry-side check
/// would accept against the corresponding `StoreColumnType`, and vice
/// versa.
pub fn udt_to_canonical_type(pg_udt: &str) -> Option<StoreColumnType> {
    use StoreColumnType as C;
    match pg_udt.to_ascii_lowercase().as_str() {
        "uuid" => Some(C::Uuid),
        "text" | "varchar" | "bpchar" | "name" => Some(C::Text),
        "int4" | "integer" => Some(C::Int),
        "int8" | "bigint" => Some(C::BigInt),
        "float4" | "real" => Some(C::Float),
        "float8" | "double precision" => Some(C::Double),
        "bool" | "boolean" => Some(C::Bool),
        "timestamptz" => Some(C::Timestamptz),
        "timestamp" => Some(C::Timestamp),
        "date" => Some(C::Date),
        "time" => Some(C::Time),
        "jsonb" => Some(C::Jsonb),
        "json" => Some(C::Json),
        "bytea" => Some(C::Bytea),
        "numeric" | "decimal" => Some(C::Numeric),
        _ => None,
    }
}

// ════════════════════════════════════════════════════════════════════
//  Introspection input row
// ════════════════════════════════════════════════════════════════════

/// One column row produced by the deep `pg_catalog` introspection
/// the §Fase 38.h impure side runs. Carries everything the manifest
/// needs to faithfully reproduce the column declaration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntrospectionRow {
    pub column_name: String,
    /// The raw `pg_type.typname` value — case-preserved so the
    /// adopter-facing omission diagnostic names the EXACT live type.
    pub pg_udt: String,
    /// `true` iff the column is `NOT NULL` per `pg_attribute.attnotnull`.
    pub not_null: bool,
    /// `true` iff this column is part of the table's primary key.
    pub primary_key: bool,
    /// `true` iff a `UNIQUE` constraint covers this column alone.
    pub unique: bool,
    /// Verbatim `pg_get_expr(adbin, adrelid)` text — empty when the
    /// column has no default. `nextval(...)` substrings are how the
    /// auto_increment heuristic ([`detect_auto_increment`]) decides.
    pub default_expression: String,
    /// §Fase 38.x.c (D1) — `pg_attribute.attidentity` value:
    /// - `Some('a')` = `GENERATED ALWAYS AS IDENTITY` (Postgres rejects
    ///   user-supplied values; `OVERRIDING SYSTEM VALUE` required to
    ///   override)
    /// - `Some('d')` = `GENERATED BY DEFAULT AS IDENTITY` (auto-filled
    ///   when omitted; user-supplied values accepted)
    /// - `None` = not an identity column (legacy SERIAL still appears
    ///   via the `default_expression` `nextval(...)` channel)
    ///
    /// `build_manifest_store` converts this into the manifest's
    /// `identity: bool` field (true when `Some(_)`); the 'a' vs 'd'
    /// distinction is preserved here for a future Fase 38.x.d that
    /// could surface it in T802 (rejecting `persist` values INTO a
    /// `GENERATED ALWAYS` column).
    pub identity_kind: Option<char>,
}

/// `true` iff the column's `default_expression` resembles a Postgres
/// serial / identity sequence — `nextval('<schema>.<seq>'::regclass)`
/// or `nextval('<seq>')`. The runtime's `serial` / `bigserial` macro
/// expands to a default-expression of that shape; a column declared
/// `GENERATED ... AS IDENTITY` does NOT appear in
/// `pg_attrdef` (Postgres handles identity separately), and adopter-
/// authored sequences via `DEFAULT nextval(...)` round-trip here too.
///
/// Pure + total — case-insensitive substring match on `nextval(`.
pub fn detect_auto_increment(default_expression: &str) -> bool {
    default_expression
        .to_ascii_lowercase()
        .contains("nextval(")
}

// ════════════════════════════════════════════════════════════════════
//  Manifest building — honest, omissions tracked
// ════════════════════════════════════════════════════════════════════

/// A column the introspection observed but could NOT map to the
/// closed 15-type catalog. The CLI surfaces these in a sidecar
/// section so the adopter sees exactly which columns the manifest
/// dropped (and why).
///
/// Examples:
///   - `OmittedColumn { name: "tier", pg_udt: "tier_enum", reason:
///     "outside the v1.38.0 closed type catalog" }`
///   - `OmittedColumn { name: "shape", pg_udt: "geometry", reason: …  }`
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OmittedColumn {
    pub name: String,
    pub pg_udt: String,
    pub reason: String,
}

impl OmittedColumn {
    /// Render this omission as a `# omitted: <name> <pg_udt> — <reason>`
    /// comment line — the shape the §Fase 38.h CLI emits beside the
    /// manifest's canonical JSON.
    pub fn as_comment_line(&self) -> String {
        format!(
            "# omitted: column `{}` (pg type `{}`) — {}",
            self.name, self.pg_udt, self.reason
        )
    }
}

/// §Fase 38.h — build a [`ManifestStore`] from a vector of
/// [`IntrospectionRow`] entries.
///
/// Returns the manifest store + a side list of [`OmittedColumn`]
/// entries for columns whose Postgres type was outside the closed
/// 15-type catalog. Pure + total — every input shape yields exactly
/// one `(store, omissions)` pair.
pub fn build_manifest_store(
    rows: &[IntrospectionRow],
) -> (ManifestStore, Vec<OmittedColumn>) {
    let mut columns: BTreeMap<String, ManifestColumn> = BTreeMap::new();
    let mut omissions: Vec<OmittedColumn> = Vec::new();

    for row in rows {
        let Some(col_type) = udt_to_canonical_type(&row.pg_udt) else {
            omissions.push(OmittedColumn {
                name: row.column_name.clone(),
                pg_udt: row.pg_udt.clone(),
                reason:
                    "outside the v1.38.0 closed type catalog \
                     (enum/domain/array/citext/PostGIS/custom \
                     composites are honest-omitted, never silently \
                     lossily mapped — `tier_enum` ≠ `Text` even \
                     though they look alike at the wire)"
                        .to_string(),
            });
            continue;
        };
        let auto_increment = detect_auto_increment(&row.default_expression);
        // §Fase 38.x.c (D1) — `pg_attribute.attidentity` is the
        // second-channel auto-fill marker; channel #1 (`default_expression`
        // ↔ `auto_increment`) only catches the legacy SERIAL pattern.
        // A `GENERATED ALWAYS/BY DEFAULT AS IDENTITY` column has NO
        // default expression but IS auto-filled by Postgres. Detect via
        // `identity_kind`. We do NOT set `auto_increment: true` for
        // identity columns (they have no `nextval(...)`); we set
        // `identity: true` separately, preserving SQL semantics.
        let identity = row.identity_kind.is_some();
        columns.insert(
            row.column_name.clone(),
            ManifestColumn {
                col_type,
                primary_key: row.primary_key,
                auto_increment,
                not_null: row.not_null,
                unique: row.unique,
                // §Fase 38.h: when the default IS a sequence, omit the
                // `default_value` from the manifest — auto_increment
                // already encodes "the DB supplies it"; carrying the
                // raw `nextval(...)` expression would couple the
                // manifest to a specific sequence object. Hand-coded
                // defaults (`CURRENT_TIMESTAMP`, `'standard'`, …) DO
                // round-trip through.
                default_value: if auto_increment {
                    String::new()
                } else {
                    row.default_expression.clone()
                },
                identity,
            },
        );
    }

    (ManifestStore { columns }, omissions)
}

// ════════════════════════════════════════════════════════════════════
//  Manifest diff — for `axon store introspect --diff`
// ════════════════════════════════════════════════════════════════════

/// One per-column change between an OLD manifest and a freshly-
/// introspected NEW manifest. Used by `axon store introspect --diff`
/// to emit a concise drift summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ColumnDelta {
    /// Column present in the new manifest but absent in the old.
    Added {
        column: String,
        new_type: StoreColumnType,
    },
    /// Column present in the old manifest but absent in the new.
    Removed {
        column: String,
        old_type: StoreColumnType,
    },
    /// Column present in both with a different declared type.
    TypeChanged {
        column: String,
        old_type: StoreColumnType,
        new_type: StoreColumnType,
    },
    /// Column present in both with the same type but a different
    /// constraint (primary_key / not_null / unique / default_value /
    /// auto_increment). Carries the offending facet name + the old
    /// and new boolean (or default-expression text).
    ConstraintChanged {
        column: String,
        facet: &'static str,
        old: String,
        new: String,
    },
}

/// A diff between two manifests, per-store.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ManifestDiff {
    /// Per-store ordered list of column changes. Stores appear in
    /// alphabetic order; columns within a store also alphabetic.
    pub per_store: BTreeMap<String, Vec<ColumnDelta>>,
    /// Stores added in the new manifest (not in the old).
    pub added_stores: Vec<String>,
    /// Stores removed in the new manifest (in the old, not in new).
    pub removed_stores: Vec<String>,
}

impl ManifestDiff {
    /// `true` iff every facet matches (no per-store columns, no
    /// added/removed stores).
    pub fn is_empty(&self) -> bool {
        self.per_store.values().all(|deltas| deltas.is_empty())
            && self.added_stores.is_empty()
            && self.removed_stores.is_empty()
    }
}

/// Compute a [`ManifestDiff`] between an OLD manifest (the
/// adopter's checked-in `.axon-schema.json`) and a freshly-
/// introspected NEW manifest. Pure + total.
pub fn manifest_diff(old: &Manifest, new: &Manifest) -> ManifestDiff {
    let mut diff = ManifestDiff::default();

    // Added / removed stores.
    for store_name in new.stores.keys() {
        if !old.stores.contains_key(store_name) {
            diff.added_stores.push(store_name.clone());
        }
    }
    for store_name in old.stores.keys() {
        if !new.stores.contains_key(store_name) {
            diff.removed_stores.push(store_name.clone());
        }
    }
    diff.added_stores.sort();
    diff.removed_stores.sort();

    // Per-store column deltas (only for stores in BOTH old and new).
    for store_name in new.stores.keys() {
        let Some(new_store) = new.stores.get(store_name) else { continue };
        let Some(old_store) = old.stores.get(store_name) else { continue };
        let deltas = diff_store_columns(old_store, new_store);
        if !deltas.is_empty() {
            diff.per_store.insert(store_name.clone(), deltas);
        }
    }
    diff
}

fn diff_store_columns(old: &ManifestStore, new: &ManifestStore) -> Vec<ColumnDelta> {
    let mut deltas: Vec<ColumnDelta> = Vec::new();

    // Added columns.
    for (col_name, new_col) in &new.columns {
        if !old.columns.contains_key(col_name) {
            deltas.push(ColumnDelta::Added {
                column: col_name.clone(),
                new_type: new_col.col_type,
            });
        }
    }
    // Removed columns.
    for (col_name, old_col) in &old.columns {
        if !new.columns.contains_key(col_name) {
            deltas.push(ColumnDelta::Removed {
                column: col_name.clone(),
                old_type: old_col.col_type,
            });
        }
    }
    // Type-changed columns + constraint-changed columns.
    for (col_name, new_col) in &new.columns {
        let Some(old_col) = old.columns.get(col_name) else { continue };
        if new_col.col_type != old_col.col_type {
            deltas.push(ColumnDelta::TypeChanged {
                column: col_name.clone(),
                old_type: old_col.col_type,
                new_type: new_col.col_type,
            });
            continue; // type change subsumes constraint changes for the diff
        }
        // Constraint facets — surface ONE delta per facet that
        // differs (rare in practice; explicit so the operator
        // sees every drift).
        if old_col.primary_key != new_col.primary_key {
            deltas.push(ColumnDelta::ConstraintChanged {
                column: col_name.clone(),
                facet: "primary_key",
                old: old_col.primary_key.to_string(),
                new: new_col.primary_key.to_string(),
            });
        }
        if old_col.not_null != new_col.not_null {
            deltas.push(ColumnDelta::ConstraintChanged {
                column: col_name.clone(),
                facet: "not_null",
                old: old_col.not_null.to_string(),
                new: new_col.not_null.to_string(),
            });
        }
        if old_col.unique != new_col.unique {
            deltas.push(ColumnDelta::ConstraintChanged {
                column: col_name.clone(),
                facet: "unique",
                old: old_col.unique.to_string(),
                new: new_col.unique.to_string(),
            });
        }
        if old_col.auto_increment != new_col.auto_increment {
            deltas.push(ColumnDelta::ConstraintChanged {
                column: col_name.clone(),
                facet: "auto_increment",
                old: old_col.auto_increment.to_string(),
                new: new_col.auto_increment.to_string(),
            });
        }
        if old_col.default_value != new_col.default_value {
            deltas.push(ColumnDelta::ConstraintChanged {
                column: col_name.clone(),
                facet: "default_value",
                old: old_col.default_value.clone(),
                new: new_col.default_value.clone(),
            });
        }
    }
    deltas
}

/// Render a [`ManifestDiff`] as a human-readable summary — the shape
/// `axon store introspect --diff` emits on stdout. Empty diff returns
/// the empty string (the CLI then prints "manifest is up to date").
pub fn format_manifest_diff(diff: &ManifestDiff) -> String {
    if diff.is_empty() {
        return String::new();
    }
    let mut out = String::new();
    for store in &diff.added_stores {
        out.push_str(&format!("+ store `{store}` (added)\n"));
    }
    for store in &diff.removed_stores {
        out.push_str(&format!("- store `{store}` (removed)\n"));
    }
    for (store, deltas) in &diff.per_store {
        out.push_str(&format!("~ store `{store}`:\n"));
        for d in deltas {
            match d {
                ColumnDelta::Added { column, new_type } => out.push_str(&format!(
                    "    + column `{column}` ({})\n",
                    new_type.canonical_name()
                )),
                ColumnDelta::Removed { column, old_type } => out.push_str(&format!(
                    "    - column `{column}` (was {})\n",
                    old_type.canonical_name()
                )),
                ColumnDelta::TypeChanged {
                    column,
                    old_type,
                    new_type,
                } => out.push_str(&format!(
                    "    ~ column `{column}` type: {}{}\n",
                    old_type.canonical_name(),
                    new_type.canonical_name()
                )),
                ColumnDelta::ConstraintChanged {
                    column,
                    facet,
                    old,
                    new,
                } => out.push_str(&format!(
                    "    ~ column `{column}` {facet}: {old}{new}\n"
                )),
            }
        }
    }
    out
}

// ════════════════════════════════════════════════════════════════════
//  Unit tests — 26 cases (>20 plan-vivo target)
// ════════════════════════════════════════════════════════════════════

#[cfg(test)]
mod tests {
    use super::*;

    fn row(name: &str, udt: &str) -> IntrospectionRow {
        IntrospectionRow {
            column_name: name.to_string(),
            pg_udt: udt.to_string(),
            not_null: false,
            primary_key: false,
            unique: false,
            default_expression: String::new(),
            identity_kind: None,
        }
    }

    // ── udt_to_canonical_type ────────────────────────────────────────

    #[test]
    fn udt_recognises_every_catalog_type_canonically() {
        for (udt, expected) in [
            ("uuid", StoreColumnType::Uuid),
            ("text", StoreColumnType::Text),
            ("varchar", StoreColumnType::Text),
            ("bpchar", StoreColumnType::Text),
            ("name", StoreColumnType::Text),
            ("int4", StoreColumnType::Int),
            ("integer", StoreColumnType::Int),
            ("int8", StoreColumnType::BigInt),
            ("bigint", StoreColumnType::BigInt),
            ("float4", StoreColumnType::Float),
            ("real", StoreColumnType::Float),
            ("float8", StoreColumnType::Double),
            ("double precision", StoreColumnType::Double),
            ("bool", StoreColumnType::Bool),
            ("boolean", StoreColumnType::Bool),
            ("timestamptz", StoreColumnType::Timestamptz),
            ("timestamp", StoreColumnType::Timestamp),
            ("date", StoreColumnType::Date),
            ("time", StoreColumnType::Time),
            ("jsonb", StoreColumnType::Jsonb),
            ("json", StoreColumnType::Json),
            ("bytea", StoreColumnType::Bytea),
            ("numeric", StoreColumnType::Numeric),
            ("decimal", StoreColumnType::Numeric),
        ] {
            assert_eq!(
                udt_to_canonical_type(udt),
                Some(expected),
                "expected `{udt}` → `{}`",
                expected.canonical_name()
            );
        }
    }

    #[test]
    fn udt_recognition_is_case_insensitive() {
        assert_eq!(udt_to_canonical_type("UUID"), Some(StoreColumnType::Uuid));
        assert_eq!(udt_to_canonical_type("TEXT"), Some(StoreColumnType::Text));
        assert_eq!(udt_to_canonical_type("Int4"), Some(StoreColumnType::Int));
    }

    #[test]
    fn udt_outside_catalog_returns_none() {
        for udt in [
            "enum", "geometry", "citext", "tier_enum", "_int4",
            "money", "interval", "cidr", "macaddr", "geography",
        ] {
            assert_eq!(udt_to_canonical_type(udt), None, "`{udt}` must be unmapped");
        }
    }

    // ── detect_auto_increment ────────────────────────────────────────

    #[test]
    fn detect_auto_increment_recognises_nextval_call() {
        for expr in [
            "nextval('users_id_seq'::regclass)",
            "nextval('public.events_id_seq'::regclass)",
            "NEXTVAL('s')",
        ] {
            assert!(
                detect_auto_increment(expr),
                "expected `{expr}` to indicate auto_increment"
            );
        }
    }

    #[test]
    fn detect_auto_increment_rejects_static_defaults() {
        for expr in [
            "",
            "0",
            "'standard'::text",
            "now()",
            "CURRENT_TIMESTAMP",
            "gen_random_uuid()",
            "'{}'::jsonb",
        ] {
            assert!(!detect_auto_increment(expr), "`{expr}` must NOT be auto");
        }
    }

    // ── build_manifest_store ─────────────────────────────────────────

    #[test]
    fn build_manifest_store_maps_every_catalog_udt_to_canonical_type() {
        let rows = vec![
            row("id", "uuid"),
            row("name", "varchar"),
            row("count", "int4"),
            row("balance", "numeric"),
            row("active", "boolean"),
        ];
        let (store, omitted) = build_manifest_store(&rows);
        assert_eq!(store.columns.len(), 5);
        assert!(omitted.is_empty());
        assert_eq!(
            store.columns.get("id").unwrap().col_type,
            StoreColumnType::Uuid
        );
        assert_eq!(
            store.columns.get("name").unwrap().col_type,
            StoreColumnType::Text
        );
    }

    #[test]
    fn build_manifest_store_omits_unmappable_types_with_reason() {
        let rows = vec![
            row("id", "uuid"),
            row("tier", "tier_enum"), // unmappable
            row("shape", "geometry"), // unmappable
        ];
        let (store, omitted) = build_manifest_store(&rows);
        assert_eq!(store.columns.len(), 1, "only `id` survives");
        assert_eq!(omitted.len(), 2);
        let names: Vec<&str> = omitted.iter().map(|o| o.name.as_str()).collect();
        assert!(names.contains(&"tier"));
        assert!(names.contains(&"shape"));
        // Reason mentions the closed catalog — adopter knows why.
        assert!(omitted[0].reason.contains("closed type catalog"));
    }

    #[test]
    fn build_manifest_store_threads_constraints_through() {
        let rows = vec![IntrospectionRow {
            column_name: "id".into(),
            pg_udt: "uuid".into(),
            not_null: true,
            primary_key: true,
            unique: true,
            default_expression: "gen_random_uuid()".into(),
            identity_kind: None,
        }];
        let (store, _) = build_manifest_store(&rows);
        let col = store.columns.get("id").unwrap();
        assert!(col.primary_key);
        assert!(col.not_null);
        assert!(col.unique);
        assert!(!col.auto_increment); // gen_random_uuid is NOT a sequence
        assert!(!col.identity); // gen_random_uuid is NOT an identity column
        assert_eq!(col.default_value, "gen_random_uuid()");
    }

    #[test]
    fn build_manifest_store_marks_serial_columns_auto_increment_and_drops_nextval_expr() {
        // `SERIAL` / `BIGSERIAL` columns carry `nextval(...)` defaults;
        // the manifest sets `auto_increment: true` and DROPS the
        // `nextval(...)` expression (it's a sequence reference,
        // adopter-private).
        let rows = vec![IntrospectionRow {
            column_name: "id".into(),
            pg_udt: "int4".into(),
            not_null: true,
            primary_key: true,
            unique: false,
            default_expression:
                "nextval('public.users_id_seq'::regclass)".into(),
            identity_kind: None,
        }];
        let (store, _) = build_manifest_store(&rows);
        let col = store.columns.get("id").unwrap();
        assert!(col.auto_increment);
        assert!(!col.identity, "SERIAL is auto_increment, NOT identity");
        assert!(col.default_value.is_empty(), "auto_increment drops the sequence expr");
    }

    #[test]
    fn build_manifest_store_preserves_static_defaults() {
        let rows = vec![IntrospectionRow {
            column_name: "tier".into(),
            pg_udt: "text".into(),
            not_null: true,
            primary_key: false,
            unique: false,
            default_expression: "'standard'::text".into(),
            identity_kind: None,
        }];
        let (store, _) = build_manifest_store(&rows);
        assert_eq!(
            store.columns.get("tier").unwrap().default_value,
            "'standard'::text"
        );
    }

    #[test]
    fn build_manifest_store_columns_sort_alphabetically() {
        let rows = vec![
            row("tier", "text"),
            row("active", "boolean"),
            row("tenant_id", "uuid"),
        ];
        let (store, _) = build_manifest_store(&rows);
        let order: Vec<&str> = store.columns.keys().map(|s| s.as_str()).collect();
        assert_eq!(order, vec!["active", "tenant_id", "tier"]);
    }

    #[test]
    fn build_manifest_store_empty_rows_yields_empty_store() {
        let (store, omitted) = build_manifest_store(&[]);
        assert!(store.columns.is_empty());
        assert!(omitted.is_empty());
    }

    // ── OmittedColumn rendering ──────────────────────────────────────

    #[test]
    fn omitted_column_renders_as_human_readable_comment_line() {
        let o = OmittedColumn {
            name: "tier".into(),
            pg_udt: "tier_enum".into(),
            reason: "outside the v1.38.0 closed type catalog".into(),
        };
        let line = o.as_comment_line();
        assert!(line.starts_with("# omitted: "));
        assert!(line.contains("`tier`"));
        assert!(line.contains("`tier_enum`"));
        assert!(line.contains("closed type catalog"));
    }

    // ── manifest_diff ────────────────────────────────────────────────

    fn manifest_from_json(src: &str) -> Manifest {
        Manifest::parse_json(src).expect("parse manifest fixture")
    }

    #[test]
    fn manifest_diff_empty_when_manifests_match() {
        let m = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid","primary_key":true}}}}}"#,
        );
        let diff = manifest_diff(&m, &m);
        assert!(diff.is_empty());
        assert_eq!(format_manifest_diff(&diff), "");
    }

    #[test]
    fn manifest_diff_detects_added_store() {
        let old = manifest_from_json(
            r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}}}}"#,
        );
        let new = manifest_from_json(
            r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}},"b":{"columns":{"x":{"type":"Int"}}}}}"#,
        );
        let diff = manifest_diff(&old, &new);
        assert!(!diff.is_empty());
        assert_eq!(diff.added_stores, vec!["b"]);
        assert!(diff.removed_stores.is_empty());
    }

    #[test]
    fn manifest_diff_detects_removed_store() {
        let old = manifest_from_json(
            r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}},"b":{"columns":{"x":{"type":"Int"}}}}}"#,
        );
        let new = manifest_from_json(
            r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}}}}"#,
        );
        let diff = manifest_diff(&old, &new);
        assert_eq!(diff.removed_stores, vec!["b"]);
    }

    #[test]
    fn manifest_diff_detects_added_column() {
        let old = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
        );
        let new = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"},"tier":{"type":"Text"}}}}}"#,
        );
        let diff = manifest_diff(&old, &new);
        let deltas = diff.per_store.get("t").expect("t store has deltas");
        assert_eq!(deltas.len(), 1);
        matches!(
            &deltas[0],
            ColumnDelta::Added { column, new_type }
                if column == "tier" && *new_type == StoreColumnType::Text
        );
    }

    #[test]
    fn manifest_diff_detects_removed_column() {
        let old = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"},"tier":{"type":"Text"}}}}}"#,
        );
        let new = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
        );
        let diff = manifest_diff(&old, &new);
        let deltas = diff.per_store.get("t").expect("t store has deltas");
        assert!(matches!(
            &deltas[0],
            ColumnDelta::Removed { column, .. } if column == "tier"
        ));
    }

    #[test]
    fn manifest_diff_detects_column_type_change() {
        let old = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Int"}}}}}"#,
        );
        let new = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
        );
        let diff = manifest_diff(&old, &new);
        let deltas = diff.per_store.get("t").unwrap();
        assert!(matches!(
            &deltas[0],
            ColumnDelta::TypeChanged { column, old_type, new_type }
                if column == "id"
                    && *old_type == StoreColumnType::Int
                    && *new_type == StoreColumnType::Uuid
        ));
    }

    #[test]
    fn manifest_diff_type_change_subsumes_constraint_changes_on_same_column() {
        // A type change is the dominant fact; constraint flips on the
        // same column are NOT separately reported — adopter applies
        // the type change first, then any remaining constraint drift
        // surfaces on the next introspect.
        let old = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Int","primary_key":true}}}}}"#,
        );
        let new = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
        );
        let diff = manifest_diff(&old, &new);
        let deltas = diff.per_store.get("t").unwrap();
        assert_eq!(deltas.len(), 1, "exactly one delta — type change");
    }

    #[test]
    fn manifest_diff_detects_each_constraint_facet_independently() {
        // primary_key, not_null, unique, default_value, auto_increment
        // — each surfaces as its own ColumnDelta.
        let old = manifest_from_json(
            r#"{
                "version": 1,
                "stores": { "t": { "columns": {
                    "x": { "type": "Int", "primary_key": false, "not_null": false,
                            "unique": false }
                }}}
            }"#,
        );
        let new = manifest_from_json(
            r#"{
                "version": 1,
                "stores": { "t": { "columns": {
                    "x": { "type": "Int", "primary_key": true, "not_null": true,
                            "unique": true }
                }}}
            }"#,
        );
        let diff = manifest_diff(&old, &new);
        let deltas = diff.per_store.get("t").unwrap();
        let facets: std::collections::BTreeSet<&str> = deltas
            .iter()
            .filter_map(|d| match d {
                ColumnDelta::ConstraintChanged { facet, .. } => Some(*facet),
                _ => None,
            })
            .collect();
        assert!(facets.contains("primary_key"));
        assert!(facets.contains("not_null"));
        assert!(facets.contains("unique"));
    }

    #[test]
    fn format_manifest_diff_emits_a_human_readable_summary() {
        let old = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Int"}}}}}"#,
        );
        let new = manifest_from_json(
            r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"},"tier":{"type":"Text"}}}}}"#,
        );
        let diff = manifest_diff(&old, &new);
        let text = format_manifest_diff(&diff);
        assert!(text.contains("~ store `t`"));
        assert!(text.contains("+ column `tier` (Text)"));
        assert!(text.contains("~ column `id` type: Int → Uuid"));
    }

    #[test]
    fn format_manifest_diff_empty_diff_yields_empty_string() {
        let diff = ManifestDiff::default();
        assert_eq!(format_manifest_diff(&diff), "");
    }
}