Skip to main content

max / quasi

Write the scaffolder, and the store's migration runner under it `quasi new <name>` generates a workspace of three crates: <name>-core holds the state, the store and the described screens and imports no host crate; <name>-desktop and <name>-server are a window and a listener over the same router. Both serve the same screens and neither knows the other exists. The template is real Rust and real manifests under crates/quasi/template/, excluded from the workspace and embedded in the binary. Readable, greppable and diffable, against a liquid dialect that compiles nowhere and costs an install step. What that buys is a name validated before anything is written, a directory wiped if any write fails, and cargo fmt and git init afterwards; what it costs is a substitution pass, which is four placeholders long and deliberately has no control flow. quasi-store is what sqlx::migrate! used to be: SQL embedded at build time, applied once, recorded in a ledger with a checksum, so an applied migration is immutable. It is a crate rather than a fourth copy because goingson and Balanced Breakfast each hand-wrote one after leaving sqlx-sqlite, and a generated app would have been the third. Two features sharing no dependency, since feature unification does not cross the build-dependency line: an app links `runtime` and its build script links `build`, and the embedder never drags libsqlite3-sys into a second compile for the host. The ledger name is a parameter so an app with sqlx history adopts its own rows rather than re-running every migration against a populated database. The generated app demonstrates rather than describes: filters are addresses, a read swaps a region and a write answers with the whole screen, a refused form re-offers what was typed, and a body travels as markdown source. 25 tests, all through the router with no host, no window and no runtime. No auth, said out loud in the README and the core crate's docs rather than left as a silent gap. Themes are materialised and two variants baked, so a generated app is styled without a runtime theme picker it does not have.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 19:21 UTC
Signed with PGP, not checked
Commit: 5960f0a6cd66fdf95f017ca47cc87994d1ccab43
Parent: 6034857
36 files changed, +3460 insertions, -22 deletions
M Cargo.lock +109
@@ -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"
M Cargo.toml +5
@@ -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
M README.md +34 -7
@@ -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