Generated: 2026-05-24 Commit: 9103bb3 Branch: refactor Targeting: 0.2.0 (API stability + LSP hot-spot caching)
Rust workspace for declarative database schema management. Define schemas in JSON, diff against migration history, generate typed actions and SQL.
vespertide/
├── crates/
│ ├── vespertide-core/ # Data structures: TableDef, ColumnDef, MigrationAction; newtype names
│ ├── vespertide-planner/ # Schema diffing, baseline reconstruction, validation
│ ├── vespertide-query/ # SQL generation (Postgres/MySQL/SQLite)
│ ├── vespertide-cli/ # CLI commands: init, diff, sql, revision, export
│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle
│ ├── vespertide-loader/ # Filesystem loading of models/migrations
│ ├── vespertide-config/ # vespertide.json configuration
│ ├── vespertide-lsp/ # Language server: 13 LSP capabilities + HS-7~11 caching
│ ├── vespertide-macro/ # Compile-time migration macro
│ ├── vespertide-naming/ # Naming convention utilities
│ ├── vespertide-schema-gen/# JSON Schema generation
│ └── vespertide/ # Re-export crate (user-facing API)
├── bridge/node/ # N-API bridge: the CLI as the `vespertide` npm package (+ @vespertide/cli-* platform packages)
├── examples/app/ # Example project with models/migrations (out-of-workspace)
├── tools/lsp-profile/ # LSP synthetic / realistic workload + latency profiler (out-of-workspace)
├── fuzz/ # cargo-fuzz targets (4 targets, see FUZZING section)
├── tests/runtime-sqlite/ # vespertide-macro runtime SQLite tests (out-of-workspace)
├── schemas/ # Generated JSON Schemas for IDE support
├── docs/ # profiling.md, profiling-baseline.json, clippy-allow-audit.md
└── CLAUDE.md # Detailed implementation guidance
| Task | Location | Notes |
|---|---|---|
| Core types (TableDef, ColumnDef) | vespertide-core/src/schema/ |
Start with table.rs, column.rs |
| Newtype name wrappers | vespertide-core/src/schema/names.rs |
TableName / ColumnName / IndexName with #[serde(transparent)] |
| Column type system | vespertide-core/src/schema/column.rs |
ColumnType::Simple/Complex variants |
| Migration actions | vespertide-core/src/action/ |
15 action variants (incl. RawSql escape hatch), MigrationPlan struct |
| QueryError variants | vespertide-query/src/error.rs |
InvalidColumnType / SchemaError / BackendError / UnsupportedAction; Other is #[deprecated] |
| Schema diffing | vespertide-planner/src/diff/ |
topological sort for FK deps |
| SQL generation | vespertide-query/src/sql/ |
One file per action type |
| CLI commands | vespertide-cli/src/commands/ |
cmd_* functions |
| ORM export | vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle}/ |
Backend-specific generators |
| Compile-time macro | vespertide-macro/src/lib.rs |
vespertide_migration! proc macro |
| LSP RingCache (HS-7~11) | vespertide-lsp/src/cache.rs |
Generic ring-buffer LRU shared across symbols/diagnostics/drift/semantic-token caches |
| LSP drift cache | vespertide-lsp/src/drift/cache.rs |
HS-10 drift cache implementation |
| LSP profiler | tools/lsp-profile/src/ |
Synthetic + realistic workloads with p50/p95/p99 latency stats |
JSON Models → load_models() → Vec<TableDef>
↓
Applied Migrations → schema_from_plans() → Baseline Schema
↓
diff_schemas() → Vec<MigrationAction>
↓
plan_next_migration() → MigrationPlan
↓
build_action_queries() → Vec<BuiltQuery>
↓
BuiltQuery.build(backend) → SQL String
// CORRECT - Always use wrapped variant
ColumnType::Simple(SimpleColumnType::Integer)
SimpleColumnType::Integer.into()
// WRONG - Old flat syntax
ColumnType::Integer // Does not existTableName, ColumnName, IndexName are newtypes with #[serde(transparent)] —
JSON wire format is byte-identical to plain String, but the Rust type system
distinguishes them.
use vespertide_core::schema::names::{TableName, ColumnName};
let table: TableName = "user".into(); // From<&str>
let col = ColumnName::new("email".to_string()); // explicit constructor
assert_eq!(table.as_str(), "user"); // explicit accessor
assert!(table == "user"); // PartialEq<&str>
println!("{table}"); // Display
let owned: String = table.into_inner(); // consume back to StringNewtypes auto-deref to &str, so most function-call sites work without .into().
When constructing struct literals (e.g. TableDef { name: ... }), prefer .into()
from string literals over the explicit constructor for terseness.
VespertideConfig, SeaOrmConfig, MigrationOptions are #[non_exhaustive]:
external callers MUST construct via ..Default::default() or the provided
constructor.
// CORRECT
let opts = MigrationOptions { dry_run: true, ..Default::default() };
let opts = MigrationOptions::new();
// WRONG - exhaustive struct literal will fail E0639
let opts = MigrationOptions { dry_run: true, force: false /* ... */ };Prefer the specific variant. Other(String) is #[deprecated] and emits a warning:
// CORRECT - specific variants
return Err(QueryError::SchemaError(format!("Failed to normalize {table}: {e}")));
return Err(QueryError::InvalidColumnType { column, reason });
return Err(QueryError::BackendError { backend, reason });
return Err(QueryError::UnsupportedAction { action, backend });
// WRONG - triggers deprecation warning + uninformative match arms downstream
return Err(QueryError::Other("Failed to ...".into()));Workspace [lints.clippy] enforces allow_attributes_without_reason = "warn" and
allow_attributes = "warn". Every suppression MUST be #[expect(...)] with a
domain-specific reason = "..." string.
// CORRECT - self-reports if the lint stops firing
#[expect(clippy::cast_possible_truncation, reason = "LSP wire format mandates u32; values bounded by file size")]
fn byte_to_lsp_position(...) -> u32 { ... }
// WRONG - silent, perpetual; will fail allow_attributes_without_reason
#[allow(clippy::cast_possible_truncation)]
fn byte_to_lsp_position(...) -> u32 { ... }Test oracle code (production-public functions only called by tests) should use
#[cfg(test)] rather than #[expect(dead_code)]. See
vespertide-lsp/src/diagnostics/validation/visitors.rs for the canonical pattern.
See docs/clippy-allow-audit.md for the full audit history.
- Indexes:
ix_{table}__{columns}orix_{table}__{name} - Unique:
uq_{table}__{columns} - Foreign keys:
fk_{table}__{columns}
| Pattern | Why Bad |
|---|---|
ColumnType::Integer |
Use ColumnType::Simple(SimpleColumnType::Integer) |
| Forgetting inline fields in ColumnDef | Will cause compile errors - 4 Option fields required |
| Raw SQL in migrations | Prefer typed MigrationAction enums. MigrationAction::RawSql exists as a documented emergency escape hatch only — non-portable across backends, skipped by baseline replay, and not recommended for normal use |
Skipping normalize() on TableDef |
Inline constraints won't convert to table-level |
.rs file exceeding 1000 lines |
Maintainability hard limit - split into focused submodules |
#[allow(LINT)] without reason = "..." |
Workspace lint denies — use #[expect(LINT, reason = "...")] instead |
#[allow(...)] on dead code |
If the item is only used by tests, gate with #[cfg(test)] instead. If truly dead, delete it. |
QueryError::Other(...) in new code |
Emits deprecation warning. Use SchemaError / InvalidColumnType / BackendError / UnsupportedAction |
Exhaustive struct literal for MigrationOptions / VespertideConfig |
#[non_exhaustive] — use ..Default::default() |
Comparing newtype with String::eq(&name.to_string(), "user") |
TableName: PartialEq<&str> — use name == "user" directly |
| Per-ORM exporter snapshot test (single ORM) | Use the 6-ORM orm_cases! macro; snapshots must cross-compare all ORMs |
# Build/Test
cargo build --workspace --exclude vespertide-fuzz
cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all --check
# CLI (always use -p vespertide-cli)
cargo run -p vespertide-cli -- init
cargo run -p vespertide-cli -- new <model>
cargo run -p vespertide-cli -- diff
cargo run -p vespertide-cli -- sql
cargo run -p vespertide-cli -- revision -m "message"
cargo run -p vespertide-cli -- export --orm seaorm
# Regenerate JSON schemas (must produce zero diff vs `schemas/`)
cargo run -p vespertide-schema-gen -- --out schemas
# Schema drift verification (CI gate)
cargo run -p vespertide-schema-gen -- --out _tmp_schemas
git diff --no-index schemas _tmp_schemas # must be empty
# Snapshot testing
cargo insta test -p vespertide-exporter
cargo insta accept
# LSP performance profiler (out-of-workspace tool — uses its own Cargo.lock)
cargo run --release --manifest-path tools/lsp-profile/Cargo.toml -- \
--workload synthetic --baseline docs/profiling-baseline.json
cargo run --release --manifest-path tools/lsp-profile/Cargo.toml -- \
--workload realistic --baseline docs/profiling-realistic.json
# Verify zero unjustified clippy `allow`s
cargo clippy --workspace --all-targets --all-features 2>&1 | grep -c allow_attributes_without_reason
# Expected: 0Policy (CI-enforced by scripts/check-line-budget.sh, run in the
line-budget job): two tiers —
- Production-only
.rsfiles: ≤ 1000 lines. - Files carrying test code (anything under a
tests/directory, OR a production file with an inline#[cfg(test)] mod tests { ... }block): ≤ 1200 lines (the +200 is the test budget; it must not be used to grow production logic).
The script greps each tracked .rs for a tests/ path segment or a top-level
mod tests { block to pick the tier. Current state: ✅ zero violations.
Files near the ceiling (next split candidates — line counts as of the
origin/main (Prisma exporter) merge into improve-performance):
| File | Lines | Tier | What |
|---|---|---|---|
macro/src/tests/mod.rs |
1150 | test-file (≤1200) | vespertide_migration! expansion tests |
cli/src/commands/erd/tests/mod.rs |
1147 | test-file (≤1200) | ERD command tests |
query/src/sql/modify_column_type/mod.rs |
1140 | prod+inline-tests (≤1200) | ALTER COLUMN TYPE |
query/src/sql/delete_column/mod.rs |
1138 | prod+inline-tests (≤1200) | DROP COLUMN with SQLite rebuild |
query/src/sql/add_constraint/mod.rs |
1138 | prod+inline-tests (≤1200) | ADD CONSTRAINT |
core/src/schema/table/tests/mod.rs |
1137 | test-file (≤1200) | Table normalization tests |
exporter/src/tests/fixtures/mod.rs |
1146 | test-file (≤1200) | Shared 6-ORM fixture schemas |
planner/src/validate/check_strengthening.rs |
1121 | prod+inline-tests (≤1200) | CHECK strengthening analysis |
query/src/sql/helpers.rs |
1109 | prod+inline-tests (≤1200) | Identifier quoting / type-cast helpers |
lsp/src/code_actions.rs |
1107 | prod+inline-tests (≤1200) | LSP code actions (incl. CHECK BETWEEN-swap) |
planner/src/validate/sequence_exhaustion/tests/mod.rs |
1069 | test-file (≤1200) | Sequence-exhaustion tests |
lsp/src/diagnostics/mod.rs |
1059 | prod+inline-tests (≤1200) | LSP diagnostics (incl. CHECK faults) |
planner/src/validate/check_type_mismatch.rs |
995 | prod+inline-tests | CHECK literal type-mismatch detection |
exporter/src/prisma/render.rs |
891 | production | Prisma model/field/attribute rendering |
Several prod+inline-tests files sit within ~60 lines of the 1200 ceiling
(modify_column_type/mod.rs, delete_column/mod.rs, add_constraint/mod.rs).
When they next grow, extract the inline #[cfg(test)] mod tests to
<module>/tests/mod.rs (the sanctioned pattern — keeps production logic under
the 1000-line cap while the test code keeps the +200 budget).
cli/commands/diff/mod.rs and core/action/mod.rs were compacted in-place
(verbose ColumnDef {...} → ColumnDef::new(...) + constraint-builder
helpers) rather than extracted.
Historical splits (Waves 1-9 of optimization work):
planner/src/diff.rs(4739) →diff/{mod,columns,constraints,ordering,tables}.rsexporter/src/seaorm/mod.rs(4122) → split intomod.rs+relations.rs+helper_tests.rscli/src/commands/revision.rs(3064) →revision/{mod,prompts,recreate,tests}.rsplanner/src/validate.rs(2299) →validate/{plan,schema,foreign_keys,tests}.rsplanner/src/apply.rs(1534) →apply/{mod,tests}.rscore/src/schema/table.rs(1526) →table/{mod,tests}.rsquery/src/sql/mod.rs(1507) →sql/{mod,tests}.rsquery/src/sql/remove_constraint.rs(1465) →remove_constraint/{mod,sqlite,...}.rsexporter/src/sqlalchemy/mod.rs(1383) →sqlalchemy/{mod,render,types,tests}.rsquery/src/sql/add_constraint.rs(1356) →add_constraint/{mod,tests}.rsexporter/src/sqlmodel/mod.rs(1274) →sqlmodel/{mod,render,types,tests}.rscore/src/action.rs(1236) →action/{mod,tests}.rsexporter/src/jpa/mod.rs(1122) →jpa/{mod,render,types}.rsquery/src/sql/delete_column.rs(1084) →delete_column/{mod,tests}.rsquery/src/sql/modify_column_type.rs(1056, Wave 9) →modify_column_type/{mod,direct,sqlite_rebuild,tests}.rsquery/src/builder.rs(995, Wave 9 preventive) →builder/{mod,sequential,transaction,parallel,tests}.rslsp/src/backend/mod.rs(970, preventive) → extracted 7 navigation/feature handler bodies (completion,hover,goto_definition,references,code_action,inlay_hint,symbol) intobackend/handler_navigation.rs. Trait methods inmod.rsare now one-line delegations tohandler_navigation::*_impl(self, params).await. Final:mod.rs599 lines,handler_navigation.rs358 lines. Mirrors the pre-existinghandler_file_features.rs/handler_rename.rspattern.lsp/src/drift/mod.rs(715 production-only, preventive) →drift/{types,compute,sources,actions}.rs(with pre-existingcache.rsunchanged). Carved by responsibility:types.rs(118) holdsDriftKind+DomainDrift+ internalDriftRecordtuple alias;compute.rs(240) holdscompute/compute_with_cache/loaded_state_with_cache+ path resolution helpers (find_config_and_mtime,resolve_models_dir,guess_uri,path_to_uri);sources.rs(31) holdssource_and_tree+source_and_tree_from_disk;actions.rs(356) holds theaction_to_driftdispatcher, per-action drift builders, render helpers (render_column_type/render_default/render_nullable/render_comment),lookup_baseline_column, and tree-sitter range helpers. Public API surface unchanged (pub use {DriftKind, DomainDrift, DriftCache, compute, compute_with_cache}). Cross-module helpers narrowed frompub(crate)topub(super)since callers all live underdrift::. With production now ~22 lines, the previously out-of-linedrift/tests/mod.rs(484 lines) was inlined intodrift/mod.rsas a#[cfg(test)] mod tests { ... }block — finaldrift/mod.rs528 lines (well under the 1200 combined ceiling);tests/mod.rscount 10 → 9.exporter/src/seaorm/relations.rs(1000 production-only, at workspace cap) →seaorm/relations/{mod,fk_resolve,naming,self_ref,reverse}.rs. Carved by responsibility:fk_resolve.rs(118) holdsas_fk(private) + theresolve_fk_target/resolve_fk_target_innerchain walker + theForwardRelationResolutionstruct emitted byresolve_table_fks_pure(sequential/parallel split onSEAORM_RELATION_PAR_FK_THRESHOLD);naming.rs(134) holds the pure naming helpersgenerate_relation_enum_name/unique_relation_enum_name/infer_field_name_from_fk_column/pluralize/fk_attr_value;self_ref.rs(233) holdsSelfRefJunction+collect_self_ref_junction/self_ref_link_name/resolve_self_ref_link_module_path/render_self_ref_link_helpers/render_self_ref_query_helpers;reverse.rs(467) holds the privateReverseRelationstruct +collect_reverse_relation_targets/collect_many_to_many_targets/reverse_relation_field_defs(+ privateReverseRelationFieldCtxandcollect_many_to_many_relations);mod.rs(140) owns the forward (belongs_to)relation_field_defs_with_schemaentry point and thepub(in crate::seaorm) usere-exports that satisfy the existinguse super::relations::{...}import inseaorm/render.rsand the#[cfg(test)] use relations::*;glob inseaorm/mod.rs. Visibility envelope unchanged: items previouslypub(super)ofseaorm::relations(i.e. visible throughoutseaorm) are nowpub(in crate::seaorm)on items hosted in submodules — same scope, just spelled differently to survive the extra module hop. SeaORM codegen output is byte-identical (0.snap.newfiles across the 232 cross-ORM snapshots + per-ORM seaorm snapshots). Largest sub-file (reverse.rs, 467) is well under the 1000-line policy; aggregate relations-tree = 1092 lines.cli/src/commands/erd/svg.rs(995 production-only, preventive) →erd/svg/{mod,style,model,layout,edges,render,util}.rs. Carved by responsibility:style.rs(55) holds every palette / sizing / typography constant aspub(super);model.rs(189) holdsTableBox/RowSpec/EdgeSpecplusbuild_boxes/build_edges/measure_table_width/badge_block_width;layout.rs(116) holdscompute_ranks/layout_grid/rebalance_groups/view_size;edges.rs(247) holds the privateSideenum,edge_geometry,render_edge_path/render_edge_label,pick_anchors,bezier_path/bezier_at/control_point, and theparallel_curvature_offset/label_t_for_parallelhelpers;render.rs(297) holdsrender_doc+render_defs+render_table/render_row/render_badge+ therounded_top_path/rounded_bottom_pathSVG-path emitters;util.rs(32) holdsrender_emptyandescape_xml;mod.rs(49) keeps the single public entrypub fn render_svg(...)orchestrating the pipeline. Public API surface unchanged —erd::svg::render_svgresolves identically. The original 9-lint file-level#![expect(clippy::...)]block was distributed per-file to exactly the lints each module triggers:cast_precision_loss(every coord-math file),cast_lossless(model only, foru32→f64badge counts),cast_possible_truncation+cast_sign_loss(layout only, forsqrt().ceil() as usize),range_plus_one(layout only, for0..(n+1)rank fixed-point),uninlined_format_args(edges/render/util, forwriteln!("{x}", x = …)SVG templates),too_many_arguments+similar_names(edges only, for Bézier helpers),unnecessary_wraps(mod.rs only, forrender_svg -> Result).style.rscarries zero lint exemptions. Largest sub-file (render.rs, 297) is well under the 1000-line policy; aggregate svg-tree = 985 lines.
Verify line policy (canonical, same as CI): sh scripts/check-line-budget.sh
(prints offenders + exits non-zero if any file breaks its tier; production-only
≤ 1000, files carrying test code ≤ 1200).
rstestfor parameterized tests — default choice for any test with ≥ 2 input variants (multi-backend, multi-ORM, multi-format). Plain#[test]is reserved for single-case unit tests. See Snapshot vs assertion, one case vs many below for which form a given test takes.serial_test::serialfor filesystem testsinstafor snapshot testing (exporter, query, planner, lsp, CLI)proptestfor property-based testing (vespertide-plannerdiff +vespertide-querySQL)- Helper functions:
col(),table()reduce boilerplate - 4234 tests across ~383
.rsfiles, 0 failed, 3 documented#[ignore](offline trybuild + 2///doctest blocks)
| Pattern | Verdict | Rationale |
|---|---|---|
#[cfg(test)] mod tests { ... } inline at the bottom of a production .rs |
✅ Preferred — default choice | Closest to the code under test; zero confusion; no mod tests; declaration needed; private items reachable via use super::*; without widening visibility. Allowed when production_lines + inline_test_lines + 5 wrapper ≤ 1200. |
src/<module>/tests/mod.rs (entry file inside a tests/ directory) |
✅ Acceptable fallback | Use only when (a) inlining would push the parent .rs over the 1200-line combined ceiling (production + inline tests), or (b) the test entry needs to declare sub-modules (mod foo; mod bar;) for src/<module>/tests/<name>.rs siblings |
src/<module>/tests/<name>.rs (anything under a tests/ directory) |
✅ Acceptable | Directory name marks it as test-only |
crates/<crate>/tests/<name>.rs (cargo integration tests) |
✅ Acceptable | Standard cargo layout |
src/<module>/tests.rs (bare sibling file named tests.rs) |
❌ Forbidden | Owner directive: test files must live inside a tests/ directory only. Convert to src/<module>/tests/mod.rs (parent mod tests; declaration is unchanged — cargo resolves the directory entry automatically), or preferably inline into the parent .rs |
src/<module>/<name>_tests.rs (e.g. helper_tests.rs next to helper.rs) |
❌ Forbidden | Confused with production helpers; move under src/<module>/tests/<name>.rs |
src/<module>/test_<name>.rs (e.g. test_fixtures.rs) |
Prefer src/<module>/tests/fixtures.rs for new code; existing exceptions documented per-crate |
Line-policy ceilings (two-tier):
- Production-only
.rsfiles (without inline tests): bound by the workspace ≤ 1000-line policy. This is the long-standing maintainability cap and is unchanged. - Production
.rsfiles carrying inline#[cfg(test)] mod tests { ... }: bound by ≤ 1200 lines combined (production_lines + inline_test_lines + 5 wrapper). The additional 200 lines is the budget for tests — it exists only when a production file carries inline test code, and it must not be used to grow production logic. - Canonical inline-with-tests examples at the new 1200 ceiling:
commands/erd/mod.rs(1065 lines),vespertide-macro/src/lib.rs(1177 lines),vespertide-core/src/action/mod.rs(1101 lines).
Decision flow for a new test module:
- Default: append
#[cfg(test)] mod tests { use super::*; ... }at the bottom of the production file. Nomod tests;declaration; the inline block defines the module. - If
parent.rs + tests > 1200 lines(the combined ceiling for files carrying inline tests): usesrc/<module>/tests/mod.rsinstead. (Production-only files remain bound by the ≤ 1000-line workspace cap.) - If the test needs to split into sub-files (
mod foo; mod bar;): usesrc/<module>/tests/mod.rsas the entry and put siblings undersrc/<module>/tests/<name>.rs. - Never widen visibility (
pub,pub(crate),pub(super)) of a production item just to make an out-of-line test reach it. Inline placement makes this unnecessary, sincesuper::*from inside an inlinemod testsalready sees every private item of the parent module.
Snapshot-path implication for migrations: insta's default snapshot directory is resolved relative to the test file's location. Inlining a test from parent/tests/mod.rs into parent.rs shifts the default from parent/tests/snapshots/ to parent/snapshots/. If the test uses explicit with_settings!({ snapshot_path => "../../snapshots" }) from parent/tests/mod.rs, change it to "../snapshots" after inlining so the same physical snapshots/ directory keeps resolving. Module-path naming inside snapshot filenames is unchanged (the inline module is still named tests, so <crate>__<module>__tests__<name>.snap stays byte-identical).
Migration rule: When you split a test file or extract fixtures, the new files live under src/<module>/tests/ — never as *_tests.rs siblings of production code, and never as a bare src/<module>/tests.rs file.
Policy (as of the magic-elimination wave, commit on refactor): the only
sanctioned wiring pattern is plain mod <name>; declarations. #[path = "..."]
on test modules and include!("tests/<name>.rs") inside test entry files are
both forbidden — owner directive: "no magic test wiring."
tests/mod.rsdeclaresmod <name>;for each sibling test file.- Sub-test files access shared imports via
use super::*;(which inherits the imports the entrytests/mod.rsbrings into scope). - When a test file needs private items of a production sibling module, do
not re-root it via
#[path]. Instead, raise the production item topub(super)(narrowest scope that works) and import it explicitly:use super::super::<module>::{item1, item2};.pub(super)keeps the item invisible to other crates and to sibling production modules — only the parent module's subtree (which includestests::<name>) can reach it. - Example:
vespertide-query/src/sql/tests/helpers.rsis a child ofsql::tests. It accesses threepub(super)helpers insql/helpers.rsviause super::super::helpers::{parse_pg_type_cast, is_enum_type, needs_quoting};.
Rationale: #[path] and include! hide the module tree from cargo modules, rustdoc, and any tooling that walks mod declarations. The
pub(super) + explicit use pattern is fully transparent and self-documenting.
Two independent decisions. Getting them right is what keeps a test readable and regression-proof.
What the test asserts:
| The subject is | Use | Why |
|---|---|---|
| Generated text — rendered ORM code, SQL, a file the command wrote | insta snapshot |
The whole output is the contract; contains("…") pins a fragment and lets the rest rot silently |
| A behavioural or structural fact — a file survived / was deleted, a derived path, a count invariant, "every emitted symbol is in this set" | assert! / assert_eq! |
There is no text to pin; the fact is the assertion |
A negative check on generated text (assert!(!out.contains("pgEnum"))) is a
snapshot in disguise — snapshot the case instead, where the absence is visible
alongside what the dialect emits in place of the missing construct.
How many cases:
Where the axis has a documented matrix — vespertide-query's
{PG, MySQL, SQLite} triple and the exporter's six-ORM orm_cases! — fan out
always, even when every case renders the same bytes: identity across the
matrix is itself the assertion (uniform_sql_is_emitted_byte_for_byte), and a
lone single-backend snapshot is a fault (vespertide-query/AGENTS.md).
On an axis with no such mandate — the Drizzle dialect inside a module test — split when the variant actually changes the output, each case owning its own snapshot; use one canonical case (PostgreSQL) when the fixture carries nothing that can fork, where three identical snapshots would only be noise.
use rstest::rstest;
use insta::{assert_snapshot, with_settings};
#[rstest]
#[case::postgres(DatabaseBackend::Postgres)]
#[case::mysql(DatabaseBackend::MySql)]
#[case::sqlite(DatabaseBackend::Sqlite)]
fn create_table_snapshot(#[case] backend: DatabaseBackend) {
let sql = build_create_table(/* ... */).build(backend);
with_settings!(
{ snapshot_suffix => format!("create_table_{backend:?}") },
{ assert_snapshot!(sql); }
);
}This is the same pattern used by vespertide-query (3 backends, 564 snapshots)
and vespertide-exporter (6 ORMs via Orm enum, 414 cross-ORM snapshots). When
adding a new backend / ORM / format, the change is one #[case::name(Value)]
line.
Every vespertide-exporter snapshot test MUST be written through the shared orm_cases! rstest macro in crates/vespertide-exporter/src/tests/mod.rs, which renders each fixture for all six ORMs (Orm::SeaOrm, Orm::SqlAlchemy, Orm::SqlModel, Orm::Jpa, Orm::Prisma, Orm::Drizzle). A new export scenario = ONE fixture + ONE orm_cases!(...) line, producing exactly six snapshots (one per ORM) in the single shared crates/vespertide-exporter/src/tests/snapshots/ directory.
FORBIDDEN: per-ORM #[test] snapshot functions inside src/seaorm/, src/sqlalchemy/, src/sqlmodel/, src/jpa/, src/prisma/, src/drizzle/, or any snapshots/ directory other than src/tests/snapshots/. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all six. When adding a new ORM the change is a single #[case::<orm>(Orm::<Variant>)] line in the macro, never a new per-ORM test.
Exception: an entry point that exists in only one backend (Prisma's single-file render_schema, which deduplicates enums globally; Drizzle's dialect-aware render_schema, whose axis is the SQL dialect rather than the ORM) is not a cross-ORM scenario, so its snapshot tests live as inline tests of that module — with the snapshot files still written to the shared src/tests/snapshots/ via with_settings!(snapshot_path => ...).
When a function exists solely as an oracle for a regression test (e.g. comparing
a fused/optimized pipeline against the equivalent unfused implementation), gate
it with #[cfg(test)] rather than #[allow(dead_code)]. Canonical example:
vespertide-lsp/src/diagnostics/validation/visitors.rs keeps
collect_syntax_errors/collect_unknown_column_types/etc. as #[cfg(test)]
oracles for the fused_walk_matches_unfused_pipeline test.
The toolchain is pinned to stable (rust-toolchain.toml); nightly-only
attributes like coverage(off) (gated behind feature(coverage_attribute))
are not available and MUST NOT be reintroduced. The sanctioned exclusion
mechanism is the single-attribute #[cfg(not(tarpaulin_include))] form,
which tarpaulin --engine llvm honors via the tarpaulin_include cfg flag
the runner sets while instrumenting. Workspace check-cfg declares
cfg(tarpaulin_include) so the cfg compiles cleanly outside tarpaulin (the
attribute body is included by default; tarpaulin removes it during its own
run). CI invokes the standard cargo tarpaulin --engine llvm ... --fail-under 100
in .github/workflows/CI.yml — no RUSTFLAGS, no --cfg coverage_nightly,
no special toolchain.
// Genuinely-irreducible shell (interactive prompt, main entrypoint, tracing
// macro internals, async-trait scaffolding, runtime-only logging):
#[cfg(not(tarpaulin_include))]
pub fn cmd_revision_interactive_prompts(/* ... */) -> Result<()> { /* ... */ }
// Per-arm on a proven-unreachable `_ =>` of a `#[non_exhaustive]` enum
// (currently single-variant; every reachable arm covered by tests). A
// justification comment is mandatory next to the attribute.
let keep = match strategy {
UniqueConstraintStrategy::DeleteDuplicates { keep } => *keep,
// `#[non_exhaustive]` future-variant guard; unreachable today.
#[cfg(not(tarpaulin_include))]
_ => return Err(QueryError::UnsupportedAction(/* ... */)),
};Forbidden (proven to break the build under tarpaulin):
- Dual-block
#[cfg(not(tarpaulin_include))] X; #[cfg(tarpaulin_include)] Ypattern. Tarpaulin removes thenot(...)block during instrumentation, leaving the file with two identical definitions ofX(or none, ifYwas a no-op). Use the single-attr form on a single definition only. - Whole-file or whole-function exclusions of real production logic
(gaming). Allowed exclusions are limited to:
- Irreducible shells:
main, interactive prompts, runtime tracing macro expansions, process-global logging configuration, async-trait delegations whose body is the trait method'sawaitline, runtime migration drivers that need a live DB. - Proven-unreachable arms on
#[non_exhaustive]enums where every existing variant is covered and the_ =>exists only to absorb future variants. Every such per-arm exclusion MUST carry a comment stating "currently unreachable" and pointing at the enum.
- Irreducible shells:
Prefer real deterministic tests for reachable code (parameterized rstest
across the matrix), and restructure closure-heavy code (e.g. .filter(|x| predicate(x)) → for x in ... { if !predicate(x) { continue; } ... },
or chained else if → match) when LLVM source-map attribution misses
otherwise-executed lines.
If you ever see coverage(off) or feature(coverage_attribute) reappear,
revert it — the stable-channel pin makes those attributes a hard build
error, and the agreed exclusion idiom is #[cfg(not(tarpaulin_include))]
only.
Never delete or #[ignore] a failing test to make CI green. Fix the code, not
the test. Documented #[ignore] tests must include a concrete reason in a
#[ignore = "..."] attribute or adjacent comment.
| Backend | Identifier Quoting | Notes |
|---|---|---|
| PostgreSQL | "identifier" |
Full feature support |
| MySQL | `identifier` |
Full feature support |
| SQLite | "identifier" |
Full feature support (ALTER limitations implemented via canonical temp-table-rebuild pattern in query/src/sql/remove_constraint.rs etc.) |
Both JSON and YAML are supported for model and migration files. Loaders accept .json, .yaml, and .yml extensions. JSON is preferred (canonical schema URLs reference JSON) but YAML loading is a first-class, tested feature — see vespertide-loader/src/models.rs and vespertide-config/src/file_format.rs.
- Edition 2024 (bleeding edge)
- rust-analyzer is unreliable on this workspace (large macro expansions in
vespertide-macro+ cargo-flamegraph profile intools/lsp-profilecause indexer churn); prefercargo check,cargo clippy, ast-grep, and ripgrep over LSP-based navigation when iterating - Two-tier line policy (CI-enforced via
scripts/check-line-budget.sh): production-only.rs≤ 1000 lines; files carrying test code (tests/dir or inline#[cfg(test)] mod tests {}) ≤ 1200 lines - Migration replay pattern: baseline always reconstructed from history (raw SQL actions are opaque to replay)
- Wire format stability: JSON output of every newtype, action, and config struct must remain byte-identical to 0.1.x. Verify via the schema-drift command in COMMANDS section.
tools/lsp-profile,examples/app, andtests/runtime-sqliteare out-of-workspace crates (separateCargo.lock); see rootCargo.tomlcomment for the rationale
All release artefacts (crates.io publishes, LSP binaries, VSCode VSIX) ship
through a single unified changepacks pipeline in .github/workflows/CI.yml.
There is no separate lsp-release.yml or vscode-release.yml.
- Author a changepack locally before merging the PR:
bunx @changepacks/cli # → writes a markdown descriptor under .changepacks/ - Merge the PR. CI runs the full quality gate (
fmt,clippy,test,coverage,deny,semver-checks, etc.), then thechangepacksjob:- Bumps versions in every Cargo.toml / package.json listed in the descriptor
- Creates a GitHub Release with the new tag
- Runs
cargo publishfor every changed Rust crate (in dependency order) - Emits two outputs:
changepacks(list of changed package files) andrelease_assets_urls(per-package upload URL into the new release)
- Conditional follow-up jobs consume those outputs:
lsp-release(matrix × 5 platforms) — fires only whencrates/vespertide-lsp/Cargo.tomlis in the wave. Builds thevespertide-lspbinary natively + cross + windows, packagestar.gz/zipwithsha256, uploads to the changepacks release.node-build(matrix × 5 targets) +node-publish— fire only whenbridge/node/package.jsonis in the wave. Build the napi addon per target, rebuild once on the publish host (onlynapi buildwrites the gitignoredindex.jsloader), assemble the@vespertide/cli-*platform packages withnapi create-npm-dirs/napi artifacts, upload the.nodefiles to the changepacks release, thennpm publish(skipped with a warning whenNPM_TOKENis unset).vscode-release(matrix × 5 vsce targets) — fires only whenapps/vscode-extension/package.jsonis in the wave. Pulls the matching LSP binary (just-released if LSP is also in the wave, otherwise the latest prior release), packages VSIX, uploads to the release, and publishes to VS Code Marketplace (VSCE_PAT) + Open VSX (OVSX_PAT).
.changepacks/config.json— trackscrates/**/Cargo.toml(exceptvespertide-schema-genwhich ispublish=false),apps/vscode-extension/package.jsonandbridge/node/package.json.updateOnadds the npm manifest to every crate wave as a Patch bump sonode-publishalways fires; list it explicitly in the descriptor when the npm version should move at the same level as the crates.apps/landing,apps/zed-extension,tools/, andtests/are intentionally not tracked.- Required secrets:
CARGO_REGISTRY_TOKEN,VSCE_PAT,OVSX_PAT,NPM_TOKEN. .changepacks/changepack_log_*.jsonfiles are the committed bump descriptors (written bybunx @changepacks/cli, consumed bychangepacks/actionon merge — analogous to changesets'.changeset/*.md), not runtime state. They MUST be committed; the random suffix avoids parallel-PR conflicts and the Action deletes them when it opens the "Update Versions" PR. Only thechangepacksCLI binary the Action downloads into the repo root during CI is gitignored.
Zed publishing is now automated via the zed-release job in
.github/workflows/CI.yml (community huacnlee/zed-extension-action@v1). It
fires whenever crates/vespertide-lsp/Cargo.toml is in the changepacks wave
(same trigger as lsp-release, because the Zed extension is a thin WASM shim
that downloads vespertide-lsp from GitHub Releases at runtime), or on a manual
workflow_dispatch with a zed_version input. The job bumps
apps/zed-extension/{extension.toml,Cargo.toml} to the released version, pushes
a lightweight zed-extension-v<ver> tag carrying that bump (main is left
untouched), then opens/updates a PR against zed-industries/extensions.
Requirements (one-time, manual):
- A
dev-five-git/extensionsfork ofzed-industries/extensions. - A
ZED_EXTENSIONS_TOKENrepo secret — a PAT (or GitHub App token) withrepo+workflowscopes, able to push to the fork and open the upstream PR. - Initial registration: the very first time, manually open a PR to
zed-industries/extensionsadding the extension as a git submodule plus anextensions.tomlentry withpath = "apps/zed-extension"(monorepo subdir). Subsequent automated bumps only editversion+ the submodule SHA, so thepathfield persists.
cargo-mutants runs in CI on every PR for changed lines only. Locally:
# Full pass on the planner crate (slow, ~30 min)
cargo install --locked cargo-mutants
cargo mutants -p vespertide-planner --in-place --timeout-multiplier 3.0
# Only mutations introduced by current changes
cargo mutants --in-diff <(git diff main..) --in-placeSurvived mutants indicate test gaps. Fix by adding assertions, not by suppressing the mutant.
cargo-fuzz runs on every main push via .github/workflows/fuzz.yml
(no cron schedule — actions/cache is immutable per SHA, so cron runs
on unchanged code can't persist their discovered corpus). For deep-fuzz
sessions, use workflow_dispatch with a larger duration_seconds.
Four targets in fuzz/fuzz_targets/:
fuzz_model_deser— JSON deserialization ofTableDef/MigrationPlanfuzz_sql_identifier—quote_identsafety invariantsfuzz_migration_apply—apply_actionnever-panic propertyfuzz_lsp_request— LSP request handler sweep (9 capabilities) over randommodel.jsonbodies
Local run (requires nightly):
rustup install nightly
cargo install cargo-fuzz
cd fuzz
cargo +nightly fuzz run fuzz_model_deser -- -max_total_time=60Corpus and artifacts are gitignored except the .gitkeep markers.
Discovered crashes appear under fuzz/artifacts/<target>/ and should be
committed to a regression test before fixing.
criterion benchmarks in crates/*/benches/. Run locally:
# All benchmarks
cargo bench --workspace
# Single crate
cargo bench -p vespertide-planner
# Single benchmark with statistical comparison
cargo bench -p vespertide-planner --bench diff_benchmarks -- diff_identity/100HTML reports at target/criterion/<bench>/report/index.html.
Save baseline for comparison:
cargo bench -- --save-baseline main
git checkout feature/foo
cargo bench -- --baseline mainCI workflow in .github/workflows/bench.yml runs on PR for informational
trend tracking (not currently blocking).