Files
notescraft/docs/ARCHITECTURE.md
T
2026-10-13 19:45:22 -03:00

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

  1. Native egui editor on ropey (no CodeMirror/JS). 2. notescraft-* crate names; craft-* reserved for craft-libs. 3. WASM plugins via wasmi. 4. Hand-rolled MCP over serde_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 root SPEC.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).