Files
margin/simplify/rust-crates.md
T
2026-10-03 22:48:49 +05:30

31 KiB

Rust crates

The plumbing under the four apps that is genuinely the same code: the Tauri builder, the menu, the window lifecycle, the updater wiring, a log file, and a thin layer over rusqlite. Three crates, not ten. Every number below was measured on the four trees on 2026-09-06 and is recheckable against .research/rust-core.md. margin-google, margin-secrets and margin-http belong to accounts.md, margin-typeset and margin-mac to typesetting.md; they appear here only where a dependency between crates matters.

Where this departs from repo-layout.md

repo-layout.md named its crates before this audit existed, and where the two disagree this document is the authority. Five changes, all narrowings except the last. margin-db becomes margin-sqlite and drops the FTS5 helpers, because FTS5 is in two apps and everything above the one shared tokenizer line differs: Docs ranks with bm25 and highlight (index.rs:1184-1188), Mail uses the index as a membership subquery (mirror/read.rs:234) under a query language with from: and has: operators. margin-paths is not built: the five lines worth sharing are app_data_dir, which moves into margin-shell, and the rest, atomic write, trash and path validation, is three different algorithms with the divergence justified in a comment in each. margin-ipc is not built: all 165 commands already return the same shape, so there is no error type to unify. margin-update is a module inside margin-shell rather than a crate, because Margin's updates.rs is 90 lines and has no consumer that does not also want the builder prologue next to it. And margin-shell is new, has no entry in repo-layout.md, and is the largest single win in the audit.

1. The headline

73 distinct lines appear verbatim, indentation included, in all four apps' src-tauri/src/lib.rs.

lib.rs lines code lines verbatim in all four share of code
Margin 256 237 114 48%
Margin Calendar 354 279 123 44%
Margin Docs 365 291 120 41%
Margin Mail 475 386 124 32%

That is 481 lines of one file written four times, 84 to 96 per app excluding brace-only lines, and two copies say so: Calendar lib.rs:248-249 and Mail lib.rs:251-252 both carry the comment "Ported from margin's lib.rs". The shell deletes roughly 400 of the 481, plus the four copies of app_data_dir (Calendar's and Docs' library.rs are byte-identical nine-line files, Margin's copy is library.rs:49-53, Mail's library.rs:8-12) and the second copies of packaged_by and show_main_window; margin-sqlite takes another 150.

Six hundred lines out against 600 written once is not the argument. The argument is that the close button has three answers, the schema ladder has two and one does not compose, the transaction boundary is missing where it is most needed, and three apps cannot say why anything failed once the process has exited.

2. The crates to build

margin-shell

What it owns. The desktop plugin prologue, the menu scaffold and its event forwarding, the window close policy and the Dock reopen, app_data_dir, packaged_by, and the update channel logic.

The duplication. The prologue is verbatim in all four (Margin lib.rs:149-162, Calendar 250-263, Docs 250-266, Mail 253-268). The menu scaffold, meaning Menu::default(handle), the submenus collect, the find_submenu closure, the match find_submenu("File") with its prepend_items and SubmenuBuilder arms, the Edit and Help appends and the platform blocks, is Margin lib.rs:46-60 and 62-93, Calendar 50-64 and 66-95, Docs 100-114 and 115-146, Mail 84-98 and 100-135; a pairwise diff of the whole build_menu puts Calendar against Mail at 49 differing lines out of 118 and 123. The forwarding line is character-identical in all four: app.emit("menu-action", event.id().0.as_str()).ok(); (Margin 192, Calendar 308, Docs 318, Mail 341). show_main_window is identical between Calendar 227-234 and Mail 184-191, packaged_by between Calendar 239-244 and Mail 196-201 but for the env var name. And Margin's updates.rs hardcodes no product name, bundle id or endpoint, reading handle.config().plugins.0, config().identifier and package_info().version, so it drops into the other three unchanged.

pub fn app_data_dir<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String>;

/// Process and updater plugins on desktop only, the updater only when the merged config carries an
/// `updater` key. Verbatim from all four today.
pub fn desktop_plugins<R: Runtime>(builder: Builder<R>, context: &Context<R>) -> Builder<R>;
pub enum Row<'a> { Item { id: &'a str, label: &'a str, accel: Option<&'a str> }, Separator }
pub enum AppSubmenu { TauriDefault, Trimmed }
pub enum Handled { Yes, No }
pub enum OnClose { HideWindow, DestroyAndKeepRunning, Quit }

pub struct MenuSpec<'a> {
    pub file: &'a [Row<'a>],          // prepended, or built fresh when there is no File submenu
    pub edit_append: &'a [Row<'a>],
    pub view_prepend: &'a [Row<'a>],
    pub window_append: &'a [Row<'a>],
    pub help_append: &'a [Row<'a>],
    pub check_updates: bool,          // suppressed when updates::channel() is "none"
    pub app_submenu: AppSubmenu,
}

pub fn standard_menu<R: Runtime>(handle: &AppHandle<R>, spec: &MenuSpec<'_>) -> tauri::Result<Menu<R>>;

/// Builds the menu and installs the forwarder in one call. Every id in the spec is emitted as
/// `menu-action` unless the hook claims it, which is what Margin needs for `show-window`
/// (`lib.rs:194-197`). The hook defaults to a function returning `Handled::No`.
pub fn with_menu<R: Runtime>(builder: Builder<R>, spec: &'static MenuSpec<'static>,
                             hook: fn(&AppHandle<R>, &str) -> Handled) -> Builder<R>;

pub fn on_close<R: Runtime>(builder: Builder<R>, policy: OnClose) -> Builder<R>;
pub fn run<R: Runtime>(app: App<R>, policy: OnClose);   // wraps app.run with the matching Reopen arm
pub fn show_main_window<R: Runtime>(app: &AppHandle<R>);
pub fn packaged_by_env(identifier: &str) -> String;     // studio.margin.mail -> MARGIN_MAIL_PACKAGED_BY
#[tauri::command] pub fn packaged_by(app: AppHandle) -> Option<String>;

pub mod updates {
    pub fn channel<R: Runtime>(handle: &AppHandle<R>) -> &'static str;   // direct | appstore | none
    #[tauri::command] pub fn update_channel(app: AppHandle) -> &'static str;
    #[tauri::command] pub async fn appstore_latest(app: AppHandle) -> Result<Option<AppStoreRelease>, String>;
    #[tauri::command] pub fn open_appstore(track_id: u64) -> Result<(), String>;
}

Deriving the id list from the spec rather than repeating it in a matches! guard removes a class of drift: an id can be built into the menu, left out of the guard, and silently do nothing. All four id sets agree today, checked; Margin's one extra, show-window, is handled in Rust, which is why the hook exists. updates::appstore_latest takes its client from margin-http rather than Margin's own LazyLock<reqwest::Client> (updates.rs:6), since Margin's graph already resolves two reqwest majors.

Two open decisions the crate forces. AppSubmenu::Trimmed is Docs' rebuilt macOS app submenu (lib.rs:164-176 explains why: Services advertises a list the app cannot see or order, and the Hide rows are a window state nobody reaches for through a menu). It is the better implementation and risks.md says extraction takes the better version, but adopting it costs Cmd+H in the other three, so confirm before flipping them. OnClose has three variants because the apps have three behaviours; the crate does not choose, it makes each app name one.

What an app still supplies. The menu contents, the invoke_handler list, every manage call, the body of setup, deep link registration (Calendar and Mail), and main.rs. One caveat: the name the frontend invokes is the bare function name even for a command defined in a dependency, so the shared crates own five global names, update_channel, appstore_latest, open_appstore, packaged_by and log_note.

margin-log

What it owns. A single log file in the app data directory, capped, with one line per failure.

The duplication. There is none, and that is the point. Only Mail logs: src-tauri/src/log.rs, 137 lines, 94 not tests. Docs has six eprintln!s (lib.rs:270, :278, index.rs:437, :596, watch.rs:210, writingtools.rs:136), all going nowhere under a Finder launch, and Margin and Calendar have neither: when a Drive backup or a calendar sync fails the string reaches the frontend and the process forgets it. Cheapest crate to write, largest change, because the standing rule for this suite is that an error report starts by reading the log.

pub const CAP_BYTES: u64 = 256 * 1024;

/// Told its directory rather than reaching for an `AppHandle`. Mail calls this from `Db::open`
/// (`db.rs:46`) so the engine still works under `cargo test`; keep that property.
pub fn init(dir: &Path, file_name: &str);
pub fn path() -> Option<&'static Path>;

/// Always to stderr; to the file as well once `init` has run. `who` is an account id or an area.
pub fn note(who: &str, line: &str);
pub fn note_result(who: &str, line: &str) -> std::io::Result<()>;   // same, without dropping the error

#[tauri::command] pub fn log_note(who: String, line: String);

Lift Mail's implementation, trim at a 2,000 character line cap and the keep-the-newest-half rewrite in append included, and fix two things on the way: note discards the result of append (log.rs:52), so a failed write is invisible, and it does blocking file I/O under a std::sync::Mutex from async call sites (sync/engine.rs:327, :363, :448, sync/hydrate.rs:276, google/gmail.rs:106), which is bounded and infrequent but should not be copied into three more apps unexamined.

What an app supplies. The file name, and the frontend half, 15 lines per app and the half that catches most real failures: the .catch in the IPC wrapper that logs every rejected invoke (Mail src/ipc.ts:752-758) and the window.onerror and unhandledrejection handlers (Mail src/main.tsx:20-26), which belong in @margin/ipc. The crate depends on chrono, adding it to Margin's and Docs' graphs, a small price for four logs stamped alike.

margin-sqlite

What it owns. Opening a connection with the right pragmas, transaction and savepoint guards, the migration runner, the meta table accessors, and an error type that converts.

The duplication. Three apps have SQLite, about 8,500 lines between them, of which 80 to 120 are genuinely the same code. Do not build this for the volume. version() is byte-identical between Calendar store/schema.rs:130-136 and Mail mirror/schema.rs:127-135, and identical again modulo the state. prefix at Mail state/schema.rs:56-64. The meta upsert, one INSERT ... ON CONFLICT DO UPDATE, exists four times: Calendar store/write.rs:214-228, Mail mirror/write.rs:57-73, inside both migrate functions, and as Docs' remember (index.rs:891-899). now_ms is identical between Docs index.rs:905-910 and Mail mirror/write.rs:42-46, with Calendar's chrono equivalent at store/write.rs:87-89. The placeholder helper is one line under two names, placeholders (index.rs:901-903) and holes (mirror/read.rs:898-900). And .map_err(|e| e.to_string()) appears 309 times in the database code alone (Calendar 59, Docs 36, Mail 214), which is not a function waiting to be extracted, it is a From impl waiting to be written.

pub struct Error(String);
impl From<rusqlite::Error> for Error;
impl From<Error> for String;          // so a command can keep returning Result<T, String>

/// Defaults to WAL, synchronous NORMAL, foreign keys on, no journal size limit (Docs sets one at
/// `index.rs:208-221`) and a 5s busy timeout, which only Mail sets today (`db.rs:129-151`).
pub struct Pragmas { pub wal: bool, pub synchronous: Synchronous, pub foreign_keys: bool,
                     pub journal_size_limit: Option<i64>, pub busy_timeout: Duration }
impl Default for Pragmas;

pub fn open(path: &Path, pragmas: &Pragmas) -> Result<Connection, Error>;

/// Takes `&Connection`, not `&mut`. Calendar's `store/write.rs:62-85` already has this shape and it
/// is exactly what Mail needs from inside `Db::with`, which hands out a shared reference. Drop
/// rolls back.
pub struct Tx<'a>;
impl<'a> Tx<'a> {
    pub fn begin(conn: &'a Connection) -> Result<Tx<'a>, Error>;   // BEGIN IMMEDIATE
    pub fn commit(self) -> Result<(), Error>;
}

pub struct Savepoint<'a>;             // begin(conn, name) / release, Drop rolls back to it

pub enum VersionStore {
    UserPragma,                                         // Docs
    MetaRow { table: &'static str, key: &'static str },  // Calendar, Mail, and Mail's `state.meta`
}

/// `steps[i]` runs when the stored version is below `i + 1`. Sequential, so V3 composes. Refuses a
/// database newer than `steps.len()`, naming `noun` in the message the way all three do today.
pub fn migrate(conn: &Connection, steps: &[&str], store: VersionStore, noun: &str) -> Result<(), Error>;

pub fn meta_get(conn: &Connection, table: &str, key: &str) -> Result<Option<String>, Error>;
pub fn meta_set(conn: &Connection, table: &str, key: &str, value: &str) -> Result<(), Error>;
pub fn holes(n: usize) -> String;     // "?,?,?"
pub fn now_ms() -> i64;               // SystemTime, not chrono: adds nothing to Margin's or Docs' graph

What an app still supplies. Every schema, every read, every write, and the connection ownership model, three things each right for its app: Mutex<Connection> (Calendar store/mod.rs:17), Mutex<Option<Connection>> behind a writer thread (Docs index.rs:143-151), and Mutex<HashMap<String, Connection>> with one pair of ATTACHed files per account (Mail db.rs:31-38), whose formatted ATTACH stays in Mail because ATTACH takes no bound parameter.

3. The crates not to build

No settings crate. Only Mail has settings in Rust: settings.rs, 479 lines, a recursive JSON merge for patches (merge_into, settings.rs:234), an atomic write, and a deliberate refusal to reset on a malformed file (settings.rs:210, tested at :430). The other three keep preferences in localStorage, 16 keys in Margin, five in Calendar, seventeen in Docs; Calendar's whole Settings screen edits one preference, week start day. Lifting this means inventing a settings backend for three apps that do not have one, a feature rather than a refactor, and the 25-field struct cannot move regardless. The reusable core, if wanted, is 60 lines.

No error crate. Every one of the 165 #[tauri::command]s across the four apps returns either a bare value or Result<T, String>, with zero exceptions: Margin 24, Calendar 13, Docs 38, Mail 90. Counts of -> Result<T, String> anywhere are 52, 94, 91 and 485, and neither thiserror nor anyhow is a dependency of any of them. The custom enums are internal and never reach the frontend, and the two that share a name are not the same type: Calendar's ApiError (google/api.rs:132) needs SyncTokenExpired and PreconditionFailed, Mail's (google/api.rs:184) needs Unauthorized, InsufficientScope and Dropped, and even their From<reqwest::Error> differs deliberately, with a comment in Mail on why Calendar's classification would be wrong for a mail client waking from sleep. The one useful piece, the From<rusqlite::Error> that kills 309 .map_err, lives in margin-sqlite.

No filesystem crate. Four atomic_writes, three genuinely different algorithms. Docs' (fs.rs:311-347, helpers at :222-288) carries a per-path Arc<Mutex<()>> lock map so a debounced autosave cannot race Cmd+S, copies the original into the temp so macOS ACLs, Finder tags and the exec bit survive, uses a hidden collision-retried temp name, and makes four watch::note_self_write calls; Margin's (project.rs:13-30) adds .bak rotation, Mail's (library.rs:19-33) adds create_dir_all. Sharing one either drops Docs' watcher integration and lock map or drags the file watcher into the shared crate. Path validation is the same story: Docs has the only real gate (checked at fs.rs:212-214), Mail never lets the frontend name a write target, and Margin has none, which is a defect in Margin rather than an argument for a crate. Trash is one app; file watching is one app.

No async crate. Margin has no tokio dependency at all, and Docs declares one at Cargo.toml:34 and never uses it: grep for tokio:: in its src-tauri/src returns nothing. tauri::async_runtime::spawn is the house style in the three apps that spawn. The two poll loops (Calendar sync.rs:204-215, Mail sync/mod.rs:374-386) share a skeleton, FIRST_PASS_SECS = 2 and a character-identical focused(), then diverge on what matters: Calendar awaits a tokio::sync::Notify with a timeout so kick can pull the next tick forward, Mail does a bare sleep and has kick spawn a separate sync_now behind an AtomicBool guard. The one abstraction arrived at twice, the Sink trait (Calendar sync.rs:68-78, Mail sync/mod.rs:230-266), is 12 lines and belongs with the sync engine, which is not shared. The lock rule all three follow, std::sync::Mutex for SQLite behind a with(|conn| ...) closure and tokio::sync::Mutex for anything held across an .await, goes in the guidelines and is not code.

No DTO macro. One convention, held rigidly across 65 structs: #[derive(Debug, Clone, Serialize, Deserialize)] with #[serde(rename_all = "camelCase")], #[serde(default)] on patch fields, Option<T> rather than a sentinel, i64 epoch milliseconds for time, and a string field with the legal values in a doc comment instead of an enum; #[serde(rename = ...)] appears exactly once in the suite (Calendar dto.rs:36). A derive macro would save one line per struct and put a proc-macro crate in four build graphs. What costs something is that each of those 65 structs has a hand-written TypeScript interface in src/ipc.ts with nothing checking they agree, and the first 41 lines of Calendar's and Docs' ipc.ts are byte identical: a codegen question, ts-rs or tauri-specta, for the frontend plan.

4. The defects found on the way

Ranked by what a user loses. "Ride along" means the extraction fixes it; "before" means fix it in place first, because the extraction would otherwise carry a broken line forward.

1. Mail applies flags and queues the push without a transaction. apply_and_queue (mirror/mod.rs:167-205) runs apply_flags and outbox::queue_flags for N messages, per account, unwrapped. Grepping the crate for unchecked_transaction, BEGIN IMMEDIATE, .transaction() and SAVEPOINT returns three hits: two execute_batch("BEGIN;") in the migrations (mirror/schema.rs:37, state/schema.rs:33) and one savepoint at state/journal.rs:278. A failure mid-loop leaves a flag changed locally with no outbox row, so it never reaches the server, or the reverse. Every Mail user, silently. Before, with Calendar's Tx shape, which the shared crate then replaces.

2. Margin takes an arbitrary absolute path from the webview. project.rs:33, :38 and :43 expose read_file, write_file and write_bytes as commands taking a String path with no validation at all; the only checked path in the crate is the book id whitelist at library.rs:61-66. Script execution in the webview becomes arbitrary read and write with the app's privileges, and Margin renders prose from files it did not write. Docs has the answer to copy, checked at fs.rs:212-214. Before, and not blocked on any of this work.

3. Docs and Mail ship a placeholder updater pubkey. src-tauri/tauri.release.conf.json:8 reads REPLACE_WITH_TAURI_SIGNER_PUBKEY in Docs and REPLACE_WITH_THE_MINISIGN_PUBLIC_KEY in Mail, and neither workflow substitutes it: both only set TAURI_SIGNING_PRIVATE_KEY (Docs release.yml:203-204, Mail :207-208). Margin and Calendar have real keys. Every direct-download install of two apps is stranded on the version it was downloaded at, with no verifiable update path. Before; a release concern rather than a crate one, but margin-shell owning the channel logic is when it stops hiding.

4. Mail's Settings has 25 fields and one #[serde(default)]. dto.rs:768-813, the single default at :805 on notifications. Every other field is required, so the next field added without one fails to parse every existing install's settings.json and settings_get (settings.rs:144) errors out for good, the deliberate no-reset-on-malformed policy (settings.rs:210) keeping it that way. There is a regression test for the one field that has a default (settings.rs:389); the pattern was never generalised. Every Mail user, on the first upgrade after the mistake. Before: #[serde(default)] on all 25 plus a Default impl backed by defaults() (settings.rs:32).

5. Margin Mail cannot compile for mobile. #[cfg_attr(mobile, tauri::mobile_entry_point)] sits at lib.rs:203, directly above attach_account, not above pub fn run() at lib.rs:250. Separately, setup calls listen_for_redirects (lib.rs:307), stop_uikit_shrinking_the_viewport (:310) and watch_for_the_consent_tab_closing (:314) under cfg(mobile), cfg(target_os = "ios") and cfg(target_os = "android"), and none of the three is defined anywhere in the crate: grep returns the call sites and nothing else. All three exist in Calendar (lib.rs:150-184, 186-212, 214-226) and were meant to be ported with the rest, the iOS and Android dependency blocks being already in Mail's Cargo.toml. Nobody is affected today because no mobile build runs; everybody is, the first day one does. Fix the attribute before; the two webview helpers then ride along into margin-shell and listen_for_redirects into margin-google.

6. Four apps, three window close behaviours. Calendar (lib.rs:313-321) and Mail (:346-354) prevent the close and hide the window, then restore on RunEvent::Reopen (Calendar :342-348, Mail :463-469). Margin lets the window be destroyed but calls api.prevent_exit() (lib.rs:234) and rebuilds from config on Reopen (open_main_window, :241-256). Docs calls .run(context) at lib.rs:363 with no RunEvent closure and no CloseRequested handler anywhere in the crate, so closing the window quits the app and unsaved state goes with it. Rides along: OnClose makes each app name its policy, and Docs' is the one to reopen with the user.

7. Calendar's migration ladder will not compose. store/schema.rs:115-120 is if found < 1 { V1 } else if found < 2 { V2 }. An else if, so a database at version 0 when V3 lands runs V1, stops, and gets stamped as current. Mail's sequential ifs (mirror/schema.rs:39-44) are correct. Latent and total: every Calendar user with an old database, the day a third migration ships. Rides along: margin_sqlite::migrate runs every step below the target.

8. Four smaller ones. No atomic_write in the suite fsyncs the parent directory, so a crash can still lose the rename; Margin's also remove_files the target before renaming when backup is false (project.rs:26), and Mail's with_extension (library.rs:23-26) doubles the dot for a path with no extension. note discards the result of append (log.rs:52), so a failed log write is invisible: fix that one before publishing margin-log. Margin's window permissions sit in capabilities/default.json:8-9 rather than desktop.json, so they apply on phones, and Docs alone carries core:window:allow-toggle-maximize. And prepare_cached appears zero times in the three apps with a database, still_bodiless (mirror/read.rs:949-969) preparing inside a loop.

5. Version drift

Agreed everywhere and not worth a row: tauri and tauri-build at 2, serde and serde_json at 1, base64 0.22, tauri-plugin-opener 2, objc2 0.6, objc2-foundation 0.3, and where shared, sha2 0.10, rand 0.8, url 2, chrono 0.4, tempfile 3, harper-core =2.5.0, typst and typst-pdf 0.14.2, typst-as-lib 0.15.5, tauri-plugin-dialog and tauri-plugin-deep-link at 2. Where they disagree, blank meaning the app does not have it:

Crate Margin Calendar Docs Mail Land on
rusqlite 0.37 0.40 0.40 0.40
reqwest 0.12 0.12 0.13 0.13
chacha20poly1305 0.10 0.11 0.11
fontdb 0.23 0.23 0.24 0.24
tokio none 1 (sync, time) 1 (unused) 1 (sync, time, net, io-util, rt) drop from Docs
tauri-plugin-process 2 (ungated) 2 gated 2 gated 2 gated gate in Margin
tauri-plugin-updater 2 (ungated) 2 gated 2 gated 2 gated gate in Margin

Resolved in the lockfiles: tauri 2.11.3 in Margin against 2.11.5 elsewhere, serde 1.0.228 against 1.0.229, tokio 1.52.3 against 1.53.1, libsqlite3-sys 0.35.0 in Calendar against 0.38.2 in Docs and Mail, reqwest 0.13.1 in Mail against 0.13.4 elsewhere; wry 0.55.1 and objc2 0.6.4 are uniform. Margin's, Calendar's and Mail's graphs each carry two reqwest majors already, 0.12.28 and 0.13.x, because tauri-plugin-updater pulls 0.13 whatever the app declares, so moving the direct dependency to 0.13 collapses that to one copy in three apps.

These matter beyond tidiness. rusqlite 0.37 against 0.40 is a hard blocker for margin-sqlite, since one crate cannot compile against two rusqlite majors in one graph, so Calendar moves first. Calendar and Mail share a sealed-token format, so chacha20poly1305 0.10 against 0.11 is a split inside a pair meant to be one implementation, and Margin and Docs share a Typst pipeline, so fontdb matters the day margin-typeset lands. The shared crates themselves land on rusqlite 0.40 with bundled (the feature that brings FTS5, already commented in Docs' and Mail's manifests), tauri 2 with default features off, chrono 0.4 in margin-log only, and no reqwest in margin-shell at all.

6. Four repos, no workspace, one with no remote

Margin has a remote and 149 commits. Calendar has a remote, 27 commits and a clean tree. Docs has a remote, 8 commits and 129 uncommitted files. Margin Mail has no remote at all, one scaffold commit and 123 uncommitted files. There is no cargo workspace spanning them and no path dependency between them.

A cargo path dependency walking out of one checkout into a sibling would fail on a fresh clone and in CI in exactly the way the npm one already does. That is not a prediction: Docs' package.json:33 and Mail's package.json:27 both read "margin-shared": "file:../../python/margin/shared", which resolves on this machine because of the order things were created in and nowhere else. Writing margin-log = { path = "../../python/margin-shared/crates/margin-log" } reproduces it with a different tool. So the mechanism is a cargo git dependency on the fifth repo, pinned to a tag:

margin-shell = { git = "https://github.com/priyanshujain/margin-shared", tag = "v0.3.0" }

Cargo needs no registry for this, which is the one place Rust has it easier than npm. Three consequences. Each app's Cargo.lock records the resolved git rev and stays committed, so moving a tag changes nothing until someone runs cargo update -p margin-shell: treat tags as immutable. Local iteration goes through a [patch] section or a paths entry in .cargo/config.toml, and per risks.md that is a switch a developer turns on, never a value the repo ships. And the shared repo's CI has to build all four apps against a candidate before a tag is cut, which needs all four checkoutable from CI, which needs Mail to have a remote. Nothing starts until Docs' 129 files and Mail's 123 files are committed and pushed; that is a precondition, not a precaution.

7. Per app, exactly what changes

Step 0, the shared repo. Create the three crates under crates/ in the MIT-licensed margin-shared repository. margin-log first: 100 lines, no dependents among the other two, and the only one that gives three apps a capability they lack. Then margin-sqlite, then margin-shell, which needs margin-http for the App Store probe and so lands after accounts.md's first crate. Tag v0.3.0.

Margin Mail first: it is the source of margin-log, the worst affected by the missing transactions, and the one needing the mobile fix.

  1. Give it a remote and commit the 123 files.
  2. Fix apply_and_queue with a local Tx, put #[serde(default)] on all 25 Settings fields, move the attribute at lib.rs:203 onto pub fn run() at :250 (defects 1, 4, 5).
  3. Take margin-log; delete log.rs but keep the log::init(&data) call in Db::open (db.rs:46).
  4. Take margin-sqlite: replace both version()s, both meta upserts, holes, now_ms and the two migrate skeletons; keep the ATTACH at db.rs:129-151; convert the 214 .map_err to ?.
  5. Take margin-shell: delete library.rs:8-12, show_main_window, packaged_by, the prologue and build_menu, the last becoming a MenuSpec const. OnClose::HideWindow, which is today's behaviour. Register margin_shell::updates::* and put a real updater pubkey in the release config.
  6. Port Calendar's three mobile helpers, or drop the three call sites until mobile is real.

Margin Calendar second: the only clean tree, and the only app that has to move a rusqlite major.

  1. Bump rusqlite 0.37 to 0.40 and chacha20poly1305 0.10 to 0.11; run the store tests.
  2. Take margin-sqlite: Tx and Savepoint leave store/write.rs:62-85, version(), meta_get, meta_set and now_ms are deleted, and migrate (store/schema.rs:105-128) takes a &[&str] so the else if at :117 stops being a bug.
  3. Take margin-log, initialise it where the store opens, and route through note the errors that currently only reach the frontend.
  4. Take margin-shell: delete library.rs, show_main_window, packaged_by, the prologue and build_menu. OnClose::HideWindow. Register the updates commands, which Calendar lacks.

Margin Docs third.

  1. Commit the 129 files. Drop the unused tokio line at Cargo.toml:34; bump fontdb to 0.24.
  2. Put a real pubkey in tauri.release.conf.json:8 and make the workflow assert it is not a placeholder.
  3. Take margin-log and convert the six eprintln!s into note calls. Largest behavioural gain of the whole plan for Docs.
  4. Take margin-sqlite with VersionStore::UserPragma, keeping the writer thread and the journal_size_limit pragma, which becomes a Pragmas field.
  5. Take margin-shell. Docs' rebuilt macOS app submenu is what the crate adopts, so this is a deletion here and a change for the other three. Choose an OnClose: today it is Quit by omission.

Margin last: the most local history, the least to gain.

  1. Fix project.rs:33, :38 and :43 with Docs' checked, and the remove-then-rename window at project.rs:26.
  2. Move tauri-plugin-process and tauri-plugin-updater from [dependencies] (Cargo.toml:25-26) into the cfg(not(android, ios)) target block, and the two window permissions from capabilities/default.json:8-9 into desktop.json. Bump reqwest to 0.13.
  3. Take margin-log; Margin currently logs nothing at all.
  4. Take margin-shell: updates.rs moves out wholesale and comes back as a dependency, app_data_dir leaves library.rs:49-53, build_menu becomes a MenuSpec with the show-window hook, and OnClose::DestroyAndKeepRunning preserves today's behaviour. Add packaged_by and tell the Nix wrapper the variable is MARGIN_APP_PACKAGED_BY.
  5. No margin-sqlite: Margin has no database.