10 KiB
NotesCraft architecture
Last updated: 2026-10-13 · branch develop · see SPEC.md for the contract and ROADMAP.md for status.
Shape
Rust workspace of one crate per concern (crates/<name>, package notescraft-<name>), layered so a crate may depend only on strictly lower layers (cargo xtask layers). Pure data and format crates sit at the bottom; the engine (command registry and Session) sits in the middle; egui lives only in L5 crates. Every user-visible action will be an engine command shared by the UI, notescraft-cli, the control channel, MCP and plugins (SPEC 10).
L0 vault markdown
L1 index canvas diagrams whiteboard
L2 backlinks graph templates
L3 plugins themes
L4 engine
L5 editor ui-egui mcp i18n (egui only here and in apps)
testkit (test helpers) apps/notescraft apps/notescraft-cli xtask
Crate status and public API
Status: done (implemented, tested, clippy-clean, merged), partial, stub (placeholder with one TODO(phase N)).
| Crate | Layer | Status | Src / test LOC | Tests |
|---|---|---|---|---|
vault |
L0 | done | 1016 / 587 | 42 |
markdown |
L0 | done | 2051 / 222 | 51 |
index |
L1 | done (links, tags; search pending) | 1493 / (integration tests) | 70 |
backlinks |
L2 | done | (see below) | 19 |
canvas |
L1 | done | 1193 / 379 | 31 |
diagrams |
L1 | partial: flowchart only | 1614 / 116 | 31 |
whiteboard |
L1 | done | 1789 / 551 | 60 |
graph |
L2 | done | 915 / 302 | 33 |
templates |
L2 | done | 1102 / 287 | 40 |
plugins |
L3 | done | 1558 / (integration tests) | 58 |
themes |
L3 | done | 1539 / 319 | 15 |
engine |
L4 | partial: command registry only | 1049 / (integration tests) | 23 |
editor ui-egui mcp i18n |
L5 | stub | stub | 1 each |
notescraft-vault (L0)
Vault root, lock, path-traversal guard, atomic IO, watcher. Vault::open(root) / open_read_only, resolve(rel) (rejects .., absolute, drive/UNC, NUL, escaping symlinks, with Error::OutsideVault), read, write (via craft-atomic, returns WriteOutcome::{Written, Unchanged}), rename, delete_to_trash (into .notescraft/trash/), create_dir, list (sorted, hidden dirs skipped, depth 64), watch() (notify, 250 ms debounce, self-write suppression, VaultEvent), write_private for .notescraft/. .obsidian/ and .notescraft/ are Error::Reserved for normal writes. Lock .notescraft/lock with stale-pid takeover (/proc, kill -0, tasklist). Read cap MAX_READ_BYTES = 64 MiB.
notescraft-markdown (L0)
Lossless Obsidian-flavoured parsing. parse(&str) -> Document { source, frontmatter, items }; Document::write() returns the source unchanged; edits go through replace_range(Range, &str) -> Result<String> (never clamps; Error::InvalidRange / NotCharBoundary) and text(range). Items: Heading, WikiLink, Embed, Link, Tag, BlockId, Highlight, Comment, Callout, Math, TaskItem, with iterators wikilinks(), headings(), tags(), embeds(), callouts(), block_ids(), all_tags(). CalloutKind covers the 13 types plus aliases and Unknown. Frontmatter is a minimal YAML subset with get/keys, edit_key / remove_key that change only that key's lines. Helpers: parse_link_target, anchor, anchors_match, decode_percent, is_internal_dest. Nesting depth capped at 64.
notescraft-index (L1)
In-memory note, link and tag index fed (path, content) pairs (no vault dependency). Index::update_note / add_file / remove_note / rename_note (re-keys only, never rewrites other notes), queries notes, note, headings, outgoing, backlinks (source, byte range, context line), unresolved, orphans, tags() -> TagTree (nested counts), notes_with_tag, all_link_edges and node_ids (stable numbering by sorted path, for the graph crate). Link resolution follows SPEC 4.3: case-insensitive basename, nearest-folder ambiguity, path suffix and vault-absolute, alias below basename, #Heading / #^block subpath validation (Resolution::Resolved { path, ambiguous, subpath_ok }), markdown links and embeds. Links are stored unresolved and resolved at query time, so incremental updates need no invalidation. Measured in release: 20 000 notes indexed in 123 ms, worst update_note 16.6 us. Full-text search (BM25, operators) is a later task and will live in src/search*.rs.
notescraft-canvas (L1)
JSON Canvas 1.0: Canvas { nodes, edges, extra }, Node (Text, File, Link, Group, Unknown), Edge, Color, Side, End; tolerant from_str, canonical to_string_pretty (stable key order, unknown fields preserved), validate() -> Vec<Diagnostic>, add_node / remove_node / add_edge, bounding_box, file_nodes, new_id(seed) / IdGen (16 hex). Caps: 32 MiB, 100 000 nodes. Modules: error, model, read, write, validate, id.
notescraft-whiteboard (L1)
Excalidraw v2 JSON: Whiteboard { elements, app_state, files, extra }, Element + ElementKind (rectangle, diamond, ellipse, arrow, line, freedraw, text, image, frame, unknown), unknown fields preserved, canonical to_string_pretty, builders and IdGen. rough::outline(&Element, Style::{HandDrawn, Clean}) returns deterministic polylines (seeded xorshift; same seed gives identical output). Caps MAX_ELEMENTS, MAX_INPUT_BYTES.
notescraft-diagrams (L1, partial)
Mermaid subset. parse(&str) -> Result<Diagram> with Diagram::{Flowchart, Unsupported{kind, source}}; flowchart supports flowchart/graph + direction, shapes [] () {} (()) ([]), edges --> --- -.-> ==> with labels (both syntaxes), chains, &, nested subgraph (depth 8); malformed lines become Diagnostics; source() is always the input. layout(&Diagram) -> Result<Layout> is Sugiyama-lite (iterative cycle breaking, longest-path ranks, 4 barycenter sweeps, deterministic); to_svg / to_svg_with(Palette) produce escaped SVG. Caps: 1 MiB, 5000 nodes, 20 000 edges. Sequence, class/state (UML), ER, gantt and pie are recognised but not yet parsed.
notescraft-graph (L2)
Graph::new(nodes, edges); deterministic Fruchterman-Reingold layout(&Graph, &LayoutConfig) -> Vec<[f32;2]> with a spatial grid, Layout for incremental step, pinning, local_graph(depth), degrees, components, orphans. 5000 nodes / 15 000 edges / 300 iterations lays out in about 525 ms in release.
notescraft-templates (L2)
No chrono: own Date/DateTime/Weekday (civil-from-days), format() (Moment token subset), expand(template, &Context) for {{title}}, {{date[:FMT]}}, {{time[:FMT]}}, daily_note_path, parse_daily_name, month_grid for the calendar widget.
notescraft-plugins (L3)
Sandboxed WebAssembly host on wasmi (SPEC 7). Manifest::from_json (id charset, permissions VaultRead/VaultWrite/Editor, command declarations), trait HostApi (log, vault_read/write/list, search, links, backlinks, active_note, insert_text, notice, now; implemented by the app later), Plugin::load(manifest, wasm, Arc<dyn HostApi>, Limits) validates the module, requires the ABI v1 exports and memory, and links only the 11 ns imports (any other import is an error). Permissions are checked before the host trait is touched. Limits defaults: fuel 50 M, memory 64 MiB, 2 s, 4 MiB results, 8 host calls/ms; breaches are Error::PluginLimit / PluginTrap, and three consecutive failures disable the plugin. PluginSet lists commands as plugin.<id>.<command> and dispatches by that id. Guest pointers are bounds-checked with get. 15 wat fixtures cover hostile modules (loops, memory growth, bad pointers, oversize results, missing exports, wrong ABI, foreign imports, traps).
notescraft-engine (L4, partial: registry slice)
Generic command registry Registry<C> (the context C becomes Session later). CommandSpec<C> has id, label, menu path, shortcut, typed ParamSpecs, enabled and run as plain function pointers. register validates ids ([a-z0-9._-], 1..=128, no empty segments, no duplicates, cap 10 000); register_plugin_command forces the plugin.<id>. prefix and unregister_prefix removes a plugin's commands. list() returns serialisable CommandInfo sorted by id (for CLI commands and MCP list_commands). run checks, in order: unknown id, size cap (4 MiB, iterative walk), object-or-null, enabled, required/typed/unknown params, then calls the command inside catch_unwind so a panic becomes Error::Panicked(id) and the registry stays usable. panic_hunt::adversarial_params() plus a test runs 12 sample commands against 22 hostile inputs. Not yet: Session, document/buffer state, settings, undo, dirty tracking.
notescraft-themes (L3)
Theme { colors, callouts, graph, radii, fonts }, Rgba + WCAG contrast_ratio, Theme::from_toml with per-key diagnostics and fallback to the built-in of the same variant, to_toml, eight built-ins (dark, light, catppuccin-{mocha,latte,frappe,macchiato}, solarized-{dark,light}) verified for contrast in tests. Some Latte colours are darkened from stock Catppuccin to pass 4.5:1 (documented in builtin.rs).
Not yet implemented
index search (BM25 + operators), backlinks, engine (commands, Session, settings), editor, ui-egui, mcp, i18n, apps. Their stubs carry TODO(phase N).
Key decisions
- Native egui editor on
ropey(no CodeMirror/JS). 2.notescraft-*crate names;craft-*reserved for craft-libs. 3. WASM plugins viawasmi. 4. Hand-rolled MCP overserde_json. 5. Hand-rolled in-memory BM25 index, not Tantivy. 6. Hand-written force layout. 7. Own minimal YAML-subset frontmatter parser; unknown YAML preserved. 8. No wasm/web build in v1. 9. Everything is an engine command. 10. Spec in rootSPEC.md. Details and reasons: SPEC section 1.
Lossless-by-construction rule: parsers keep the original source and byte-range items; writers edit only the targeted bytes, so files stay readable and byte-stable for Obsidian.
Quality gates
cargo xtask ci = fmt check + clippy -D warnings (pedantic) + tests + layer check. Perf numbers recorded in tests marked #[ignore] (run with --release -- --ignored).
Build and workflow notes
Use rustup stable (1.99 here); the Homebrew cargo is too old for rust-version = 1.95. Parallel pi agents: see AGENTS.md (at most 2 at once because of the MiniMax rate limit).