max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
36 files changed,
+3460 insertions,
-22 deletions
| @@ -794,6 +794,18 @@ | |||
| 794 | 794 | "typeid", | |
| 795 | 795 | ] | |
| 796 | 796 | ||
| 797 | + | [[package]] | |
| 798 | + | name = "fallible-iterator" | |
| 799 | + | version = "0.3.0" | |
| 800 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 801 | + | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 802 | + | ||
| 803 | + | [[package]] | |
| 804 | + | name = "fallible-streaming-iterator" | |
| 805 | + | version = "0.1.9" | |
| 806 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 807 | + | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 808 | + | ||
| 797 | 809 | [[package]] | |
| 798 | 810 | name = "fastrand" | |
| 799 | 811 | version = "2.5.0" | |
| @@ -1260,11 +1272,32 @@ | |||
| 1260 | 1272 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1261 | 1273 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" | |
| 1262 | 1274 | ||
| 1275 | + | [[package]] | |
| 1276 | + | name = "hashbrown" | |
| 1277 | + | version = "0.16.1" | |
| 1278 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1279 | + | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 1280 | + | dependencies = [ | |
| 1281 | + | "foldhash", | |
| 1282 | + | ] | |
| 1283 | + | ||
| 1263 | 1284 | [[package]] | |
| 1264 | 1285 | name = "hashbrown" | |
| 1265 | 1286 | version = "0.17.1" | |
| 1266 | 1287 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1267 | 1288 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 1289 | + | dependencies = [ | |
| 1290 | + | "foldhash", | |
| 1291 | + | ] | |
| 1292 | + | ||
| 1293 | + | [[package]] | |
| 1294 | + | name = "hashlink" | |
| 1295 | + | version = "0.12.1" | |
| 1296 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1297 | + | checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" | |
| 1298 | + | dependencies = [ | |
| 1299 | + | "hashbrown 0.17.1", | |
| 1300 | + | ] | |
| 1268 | 1301 | ||
| 1269 | 1302 | [[package]] | |
| 1270 | 1303 | name = "heck" | |
| @@ -1748,6 +1781,17 @@ | |||
| 1748 | 1781 | "libc", | |
| 1749 | 1782 | ] | |
| 1750 | 1783 | ||
| 1784 | + | [[package]] | |
| 1785 | + | name = "libsqlite3-sys" | |
| 1786 | + | version = "0.38.2" | |
| 1787 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1788 | + | checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" | |
| 1789 | + | dependencies = [ | |
| 1790 | + | "cc", | |
| 1791 | + | "pkg-config", | |
| 1792 | + | "vcpkg", | |
| 1793 | + | ] | |
| 1794 | + | ||
| 1751 | 1795 | [[package]] | |
| 1752 | 1796 | name = "litemap" | |
| 1753 | 1797 | version = "0.8.2" | |
| @@ -2464,6 +2508,16 @@ | |||
| 2464 | 2508 | "makeover-layout 0.12.0", | |
| 2465 | 2509 | ] | |
| 2466 | 2510 | ||
| 2511 | + | [[package]] | |
| 2512 | + | name = "quasi-store" | |
| 2513 | + | version = "0.1.0" | |
| 2514 | + | dependencies = [ | |
| 2515 | + | "rusqlite", | |
| 2516 | + | "sha2", | |
| 2517 | + | "thiserror 2.0.20", | |
| 2518 | + | "tracing", | |
| 2519 | + | ] | |
| 2520 | + | ||
| 2467 | 2521 | [[package]] | |
| 2468 | 2522 | name = "quasi-tauri" | |
| 2469 | 2523 | version = "0.1.0" | |
| @@ -2625,6 +2679,31 @@ | |||
| 2625 | 2679 | "web-sys", | |
| 2626 | 2680 | ] | |
| 2627 | 2681 | ||
| 2682 | + | [[package]] | |
| 2683 | + | name = "rsqlite-vfs" | |
| 2684 | + | version = "0.1.1" | |
| 2685 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2686 | + | checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 2687 | + | dependencies = [ | |
| 2688 | + | "hashbrown 0.16.1", | |
| 2689 | + | "thiserror 2.0.20", | |
| 2690 | + | ] | |
| 2691 | + | ||
| 2692 | + | [[package]] | |
| 2693 | + | name = "rusqlite" | |
| 2694 | + | version = "0.40.2" | |
| 2695 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2696 | + | checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" | |
| 2697 | + | dependencies = [ | |
| 2698 | + | "bitflags 2.13.1", | |
| 2699 | + | "fallible-iterator", | |
| 2700 | + | "fallible-streaming-iterator", | |
| 2701 | + | "hashlink", | |
| 2702 | + | "libsqlite3-sys", | |
| 2703 | + | "smallvec", | |
| 2704 | + | "sqlite-wasm-rs", | |
| 2705 | + | ] | |
| 2706 | + | ||
| 2628 | 2707 | [[package]] | |
| 2629 | 2708 | name = "rustc-hash" | |
| 2630 | 2709 | version = "2.1.3" | |
| @@ -3026,6 +3105,18 @@ | |||
| 3026 | 3105 | "system-deps", | |
| 3027 | 3106 | ] | |
| 3028 | 3107 | ||
| 3108 | + | [[package]] | |
| 3109 | + | name = "sqlite-wasm-rs" | |
| 3110 | + | version = "0.5.5" | |
| 3111 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3112 | + | checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 3113 | + | dependencies = [ | |
| 3114 | + | "cc", | |
| 3115 | + | "js-sys", | |
| 3116 | + | "rsqlite-vfs", | |
| 3117 | + | "wasm-bindgen", | |
| 3118 | + | ] | |
| 3119 | + | ||
| 3029 | 3120 | [[package]] | |
| 3030 | 3121 | name = "stable_deref_trait" | |
| 3031 | 3122 | version = "1.2.1" | |
| @@ -3698,9 +3789,21 @@ | |||
| 3698 | 3789 | dependencies = [ | |
| 3699 | 3790 | "log", | |
| 3700 | 3791 | "pin-project-lite", | |
| 3792 | + | "tracing-attributes", | |
| 3701 | 3793 | "tracing-core", | |
| 3702 | 3794 | ] | |
| 3703 | 3795 | ||
| 3796 | + | [[package]] | |
| 3797 | + | name = "tracing-attributes" | |
| 3798 | + | version = "0.1.31" | |
| 3799 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3800 | + | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | |
| 3801 | + | dependencies = [ | |
| 3802 | + | "proc-macro2", | |
| 3803 | + | "quote", | |
| 3804 | + | "syn 2.0.119", | |
| 3805 | + | ] | |
| 3806 | + | ||
| 3704 | 3807 | [[package]] | |
| 3705 | 3808 | name = "tracing-core" | |
| 3706 | 3809 | version = "0.1.36" | |
| @@ -3858,6 +3961,12 @@ | |||
| 3858 | 3961 | "wasm-bindgen", | |
| 3859 | 3962 | ] | |
| 3860 | 3963 | ||
| 3964 | + | [[package]] | |
| 3965 | + | name = "vcpkg" | |
| 3966 | + | version = "0.2.15" | |
| 3967 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3968 | + | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 3969 | + | ||
| 3861 | 3970 | [[package]] | |
| 3862 | 3971 | name = "version-compare" | |
| 3863 | 3972 | version = "0.2.1" |
| @@ -5,9 +5,14 @@ | |||
| 5 | 5 | "crates/quasi-axum", | |
| 6 | 6 | "crates/quasi-http", | |
| 7 | 7 | "crates/quasi-router", | |
| 8 | + | "crates/quasi-store", | |
| 8 | 9 | "crates/quasi-tauri", | |
| 9 | 10 | "crates/quasi-webview", | |
| 10 | 11 | ] | |
| 12 | + | # The scaffolder's template is real Rust and real manifests rather than liquid, | |
| 13 | + | # so that it is readable, greppable and diffable. Excluded because those | |
| 14 | + | # manifests name a crate that does not exist until `quasi new` renders them. | |
| 15 | + | exclude = ["crates/quasi/template"] | |
| 11 | 16 | ||
| 12 | 17 | [workspace.dependencies] | |
| 13 | 18 |
| @@ -61,13 +61,32 @@ | |||
| 61 | 61 | status mapping, the htmx contract, the `Render` trait. | |
| 62 | 62 | - `crates/quasi-axum/` — the axum host adapter. | |
| 63 | 63 | - `crates/quasi-tauri/` — the Tauri custom-protocol host adapter. | |
| 64 | - | - `crates/quasi/` — the scaffolder binary. | |
| 64 | + | - `crates/quasi-webview/` — the webview renderer: a screen in, an htmx document | |
| 65 | + | out. | |
| 66 | + | - `crates/quasi-store/` — the embedded store's migration runner. | |
| 67 | + | - `crates/quasi/` — the scaffolder binary, and `template/`, the app it writes. | |
| 65 | 68 | ||
| 66 | 69 | Crates are added when their component starts, not up front. | |
| 67 | 70 | ||
| 71 | + | ## Getting an app | |
| 72 | + | ||
| 73 | + | ``` | |
| 74 | + | cargo run -p quasi -- new fieldnotes | |
| 75 | + | cd fieldnotes | |
| 76 | + | cargo test the screens, with no host | |
| 77 | + | cargo run -p fieldnotes-server http://127.0.0.1:3000 | |
| 78 | + | cargo run -p fieldnotes-desktop a window | |
| 79 | + | ``` | |
| 80 | + | ||
| 81 | + | What comes out is a workspace of three crates: `-core` holds the state, the | |
| 82 | + | store and the described screens and imports no host crate; `-desktop` and | |
| 83 | + | `-server` are a window and a listener over the same router. Both serve the same | |
| 84 | + | screens and neither knows the other exists. | |
| 85 | + | ||
| 68 | 86 | ## Status | |
| 69 | 87 | ||
| 70 | - | The router and both host adapters are implemented. `quasi` is still a stub. | |
| 88 | + | The router, both host adapters, the webview renderer, the store's migration | |
| 89 | + | runner and the scaffolder are implemented. | |
| 71 | 90 | ||
| 72 | 91 | The router carries the contract in full: one address space where the verb | |
| 73 | 92 | separates a read from a write, a screen tree composed from `makeover-layout`'s | |
| @@ -96,12 +115,20 @@ | |||
| 96 | 115 | ||
| 97 | 116 | Neither adapter emits markup. A `Render` implementation supplies that, because | |
| 98 | 117 | both serve HTML to a webview and generating it inside one would guarantee a | |
| 99 | - | second copy. Whole-screen markup is `makeover-webview` phase B and is not | |
| 100 | - | written yet, so an app brings its own renderer until it is. | |
| 118 | + | second copy. `quasi-webview` is that implementation: a screen in, an htmx | |
| 119 | + | document out, with the transport entering in a single function. | |
| 101 | 120 | ||
| 102 | - | Not yet written: the scaffolder. It followed the router on the condition that | |
| 103 | - | the router's shape be proven against two hosts, which it now is, so it is next | |
| 104 | - | rather than blocked. | |
| 121 | + | `quasi-store` is what `sqlx::migrate!` used to be. SQL files embedded at build | |
| 122 | + | time, applied once, recorded in a ledger with a checksum, so an applied | |
| 123 | + | migration is immutable. It is here because goingson and Balanced Breakfast each | |
| 124 | + | hand-wrote one after leaving `sqlx-sqlite`, and a generated app would have been | |
| 125 | + | the third. | |
| 126 | + | ||
| 127 | + | `quasi` is the scaffolder. It followed the router on the condition that the | |
| 128 | + | router's shape be proven against two hosts, which it was. Its template is real | |
| 129 | + | Rust and real manifests under `crates/quasi/template/`, excluded from the | |
| 130 | + | workspace and embedded in the binary — readable and diffable, rather than a | |
| 131 | + | liquid dialect that compiles nowhere. | |
| 105 | 132 | ||
| 106 | 133 | Design and sequencing live in the wiki note `quasi-overview`; the backlog is in | |
| 107 | 134 | GoingsOn under project `quasicoherent`. |
| @@ -2,22 +2,202 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! <!-- wiki: quasi-overview --> | |
| 4 | 4 | //! | |
| 5 | - | //! The `create-t3-app` equivalent: produce a running app with a router, a | |
| 6 | - | //! description, renderers wired, migrations, themes materialised and a Bento | |
| 7 | - | //! config. Without it the stack is documentation about repos that happen to | |
| 8 | - | //! agree. | |
| 5 | + | //! The `create-t3-app` equivalent: `quasi new <name>` produces a workspace that | |
| 6 | + | //! runs, with a router, a description, both host adapters wired, migrations, | |
| 7 | + | //! the makeover themes materialised and a Bento config. Without it the stack is | |
| 8 | + | //! documentation about repos that happen to agree. | |
| 9 | 9 | //! | |
| 10 | - | //! Not written yet. It cannot generate a stack that does not exist, so it | |
| 11 | - | //! follows the router rather than leading it. | |
| 10 | + | //! # Why a binary rather than a `cargo generate` template | |
| 12 | 11 | //! | |
| 13 | - | //! What it will not generate: an auth story. quasi names the router, the | |
| 14 | - | //! description and the renderers, and says nothing about authentication, which | |
| 15 | - | //! stays per-host by design rather than by omission (settled 2026-08-06). The | |
| 16 | - | //! router imports no host crate, and auth is the most host-shaped thing there | |
| 17 | - | //! is — a keychain in Tauri, a session cookie on a server, neither in a TUI. | |
| 18 | - | //! A generated app therefore says in its README what its host is expected to | |
| 19 | - | //! provide, rather than leaving a silent gap every new app rediscovers. | |
| 12 | + | //! Decided 2026-08-09 (Max). `cargo-generate` is the tool a Rust developer | |
| 13 | + | //! recognises and it costs an install step, a liquid dialect, and a template | |
| 14 | + | //! whose `.rs` files do not compile or lint in the workspace that holds them. | |
| 15 | + | //! Against that: a name to validate before anything is written, `cargo fmt` and | |
| 16 | + | //! `git init` to run afterwards, and a substitution pass that is four | |
| 17 | + | //! placeholders long. The four placeholders won. | |
| 18 | + | //! | |
| 19 | + | //! What that decision costs is one hand-rolled renderer, in [`render`], and it | |
| 20 | + | //! is deliberately not a templating language: no conditionals, no loops, no | |
| 21 | + | //! partials. A template with control flow is a program, and a program that | |
| 22 | + | //! generates programs wants tests before it has earned them. | |
| 23 | + | //! | |
| 24 | + | //! # What it will not generate: an auth story | |
| 25 | + | //! | |
| 26 | + | //! quasi names the router, the description and the renderers, and says nothing | |
| 27 | + | //! about authentication, which stays per-host by design rather than by omission | |
| 28 | + | //! (settled 2026-08-06). The router imports no host crate, and auth is the most | |
| 29 | + | //! host-shaped thing there is — a keychain in Tauri, a session cookie on a | |
| 30 | + | //! server, neither in a TUI. So a generated app says in its README and in its | |
| 31 | + | //! core crate's docs what its host is expected to provide, rather than leaving | |
| 32 | + | //! a silent gap every new app rediscovers. | |
| 20 | 33 | ||
| 21 | - | fn main() { | |
| 22 | - | println!("quasi: not implemented yet. See wiki note quasi-overview."); | |
| 34 | + | mod render; | |
| 35 | + | mod template; | |
| 36 | + | ||
| 37 | + | use std::path::{Path, PathBuf}; | |
| 38 | + | use std::process::ExitCode; | |
| 39 | + | ||
| 40 | + | use crate::render::Name; | |
| 41 | + | use crate::template::{Body, FILES}; | |
| 42 | + | ||
| 43 | + | fn main() -> ExitCode { | |
| 44 | + | let mut args = std::env::args().skip(1); | |
| 45 | + | ||
| 46 | + | match args.next().as_deref() { | |
| 47 | + | Some("new") => { | |
| 48 | + | let given = args.next(); | |
| 49 | + | let into = args.next(); | |
| 50 | + | match new(given.as_deref(), into.as_deref()) { | |
| 51 | + | Ok((root, name)) => { | |
| 52 | + | report(&root, &name); | |
| 53 | + | ExitCode::SUCCESS | |
| 54 | + | } | |
| 55 | + | Err(message) => { | |
| 56 | + | eprintln!("quasi: {message}"); | |
| 57 | + | ExitCode::FAILURE | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | Some("--help" | "-h" | "help") | None => { | |
| 62 | + | println!("{USAGE}"); | |
| 63 | + | ExitCode::SUCCESS | |
| 64 | + | } | |
| 65 | + | Some("--version" | "-V") => { | |
| 66 | + | println!("quasi {}", env!("CARGO_PKG_VERSION")); | |
| 67 | + | ExitCode::SUCCESS | |
| 68 | + | } | |
| 69 | + | Some(unknown) => { | |
| 70 | + | eprintln!("quasi: unknown command `{unknown}`\n\n{USAGE}"); | |
| 71 | + | ExitCode::FAILURE | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | const USAGE: &str = "\ | |
| 77 | + | quasi — the quasicoherent stack's scaffolder | |
| 78 | + | ||
| 79 | + | quasi new <name> [directory] generate an app | |
| 80 | + | quasi --help | |
| 81 | + | quasi --version | |
| 82 | + | ||
| 83 | + | <name> is a crate name: letters, digits, `-` and `_`. It becomes the package | |
| 84 | + | prefix, the Rust paths, the window title and the custom scheme. | |
| 85 | + | ||
| 86 | + | [directory] defaults to ./<name>. It must not already exist. | |
| 87 | + | ||
| 88 | + | What you get is a workspace of three crates — <name>-core, <name>-desktop and | |
| 89 | + | <name>-server — the same screens served by both hosts, and no auth, which the | |
| 90 | + | generated README explains rather than leaves silent."; | |
| 91 | + | ||
| 92 | + | /// Generate an app. Answers with where it landed, and under what name. | |
| 93 | + | /// | |
| 94 | + | /// The two are not the same thing: `[directory]` moves where it lands, and the | |
| 95 | + | /// name is what the crates are called. Reporting the directory as the name is | |
| 96 | + | /// how the printed next step names a package that does not exist. | |
| 97 | + | fn new(given: Option<&str>, into: Option<&str>) -> Result<(PathBuf, Name), String> { | |
| 98 | + | let Some(given) = given else { | |
| 99 | + | return Err(format!("a name is required\n\n{USAGE}")); | |
| 100 | + | }; | |
| 101 | + | let name = Name::parse(given).map_err(|error| error.to_string())?; | |
| 102 | + | ||
| 103 | + | let root = into.map_or_else(|| PathBuf::from(&name.kebab), PathBuf::from); | |
| 104 | + | ||
| 105 | + | // Checked before anything is written rather than per-file, so a refusal | |
| 106 | + | // leaves nothing behind. `create_dir` rather than `create_dir_all` for the | |
| 107 | + | // root itself: it fails if the directory is there, which is the check. | |
| 108 | + | if root.exists() { | |
| 109 | + | return Err(format!( | |
| 110 | + | "{} already exists; pass a different directory", | |
| 111 | + | root.display() | |
| 112 | + | )); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | write_all(&root, &name).inspect_err(|_| { | |
| 116 | + | // A half-written app is worse than none: it looks generated. Nothing | |
| 117 | + | // here existed a moment ago, so removing it is safe by construction. | |
| 118 | + | let _ = std::fs::remove_dir_all(&root); | |
| 119 | + | })?; | |
| 120 | + | ||
| 121 | + | // Both are conveniences and neither is load-bearing, so a machine without | |
| 122 | + | // the tool gets the app and a note rather than a failure. | |
| 123 | + | tidy(&root); | |
| 124 | + | ||
| 125 | + | Ok((root, name)) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// Write every template file under `root`. | |
| 129 | + | fn write_all(root: &Path, name: &Name) -> Result<(), String> { | |
| 130 | + | for entry in FILES { | |
| 131 | + | let path = root.join(entry.path_for(name)); | |
| 132 | + | if let Some(parent) = path.parent() { | |
| 133 | + | std::fs::create_dir_all(parent) | |
| 134 | + | .map_err(|error| format!("could not create {}: {error}", parent.display()))?; | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | let written = match entry.body { | |
| 138 | + | Body::Text(source) => std::fs::write(&path, name.fill(source)), | |
| 139 | + | Body::Bytes(bytes) => std::fs::write(&path, bytes), | |
| 140 | + | }; | |
| 141 | + | written.map_err(|error| format!("could not write {}: {error}", path.display()))?; | |
| 142 | + | } | |
| 143 | + | Ok(()) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// `cargo fmt` and `git init`, best effort. | |
| 147 | + | fn tidy(root: &Path) { | |
| 148 | + | // The template is formatted, but substitution changes line lengths: a name | |
| 149 | + | // long enough to push a call over the width leaves the generated file | |
| 150 | + | // unformatted, and the first thing anyone runs would rewrite it. | |
| 151 | + | run(root, "cargo", &["fmt"]); | |
| 152 | + | if !root.join(".git").exists() { | |
| 153 | + | run(root, "git", &["init", "--quiet"]); | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Run a command in the generated app, ignoring a missing tool. | |
| 158 | + | fn run(root: &Path, program: &str, args: &[&str]) { | |
| 159 | + | let outcome = std::process::Command::new(program) | |
| 160 | + | .args(args) | |
| 161 | + | .current_dir(root) | |
| 162 | + | .output(); | |
| 163 | + | ||
| 164 | + | match outcome { | |
| 165 | + | Ok(output) if output.status.success() => {} | |
| 166 | + | Ok(output) => { | |
| 167 | + | eprintln!( | |
| 168 | + | "quasi: `{program} {}` failed, carrying on:\n{}", | |
| 169 | + | args.join(" "), | |
| 170 | + | String::from_utf8_lossy(&output.stderr).trim() | |
| 171 | + | ); | |
| 172 | + | } | |
| 173 | + | Err(_) => eprintln!("quasi: `{program}` is not installed, skipping"), | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// What was made, and what to do next. | |
| 178 | + | fn report(root: &Path, name: &Name) { | |
| 179 | + | let dir = root.display(); | |
| 180 | + | let app = &name.kebab; | |
| 181 | + | ||
| 182 | + | println!("Created {dir} — {} files, three crates.", FILES.len()); | |
| 183 | + | println!(); | |
| 184 | + | // Padded against the longest of the three, so the notes line up whatever | |
| 185 | + | // the name's length. | |
| 186 | + | let width = format!("cargo run -p {app}-desktop").len(); | |
| 187 | + | println!(" cd {dir}"); | |
| 188 | + | println!(" {:width$} the screens, with no host", "cargo test"); | |
| 189 | + | println!( | |
| 190 | + | " {:width$} http://127.0.0.1:3000", | |
| 191 | + | format!("cargo run -p {app}-server") | |
| 192 | + | ); | |
| 193 | + | println!( | |
| 194 | + | " {:width$} a window", | |
| 195 | + | format!("cargo run -p {app}-desktop") | |
| 196 | + | ); | |
| 197 | + | println!(); | |
| 198 | + | println!("The first build generates the design system's stylesheets and"); | |
| 199 | + | println!("materialises the themes, so it is slower than the ones after it."); | |
| 200 | + | println!(); | |
| 201 | + | println!("crates/{app}-core/src/notes.rs is the worked example. Read it,"); | |
| 202 | + | println!("then delete it."); | |
| 23 | 203 | } |
| @@ -1,0 +1,32 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "quasi-store" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | description = "The embedded store's migration runner: SQL files embedded at build time, applied once, recorded in a ledger" | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | authors.workspace = true | |
| 8 | + | repository.workspace = true | |
| 9 | + | license.workspace = true | |
| 10 | + | publish = false | |
| 11 | + | ||
| 12 | + | [lints] | |
| 13 | + | workspace = true | |
| 14 | + | ||
| 15 | + | [dependencies] | |
| 16 | + | rusqlite = { version = "0.40.0", optional = true } | |
| 17 | + | sha2 = { version = "0.10.9", optional = true } | |
| 18 | + | thiserror = { version = "2.0.17", optional = true } | |
| 19 | + | tracing = { version = "0.1.41", optional = true } | |
| 20 | + | ||
| 21 | + | [features] | |
| 22 | + | default = ["runtime"] | |
| 23 | + | # The runner. What an app links. | |
| 24 | + | runtime = ["dep:rusqlite", "dep:sha2", "dep:thiserror", "dep:tracing"] | |
| 25 | + | # The embedder. What an app's `build.rs` links, and it deliberately shares no | |
| 26 | + | # dependency with `runtime`: feature unification does not cross the | |
| 27 | + | # build-dependency line under resolver 2, so a build script asking for this | |
| 28 | + | # does not drag `libsqlite3-sys` into a second compile for the host. | |
| 29 | + | build = [] | |
| 30 | + | ||
| 31 | + | [dev-dependencies] | |
| 32 | + | rusqlite = { version = "0.40.0", features = ["bundled"] } |
| @@ -1,0 +1,157 @@ | |||
| 1 | + | //! Turning a directory of `.sql` files into a table compiled into the binary. | |
| 2 | + | //! | |
| 3 | + | //! This is what `sqlx::migrate!` was as a proc macro. A build script instead, | |
| 4 | + | //! for one reason worth stating: the checksums are computed at runtime from the | |
| 5 | + | //! embedded bytes rather than baked in here, so the runner cannot disagree with | |
| 6 | + | //! what it shipped. A macro that recorded both would have two sources for one | |
| 7 | + | //! fact. | |
| 8 | + | //! | |
| 9 | + | //! Nothing in this module links `rusqlite`, and it is behind its own feature so | |
| 10 | + | //! that a `build.rs` asking for it does not compile the driver a second time for | |
| 11 | + | //! the host. | |
| 12 | + | //! | |
| 13 | + | //! # Filenames | |
| 14 | + | //! | |
| 15 | + | //! `<version>_<description>.sql`, the version an integer and the underscores in | |
| 16 | + | //! the description becoming spaces. `001_initial_schema.sql` is version 1, | |
| 17 | + | //! "initial schema". Anything else in the directory is skipped rather than | |
| 18 | + | //! rejected, which is what leaves room for a `README` next to the files. | |
| 19 | + | //! | |
| 20 | + | //! The convention is `sqlx`'s, deliberately: goingson and Balanced Breakfast | |
| 21 | + | //! have sixty-odd files each already named this way, and a runner they could | |
| 22 | + | //! not adopt without renaming every one of them is a runner they would not | |
| 23 | + | //! adopt. | |
| 24 | + | ||
| 25 | + | use std::fmt::Write as _; | |
| 26 | + | use std::path::{Path, PathBuf}; | |
| 27 | + | ||
| 28 | + | /// What went wrong reading the directory. | |
| 29 | + | /// | |
| 30 | + | /// A build script's failure is a panic with a message, so this exists to make | |
| 31 | + | /// the message specific rather than to be handled. | |
| 32 | + | #[derive(Debug)] | |
| 33 | + | pub enum EmbedError { | |
| 34 | + | /// The directory could not be read. | |
| 35 | + | Unreadable { | |
| 36 | + | dir: PathBuf, | |
| 37 | + | source: std::io::Error, | |
| 38 | + | }, | |
| 39 | + | /// A file matched the shape but its prefix is not an integer. | |
| 40 | + | BadVersion { file: String }, | |
| 41 | + | /// Two files claim the same version. | |
| 42 | + | Duplicate { version: i64 }, | |
| 43 | + | /// `OUT_DIR` was unset, so this is not running as a build script. | |
| 44 | + | NoOutDir, | |
| 45 | + | /// The generated file could not be written. | |
| 46 | + | Unwritable { | |
| 47 | + | dest: PathBuf, | |
| 48 | + | source: std::io::Error, | |
| 49 | + | }, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | impl std::fmt::Display for EmbedError { | |
| 53 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 54 | + | match self { | |
| 55 | + | Self::Unreadable { dir, source } => { | |
| 56 | + | write!( | |
| 57 | + | f, | |
| 58 | + | "migrations directory {} is unreadable: {source}", | |
| 59 | + | dir.display() | |
| 60 | + | ) | |
| 61 | + | } | |
| 62 | + | Self::BadVersion { file } => { | |
| 63 | + | write!(f, "migration {file}: expected an integer version prefix") | |
| 64 | + | } | |
| 65 | + | Self::Duplicate { version } => write!(f, "two migrations claim version {version}"), | |
| 66 | + | Self::NoOutDir => write!(f, "OUT_DIR is unset; this belongs in a build script"), | |
| 67 | + | Self::Unwritable { dest, source } => { | |
| 68 | + | write!(f, "could not write {}: {source}", dest.display()) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl std::error::Error for EmbedError {} | |
| 75 | + | ||
| 76 | + | /// Embed every migration in `dir`, relative to the crate's manifest. | |
| 77 | + | /// | |
| 78 | + | /// Writes `$OUT_DIR/quasi_migrations.rs`, which | |
| 79 | + | /// [`quasi_store::migrations!`](crate::migrations) includes. Emits the | |
| 80 | + | /// `rerun-if-changed` lines that make an added or edited migration rebuild the | |
| 81 | + | /// crate. | |
| 82 | + | /// | |
| 83 | + | /// # Errors | |
| 84 | + | /// | |
| 85 | + | /// If the directory cannot be read, a filename has no integer version, two | |
| 86 | + | /// files claim one version, or the generated file cannot be written. | |
| 87 | + | pub fn from_dir(dir: impl AsRef<Path>) -> Result<(), EmbedError> { | |
| 88 | + | let manifest = | |
| 89 | + | std::env::var("CARGO_MANIFEST_DIR").map_or_else(|_| PathBuf::from("."), PathBuf::from); | |
| 90 | + | let dir = manifest.join(dir.as_ref()); | |
| 91 | + | let dir = dir.canonicalize().unwrap_or(dir); | |
| 92 | + | ||
| 93 | + | // The directory itself, so that adding a file is a rebuild and not only | |
| 94 | + | // editing one. | |
| 95 | + | println!("cargo:rerun-if-changed={}", dir.display()); | |
| 96 | + | ||
| 97 | + | let mut entries: Vec<(i64, String, PathBuf)> = Vec::new(); | |
| 98 | + | let listing = std::fs::read_dir(&dir).map_err(|source| EmbedError::Unreadable { | |
| 99 | + | dir: dir.clone(), | |
| 100 | + | source, | |
| 101 | + | })?; | |
| 102 | + | ||
| 103 | + | for entry in listing { | |
| 104 | + | let path = entry | |
| 105 | + | .map_err(|source| EmbedError::Unreadable { | |
| 106 | + | dir: dir.clone(), | |
| 107 | + | source, | |
| 108 | + | })? | |
| 109 | + | .path(); | |
| 110 | + | let Some(name) = path.file_name().and_then(|n| n.to_str()) else { | |
| 111 | + | continue; | |
| 112 | + | }; | |
| 113 | + | let Some((version, rest)) = name.split_once('_') else { | |
| 114 | + | continue; | |
| 115 | + | }; | |
| 116 | + | if !Path::new(rest) | |
| 117 | + | .extension() | |
| 118 | + | .is_some_and(|ext| ext.eq_ignore_ascii_case("sql")) | |
| 119 | + | { | |
| 120 | + | continue; | |
| 121 | + | } | |
| 122 | + | let version = version.parse::<i64>().map_err(|_| EmbedError::BadVersion { | |
| 123 | + | file: name.to_owned(), | |
| 124 | + | })?; | |
| 125 | + | ||
| 126 | + | println!("cargo:rerun-if-changed={}", path.display()); | |
| 127 | + | entries.push(( | |
| 128 | + | version, | |
| 129 | + | rest.trim_end_matches(".sql").replace('_', " "), | |
| 130 | + | path, | |
| 131 | + | )); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | entries.sort_by_key(|(version, _, _)| *version); | |
| 135 | + | if let Some(pair) = entries.windows(2).find(|w| w[0].0 == w[1].0) { | |
| 136 | + | return Err(EmbedError::Duplicate { version: pair[0].0 }); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | let mut out = String::from( | |
| 140 | + | "// @generated by quasi_store::embed::from_dir. Do not edit.\n\ | |
| 141 | + | static MIGRATIONS: &[::quasi_store::Migration] = &[\n", | |
| 142 | + | ); | |
| 143 | + | for (version, description, path) in &entries { | |
| 144 | + | // `include_str!` rather than the bytes inline: the file stays the | |
| 145 | + | // source of truth and a diff of this generated file stays readable. | |
| 146 | + | let _ = writeln!( | |
| 147 | + | out, | |
| 148 | + | " ::quasi_store::Migration {{ version: {version}, description: {description:?}, sql: include_str!({:?}) }},", | |
| 149 | + | path.display().to_string() | |
| 150 | + | ); | |
| 151 | + | } | |
| 152 | + | out.push_str("];\n"); | |
| 153 | + | ||
| 154 | + | let dest = Path::new(&std::env::var("OUT_DIR").map_err(|_| EmbedError::NoOutDir)?) | |
| 155 | + | .join("quasi_migrations.rs"); | |
| 156 | + | std::fs::write(&dest, out).map_err(|source| EmbedError::Unwritable { dest, source }) | |
| 157 | + | } |
| @@ -1,0 +1,90 @@ | |||
| 1 | + | //! The embedded store's migration runner. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! # Why this is a quasi crate | |
| 6 | + | //! | |
| 7 | + | //! The admission test in `CONTRIBUTING.md` asks which two implementations a | |
| 8 | + | //! boundary has. This one is not answering that question, because it is not a | |
| 9 | + | //! boundary over a vendor: it is the thing `sqlx::migrate!` used to be and | |
| 10 | + | //! nothing replaced. When goingson and Balanced Breakfast moved to rusqlite on | |
| 11 | + | //! 2026-08-07 they each hand-wrote a runner, and a third would have been | |
| 12 | + | //! written by the first app the scaffolder generated. Two copies existing is | |
| 13 | + | //! the evidence; the crate is what stops the third. | |
| 14 | + | //! | |
| 15 | + | //! It is a quasi crate rather than a makeover one on the audience rule settled | |
| 16 | + | //! 2026-08-08: a migration runner tied to our ledger conventions is something | |
| 17 | + | //! you take once you are on the stack, not something another developer picks up | |
| 18 | + | //! on its own. There are good standalone migration crates and this is not | |
| 19 | + | //! competing with them. | |
| 20 | + | //! | |
| 21 | + | //! # The two halves | |
| 22 | + | //! | |
| 23 | + | //! They share no dependency, and that is the point rather than tidiness. | |
| 24 | + | //! | |
| 25 | + | //! - [`runtime`](crate::migrate) reads the ledger and applies what is missing. | |
| 26 | + | //! An app links it. | |
| 27 | + | //! - [`build`](crate::embed) turns a directory of `.sql` files into a table | |
| 28 | + | //! compiled into the binary. An app's `build.rs` links it, with | |
| 29 | + | //! `default-features = false`, so a build script does not pull | |
| 30 | + | //! `libsqlite3-sys` into a second compile for the host. | |
| 31 | + | //! | |
| 32 | + | //! ```toml | |
| 33 | + | //! [dependencies] | |
| 34 | + | //! quasi-store = { path = "../../../quasi/crates/quasi-store" } | |
| 35 | + | //! | |
| 36 | + | //! [build-dependencies] | |
| 37 | + | //! quasi-store = { path = "../../../quasi/crates/quasi-store", default-features = false, features = ["build"] } | |
| 38 | + | //! ``` | |
| 39 | + | //! | |
| 40 | + | //! # Using it | |
| 41 | + | //! | |
| 42 | + | //! `build.rs`, once. Not compiled as a doctest: the two halves are separate | |
| 43 | + | //! features and a doctest gets one set of them, so an example naming both would | |
| 44 | + | //! have to turn on the driver this half exists to avoid. | |
| 45 | + | //! | |
| 46 | + | //! ```ignore | |
| 47 | + | //! fn main() { | |
| 48 | + | //! quasi_store::embed::from_dir("../../migrations").expect("migrations"); | |
| 49 | + | //! } | |
| 50 | + | //! ``` | |
| 51 | + | //! | |
| 52 | + | //! Then the crate, once: | |
| 53 | + | //! | |
| 54 | + | //! ```ignore | |
| 55 | + | //! quasi_store::migrations!(); | |
| 56 | + | //! | |
| 57 | + | //! let mut conn = rusqlite::Connection::open("app.db")?; | |
| 58 | + | //! quasi_store::Migrator::new(MIGRATIONS).run(&mut conn)?; | |
| 59 | + | //! ``` | |
| 60 | + | //! | |
| 61 | + | //! # What it does not do | |
| 62 | + | //! | |
| 63 | + | //! Connections, pools, queries, or anything resembling a query layer. quasi | |
| 64 | + | //! owns which driver is configured and how migrations run, and queries stay | |
| 65 | + | //! written against `rusqlite` directly — the same line the README draws around | |
| 66 | + | //! `sqlx`, for the same reason. There is no Postgres half here and there should | |
| 67 | + | //! not be one: `sqlx` ships its own migrator and it is one of the reasons to | |
| 68 | + | //! use `sqlx`. | |
| 69 | + | ||
| 70 | + | #[cfg(feature = "build")] | |
| 71 | + | pub mod embed; | |
| 72 | + | ||
| 73 | + | #[cfg(feature = "runtime")] | |
| 74 | + | pub mod migrate; | |
| 75 | + | ||
| 76 | + | #[cfg(feature = "runtime")] | |
| 77 | + | pub use crate::migrate::{MigrateError, Migration, Migrator}; | |
| 78 | + | ||
| 79 | + | /// Include the table [`embed::from_dir`] generated. | |
| 80 | + | /// | |
| 81 | + | /// A macro rather than a documented `include!` line, so the path the build | |
| 82 | + | /// script writes to is named in one place. Expands to a | |
| 83 | + | /// `static MIGRATIONS: &[Migration]`. | |
| 84 | + | #[cfg(feature = "runtime")] | |
| 85 | + | #[macro_export] | |
| 86 | + | macro_rules! migrations { | |
| 87 | + | () => { | |
| 88 | + | include!(concat!(env!("OUT_DIR"), "/quasi_migrations.rs")); | |
| 89 | + | }; | |
| 90 | + | } |
| @@ -1,0 +1,385 @@ | |||
| 1 | + | //! Applying what the ledger says is missing. | |
| 2 | + | //! | |
| 3 | + | //! Lifted from goingson's runner, which was written when that app left | |
| 4 | + | //! `sqlx-sqlite` and needed to keep reading a ledger `sqlx` had written. Two | |
| 5 | + | //! things carried over unchanged and neither is a free choice there: the table | |
| 6 | + | //! shape and the checksum function (sha384 over the raw file bytes, verified | |
| 7 | + | //! against `sqlx` 0.9). They are kept here so that an app with `sqlx` history | |
| 8 | + | //! can adopt this runner by naming its old ledger and nothing else — see | |
| 9 | + | //! [`Migrator::ledger`]. | |
| 10 | + | //! | |
| 11 | + | //! What did not carry over is the *default* name. A generated app has no | |
| 12 | + | //! installs in the field and no `sqlx` past, and calling its ledger | |
| 13 | + | //! `_sqlx_migrations` would be a new app inheriting a compatibility note about | |
| 14 | + | //! a library it never linked. | |
| 15 | + | ||
| 16 | + | use std::time::Instant; | |
| 17 | + | ||
| 18 | + | use rusqlite::{Connection, OptionalExtension}; | |
| 19 | + | use sha2::{Digest, Sha384}; | |
| 20 | + | ||
| 21 | + | /// One migration, as [`crate::embed::from_dir`] emits it. | |
| 22 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 23 | + | pub struct Migration { | |
| 24 | + | /// The integer prefix of the filename. Applied in this order. | |
| 25 | + | pub version: i64, | |
| 26 | + | /// The rest of the filename, underscores as spaces. | |
| 27 | + | pub description: &'static str, | |
| 28 | + | /// The file, verbatim. Hashed as-is. | |
| 29 | + | pub sql: &'static str, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// A migration that could not be applied. | |
| 33 | + | /// | |
| 34 | + | /// Every member needs a person. There is no variant meaning "retry": a run that | |
| 35 | + | /// stops has either found a database it does not recognise or a migration that | |
| 36 | + | /// does not apply, and running it again produces the same answer. | |
| 37 | + | #[derive(Debug, thiserror::Error)] | |
| 38 | + | pub enum MigrateError { | |
| 39 | + | #[error("database error running migrations: {0}")] | |
| 40 | + | Db(#[from] rusqlite::Error), | |
| 41 | + | ||
| 42 | + | #[error( | |
| 43 | + | "migration {version} ({description}) was already applied, but its file has changed since. \ | |
| 44 | + | Applied migrations are immutable — add a new migration instead of editing a shipped one." | |
| 45 | + | )] | |
| 46 | + | ChecksumMismatch { version: i64, description: String }, | |
| 47 | + | ||
| 48 | + | #[error("migration {0} is partially applied; fix it and remove its row from the ledger")] | |
| 49 | + | Dirty(i64), | |
| 50 | + | ||
| 51 | + | #[error("migration {version} ({description}) failed: {source}")] | |
| 52 | + | Apply { | |
| 53 | + | version: i64, | |
| 54 | + | description: String, | |
| 55 | + | #[source] | |
| 56 | + | source: rusqlite::Error, | |
| 57 | + | }, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// sha384 of a migration's bytes. | |
| 61 | + | /// | |
| 62 | + | /// `sqlx` 0.9's function, so a ledger it wrote verifies against this one. Not a | |
| 63 | + | /// free choice for an adopting app, and not worth changing for a new one. | |
| 64 | + | #[must_use] | |
| 65 | + | pub fn checksum(sql: &str) -> Vec<u8> { | |
| 66 | + | Sha384::digest(sql.as_bytes()).to_vec() | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// The ledger a fresh app writes. | |
| 70 | + | const DEFAULT_LEDGER: &str = "_quasi_migrations"; | |
| 71 | + | ||
| 72 | + | /// A set of migrations and the ledger recording which of them ran. | |
| 73 | + | pub struct Migrator { | |
| 74 | + | migrations: &'static [Migration], | |
| 75 | + | ledger: &'static str, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl Migrator { | |
| 79 | + | /// A migrator over this table, recording to the default ledger. | |
| 80 | + | #[must_use] | |
| 81 | + | pub fn new(migrations: &'static [Migration]) -> Self { | |
| 82 | + | Self { | |
| 83 | + | migrations, | |
| 84 | + | ledger: DEFAULT_LEDGER, | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Record to a ledger of this name instead. | |
| 89 | + | /// | |
| 90 | + | /// For an app that has `sqlx` history: `.ledger("_sqlx_migrations")` makes | |
| 91 | + | /// this runner read the rows already there, find nothing pending, and do | |
| 92 | + | /// nothing. Writing a fresh ledger beside an existing one would instead | |
| 93 | + | /// make an upgraded install believe it had applied nothing and re-run every | |
| 94 | + | /// migration against a populated database. | |
| 95 | + | /// | |
| 96 | + | /// # Panics | |
| 97 | + | /// | |
| 98 | + | /// If the name is not a bare identifier. It is interpolated into SQL, since | |
| 99 | + | /// a table name cannot be bound as a parameter, and a startup literal is the | |
| 100 | + | /// right place to be strict about that. | |
| 101 | + | #[must_use] | |
| 102 | + | pub fn ledger(mut self, name: &'static str) -> Self { | |
| 103 | + | assert!( | |
| 104 | + | !name.is_empty() | |
| 105 | + | && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') | |
| 106 | + | && !name.starts_with(|c: char| c.is_ascii_digit()), | |
| 107 | + | "ledger name `{name}` is not a bare identifier" | |
| 108 | + | ); | |
| 109 | + | self.ledger = name; | |
| 110 | + | self | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Create the ledger if absent. | |
| 114 | + | /// | |
| 115 | + | /// The DDL is `sqlx` 0.9's verbatim, which is what makes adopting an | |
| 116 | + | /// existing ledger a no-op rather than a conflicting `CREATE TABLE`. | |
| 117 | + | fn ensure_ledger(&self, conn: &Connection) -> Result<(), rusqlite::Error> { | |
| 118 | + | conn.execute_batch(&format!( | |
| 119 | + | "CREATE TABLE IF NOT EXISTS {} ( | |
| 120 | + | version BIGINT PRIMARY KEY, | |
| 121 | + | description TEXT NOT NULL, | |
| 122 | + | installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 123 | + | success BOOLEAN NOT NULL, | |
| 124 | + | checksum BLOB NOT NULL, | |
| 125 | + | execution_time BIGINT NOT NULL | |
| 126 | + | );", | |
| 127 | + | self.ledger | |
| 128 | + | )) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Apply every migration not yet in the ledger. | |
| 132 | + | /// | |
| 133 | + | /// Each migration and its ledger row commit together, so a crash mid-run | |
| 134 | + | /// leaves the database at a migration boundary rather than half-applied. | |
| 135 | + | /// | |
| 136 | + | /// # Errors | |
| 137 | + | /// | |
| 138 | + | /// If a migration fails, if an applied migration's file has changed since, | |
| 139 | + | /// or if the ledger carries a failed row from a previous run. | |
| 140 | + | #[tracing::instrument(skip_all)] | |
| 141 | + | pub fn run(&self, conn: &mut Connection) -> Result<(), MigrateError> { | |
| 142 | + | self.ensure_ledger(conn)?; | |
| 143 | + | ||
| 144 | + | // A `success = false` row means a previous run died between applying a | |
| 145 | + | // migration and committing its ledger row. This runner cannot write | |
| 146 | + | // one, since it commits both together, but an install upgraded from | |
| 147 | + | // `sqlx` may carry one and it still needs a person. | |
| 148 | + | let dirty: Option<i64> = conn | |
| 149 | + | .query_row( | |
| 150 | + | &format!( | |
| 151 | + | "SELECT version FROM {} WHERE success = false ORDER BY version LIMIT 1", | |
| 152 | + | self.ledger | |
| 153 | + | ), | |
| 154 | + | [], | |
| 155 | + | |row| row.get(0), | |
| 156 | + | ) | |
| 157 | + | .optional()?; | |
| 158 | + | if let Some(version) = dirty { | |
| 159 | + | return Err(MigrateError::Dirty(version)); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | let applied: std::collections::BTreeMap<i64, Vec<u8>> = { | |
| 163 | + | let mut stmt = conn.prepare(&format!( | |
| 164 | + | "SELECT version, checksum FROM {} ORDER BY version", | |
| 165 | + | self.ledger | |
| 166 | + | ))?; | |
| 167 | + | let rows = stmt.query_map([], |row| { | |
| 168 | + | Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)) | |
| 169 | + | })?; | |
| 170 | + | rows.collect::<Result<_, _>>()? | |
| 171 | + | }; | |
| 172 | + | ||
| 173 | + | for migration in self.migrations { | |
| 174 | + | let digest = checksum(migration.sql); | |
| 175 | + | ||
| 176 | + | if let Some(recorded) = applied.get(&migration.version) { | |
| 177 | + | // Already applied. The only question left is whether the file | |
| 178 | + | // still hashes to what was recorded; if not, a shipped | |
| 179 | + | // migration was edited and every install that ran it now | |
| 180 | + | // disagrees with this one. | |
| 181 | + | if *recorded != digest { | |
| 182 | + | return Err(MigrateError::ChecksumMismatch { | |
| 183 | + | version: migration.version, | |
| 184 | + | description: migration.description.to_owned(), | |
| 185 | + | }); | |
| 186 | + | } | |
| 187 | + | continue; | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | tracing::info!( | |
| 191 | + | version = migration.version, | |
| 192 | + | description = migration.description, | |
| 193 | + | "applying migration" | |
| 194 | + | ); | |
| 195 | + | let started = Instant::now(); | |
| 196 | + | let tx = conn.transaction()?; | |
| 197 | + | tx.execute_batch(migration.sql) | |
| 198 | + | .map_err(|source| MigrateError::Apply { | |
| 199 | + | version: migration.version, | |
| 200 | + | description: migration.description.to_owned(), | |
| 201 | + | source, | |
| 202 | + | })?; | |
| 203 | + | tx.execute( | |
| 204 | + | &format!( | |
| 205 | + | "INSERT INTO {} (version, description, success, checksum, execution_time) | |
| 206 | + | VALUES (?1, ?2, TRUE, ?3, ?4)", | |
| 207 | + | self.ledger | |
| 208 | + | ), | |
| 209 | + | rusqlite::params![ | |
| 210 | + | migration.version, | |
| 211 | + | migration.description, | |
| 212 | + | digest, | |
| 213 | + | i64::try_from(started.elapsed().as_nanos()).unwrap_or(i64::MAX), | |
| 214 | + | ], | |
| 215 | + | )?; | |
| 216 | + | tx.commit()?; | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | Ok(()) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[cfg(test)] | |
| 224 | + | mod tests { | |
| 225 | + | use super::*; | |
| 226 | + | ||
| 227 | + | const FIRST: Migration = Migration { | |
| 228 | + | version: 1, | |
| 229 | + | description: "initial schema", | |
| 230 | + | sql: "CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT NOT NULL);", | |
| 231 | + | }; | |
| 232 | + | ||
| 233 | + | const SECOND: Migration = Migration { | |
| 234 | + | version: 2, | |
| 235 | + | description: "archived flag", | |
| 236 | + | sql: "ALTER TABLE note ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;", | |
| 237 | + | }; | |
| 238 | + | ||
| 239 | + | static ONE: &[Migration] = &[FIRST]; | |
| 240 | + | static BOTH: &[Migration] = &[FIRST, SECOND]; | |
| 241 | + | ||
| 242 | + | fn columns(conn: &Connection) -> Vec<String> { | |
| 243 | + | let mut stmt = conn | |
| 244 | + | .prepare("SELECT name FROM pragma_table_info('note')") | |
| 245 | + | .unwrap(); | |
| 246 | + | let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); | |
| 247 | + | rows.collect::<Result<_, _>>().unwrap() | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | fn applied(conn: &Connection, ledger: &str) -> Vec<i64> { | |
| 251 | + | let mut stmt = conn | |
| 252 | + | .prepare(&format!("SELECT version FROM {ledger} ORDER BY version")) | |
| 253 | + | .unwrap(); | |
| 254 | + | let rows = stmt.query_map([], |row| row.get::<_, i64>(0)).unwrap(); | |
| 255 | + | rows.collect::<Result<_, _>>().unwrap() | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn an_empty_database_gets_every_migration() { | |
| 260 | + | let mut conn = Connection::open_in_memory().unwrap(); | |
| 261 | + | Migrator::new(BOTH).run(&mut conn).unwrap(); | |
| 262 | + | assert_eq!(columns(&conn), ["id", "body", "archived"]); | |
| 263 | + | assert_eq!(applied(&conn, DEFAULT_LEDGER), [1, 2]); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn a_second_run_applies_nothing() { | |
| 268 | + | let mut conn = Connection::open_in_memory().unwrap(); | |
| 269 | + | Migrator::new(BOTH).run(&mut conn).unwrap(); | |
| 270 | + | // The second run is the one that would fail loudly if it re-applied: | |
| 271 | + | // `CREATE TABLE` on a table that exists. | |
| 272 | + | Migrator::new(BOTH).run(&mut conn).unwrap(); | |
| 273 | + | assert_eq!(applied(&conn, DEFAULT_LEDGER), [1, 2]); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn a_new_migration_applies_over_an_existing_database() { | |
| 278 | + | let mut conn = Connection::open_in_memory().unwrap(); | |
| 279 | + | Migrator::new(ONE).run(&mut conn).unwrap(); | |
| 280 | + | assert_eq!(columns(&conn), ["id", "body"]); | |
| 281 | + | ||
| 282 | + | Migrator::new(BOTH).run(&mut conn).unwrap(); | |
| 283 | + | assert_eq!(columns(&conn), ["id", "body", "archived"]); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn editing_a_shipped_migration_is_refused() { | |
| 288 | + | let mut conn = Connection::open_in_memory().unwrap(); | |
| 289 | + | Migrator::new(ONE).run(&mut conn).unwrap(); | |
| 290 | + | ||
| 291 | + | // Same version and description, one character of SQL different. | |
| 292 | + | static EDITED: &[Migration] = &[Migration { | |
| 293 | + | version: 1, | |
| 294 | + | description: "initial schema", | |
| 295 | + | sql: "CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT);", | |
| 296 | + | }]; | |
| 297 | + | let refused = Migrator::new(EDITED).run(&mut conn).unwrap_err(); | |
| 298 | + | assert!(matches!( | |
| 299 | + | refused, | |
| 300 | + | MigrateError::ChecksumMismatch { version: 1, .. } | |
| 301 | + | )); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | #[test] | |
| 305 | + | fn a_failed_row_from_a_previous_run_stops_everything() { | |
| 306 | + | let mut conn = Connection::open_in_memory().unwrap(); | |
| 307 | + | Migrator::new(ONE).run(&mut conn).unwrap(); | |
| 308 | + | conn.execute( | |
| 309 | + | &format!( | |
| 310 | + | "INSERT INTO {DEFAULT_LEDGER} (version, description, success, checksum, execution_time) | |
| 311 | + | VALUES (9, 'half done', FALSE, X'00', 0)" | |
| 312 | + | ), | |
| 313 | + | [], | |
| 314 | + | ) | |
| 315 | + | .unwrap(); | |
| 316 | + | ||
| 317 | + | let stopped = Migrator::new(BOTH).run(&mut conn).unwrap_err(); | |
| 318 | + | assert!(matches!(stopped, MigrateError::Dirty(9))); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[test] | |
| 322 | + | fn a_broken_migration_names_itself() { | |
| 323 | + | let mut conn = Connection::open_in_memory().unwrap(); | |
| 324 | + | static BROKEN: &[Migration] = &[Migration { | |
| 325 | + | version: 1, | |
| 326 | + | description: "not sql", | |
| 327 | + | sql: "CREATE TABL note (id INTEGER);", | |
| 328 | + | }]; | |
| 329 | + | ||
| 330 | + | let failed = Migrator::new(BROKEN).run(&mut conn).unwrap_err(); | |
| 331 | + | let MigrateError::Apply { | |
| 332 | + | version, | |
| 333 | + | description, | |
| 334 | + | .. | |
| 335 | + | } = failed | |
| 336 | + | else { | |
| 337 | + | panic!("expected an apply failure"); | |
| 338 | + | }; | |
| 339 | + | assert_eq!(version, 1); | |
| 340 | + | assert_eq!(description, "not sql"); | |
| 341 | + | ||
| 342 | + | // And nothing was recorded, so fixing the file and re-running works. | |
| 343 | + | assert_eq!(applied(&conn, DEFAULT_LEDGER), Vec::<i64>::new()); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn an_sqlx_ledger_is_adopted_rather_than_duplicated() { | |
| 348 | + | let mut conn = Connection::open_in_memory().unwrap(); | |
| 349 | + | // What an install upgraded from sqlx looks like: the schema is there | |
| 350 | + | // and so are the rows, written by a library this app no longer links. | |
| 351 | + | conn.execute_batch(FIRST.sql).unwrap(); | |
| 352 | + | conn.execute_batch( | |
| 353 | + | "CREATE TABLE _sqlx_migrations ( | |
| 354 | + | version BIGINT PRIMARY KEY, | |
| 355 | + | description TEXT NOT NULL, | |
| 356 | + | installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 357 | + | success BOOLEAN NOT NULL, | |
| 358 | + | checksum BLOB NOT NULL, | |
| 359 | + | execution_time BIGINT NOT NULL | |
| 360 | + | );", | |
| 361 | + | ) | |
| 362 | + | .unwrap(); | |
| 363 | + | conn.execute( | |
| 364 | + | "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) | |
| 365 | + | VALUES (1, 'initial schema', TRUE, ?1, 0)", | |
| 366 | + | rusqlite::params![checksum(FIRST.sql)], | |
| 367 | + | ) | |
| 368 | + | .unwrap(); | |
| 369 | + | ||
| 370 | + | Migrator::new(BOTH) | |
| 371 | + | .ledger("_sqlx_migrations") | |
| 372 | + | .run(&mut conn) | |
| 373 | + | .unwrap(); | |
| 374 | + | ||
| 375 | + | // Migration 1 was recognised as done and only 2 ran. | |
| 376 | + | assert_eq!(applied(&conn, "_sqlx_migrations"), [1, 2]); | |
| 377 | + | assert_eq!(columns(&conn), ["id", "body", "archived"]); | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | #[test] | |
| 381 | + | #[should_panic(expected = "not a bare identifier")] | |
| 382 | + | fn a_ledger_name_that_is_not_an_identifier_is_a_bug() { | |
| 383 | + | let _ = Migrator::new(ONE).ledger("ledger; DROP TABLE note"); | |
| 384 | + | } | |
| 385 | + | } |
| @@ -1,0 +1,224 @@ | |||
| 1 | + | //! The name, and what substituting it into the template means. | |
| 2 | + | //! | |
| 3 | + | //! Four placeholders and a literal copy for everything else. That is the whole | |
| 4 | + | //! templating language, and keeping it that small is deliberate: a template | |
| 5 | + | //! with conditionals is a program, and a program that generates programs wants | |
| 6 | + | //! tests of its own before it has earned them. | |
| 7 | + | ||
| 8 | + | /// The placeholders, and what a name expands them to. | |
| 9 | + | /// | |
| 10 | + | /// | placeholder | `field-notes` becomes | | |
| 11 | + | /// |---|---| | |
| 12 | + | /// | `{{name}}` | `field-notes` — package names, paths, the database file | | |
| 13 | + | /// | `{{snake}}` | `field_notes` — Rust paths, the URL scheme | | |
| 14 | + | /// | `{{title}}` | `Field Notes` — window title, screen title, prose | | |
| 15 | + | /// | `{{SCREAM}}` | `FIELD_NOTES` — environment variables | | |
| 16 | + | /// | |
| 17 | + | /// A fifth, `{{year}}`, is the current year and is there for the licence file. | |
| 18 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 19 | + | pub struct Name { | |
| 20 | + | /// The name as given. Kebab-case. | |
| 21 | + | pub kebab: String, | |
| 22 | + | /// The same, as a Rust identifier. | |
| 23 | + | pub snake: String, | |
| 24 | + | /// The same, for a human. | |
| 25 | + | pub title: String, | |
| 26 | + | /// The same, for an environment variable. | |
| 27 | + | pub scream: String, | |
| 28 | + | /// The current year, for the licence file. | |
| 29 | + | pub year: String, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// The current year, from the system clock. | |
| 33 | + | /// | |
| 34 | + | /// The civil-date arithmetic rather than a `chrono` dependency: the whole | |
| 35 | + | /// binary has none, and one date is not worth the first. | |
| 36 | + | fn current_year() -> u32 { | |
| 37 | + | let secs = std::time::SystemTime::now() | |
| 38 | + | .duration_since(std::time::UNIX_EPOCH) | |
| 39 | + | .map_or(0, |d| d.as_secs()); | |
| 40 | + | // Howard Hinnant's civil_from_days, year only. Shifts the era to start in | |
| 41 | + | // March so that the leap day is the last of the year and the month lengths | |
| 42 | + | // repeat, which is what makes the whole thing arithmetic. | |
| 43 | + | let days = (secs / 86_400) as i64 + 719_468; | |
| 44 | + | let era = days.div_euclid(146_097); | |
| 45 | + | let doe = days.rem_euclid(146_097); | |
| 46 | + | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; | |
| 47 | + | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | |
| 48 | + | let year = yoe + era * 400; | |
| 49 | + | // January and February belong to the previous ordinary year. | |
| 50 | + | let mp = (5 * doy + 2) / 153; | |
| 51 | + | (year + i64::from(mp >= 10)) as u32 | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Why a name was refused. | |
| 55 | + | #[derive(Debug, PartialEq, Eq)] | |
| 56 | + | pub enum NameError { | |
| 57 | + | /// Nothing was given. | |
| 58 | + | Empty, | |
| 59 | + | /// A character that cannot appear in a crate name. | |
| 60 | + | Illegal(char), | |
| 61 | + | /// A leading digit, which a Rust identifier cannot have. | |
| 62 | + | LeadingDigit, | |
| 63 | + | /// A Rust keyword, which a crate name cannot be. | |
| 64 | + | Keyword, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | impl std::fmt::Display for NameError { | |
| 68 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 69 | + | match self { | |
| 70 | + | Self::Empty => write!(f, "a name is required"), | |
| 71 | + | Self::Illegal(c) => write!( | |
| 72 | + | f, | |
| 73 | + | "`{c}` cannot appear in a crate name: letters, digits, `-` and `_` only" | |
| 74 | + | ), | |
| 75 | + | Self::LeadingDigit => write!(f, "a name cannot start with a digit"), | |
| 76 | + | Self::Keyword => write!(f, "that is a Rust keyword"), | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Keywords a crate name cannot be, because a module path would name one. | |
| 82 | + | /// | |
| 83 | + | /// The strict set only. A name colliding with a weak keyword compiles, and | |
| 84 | + | /// refusing more than the compiler does would be this tool having opinions | |
| 85 | + | /// about names it has no stake in. | |
| 86 | + | const KEYWORDS: &[&str] = &[ | |
| 87 | + | "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", "fn", | |
| 88 | + | "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", | |
| 89 | + | "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", | |
| 90 | + | "where", "while", "async", "await", | |
| 91 | + | ]; | |
| 92 | + | ||
| 93 | + | impl Name { | |
| 94 | + | /// Read a name off the command line. | |
| 95 | + | /// | |
| 96 | + | /// # Errors | |
| 97 | + | /// | |
| 98 | + | /// If it is empty, starts with a digit, holds a character a crate name | |
| 99 | + | /// cannot, or is a keyword. Checked here rather than left to cargo, because | |
| 100 | + | /// the failure otherwise arrives after a directory has been written. | |
| 101 | + | pub fn parse(given: &str) -> Result<Self, NameError> { | |
| 102 | + | let given = given.trim(); | |
| 103 | + | if given.is_empty() { | |
| 104 | + | return Err(NameError::Empty); | |
| 105 | + | } | |
| 106 | + | if given.starts_with(|c: char| c.is_ascii_digit()) { | |
| 107 | + | return Err(NameError::LeadingDigit); | |
| 108 | + | } | |
| 109 | + | if let Some(bad) = given | |
| 110 | + | .chars() | |
| 111 | + | .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_')) | |
| 112 | + | { | |
| 113 | + | return Err(NameError::Illegal(bad)); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | let kebab = given.to_ascii_lowercase().replace('_', "-"); | |
| 117 | + | let snake = kebab.replace('-', "_"); | |
| 118 | + | if KEYWORDS.contains(&snake.as_str()) { | |
| 119 | + | return Err(NameError::Keyword); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | let title = kebab | |
| 123 | + | .split('-') | |
| 124 | + | .map(|word| { | |
| 125 | + | let mut chars = word.chars(); | |
| 126 | + | match chars.next() { | |
| 127 | + | Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), | |
| 128 | + | None => String::new(), | |
| 129 | + | } | |
| 130 | + | }) | |
| 131 | + | .collect::<Vec<_>>() | |
| 132 | + | .join(" "); | |
| 133 | + | ||
| 134 | + | Ok(Self { | |
| 135 | + | scream: snake.to_ascii_uppercase(), | |
| 136 | + | year: current_year().to_string(), | |
| 137 | + | kebab, | |
| 138 | + | snake, | |
| 139 | + | title, | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Substitute every placeholder in a template file or path. | |
| 144 | + | #[must_use] | |
| 145 | + | pub fn fill(&self, source: &str) -> String { | |
| 146 | + | source | |
| 147 | + | .replace("{{name}}", &self.kebab) | |
| 148 | + | .replace("{{snake}}", &self.snake) | |
| 149 | + | .replace("{{title}}", &self.title) | |
| 150 | + | .replace("{{SCREAM}}", &self.scream) | |
| 151 | + | .replace("{{year}}", &self.year) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[cfg(test)] | |
| 156 | + | mod tests { | |
| 157 | + | use super::*; | |
| 158 | + | ||
| 159 | + | fn name(given: &str) -> Name { | |
| 160 | + | Name::parse(given).expect("a legal name") | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[test] | |
| 164 | + | fn a_one_word_name_expands_four_ways() { | |
| 165 | + | let name = name("fieldnotes"); | |
| 166 | + | assert_eq!(name.kebab, "fieldnotes"); | |
| 167 | + | assert_eq!(name.snake, "fieldnotes"); | |
| 168 | + | assert_eq!(name.title, "Fieldnotes"); | |
| 169 | + | assert_eq!(name.scream, "FIELDNOTES"); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn a_hyphenated_name_becomes_a_sentence_and_an_identifier() { | |
| 174 | + | let name = name("field-notes"); | |
| 175 | + | assert_eq!(name.kebab, "field-notes"); | |
| 176 | + | assert_eq!(name.snake, "field_notes"); | |
| 177 | + | assert_eq!(name.title, "Field Notes"); | |
| 178 | + | assert_eq!(name.scream, "FIELD_NOTES"); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | #[test] | |
| 182 | + | fn underscores_and_capitals_are_normalised_rather_than_refused() { | |
| 183 | + | // `cargo new Field_Notes` warns and carries on; refusing outright would | |
| 184 | + | // be stricter than the tool this stands in for. | |
| 185 | + | let name = name("Field_Notes"); | |
| 186 | + | assert_eq!(name.kebab, "field-notes"); | |
| 187 | + | assert_eq!(name.snake, "field_notes"); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[test] | |
| 191 | + | fn a_name_that_cannot_be_a_crate_is_refused_before_anything_is_written() { | |
| 192 | + | assert_eq!(Name::parse(""), Err(NameError::Empty)); | |
| 193 | + | assert_eq!(Name::parse(" "), Err(NameError::Empty)); | |
| 194 | + | assert_eq!(Name::parse("2fast"), Err(NameError::LeadingDigit)); | |
| 195 | + | assert_eq!(Name::parse("field notes"), Err(NameError::Illegal(' '))); | |
| 196 | + | assert_eq!(Name::parse("../escape"), Err(NameError::Illegal('.'))); | |
| 197 | + | assert_eq!(Name::parse("move"), Err(NameError::Keyword)); | |
| 198 | + | assert_eq!(Name::parse("Async"), Err(NameError::Keyword)); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn every_placeholder_is_substituted() { | |
| 203 | + | let filled = name("field-notes") | |
| 204 | + | .fill("package = \"{{name}}-core\"\nuse {{snake}}_core;\n// {{title}}\n{{SCREAM}}_DB"); | |
| 205 | + | assert_eq!( | |
| 206 | + | filled, | |
| 207 | + | "package = \"field-notes-core\"\nuse field_notes_core;\n// Field Notes\nFIELD_NOTES_DB" | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[test] | |
| 212 | + | fn the_year_is_a_year() { | |
| 213 | + | let year: u32 = name("x").fill("{{year}}").parse().expect("four digits"); | |
| 214 | + | // Wide enough not to be a clock test, narrow enough to catch the | |
| 215 | + | // arithmetic being wrong by an era. | |
| 216 | + | assert!((2020..2200).contains(&year), "{year}"); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn a_file_with_no_placeholders_is_copied_verbatim() { | |
| 221 | + | let source = "fn main() {}\n"; | |
| 222 | + | assert_eq!(name("x").fill(source), source); | |
| 223 | + | } | |
| 224 | + | } |
| @@ -1,0 +1,250 @@ | |||
| 1 | + | //! The app being generated, as files. | |
| 2 | + | //! | |
| 3 | + | //! Every entry is a real file under `template/`, embedded with `include_str!` | |
| 4 | + | //! or `include_bytes!`. Real files rather than liquid, so that the template is | |
| 5 | + | //! readable, greppable and diffable, and so that a change to it shows up in a | |
| 6 | + | //! review as the code it is. The workspace excludes the directory, since those | |
| 7 | + | //! manifests name a crate that does not exist until a name is filled in. | |
| 8 | + | //! | |
| 9 | + | //! Adding a file to the template means adding a line here. That is deliberate: | |
| 10 | + | //! walking the directory at build time would make a file that is present but | |
| 11 | + | //! unlisted silently ship, and a missing `include_str!` is a compile error. | |
| 12 | + | ||
| 13 | + | use crate::render::Name; | |
| 14 | + | ||
| 15 | + | /// One file of the generated app. | |
| 16 | + | pub struct Entry { | |
| 17 | + | /// Where it lands, relative to the app's root. Placeholders are filled. | |
| 18 | + | pub path: &'static str, | |
| 19 | + | /// What goes in it. | |
| 20 | + | pub body: Body, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// A file's contents. | |
| 24 | + | pub enum Body { | |
| 25 | + | /// Text, with placeholders substituted. | |
| 26 | + | Text(&'static str), | |
| 27 | + | /// Bytes, copied verbatim. Third-party assets, which nothing should rewrite. | |
| 28 | + | Bytes(&'static [u8]), | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Every file, in the order they are written. | |
| 32 | + | pub static FILES: &[Entry] = &[ | |
| 33 | + | Entry { | |
| 34 | + | path: "Cargo.toml", | |
| 35 | + | body: Body::Text(include_str!("../template/Cargo.toml")), | |
| 36 | + | }, | |
| 37 | + | Entry { | |
| 38 | + | path: "README.md", | |
| 39 | + | body: Body::Text(include_str!("../template/README.md")), | |
| 40 | + | }, | |
| 41 | + | Entry { | |
| 42 | + | path: "LICENSE", | |
| 43 | + | body: Body::Text(include_str!("../template/LICENSE")), | |
| 44 | + | }, | |
| 45 | + | Entry { | |
| 46 | + | path: "bento.toml", | |
| 47 | + | body: Body::Text(include_str!("../template/bento.toml")), | |
| 48 | + | }, | |
| 49 | + | Entry { | |
| 50 | + | path: "deny.toml", | |
| 51 | + | body: Body::Text(include_str!("../template/deny.toml")), | |
| 52 | + | }, | |
| 53 | + | Entry { | |
| 54 | + | path: "rust-toolchain.toml", | |
| 55 | + | body: Body::Text(include_str!("../template/rust-toolchain.toml")), | |
| 56 | + | }, | |
| 57 | + | // Stored without its dot so that it governs the generated app rather than | |
| 58 | + | // this repo's own template directory. | |
| 59 | + | Entry { | |
| 60 | + | path: ".gitignore", | |
| 61 | + | body: Body::Text(include_str!("../template/gitignore")), | |
| 62 | + | }, | |
| 63 | + | Entry { | |
| 64 | + | path: "migrations/001_initial_schema.sql", | |
| 65 | + | body: Body::Text(include_str!( | |
| 66 | + | "../template/migrations/001_initial_schema.sql" | |
| 67 | + | )), | |
| 68 | + | }, | |
| 69 | + | Entry { | |
| 70 | + | path: "static/styles.css", | |
| 71 | + | body: Body::Text(include_str!("../template/static/styles.css")), | |
| 72 | + | }, | |
| 73 | + | // Vendored rather than fetched: a desktop app is offline, and an app that | |
| 74 | + | // asked a CDN for its transport would be an app that stops working in a | |
| 75 | + | // tunnel. idiomorph is not optional — the shell emits `hx-swap="morph"` | |
| 76 | + | // only when it is loaded, and htmx falls back to `innerHTML` silently when | |
| 77 | + | // an extension is missing, which is the destructive behaviour. | |
| 78 | + | Entry { | |
| 79 | + | path: "static/htmx.min.js", | |
| 80 | + | body: Body::Bytes(include_bytes!("../template/static/htmx.min.js")), | |
| 81 | + | }, | |
| 82 | + | Entry { | |
| 83 | + | path: "static/idiomorph-ext.min.js", | |
| 84 | + | body: Body::Bytes(include_bytes!("../template/static/idiomorph-ext.min.js")), | |
| 85 | + | }, | |
| 86 | + | Entry { | |
| 87 | + | path: "crates/{{name}}-core/Cargo.toml", | |
| 88 | + | body: Body::Text(include_str!("../template/crates/core/Cargo.toml")), | |
| 89 | + | }, | |
| 90 | + | Entry { | |
| 91 | + | path: "crates/{{name}}-core/build.rs", | |
| 92 | + | body: Body::Text(include_str!("../template/crates/core/build.rs")), | |
| 93 | + | }, | |
| 94 | + | Entry { | |
| 95 | + | path: "crates/{{name}}-core/src/lib.rs", | |
| 96 | + | body: Body::Text(include_str!("../template/crates/core/src/lib.rs")), | |
| 97 | + | }, | |
| 98 | + | Entry { | |
| 99 | + | path: "crates/{{name}}-core/src/assets.rs", | |
| 100 | + | body: Body::Text(include_str!("../template/crates/core/src/assets.rs")), | |
| 101 | + | }, | |
| 102 | + | Entry { | |
| 103 | + | path: "crates/{{name}}-core/src/state.rs", | |
| 104 | + | body: Body::Text(include_str!("../template/crates/core/src/state.rs")), | |
| 105 | + | }, | |
| 106 | + | Entry { | |
| 107 | + | path: "crates/{{name}}-core/src/store.rs", | |
| 108 | + | body: Body::Text(include_str!("../template/crates/core/src/store.rs")), | |
| 109 | + | }, | |
| 110 | + | Entry { | |
| 111 | + | path: "crates/{{name}}-core/src/notes.rs", | |
| 112 | + | body: Body::Text(include_str!("../template/crates/core/src/notes.rs")), | |
| 113 | + | }, | |
| 114 | + | Entry { | |
| 115 | + | path: "crates/{{name}}-core/src/notes/tests.rs", | |
| 116 | + | body: Body::Text(include_str!("../template/crates/core/src/notes/tests.rs")), | |
| 117 | + | }, | |
| 118 | + | Entry { | |
| 119 | + | path: "crates/{{name}}-desktop/Cargo.toml", | |
| 120 | + | body: Body::Text(include_str!("../template/crates/desktop/Cargo.toml")), | |
| 121 | + | }, | |
| 122 | + | Entry { | |
| 123 | + | path: "crates/{{name}}-desktop/build.rs", | |
| 124 | + | body: Body::Text(include_str!("../template/crates/desktop/build.rs")), | |
| 125 | + | }, | |
| 126 | + | Entry { | |
| 127 | + | path: "crates/{{name}}-desktop/tauri.conf.json", | |
| 128 | + | body: Body::Text(include_str!("../template/crates/desktop/tauri.conf.json")), | |
| 129 | + | }, | |
| 130 | + | // A placeholder, and required rather than decorative: `generate_context!` | |
| 131 | + | // reads it at compile time and a missing file is a build failure with a | |
| 132 | + | // proc-macro panic for a message. | |
| 133 | + | Entry { | |
| 134 | + | path: "crates/{{name}}-desktop/icons/icon.png", | |
| 135 | + | body: Body::Bytes(include_bytes!("../template/crates/desktop/icons/icon.png")), | |
| 136 | + | }, | |
| 137 | + | Entry { | |
| 138 | + | path: "crates/{{name}}-desktop/src/main.rs", | |
| 139 | + | body: Body::Text(include_str!("../template/crates/desktop/src/main.rs")), | |
| 140 | + | }, | |
| 141 | + | Entry { | |
| 142 | + | path: "crates/{{name}}-server/Cargo.toml", | |
| 143 | + | body: Body::Text(include_str!("../template/crates/server/Cargo.toml")), | |
| 144 | + | }, | |
| 145 | + | Entry { | |
| 146 | + | path: "crates/{{name}}-server/src/main.rs", | |
| 147 | + | body: Body::Text(include_str!("../template/crates/server/src/main.rs")), | |
| 148 | + | }, | |
| 149 | + | ]; | |
| 150 | + | ||
| 151 | + | impl Entry { | |
| 152 | + | /// Where this file lands, for this name. | |
| 153 | + | pub fn path_for(&self, name: &Name) -> String { | |
| 154 | + | name.fill(self.path) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[cfg(test)] | |
| 159 | + | mod tests { | |
| 160 | + | use super::*; | |
| 161 | + | ||
| 162 | + | #[test] | |
| 163 | + | fn no_file_is_listed_twice() { | |
| 164 | + | let mut paths: Vec<_> = FILES.iter().map(|entry| entry.path).collect(); | |
| 165 | + | paths.sort_unstable(); | |
| 166 | + | let before = paths.len(); | |
| 167 | + | paths.dedup(); | |
| 168 | + | assert_eq!(paths.len(), before, "a template file is listed twice"); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | #[test] | |
| 172 | + | fn no_path_escapes_the_generated_directory() { | |
| 173 | + | for entry in FILES { | |
| 174 | + | assert!(!entry.path.starts_with('/'), "{}", entry.path); | |
| 175 | + | assert!(!entry.path.contains(".."), "{}", entry.path); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | #[test] | |
| 180 | + | fn no_placeholder_survives_rendering() { | |
| 181 | + | let name = Name::parse("field-notes").unwrap(); | |
| 182 | + | for entry in FILES { | |
| 183 | + | let path = entry.path_for(&name); | |
| 184 | + | assert!(!path.contains("{{"), "unfilled placeholder in {path}"); | |
| 185 | + | if let Body::Text(source) = entry.body { | |
| 186 | + | let filled = name.fill(source); | |
| 187 | + | // `{{` appearing at all means either a placeholder this | |
| 188 | + | // renderer does not know or a typo in one it does. Neither | |
| 189 | + | // should reach a generated app. | |
| 190 | + | assert!( | |
| 191 | + | !filled.contains("{{"), | |
| 192 | + | "unfilled placeholder in {path}: {:?}", | |
| 193 | + | filled | |
| 194 | + | .split("{{") | |
| 195 | + | .nth(1) | |
| 196 | + | .map(|rest| rest.split("}}").next().unwrap_or(rest)) | |
| 197 | + | ); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[test] | |
| 203 | + | fn nothing_in_the_core_crate_names_a_host() { | |
| 204 | + | // The rule the workspace is shaped to hold, asserted against the | |
| 205 | + | // template itself: `tauri`, `axum` and `wry` appear in the two binaries | |
| 206 | + | // and in prose, and never in the core crate's manifest. | |
| 207 | + | let manifest = FILES | |
| 208 | + | .iter() | |
| 209 | + | .find(|entry| entry.path == "crates/{{name}}-core/Cargo.toml") | |
| 210 | + | .expect("the core manifest"); | |
| 211 | + | let Body::Text(source) = manifest.body else { | |
| 212 | + | panic!("the manifest is text"); | |
| 213 | + | }; | |
| 214 | + | // Comments stripped first: the manifest says in prose which crates it | |
| 215 | + | // is refusing, and a test that could not tell a comment from a | |
| 216 | + | // dependency would forbid saying so. | |
| 217 | + | let declarations: String = source | |
| 218 | + | .lines() | |
| 219 | + | .filter(|line| !line.trim_start().starts_with('#')) | |
| 220 | + | .collect::<Vec<_>>() | |
| 221 | + | .join("\n"); | |
| 222 | + | for host in ["tauri", "axum", "wry"] { | |
| 223 | + | assert!( | |
| 224 | + | !declarations.contains(host), | |
| 225 | + | "the core manifest depends on {host}" | |
| 226 | + | ); | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[test] | |
| 231 | + | fn every_crate_the_workspace_declares_has_a_manifest() { | |
| 232 | + | let workspace = FILES | |
| 233 | + | .iter() | |
| 234 | + | .find(|entry| entry.path == "Cargo.toml") | |
| 235 | + | .expect("the workspace manifest"); | |
| 236 | + | let Body::Text(source) = workspace.body else { | |
| 237 | + | panic!("the manifest is text"); | |
| 238 | + | }; | |
| 239 | + | for member in ["core", "desktop", "server"] { | |
| 240 | + | let declared = format!("crates/{{{{name}}}}-{member}"); | |
| 241 | + | assert!(source.contains(&declared), "{member} is not a member"); | |
| 242 | + | assert!( | |
| 243 | + | FILES | |
| 244 | + | .iter() | |
| 245 | + | .any(|entry| entry.path == format!("{declared}/Cargo.toml")), | |
| 246 | + | "{member} has no manifest" | |
| 247 | + | ); | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | } |
| @@ -1,0 +1,75 @@ | |||
| 1 | + | [workspace] | |
| 2 | + | resolver = "3" | |
| 3 | + | members = [ | |
| 4 | + | "crates/{{name}}-core", | |
| 5 | + | "crates/{{name}}-desktop", | |
| 6 | + | "crates/{{name}}-server", | |
| 7 | + | ] | |
| 8 | + | ||
| 9 | + | [workspace.package] | |
| 10 | + | version = "0.1.0" | |
| 11 | + | edition = "2024" | |
| 12 | + | rust-version = "1.88" | |
| 13 | + | authors = ["Max Johnson <me@maxj.phd>"] | |
| 14 | + | # Declared here and inherited by every member. `license-file` is never inherited | |
| 15 | + | # by workspace members, which is how 26 crates across the tree sat reading as | |
| 16 | + | # unlicensed until a `cargo deny` sweep found them: a `[workspace.package] | |
| 17 | + | # license-file` covers nothing. | |
| 18 | + | # | |
| 19 | + | # MIT is the default because the licensing question is whether the software | |
| 20 | + | # itself is what a unit charges for. If this app is a product — something sold | |
| 21 | + | # rather than something linked — the answer is | |
| 22 | + | # `LicenseRef-PolyForm-Noncommercial-1.0.0` and the `LICENSE` file changes with | |
| 23 | + | # it. Never GPL, and never a GPL dependency. | |
| 24 | + | license = "MIT" | |
| 25 | + | ||
| 26 | + | [workspace.dependencies] | |
| 27 | + | # The stack. Redirected to the working copies by ~/Code/.cargo/config.toml on a | |
| 28 | + | # machine that has the tree; a clone elsewhere fetches them. | |
| 29 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git" } | |
| 30 | + | quasi-http = { git = "https://makenot.work/git/max/quasi.git" } | |
| 31 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git" } | |
| 32 | + | # Default features off here, and each side asks for the half it wants. A | |
| 33 | + | # workspace dependency's `default-features` cannot be overridden by a member — | |
| 34 | + | # cargo refuses it outright — so the crate that is on both sides of the | |
| 35 | + | # build-dependency line has to declare the smaller set centrally. | |
| 36 | + | quasi-store = { git = "https://makenot.work/git/max/quasi.git", default-features = false } | |
| 37 | + | ||
| 38 | + | rusqlite = { version = "0.40.0", features = ["bundled"] } | |
| 39 | + | thiserror = "2.0.17" | |
| 40 | + | tracing = "0.1.41" | |
| 41 | + | tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } | |
| 42 | + | ||
| 43 | + | [workspace.lints.rust] | |
| 44 | + | unsafe_code = "forbid" | |
| 45 | + | ||
| 46 | + | [workspace.lints.clippy] | |
| 47 | + | pedantic = { level = "warn", priority = -1 } | |
| 48 | + | # The house allow-list, kept identical across repos. These are the high-churn, | |
| 49 | + | # low-signal pedantic lints; everything else in `pedantic` stays a warning. | |
| 50 | + | module_name_repetitions = "allow" | |
| 51 | + | missing_errors_doc = "allow" | |
| 52 | + | missing_panics_doc = "allow" | |
| 53 | + | doc_markdown = "allow" | |
| 54 | + | cast_possible_truncation = "allow" | |
| 55 | + | cast_sign_loss = "allow" | |
| 56 | + | cast_precision_loss = "allow" | |
| 57 | + | cast_possible_wrap = "allow" | |
| 58 | + | cast_lossless = "allow" | |
| 59 | + | must_use_candidate = "allow" | |
| 60 | + | too_many_lines = "allow" | |
| 61 | + | struct_excessive_bools = "allow" | |
| 62 | + | similar_names = "allow" | |
| 63 | + | items_after_statements = "allow" | |
| 64 | + | single_match_else = "allow" | |
| 65 | + | match_same_arms = "allow" | |
| 66 | + | unnecessary_wraps = "allow" | |
| 67 | + | type_complexity = "allow" | |
| 68 | + | ||
| 69 | + | [profile.release] | |
| 70 | + | lto = "thin" | |
| 71 | + | codegen-units = 1 | |
| 72 | + | strip = "symbols" | |
| 73 | + | ||
| 74 | + | [profile.dev] | |
| 75 | + | opt-level = 1 |
| @@ -1,0 +1,21 @@ | |||
| 1 | + | MIT License | |
| 2 | + | ||
| 3 | + | Copyright (c) {{year}} Make Creative, LLC | |
| 4 | + | ||
| 5 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | + | of this software and associated documentation files (the "Software"), to deal | |
| 7 | + | in the Software without restriction, including without limitation the rights | |
| 8 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | + | copies of the Software, and to permit persons to whom the Software is | |
| 10 | + | furnished to do so, subject to the following conditions: | |
| 11 | + | ||
| 12 | + | The above copyright notice and this permission notice shall be included in all | |
| 13 | + | copies or substantial portions of the Software. | |
| 14 | + | ||
| 15 | + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | + | SOFTWARE. |
| @@ -1,0 +1,119 @@ | |||
| 1 | + | # {{title}} | |
| 2 | + | ||
| 3 | + | Built on [quasi](https://makenot.work/git/max/quasi), the quasicoherent stack. | |
| 4 | + | One description per screen, rendered by whichever host is asking. | |
| 5 | + | ||
| 6 | + | ## Running it | |
| 7 | + | ||
| 8 | + | ``` | |
| 9 | + | cargo run -p {{name}}-server # http://127.0.0.1:3000 | |
| 10 | + | cargo run -p {{name}}-desktop # a window | |
| 11 | + | cargo test # the screens, with no host at all | |
| 12 | + | ``` | |
| 13 | + | ||
| 14 | + | Both binaries serve the same screens from the same router. Neither knows the | |
| 15 | + | other exists. | |
| 16 | + | ||
| 17 | + | ## The shape | |
| 18 | + | ||
| 19 | + | ``` | |
| 20 | + | request (path + params) | |
| 21 | + | -> router {{name}}-core, imports no host crate | |
| 22 | + | -> description makeover-layout | |
| 23 | + | -> renderer quasi-webview | |
| 24 | + | -> host adapter axum route | Tauri custom protocol | |
| 25 | + | ``` | |
| 26 | + | ||
| 27 | + | | Crate | What it is | | |
| 28 | + | |---|---| | |
| 29 | + | | `{{name}}-core` | State, store, and the described screens. The app. | | |
| 30 | + | | `{{name}}-desktop` | A window and a renderer. About sixty lines. | | |
| 31 | + | | `{{name}}-server` | A listener and a renderer. About the same. | | |
| 32 | + | ||
| 33 | + | The split is the point. **Nothing in `{{name}}-core` may import a host crate** — | |
| 34 | + | not `tauri`, not `axum`, not `wry`. The moment one appears in its dependency | |
| 35 | + | tree, the app has a host again and the second binary stops being an adapter and | |
| 36 | + | starts being a port. | |
| 37 | + | ||
| 38 | + | The same rule one level up: a route returns a description, never markup. A route | |
| 39 | + | that returns HTML is a webview route wearing a neutral name. | |
| 40 | + | ||
| 41 | + | ## Adding a screen | |
| 42 | + | ||
| 43 | + | 1. A module under `crates/{{name}}-core/src/`, with a `routes(Router) -> Router`. | |
| 44 | + | 2. One line in `lib.rs`'s `router()`. | |
| 45 | + | ||
| 46 | + | There is no central route table and no `merge`, so a screen cannot drift out of | |
| 47 | + | step with a list kept somewhere else. `notes.rs` is the worked example and is | |
| 48 | + | meant to be deleted once it has been read. | |
| 49 | + | ||
| 50 | + | Two habits from it worth keeping: | |
| 51 | + | ||
| 52 | + | - **Filters are addresses, not variables.** A view you can link to and reload, | |
| 53 | + | and no state has to survive between two clicks. The cost is that every action | |
| 54 | + | the screen offers has to carry the filter it was offered under, which is what | |
| 55 | + | the `filtered` helper is. | |
| 56 | + | - **A read swaps a region; a write answers with the screen.** A write that | |
| 57 | + | changes the list and the detail pane at once cannot name one region, so it | |
| 58 | + | answers with both. The morph swap is what makes that non-destructive: focus, | |
| 59 | + | scroll and half-typed input survive it. | |
| 60 | + | ||
| 61 | + | ## Styling | |
| 62 | + | ||
| 63 | + | Four stylesheets, linked in cascade order: | |
| 64 | + | ||
| 65 | + | | File | Where it comes from | | |
| 66 | + | |---|---| | |
| 67 | + | | `static/theme.css` | generated — colour intents, from a `makeover` theme | | |
| 68 | + | | `static/geometry.css` | generated — spacing, from `makeover-geometry` | | |
| 69 | + | | `static/layout.css` | generated — components, from `makeover-webview` | | |
| 70 | + | | `static/styles.css` | yours | | |
| 71 | + | ||
| 72 | + | The first three are written by `crates/{{name}}-core/build.rs` on every build and | |
| 73 | + | are gitignored. If what you are about to write is a colour, a spacing value or a | |
| 74 | + | component, it belongs upstream: writing it in `styles.css` is how an app drifts | |
| 75 | + | out of the family. | |
| 76 | + | ||
| 77 | + | The whole theme set is materialised into `themes/` as well. Nothing reads it | |
| 78 | + | yet — the two baked variants in `build.rs` cover light and dark — and it is | |
| 79 | + | there so that adding a theme picker later is reading a file rather than | |
| 80 | + | reworking the build. | |
| 81 | + | ||
| 82 | + | ## Authentication | |
| 83 | + | ||
| 84 | + | There is none, and that is by design rather than by omission. | |
| 85 | + | ||
| 86 | + | quasi names the router, the description and the renderers, and says nothing | |
| 87 | + | about authentication, because auth is the most host-shaped thing there is: a | |
| 88 | + | desktop app's answer is the OS keychain, a server's is a session cookie, a | |
| 89 | + | terminal's is neither. A common layer over those three is where a stack starts | |
| 90 | + | lying about what it abstracts. | |
| 91 | + | ||
| 92 | + | So the host provides it: | |
| 93 | + | ||
| 94 | + | - **`{{name}}-desktop`** — the machine's user is the app's user. If this app | |
| 95 | + | grows accounts, the credential belongs in the OS keychain and the check | |
| 96 | + | belongs in the binary, before the protocol is registered. | |
| 97 | + | - **`{{name}}-server`** — a session cookie and a middleware layer, merged in | |
| 98 | + | front of the quasi fallback. | |
| 99 | + | ||
| 100 | + | Either way the router stays unaware, and a handler that needs to know who is | |
| 101 | + | asking takes it off `AppState`. | |
| 102 | + | ||
| 103 | + | ## Data | |
| 104 | + | ||
| 105 | + | `rusqlite`, synchronous, queried directly. Migrations are `.sql` files under | |
| 106 | + | `migrations/`, compiled into the binary and applied once; the runner records a | |
| 107 | + | checksum, so an applied migration is immutable and editing one after it has | |
| 108 | + | shipped anywhere is refused. Add `002_whatever.sql` instead. | |
| 109 | + | ||
| 110 | + | If this app ever grows a hosted Postgres half, that half is `sqlx` and async. | |
| 111 | + | The two stores are not one query layer and nothing here pretends otherwise. | |
| 112 | + | ||
| 113 | + | ## Licence | |
| 114 | + | ||
| 115 | + | MIT, declared in `Cargo.toml` rather than only in a file, because `license-file` | |
| 116 | + | is never inherited by workspace members. If this app is something sold rather | |
| 117 | + | than something linked, the answer is | |
| 118 | + | `LicenseRef-PolyForm-Noncommercial-1.0.0` instead. Never GPL, and never a GPL | |
| 119 | + | dependency. |
| @@ -1,0 +1,25 @@ | |||
| 1 | + | # How Bento releases {{title}}. Lives here rather than in the daemon's config so | |
| 2 | + | # it is versioned with the code it describes. | |
| 3 | + | # | |
| 4 | + | # The daemon's own bento.toml holds the build hosts and points at this repo, and | |
| 5 | + | # it is read at startup only: registering an app there needs | |
| 6 | + | # `systemctl --user restart bentod`, or the daemon answers `unknown app`. | |
| 7 | + | # | |
| 8 | + | # [app.{{name}}] | |
| 9 | + | # repo = "~/Code/Apps/{{name}}" | |
| 10 | + | ||
| 11 | + | # The root Cargo.toml is a bare [workspace] with no version, so say where the | |
| 12 | + | # release version actually lives. Without this, version resolution falls back to | |
| 13 | + | # the workspace root and finds nothing. | |
| 14 | + | version_path = "Cargo.toml" | |
| 15 | + | ||
| 16 | + | # No cross-compilation, ever: a target builds only on a host that declares it. | |
| 17 | + | # windows/x86_64 is left out rather than forgotten — the Windows checkout lives | |
| 18 | + | # at a Windows path while an app declares one unix `repo`, so declaring a target | |
| 19 | + | # Bento cannot actually release fails the preflight on its first git command. | |
| 20 | + | # Add it when the host is set up properly. | |
| 21 | + | targets = ["linux/x86_64", "linux/aarch64", "macos/aarch64"] | |
| 22 | + | ||
| 23 | + | # Recipes go in dist/recipes/<platform>.rhai, one per target above. Copy them | |
| 24 | + | # from a sibling app rather than writing them: they name no crate, and the | |
| 25 | + | # differences between apps live in this file. |
Binary file