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 | 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.
- Give it a remote and commit the 123 files.
- Fix
apply_and_queuewith a localTx, put#[serde(default)]on all 25Settingsfields, move the attribute atlib.rs:203ontopub fn run()at:250(defects 1, 4, 5). - Take
margin-log; deletelog.rsbut keep thelog::init(&data)call inDb::open(db.rs:46). - Take
margin-sqlite: replace bothversion()s, bothmetaupserts,holes,now_msand the two migrate skeletons; keep the ATTACH atdb.rs:129-151; convert the 214.map_errto?. - Take
margin-shell: deletelibrary.rs:8-12,show_main_window,packaged_by, the prologue andbuild_menu, the last becoming aMenuSpecconst.OnClose::HideWindow, which is today's behaviour. Registermargin_shell::updates::*and put a real updater pubkey in the release config. - 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.
- Bump
rusqlite0.37 to 0.40 andchacha20poly13050.10 to 0.11; run the store tests. - Take
margin-sqlite:TxandSavepointleavestore/write.rs:62-85,version(),meta_get,meta_setandnow_msare deleted, andmigrate(store/schema.rs:105-128) takes a&[&str]so theelse ifat:117stops being a bug. - Take
margin-log, initialise it where the store opens, and route throughnotethe errors that currently only reach the frontend. - Take
margin-shell: deletelibrary.rs,show_main_window,packaged_by, the prologue andbuild_menu.OnClose::HideWindow. Register the updates commands, which Calendar lacks.
Margin Docs third.
- Commit the 129 files. Drop the unused
tokioline atCargo.toml:34; bumpfontdbto 0.24. - Put a real pubkey in
tauri.release.conf.json:8and make the workflow assert it is not a placeholder. - Take
margin-logand convert the sixeprintln!s intonotecalls. Largest behavioural gain of the whole plan for Docs. - Take
margin-sqlitewithVersionStore::UserPragma, keeping the writer thread and thejournal_size_limitpragma, which becomes aPragmasfield. - 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 anOnClose: today it isQuitby omission.
Margin last: the most local history, the least to gain.
- Fix
project.rs:33,:38and:43with Docs'checked, and the remove-then-rename window atproject.rs:26. - Move
tauri-plugin-processandtauri-plugin-updaterfrom[dependencies](Cargo.toml:25-26) into thecfg(not(android, ios))target block, and the two window permissions fromcapabilities/default.json:8-9intodesktop.json. Bumpreqwestto 0.13. - Take
margin-log; Margin currently logs nothing at all. - Take
margin-shell:updates.rsmoves out wholesale and comes back as a dependency,app_data_dirleaveslibrary.rs:49-53,build_menubecomes aMenuSpecwith theshow-windowhook, andOnClose::DestroyAndKeepRunningpreserves today's behaviour. Addpackaged_byand tell the Nix wrapper the variable isMARGIN_APP_PACKAGED_BY. - No
margin-sqlite: Margin has no database.