Phase 0: workspace foundation
Spec, roadmap, 17 stub crates, two app stubs, xtask verify/layers/ci. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run -p xtask --"
|
||||
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
*.profraw
|
||||
Generated
+181
@@ -0,0 +1,181 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-backlinks"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-canvas"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-cli"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-diagrams"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-editor"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-engine"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-graph"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-i18n"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-index"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-markdown"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-mcp"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-plugins"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-templates"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-testkit"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-themes"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-vault"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-whiteboard"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/notescraft", "apps/notescraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://git.marcospaulo.dev.br/masi/notescraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e", features = ["serde"] }
|
||||
craft-atomic = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
|
||||
craft-settings = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
|
||||
craft-quit = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
|
||||
craft-print = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
|
||||
|
||||
# internal
|
||||
notescraft-vault = { path = "crates/vault" }
|
||||
notescraft-markdown = { path = "crates/markdown" }
|
||||
notescraft-index = { path = "crates/index" }
|
||||
notescraft-backlinks = { path = "crates/backlinks" }
|
||||
notescraft-canvas = { path = "crates/canvas" }
|
||||
notescraft-graph = { path = "crates/graph" }
|
||||
notescraft-templates = { path = "crates/templates" }
|
||||
notescraft-plugins = { path = "crates/plugins" }
|
||||
notescraft-themes = { path = "crates/themes" }
|
||||
notescraft-engine = { path = "crates/engine" }
|
||||
notescraft-editor = { path = "crates/editor" }
|
||||
notescraft-ui-egui = { path = "crates/ui-egui" }
|
||||
notescraft-mcp = { path = "crates/mcp" }
|
||||
notescraft-i18n = { path = "crates/i18n" }
|
||||
notescraft-diagrams = { path = "crates/diagrams" }
|
||||
notescraft-whiteboard = { path = "crates/whiteboard" }
|
||||
notescraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
# external
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
|
||||
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
|
||||
# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NotesCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# NotesCraft roadmap
|
||||
|
||||
**Stage: pre-alpha** · spec approved 2026-10-13 · current phase: **0 (foundation)**
|
||||
|
||||
> **Last updated:** 2026-10-13 · **Target:** Obsidian-compatible vault, native egui, see [SPEC.md](SPEC.md)
|
||||
|
||||
One-page summary of where NotesCraft stands. The contract is [SPEC.md](SPEC.md); the definition of done per feature is its section 12. One PR per phase, each reviewed before merge.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Crates | Status |
|
||||
|---|---|---|---|
|
||||
| 0 | Foundation | workspace, `xtask` | in progress |
|
||||
| 1 | Vault + markdown | `vault`, `markdown`, `testkit` | not started |
|
||||
| 2 | Index + links | `index`, `backlinks`, `diagrams` and `whiteboard` parsers | not started |
|
||||
| 3 | Engine + app shell | `engine`, `ui-egui` shell, `i18n` | not started |
|
||||
| 4 | Editor | `editor`, properties panel, slash menu, image paste, recovery | not started |
|
||||
| 5 | Search, tags, palette | search UI, tag tree, command palette, outline, bookmarks, breadcrumbs | not started |
|
||||
| 6 | Graph | `graph` + renderer | not started |
|
||||
| 7 | Daily notes, calendar, templates, themes | `templates`, `themes` | not started |
|
||||
| 8 | Canvas, whiteboard, diagrams UI | `canvas`, renderers | not started |
|
||||
| 9 | Plugins | `plugins` (wasmi host) | not started |
|
||||
| 10 | MCP, CLI, control channel, assistant panel | `mcp`, `notescraft-cli` | not started |
|
||||
| 11 | PDF export, packaging, i18n coverage, hardening | `packaging/`, release | not started |
|
||||
|
||||
## Phase 0: foundation (ordered tasks)
|
||||
|
||||
Goal: an empty but green workspace that enforces the rules in SPEC section 3 before any feature code exists.
|
||||
|
||||
| # | Task | Done when |
|
||||
|---|---|---|
|
||||
| 0.1 | Root `Cargo.toml`: resolver 3, edition 2024, `[workspace.dependencies]` with the pinned craft-libs rev and every `notescraft-*` crate, workspace lints (`unsafe_code = "forbid"`, clippy pedantic) | `cargo metadata` succeeds |
|
||||
| 0.2 | 16 stub crates under `crates/` plus `apps/notescraft` and `apps/notescraft-cli`, each with package metadata, module doc, `#![deny(...)]` never-crash lints, one `TODO(phase N)` and one test | `cargo test --workspace` passes |
|
||||
| 0.3 | `clippy.toml`, `rustfmt.toml`, `.cargo/config.toml` (`xtask` alias), `.gitignore`, licence files | `cargo fmt --check` passes |
|
||||
| 0.4 | `cargo xtask verify`: fmt check, clippy `-D warnings`, test, stops at the first failure | green on the empty workspace |
|
||||
| 0.5 | `cargo xtask layers`: the L0–L5 dependency rules, no egui below L5, unregistered crate is an error, with unit tests | green; rejects a deliberate upward dependency in its tests |
|
||||
| 0.6 | `cargo xtask ci` = verify + layers | green |
|
||||
| 0.7 | `AGENTS.md` and `README.md` for contributors (never-crash rules, how to add a crate, how to add a command) | reviewed |
|
||||
| 0.8 | Design reference recorded: PaperCraft project `NotesCraft` (tokens, 9 screens) linked from the README | link present |
|
||||
| 0.9 | CI workflow running `cargo xtask ci` on macOS, Linux and Windows | green on all three |
|
||||
| 0.10 | Reconcile with `craftrules` standards once the repository is available | open question in SPEC section 14 closed |
|
||||
|
||||
Tasks 0.1–0.6 are the first PR. 0.7–0.10 follow in the same phase once the owner confirms the remote and CI host.
|
||||
|
||||
## Not started, deliberately deferred
|
||||
|
||||
Sync, mobile, publish, version-history diff UI, Bases, built-in model hosting, JS plugin compatibility, web build, TeX rendering, Web Clipper, additional languages. See SPEC section 6.1.
|
||||
@@ -0,0 +1,457 @@
|
||||
# NotesCraft: specification
|
||||
|
||||
Status: **approved 2026-10-13**. Phase 0 (foundation) may start; later phases follow the ROADMAP.
|
||||
|
||||
NotesCraft is a clean-room, native, Obsidian-compatible markdown note-taking app in the Craft suite. It is written in **Rust only** (egui/eframe on wgpu), like PhotoCraft and VectorCraft. The product name is always written **NotesCraft** in user-facing text; machine names are lowercase (`notescraft-*` crates, binaries, ids).
|
||||
|
||||
## 1. Decisions taken (from the review of PhotoCraft)
|
||||
|
||||
| # | Decision | Reason |
|
||||
|---|---|---|
|
||||
| D1 | Editor is a **native egui editor** on `ropey`, not CodeMirror 6. | PhotoCraft AGENTS.md forbids JS/TS and webview UI. CM6 is JS. |
|
||||
| D2 | App crates are named **`notescraft-<name>`**. Only shared libs are `craft-*`. | Matches `photocraft-*` / `vectorcraft-*`. `craft-*` is reserved for craft-libs, which forbids app code. |
|
||||
| D3 | Plugins are **sandboxed WebAssembly** hosted by `wasmi`. | Same as `photocraft-plugins` / `vectorcraft-plugins`. No JS engine. Obsidian JS plugins do not run. |
|
||||
| D4 | MCP server is a **hand-rolled JSON-RPC** layer over `serde_json`; no `rmcp`. | Same as `vectorcraft-mcp`. No new dependency. |
|
||||
| D5 | Full-text index is **hand-rolled and in-memory** (inverted index, BM25), not Tantivy. | No heavy new dependency; vaults are tens of thousands of notes at most. Revisit if a benchmark in `perf/` misses its budget. |
|
||||
| D6 | Force layout is **hand-written** (Fruchterman–Reingold with a grid cutoff). | About 100 lines; no crate needed. |
|
||||
| D7 | Frontmatter uses an **own minimal YAML-subset parser**, not a YAML crate. | The subset Obsidian needs is small (scalars, lists, inline lists, booleans, numbers, dates). Unknown YAML is preserved verbatim. |
|
||||
| D8 | **No wasm/web build in v1.** FS code is still `cfg(not(target_arch = "wasm32"))` so L0–L3 stay portable. | Watcher and locking are native. |
|
||||
| D9 | Everything user-visible is an **engine command** (id, label, menu path, shortcut, params, `enabled`, `run`). UI, CLI, control channel and MCP dispatch by id. | PhotoCraft golden rule 1. |
|
||||
| D10 | Spec lives in root `SPEC.md`. | Requested deliverable. |
|
||||
|
||||
## 2. Dependencies
|
||||
|
||||
### 2.1 Craft libs (pinned to the same git rev as PhotoCraft/VectorCraft)
|
||||
|
||||
| Crate | Used for |
|
||||
|---|---|
|
||||
| `craft-atomic` | every write of a note, canvas, settings, plugin data, template, image |
|
||||
| `craft-settings` | per-app `config.toml`, migrations, backup |
|
||||
| `craft-ui` | icons, `IconButton`, tabs, toolbar fitting, panel docking, theme helpers |
|
||||
| `craft-quit` | native quit and unsaved-document prompts |
|
||||
| `craft-print` | submit the exported PDF to the system spooler. NotesCraft renders the PDF itself |
|
||||
|
||||
No change to the craft-libs API surface is planned. If one is needed it is raised before it is made.
|
||||
|
||||
### 2.2 Third-party additions (new to PhotoCraft and VectorCraft)
|
||||
|
||||
Each needs a written reason in the PR that adds it.
|
||||
|
||||
| Crate | Reason | Crate using it |
|
||||
|---|---|---|
|
||||
| `pulldown-cmark` | CommonMark/GFM tokenizer; we layer the Obsidian extensions on top. Writing a conformant CommonMark parser is not justified. | `markdown` |
|
||||
| `notify` | cross-platform FS events for the vault watcher. | `vault` |
|
||||
| `ropey` | rope buffer for large notes and cheap undo of edits. | `editor` |
|
||||
|
||||
Already in the workspace and reused: `wasmi`, `serde`, `serde_json`, `thiserror`, `egui`, `eframe`, `rfd`, `log`, `rayon`, `proptest`, `image`, `criterion`, `egui_kittest`.
|
||||
Licences: all MIT OR Apache-2.0 compatible. Any further dependency is a question to the owner first.
|
||||
|
||||
## 3. Workspace
|
||||
|
||||
Edition 2024, `resolver = "3"`, `rust-version = "1.95"`, license `MIT OR Apache-2.0`, `[lints] workspace = true` in every crate, `unsafe_code = "forbid"`. Members: `crates/*`, `apps/*`, `xtask`. Every crate is `crates/<name>`, named `notescraft-<name>`, and registered in the root `[workspace.dependencies]`.
|
||||
|
||||
### 3.1 Layers (enforced by `cargo xtask layers`)
|
||||
|
||||
A crate depends only on lower layers. Nothing below L5 uses egui, eframe, winit or rfd.
|
||||
|
||||
| Layer | Crate | Scope |
|
||||
|---|---|---|
|
||||
| L0 | `vault` | vault root, lock file, path-traversal guard, FS watcher, file read/write through `craft-atomic` |
|
||||
| L0 | `markdown` | Obsidian-flavored parser and writer, lossless round trip, AST with source spans |
|
||||
| L1 | `index` | in-memory BM25 full-text index, link graph, tag index, alias/path resolution |
|
||||
| L1 | `backlinks` | backlinks and unlinked mentions from `index` |
|
||||
| L1 | `canvas` | Obsidian-Canvas JSON model, read/write, validation |
|
||||
| L2 | `graph` | force-directed layout (pure data, no rendering) |
|
||||
| L2 | `templates` | daily notes and templates (date formatting, variable expansion) |
|
||||
| L3 | `plugins` | `wasmi` plugin host and ABI |
|
||||
| L3 | `themes` | theme model, loading, validation |
|
||||
| L4 | `engine` | `Session`, command registry, settings glue, undo, dirty tracking |
|
||||
| L5 | `editor` | native egui editor widget: cursor, selection, live-preview styling, autocomplete popup, image paste |
|
||||
| L5 | `ui-egui` | app shell, panels, graph and canvas renderers, command palette, settings panel |
|
||||
| L5 | `mcp` | MCP server tools over the engine |
|
||||
| L5 | `i18n` | string tables and lookup |
|
||||
| L1 | `diagrams` | Mermaid-subset parser, layout and SVG/egui shapes (flowchart, sequence, class/state UML, ER, gantt, pie) |
|
||||
| L1 | `whiteboard` | Excalidraw-compatible model, read/write, seeded hand-drawn shape generator |
|
||||
| – | `testkit` | fixtures: sample vaults, fuzz helpers |
|
||||
|
||||
Apps: `apps/notescraft` (desktop, eframe/wgpu, control server), `apps/notescraft-cli` (headless: `info`, `search`, `links`, `export`, `commands`, `mcp`).
|
||||
|
||||
Crates are added beyond this list only with written justification.
|
||||
|
||||
### 3.2 Non-negotiables
|
||||
|
||||
- **Never crash.** Every crate starts with `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. No `unwrap()` in production paths. `get()` instead of indexing for input-derived indices. Bound recursion (embed cycles, link cycles, deeply nested lists). Cap allocations sized by input.
|
||||
- **No silent panics across the plugin boundary.** Every host call into a plugin returns `Result`; traps, fuel exhaustion and memory-limit hits become errors.
|
||||
- Public APIs have rustdoc. Every module has a `//!` comment.
|
||||
- `clippy::pedantic` clean. Exceptions only through a written entry in `clippy.toml`.
|
||||
- Every crate has at least one failing-then-passing test before merge.
|
||||
- Error type pattern: one `thiserror` enum per crate (`Error`) with a `pub type Result<T>`; no `anyhow` in libraries.
|
||||
- Tests: unit in `src`, integration in `tests/`, benches in `perf/`, examples in `examples/`.
|
||||
- No hardcoded colours or radii: they come from theme tokens.
|
||||
- No font files committed; fonts come from craft-fonts via `CRAFT_FONTS_DIR`.
|
||||
|
||||
## 4. Vault format and Obsidian compatibility
|
||||
|
||||
A vault is a directory. Notes are UTF-8 `.md` files. **Vault files stay readable and writable by Obsidian; any change that breaks that needs owner approval.**
|
||||
|
||||
### 4.1 Layout
|
||||
|
||||
```
|
||||
<vault>/
|
||||
*.md, subfolders/ notes, attachments (any file type)
|
||||
*.canvas canvas files (Obsidian Canvas JSON)
|
||||
.notescraft/ NotesCraft-private state (lock, cache, plugins, themes)
|
||||
```
|
||||
|
||||
- NotesCraft **never writes to `.obsidian/`**. It may read `.obsidian/app.json` and `.obsidian/daily-notes.json` once to import the attachment folder, new-note location and daily-note folder/format as defaults, with a visible import prompt. Reading never modifies.
|
||||
- Unknown files and folders are left untouched. Hidden folders are not indexed.
|
||||
- Line endings and trailing newline of a file are preserved unless the user edits that region. Frontmatter bytes are preserved verbatim unless a command edits a key.
|
||||
- A note is **saved only if its content changed**. Saves go through `craft-atomic` (temp file, fsync, rename).
|
||||
|
||||
### 4.2 Safety
|
||||
|
||||
- **Path-traversal guard.** Every path that enters `vault` (from UI, CLI, MCP, plugin, link text) is normalised and must resolve inside the vault root after symlink resolution. `..`, absolute paths, Windows drive and UNC prefixes, and NUL are rejected with `Error::OutsideVault`. Symlinks that escape the vault are not followed.
|
||||
- **Lock file.** `.notescraft/lock` holds pid and start time. A second instance opens the vault read-only with a banner, or offers to take over if the pid is dead.
|
||||
- **External changes.** The watcher debounces events. If a file changes on disk while the buffer is dirty, the user gets a three-way choice (keep mine / take disk / open both); a clean buffer reloads silently. Our own writes are recognised and do not echo.
|
||||
- **Case-insensitive file systems.** Link resolution is case-insensitive; the on-disk case is used when writing.
|
||||
|
||||
### 4.3 Link resolution
|
||||
|
||||
Obsidian "shortest path when possible":
|
||||
1. `[[Name]]` matches a note by basename (without `.md`), case-insensitive, anywhere in the vault.
|
||||
2. If several match, the one nearest to the linking note wins, then lexicographic path. The index reports the ambiguity.
|
||||
3. `[[folder/Name]]` and `[[/folder/Name]]` match by path suffix and by vault-absolute path.
|
||||
4. Aliases from frontmatter `aliases` resolve like names but rank below exact basenames.
|
||||
5. `[[Name#Heading]]`, `[[Name#^block-id]]`, `[[#Heading]]` and `[[#^block-id]]` target a heading or block inside a note.
|
||||
6. Unresolved links are kept in the index as "unresolved" and shown in the graph and a dedicated list.
|
||||
7. Rename and move update inbound links by default (setting `links.update_on_rename`).
|
||||
|
||||
## 5. Markdown dialect
|
||||
|
||||
`notescraft-markdown` parses CommonMark + GFM (tables, task lists, strikethrough, footnotes) via `pulldown-cmark`, plus these Obsidian extensions. The writer round-trips: `write(parse(text)) == text` for every input in the corpus and in `proptest` (lossless; unknown constructs are kept as raw spans).
|
||||
|
||||
| Construct | Syntax | AST node |
|
||||
|---|---|---|
|
||||
| Wikilink | `[[target]]`, `[[target\|alias]]`, `[[target#heading]]`, `[[target#^id]]` | `WikiLink { target, subpath, alias }` |
|
||||
| Embed | `![[note]]`, `![[note#heading]]`, `![[img.png\|300]]`, `![[img.png\|300x200]]`, `![[doc.pdf#page=3]]`, `![[x.canvas]]` | `Embed { target, subpath, size }` |
|
||||
| Markdown links | `[text](path/to/note.md#heading)`, URL-encoded paths, `<path with spaces>` | `Link` (internal when it resolves in the vault) |
|
||||
| Callout | `> [!type] Title`, `> [!type]+` (open), `> [!type]-` (collapsed), nested | `Callout { kind, title, fold, body }` |
|
||||
| Frontmatter | `---` YAML block at byte 0 | `Frontmatter { raw, parsed }` |
|
||||
| Tags | `#tag`, `#nested/tag`, frontmatter `tags` / `tag` | `Tag` |
|
||||
| Block ref | `^block-id` at end of a paragraph/list item, `[[note#^id]]` | `BlockId` |
|
||||
| Highlight | `==text==` | `Highlight` |
|
||||
| Comment | `%%text%%` (inline and block), hidden in preview | `Comment` |
|
||||
| Inline code / code fences / math | `$..$`, `$$..$$` kept as `Math` nodes (source shown, no TeX render in v1) | `Math` |
|
||||
|
||||
Rules:
|
||||
- Wikilinks, tags and `==..==` are **not** recognised inside code spans, code fences, math or comments.
|
||||
- Tag grammar: `#` followed by letters, digits, `_`, `-`, `/` with at least one non-digit character; not preceded by a word character.
|
||||
- Heading anchors for `#Heading` links follow Obsidian: strip markdown, collapse whitespace, case-insensitive match.
|
||||
- Block IDs: `[A-Za-z0-9-]+`. A block ID on its own line after a block (list, quote, table) applies to the whole block.
|
||||
- Unsupported constructs are never dropped: they stay as raw text and are saved byte-for-byte.
|
||||
|
||||
### 5.1 Callouts
|
||||
|
||||
All 13 Obsidian types, with their aliases. Unknown types render with the `note` style and keep their own name.
|
||||
|
||||
| Type | Aliases | Icon token |
|
||||
|---|---|---|
|
||||
| `note` | – | `pencil` |
|
||||
| `abstract` | `summary`, `tldr` | `clipboard` |
|
||||
| `info` | – | `info` |
|
||||
| `todo` | – | `check-circle` |
|
||||
| `tip` | `hint`, `important` | `flame` |
|
||||
| `success` | `check`, `done` | `check` |
|
||||
| `question` | `help`, `faq` | `help-circle` |
|
||||
| `warning` | `caution`, `attention` | `alert-triangle` |
|
||||
| `failure` | `fail`, `missing` | `x` |
|
||||
| `danger` | `error` | `zap` |
|
||||
| `bug` | – | `bug` |
|
||||
| `example` | – | `list` |
|
||||
| `quote` | `cite` | `quote` |
|
||||
|
||||
Colours come from theme tokens `callout.<type>`; icons from the `craft-ui` SVG icon system (missing icons are added to craft-ui only after owner approval, otherwise NotesCraft ships its own app-local SVGs).
|
||||
|
||||
## 6. Features in v1
|
||||
|
||||
Each row is a command group; acceptance criteria are in §12.
|
||||
|
||||
1. **Vault and files.** Open/create vault, file tree, create/rename/move/delete note and folder (delete goes to the OS trash), recent vaults.
|
||||
2. **Editor.** Source and live-preview modes, wikilink/tag/heading/block autocomplete (`[[`, `#`, `[[note#`, `[[note#^`), list/task continuation, undo/redo, find and replace in note, tabs and split panes via `craft-ui`, unsaved prompt via `craft-quit`.
|
||||
3. **Wikilinks.** Create, follow (click, Ctrl/Cmd-click opens in a new tab), hover preview, alias, path, heading and block targets, embeds of notes (cycle-safe, depth limit 5) and images.
|
||||
4. **Backlinks pane.** Linked and unlinked mentions for the active note, with context snippets, counts, and "link it" for an unlinked mention.
|
||||
5. **Graph view.** Global and local (depth 1–3) force-directed graph, node size by degree, tag/folder colour groups, filters (orphans, unresolved, tags, attachments), zoom/pan, click to open, drag to pin.
|
||||
6. **Full-text search.** Ranked (BM25) over body, title, headings, tags, frontmatter; operators `path:`, `file:`, `tag:`, `line:`, `"phrase"`, `-term`, regex `/…/`; result snippets; updates incrementally from the watcher.
|
||||
7. **Tag tree.** Nested tags with counts; click filters search.
|
||||
8. **Daily notes and templates.** Daily note command with folder, filename format (Moment-style tokens `YYYY-MM-DD` subset) and template; templates folder; variables `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`. Core-plugin Templater syntax is not supported.
|
||||
9. **Image paste.** Paste or drop image data/files: saved into the configured attachment folder as `Pasted image YYYYMMDDHHMMSS.png`, inserted as `![[name.png]]`; name collisions get a numeric suffix; size capped (setting, default 64 MiB).
|
||||
10. **Callouts.** Render all 13 types and aliases, foldable, nested; insert via command and autocomplete after `> [!`.
|
||||
11. **Canvas.** Open, edit, save `.canvas` files (§9). Pan/zoom, add text/file/link/group nodes, edges with sides and labels, colours, resize, multi-select, undo/redo. Note files can be embedded as file nodes.
|
||||
12. **Themes.** Built-in Dark, Light, Catppuccin (Latte/Mocha), Solarized (Light/Dark); live switch without restart; user themes (§8).
|
||||
13. **Command palette.** Fuzzy search over every registered command with shortcut display; also quick switcher for notes (`Ctrl/Cmd-O`).
|
||||
14. **Settings panel.** Backed by `craft-settings`; categories: Editor, Files and links, Appearance, Hotkeys, Daily notes, Core plugins, Community plugins, AI assistant (agent command, review-writes, write permission, per-plugin tool toggles), Advanced. A setting that is not read by any code is not shown.
|
||||
15. **Atomic save.** Via `craft-atomic`; autosave after 2 s idle (setting); crash recovery buffer in `.notescraft/recovery/`.
|
||||
16. **PDF export.** `notescraft-ui-egui`/engine renders the note (headings, lists, tables, callouts, images, code) to PDF; `craft-print` submits it to the spooler. A4/Letter, margins, theme-independent print style.
|
||||
17. **Plugin host.** WASM (§7).
|
||||
18. **MCP server.** (§10).
|
||||
19. **Localization.** English only in v1, with the full `i18n` plumbing (every user string goes through it) and an xtask coverage check like PhotoCraft's `i18n_coverage`.
|
||||
|
||||
20. **Properties panel.** Typed editor for frontmatter above the note body: text, number, checkbox, date, datetime, list, tags, aliases. Types are inferred from values and from Obsidian's `.obsidian/types.json` (read-only import). Edits go through the same command as MCP `patch_frontmatter`, touch only the changed key, and keep unknown YAML verbatim. A raw-YAML toggle shows the source.
|
||||
21. **Slash menu.** Typing `/` at the start of a line or after a space opens a filterable popup of insert actions: heading 1–3, bullet/numbered/task list, quote, callout (all 13 types), table, code block, divider, link to note, embed note/image, today's date, insert template, new canvas. Every entry is an engine command, so plugins add entries by registering commands with the `slash` flag. Escape or a non-matching query closes it; the inserted text is plain markdown.
|
||||
22. **Outline pane.** Heading tree of the active note, click to jump, current heading follows the cursor, collapsible levels. Shown as a tab beside Backlinks.
|
||||
23. **Bookmarks.** Star a note, heading, block, search query or canvas; shown in the sidebar, groupable. Stored in `.notescraft/bookmarks.json`, never in `.obsidian/`. `.obsidian/bookmarks.json` may be imported once on request.
|
||||
24. **Breadcrumbs.** Folder path and note title above the editor, each folder segment clickable (opens a folder picker listing notes). Pure view over the vault tree; no extra data.
|
||||
|
||||
25. **Assistant panel (agent chat).** A docked chat panel that drives an *external* coding or chat agent the user already has (Claude, Codex, Cursor, Gemini, Copilot or a custom command). **The agent is chosen once in Settings → AI assistant, not in the panel**; the panel shows no agent switcher. NotesCraft contains **no model, API key, or network code**: it spawns the user-configured agent command over stdio and exposes the vault through the §10 MCP server. The panel shows the conversation, the active note as an attachable context chip, and **tool-call cards** (tool, target path, status `RUNNING`/`DONE`/`FAILED`, one-line summary). Agent writes go through the same engine commands as the user: they are undoable, atomic, and shown as a diff the user accepts before save when `assistant.review_writes` is on (default on). The MCP server runs in read-only mode unless the user grants write per session. Transcripts are kept in `.notescraft/chat/`, never in the vault notes. No telemetry.
|
||||
26. **Core plugins integrated with the assistant.** Calendar, Templates, Diagrams and Whiteboard ship as bundled core plugins that register ordinary engine commands. Every command a plugin registers (core or WASM) is exposed to the assistant as an MCP tool named `plugin_<id>_<command>`, gated by a per-plugin toggle in Settings → AI assistant → Plugin tools. The panel's quick-action chips (Template, Calendar, Diagram) are shortcuts to these tools.
|
||||
- **Calendar:** month grid in the sidebar; days that have a daily note are emphasised, today is filled; click opens or creates that day's note (feature 8). Agenda built from tasks with a due date (`📅 YYYY-MM-DD` or frontmatter `due`). Tools: `calendar_agenda`, `calendar_open_day`, `calendar_add_task`.
|
||||
- **Templates:** picker listing the templates folder with preview; variables per feature 8. Tools: `templates_list`, `templates_insert`, `templates_create_note`.
|
||||
- **Diagrams:** fenced blocks ```` ```mermaid ```` render inline. Native Rust renderer (crate `diagrams`), no JavaScript. v1 supports a Mermaid **subset**: `flowchart`/`graph`, `sequenceDiagram`, `classDiagram` and `stateDiagram-v2` (UML), `erDiagram`, `gantt`, `pie`. Unsupported syntax shows the source with a diagnostic and is never dropped. Output is also exportable as SVG/PNG and embeds in PDF export. Tools: `diagrams_render`, `diagrams_insert`.
|
||||
- **Whiteboard (Excalidraw alternative):** freeform drawing surface in `.excalidraw`-compatible JSON: rectangle, ellipse, diamond, arrow, line, freehand, text, image, with a hand-drawn rendering style (seeded roughness, so output is deterministic) and a clean style toggle. Read/write targets Excalidraw v2 element JSON (`type: "excalidraw"`); unknown fields are preserved. Embeds in notes as `![[board.excalidraw]]`. Distinct from Canvas, which stays strictly JSON Canvas. Tools: `whiteboard_read`, `whiteboard_add_shapes`.
|
||||
27. **Visual / Markdown toggle and status bar.** The editor switches between live preview and raw markdown with one control; a footer shows the git branch (if the vault is a repo, read-only), word and character counts and an approximate token count.
|
||||
|
||||
### 6.1 Deferred (documented, not built)
|
||||
|
||||
Sync, mobile apps, publish, version-history diff UI, Bases/database views, built-in model hosting or API-key management for AI, JS plugin compatibility, community-plugin/theme store, TeX rendering, web (wasm) build, Obsidian Publish, Web Clipper, Slides, Audio recorder, Templater, Dataview-style queries, Workspaces core plugin, additional languages.
|
||||
|
||||
## 7. Plugin contract (`notescraft-plugins`)
|
||||
|
||||
**Choice: sandboxed WebAssembly via `wasmi`** (pure Rust, no JIT, resource-limited), the same model as `photocraft-plugins`. JavaScript plugins are not supported; reasons: PhotoCraft rule against JS, no second engine to audit, deterministic resource limits.
|
||||
|
||||
### 7.1 Package
|
||||
|
||||
A plugin is a folder in `<vault>/.notescraft/plugins/<id>/`:
|
||||
|
||||
```
|
||||
manifest.json { "id", "name", "version", "min_app_version", "description", "author", "permissions": [..], "commands": [{ "id", "name" }] }
|
||||
plugin.wasm the module
|
||||
```
|
||||
|
||||
`permissions` is an explicit allow-list; the user approves it on first enable. Unknown permissions reject the plugin. Disabled by default; enabling is a command.
|
||||
|
||||
### 7.2 ABI (version 1)
|
||||
|
||||
The module exports:
|
||||
- `ns_abi_version() -> i32` (must equal 1)
|
||||
- `ns_alloc(len: i32) -> i32` / `ns_free(ptr: i32, len: i32)`
|
||||
- `ns_on_load() -> i32`, `ns_on_unload() -> i32`
|
||||
- `ns_run_command(cmd_ptr: i32, cmd_len: i32, args_ptr: i32, args_len: i32) -> i64` returning packed `(ptr << 32 | len)` of a JSON result in guest memory
|
||||
- `ns_on_event(ptr: i32, len: i32) -> i32` (JSON events: `note.opened`, `note.saved`, `note.changed`, `vault.indexed`)
|
||||
|
||||
The host imports (module `ns`), all JSON in/out through guest memory, each returning a packed `(ptr,len)` or a negative error code:
|
||||
- `ns.log(level, ptr, len)`
|
||||
- `ns.vault_read(path)` : needs `vault.read`
|
||||
- `ns.vault_write(path, content)` : needs `vault.write`; writes go through the same path guard and `craft-atomic`
|
||||
- `ns.vault_list(glob)`, `ns.search(query)`, `ns.links(path)`, `ns.backlinks(path)` : need `vault.read`
|
||||
- `ns.active_note()`, `ns.insert_text(text)` : need `editor`
|
||||
- `ns.notice(text)`
|
||||
|
||||
No network, no filesystem outside the vault API, no clock beyond `ns.now()`, no threads.
|
||||
|
||||
### 7.3 Limits (defaults, configurable by the user per plugin)
|
||||
|
||||
Fuel 50 M instructions per call, memory 64 MiB, call wall-clock 2 s, result size 4 MiB, 8 host calls per ms. Exceeding any limit aborts the call, returns `Error::PluginLimit`, and after 3 consecutive failures the plugin is auto-disabled with a notice. Plugin code never panics the host: all guest traps map to `Error::PluginTrap`.
|
||||
|
||||
### 7.4 Registration
|
||||
|
||||
Plugin commands are registered into the engine registry as `plugin.<id>.<command>` and appear in the command palette. A hostile-module test suite (WAT fixtures) covers infinite loops, huge allocations, bad pointers, oversize results and missing exports.
|
||||
|
||||
## 8. Theme contract (`notescraft-themes`)
|
||||
|
||||
A theme is a TOML file (`<vault>/.notescraft/themes/<id>.toml` or app-bundled):
|
||||
|
||||
```toml
|
||||
id = "catppuccin-mocha"
|
||||
name = "Catppuccin Mocha"
|
||||
variant = "dark" # dark | light
|
||||
[colors]
|
||||
background = "#1e1e2e"
|
||||
surface = "#181825"
|
||||
text = "#cdd6f4"
|
||||
text_muted = "#a6adc8"
|
||||
accent = "#cba6f7"
|
||||
link = "#89b4fa"
|
||||
link_unresolved = "#f38ba8"
|
||||
selection = "#45475a"
|
||||
border = "#313244"
|
||||
code_background = "#11111b"
|
||||
highlight = "#f9e2af55"
|
||||
[callouts]
|
||||
note = "#89b4fa" # … one entry per type in §5.1; missing ones derive from `accent`
|
||||
[graph]
|
||||
node = "#a6adc8" node_active = "#cba6f7" edge = "#45475a" unresolved = "#f38ba8"
|
||||
[radii]
|
||||
small = 4.0 medium = 6.0
|
||||
[fonts]
|
||||
ui = "…" editor = "…" mono = "…"
|
||||
```
|
||||
|
||||
Rules: all keys optional except `id`, `name`, `variant`; missing colours fall back to the built-in theme of the same variant. Colours parse as `#rrggbb` or `#rrggbbaa`; invalid values reject only that key with a diagnostic. No CSS, no scripting. Built-ins in v1: Dark, Light, Catppuccin Latte/Mocha, Solarized Light/Dark. Themes map onto `craft-ui` theme helpers. Switching is live and persisted via `craft-settings`.
|
||||
|
||||
### 8.1 Cross-platform window chrome
|
||||
|
||||
- **macOS:** native title bar and traffic lights (transparent, content extends under it).
|
||||
- **Windows and Linux:** custom 40 px title bar drawn by `ui-egui` (app icon, File/Edit/View/Help menus, centred note and vault title, minimise/maximise/close controls 48×40, drag to move, double-click to maximise, edge resize, snap and maximise behaviour left to the OS). Window decorations are turned off on these platforms; if the compositor refuses (some Linux Wayland setups) the app falls back to native decorations.
|
||||
- **Rounded, floating panels.** The window background is the `canvas` token; the sidebar, editor and right panel are separate cards with 16 px radius and 8 px gaps (inset look). Inner controls use 8, 12 or 999 px radii; nothing in the UI has square corners except the OS window edge. Panels separate by tone (`bg`, `surface`, `surface-2`), not by borders; borders appear only on focus rings and form inputs that need an affordance.
|
||||
- Design reference: PaperCraft project `NotesCraft` (screens Workspace / macOS, Workspace / Windows + Linux, Graph view, Command palette).
|
||||
|
||||
### 8.2 Catppuccin as the reference palette
|
||||
|
||||
The default Dark theme is Catppuccin Mocha and the default Light theme is Catppuccin Latte; Frappé and Macchiato ship as extra built-ins (§8 lists 6 built-ins; this makes 8). Token mapping: `bg`=base, `surface`=mantle, `surface-2`=surface0, `border`=surface1, `text`=text, `text-muted`=subtext0 (Latte: subtext1, for 4.5:1), `accent`=mauve, `link`=blue, `link_unresolved`=red. Every text/background pair must hold 4.5:1; Latte callout colours are darkened where the stock colour fails.
|
||||
|
||||
## 9. Canvas contract (`notescraft-canvas`)
|
||||
|
||||
Compatible with the **JSON Canvas 1.0** format that Obsidian reads and writes.
|
||||
|
||||
```json
|
||||
{ "nodes": [ … ], "edges": [ … ] }
|
||||
```
|
||||
|
||||
**Nodes** (all have `id: string`, `type`, `x`, `y`, `width`, `height`: integers; optional `color`):
|
||||
- `text`: `text: string` (markdown)
|
||||
- `file`: `file: string` (vault path), optional `subpath` (starts with `#`)
|
||||
- `link`: `url: string`
|
||||
- `group`: optional `label`, `background` (path), `backgroundStyle` (`cover` | `ratio` | `repeat`)
|
||||
|
||||
`color`: preset `"1"`..`"6"` (red, orange, yellow, green, cyan, purple) or `#rrggbb`.
|
||||
|
||||
**Edges**: `id`, `fromNode`, `toNode`; optional `fromSide`/`toSide` (`top` | `right` | `bottom` | `left`), `fromEnd`/`toEnd` (`none` | `arrow`; defaults: from `none`, to `arrow`), `color`, `label`.
|
||||
|
||||
Requirements:
|
||||
- Read is tolerant: unknown fields on nodes and edges and at the top level are preserved and written back; unknown node types are kept opaque and rendered as a placeholder.
|
||||
- Write is canonical: 2-space indent, keys in a stable order, z-order = array order (later nodes on top), so diffs are small. Written with `craft-atomic`.
|
||||
- Validation (reported as diagnostics, never a panic): duplicate IDs, edges to missing nodes (kept, flagged), non-finite or absurd coordinates (clamped for display only, not rewritten), file size cap 32 MiB, node cap 100 000.
|
||||
- Round-trip test: every sample in the corpus (the JSON Canvas spec examples plus hand-made files) satisfies `read(write(read(x))) == read(x)`.
|
||||
- IDs for new nodes: 16 lowercase hex characters, matching Obsidian's style.
|
||||
|
||||
## 10. MCP tool surface (`notescraft-mcp`)
|
||||
|
||||
Transport: stdio JSON-RPC 2.0 (`notescraft-cli mcp --vault <path>`), MCP protocol revision recorded in the crate. Implemented methods: `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`. Tools dispatch **engine commands**; the server holds no logic of its own. All paths pass the vault guard; every tool returns structured JSON plus a text summary, and errors as MCP tool errors, not protocol crashes.
|
||||
|
||||
| Tool | Params | Effect |
|
||||
|---|---|---|
|
||||
| `vault_info` | – | root, note count, tag count, unresolved link count |
|
||||
| `list_notes` | `folder?`, `tag?`, `limit?`, `cursor?` | paged note list |
|
||||
| `read_note` | `path`, `include_links?` | raw markdown, parsed frontmatter, outgoing links |
|
||||
| `create_note` | `path`, `content?`, `template?` | creates; fails if it exists |
|
||||
| `write_note` | `path`, `content`, `expected_hash?` | replaces; optional optimistic-concurrency hash |
|
||||
| `append_note` | `path`, `content`, `heading?` | appends, or under a heading |
|
||||
| `patch_frontmatter` | `path`, `set?`, `remove?` | edits keys without touching the rest |
|
||||
| `rename_note` | `from`, `to` | renames and updates inbound links |
|
||||
| `delete_note` | `path` | to trash; requires the server flag `--allow-delete` |
|
||||
| `search` | `query`, `limit?` | ranked results with snippets; same operators as the UI |
|
||||
| `get_backlinks` | `path`, `unlinked?` | linked and unlinked mentions |
|
||||
| `get_links` | `path` | outgoing resolved and unresolved links |
|
||||
| `list_tags` | `prefix?` | tag tree with counts |
|
||||
| `notes_by_tag` | `tag` | notes carrying the tag |
|
||||
| `daily_note` | `date?` | opens or creates the daily note |
|
||||
| `read_canvas` / `write_canvas` | `path`, `canvas?` | JSON Canvas read and validated write |
|
||||
| `list_commands` / `run_command` | `id`, `params?` | generic access to the registry |
|
||||
|
||||
Resources: `note://<path>` for every note. The server is **read-only** unless started with `--write`; destructive tools need their own flag. A fuzz test sends adversarial params to every tool and must not panic (PhotoCraft's `panic_hunt` pattern). The desktop app also exposes the same tools on its control channel.
|
||||
|
||||
### 10.1 CLI (`notescraft-cli`)
|
||||
|
||||
Headless binary over the same engine; no UI dependency. Every engine command is reachable, so CLI, MCP, control channel and UI are one surface.
|
||||
|
||||
```
|
||||
notescraft-cli [--vault <path>] [--json] <command>
|
||||
info vault stats (notes, tags, unresolved links)
|
||||
search <query> same operators as the UI search
|
||||
read <path> | write <path> | append <path> (write needs --yes or stdin content)
|
||||
links <path> | backlinks <path> [--unlinked]
|
||||
tags [prefix]
|
||||
daily [--date D] open or create the daily note, print its path
|
||||
template insert|list|new templates
|
||||
diagram render <file|-> [--out out.svg|png] Mermaid subset to SVG/PNG
|
||||
whiteboard export <file.excalidraw> --out out.svg|png
|
||||
canvas validate <file.canvas>
|
||||
export pdf <path> [--print] PDF export; --print submits via craft-print
|
||||
plugins list|enable|disable|run <id> <command> [json]
|
||||
commands list every registered command with params
|
||||
run <command-id> [json] run any command by id
|
||||
mcp [--write] [--allow-delete] start the MCP server on stdio (see 10)
|
||||
doctor validate vault: duplicate names, broken embeds, bad frontmatter
|
||||
```
|
||||
|
||||
Rules: output is human text by default and a stable JSON envelope `{ "ok": bool, "result": ..., "error": { "code", "message" } }` with `--json`; exit codes `0` ok, `1` command error, `2` usage error, `3` vault locked or outside vault; writes are atomic; commands are read-only unless they say otherwise; a CLI call never prompts. The app and the CLI can use the same vault at once through the lock rules in 4.2 (the CLI takes a shared lock for reads and defers to the running app for writes by talking to its control channel).
|
||||
|
||||
### 10.2 Plugin and core-plugin tools over MCP
|
||||
|
||||
Every command registered by a core plugin (Calendar, Templates, Diagrams, Whiteboard) or a WASM plugin is also an MCP tool named `plugin_<id>_<command>` with its JSON schema derived from the plugin manifest. A plugin tool is listed only if its per-plugin toggle (Settings, AI assistant, Plugin tools) is on and the plugin has the permissions the call needs. Tool calls from MCP, the CLI (`plugins run`) and the assistant panel go through the same permission check.
|
||||
|
||||
### 10.3 Assistant panel bridge
|
||||
|
||||
The assistant panel is an MCP **client** of the same server: it starts `notescraft-cli mcp` (or connects in-process) and hands that endpoint to the agent configured in Settings. The panel never sees API keys. External agents (Claude Code, Codex, Cursor, Gemini, Copilot, others) can use the same server without the panel by adding it to their MCP config:
|
||||
|
||||
```json
|
||||
{ "mcpServers": { "notescraft": { "command": "notescraft-cli", "args": ["mcp", "--vault", "/path/to/vault"] } } }
|
||||
```
|
||||
|
||||
Write access is off unless `--write` is passed or the user grants it for the session in the panel. Both the CLI and MCP server honour `assistant.review_writes`: when on, an agent write is queued as a pending diff in the app instead of being applied.
|
||||
|
||||
## 11. Architecture notes
|
||||
|
||||
- **Engine/UI seam.** `Session` owns the vault, the index, open buffers and undo stacks. Commands mutate `Session`; the UI renders from it. UI-only state (panel sizes, zoom) lives in `ui-egui/src/state.rs` (serde) so the control channel can read and drive it.
|
||||
- **Index.** Built on open from a parallel scan (`rayon`), updated per file on watcher events; persisted nowhere in v1 (rebuild is fast); a cache in `.notescraft/` is a later optimisation if the budget is missed.
|
||||
- **Editor.** `ropey` buffer, byte-range edits, tree of `markdown` spans for styling the visible region only (live preview hides markup off the cursor line). IME and bidi text must work; egui's text input path is used for composition.
|
||||
- **Control channel and CLI.** Same pattern as PhotoCraft: TCP JSON control server in the app, headless CLI with `commands`, `info`, `search`, `export`, `mcp`.
|
||||
- **Packaging.** `packaging/` and `xtask` mirror PhotoCraft's layout for macOS (dmg), Linux (AppImage, flatpak, deb/rpm via nfpm) and Windows (wix). Bundle id `ai.storyteller.notescraft`. Web packaging is deferred.
|
||||
|
||||
## 12. Definition of done (v1)
|
||||
|
||||
A phase is done when `cargo xtask verify` is green (fmt + clippy `-D warnings` + tests across the workspace), `cargo xtask layers` is green, and each acceptance item for the phase's features below is covered by a test or a recorded manual check.
|
||||
|
||||
| Feature | Acceptance |
|
||||
|---|---|
|
||||
| Vault | Opening a vault written by Obsidian and saving without edits changes **no** bytes. Path guard rejects `..`, absolute, UNC, escaping symlinks. Second instance opens read-only. |
|
||||
| Markdown | Round trip is byte-exact on the corpus and 10 000 `proptest` cases. All §5 constructs parse; none are recognised in code. |
|
||||
| Links | All six resolution rules in §4.3 have tests. Rename updates inbound links in wikilinks and markdown links. |
|
||||
| Backlinks | Linked and unlinked mentions match a hand-checked fixture vault. |
|
||||
| Graph | 5 000 nodes lay out in under 2 s on the CI machine; the layout is deterministic for a fixed seed. |
|
||||
| Search | Operators in §6.6 tested; incremental update visible within 500 ms of a file change; 10 000 notes searched in under 50 ms. |
|
||||
| Canvas | Round trip on corpus; unknown fields preserved; Obsidian opens a file we saved (manual check, recorded). |
|
||||
| Callouts | 13 types + aliases render; fold state toggles; nesting works. |
|
||||
| Image paste | Pasted PNG lands in the attachment folder with the link inserted; collision suffix works; oversize rejected with an error. |
|
||||
| Themes | Six built-ins switch live; an invalid user theme key does not reject the whole theme. |
|
||||
| Plugins | Hostile-module suite passes; limits enforced; permission denied returns an error, not a crash. |
|
||||
| CLI | Every subcommand has a text and a `--json` test; exit codes match 10.1; `run` can invoke every registered command; read-only commands never modify the vault. |
|
||||
| MCP | Every tool has a happy-path and a bad-params test; plugin tools appear and disappear with their toggle; read-only mode blocks writes; fuzz test green. |
|
||||
| PDF | Export of a fixture note opens in a PDF viewer; `craft-print` submission path is exercised with a spooler stub. |
|
||||
| Settings/save | Corrupt `config.toml` falls back to defaults and is backed up; a kill during save leaves the old file intact. |
|
||||
| Properties | Editing one key changes only that frontmatter line; unknown YAML round-trips byte-exact; each type renders and validates. |
|
||||
| Diagrams | Every supported Mermaid type renders from a fixture; unsupported syntax yields a diagnostic, not a crash; output is deterministic; 1 000-node flowchart lays out in under 1 s. |
|
||||
| Whiteboard | Excalidraw sample files round-trip with unknown fields preserved; same seed gives identical output. |
|
||||
| Calendar / templates | Calendar marks days with notes and opens/creates the right file; template variables expand per feature 8; every plugin command appears as an MCP tool and respects its toggle. |
|
||||
| Assistant panel | A stub agent (test binary) exchanging MCP calls shows tool cards in order; a write shows a diff and is applied only after accept; read-only mode rejects writes; killing the agent process leaves the vault unchanged. |
|
||||
| Slash menu | Opens on `/` rules above, filters by prefix and fuzzy, inserts valid markdown, never fires inside code fences, and each entry is reachable from the command palette. |
|
||||
| Outline, bookmarks, breadcrumbs | Outline matches the heading AST (ATX and setext); bookmarks survive rename and move; breadcrumbs match the vault path. |
|
||||
| i18n | Coverage check: zero hardcoded user strings. |
|
||||
| Never crash | A `panic_hunt`-style test runs every command with adversarial params. No `unwrap`/`expect`/`panic`/indexing in non-test code (enforced by clippy). |
|
||||
|
||||
## 13. Phases (detail goes in ROADMAP.md after approval)
|
||||
|
||||
0. **Foundation**: workspace, stubs, `xtask verify` and `layers`, CI, docs skeleton.
|
||||
1. **Vault + markdown**: `vault`, `markdown`, corpus, round-trip.
|
||||
2. **Index + links**: `index`, `backlinks`, link resolution, rename. `diagrams` and `whiteboard` parsers start here (pure data).
|
||||
3. **Engine + app shell**: `engine`, command registry, settings, `ui-egui` shell, file tree, tabs.
|
||||
4. **Editor**: `editor`, autocomplete, callouts, image paste, atomic save, recovery, properties panel, slash menu.
|
||||
5. **Search, tags, palette**: search UI, tag tree, command palette, quick switcher, outline pane, bookmarks, breadcrumbs.
|
||||
6. **Graph**: `graph` and renderer.
|
||||
7. **Daily notes, calendar, templates, themes**.
|
||||
8. **Canvas, whiteboard and diagrams UI**.
|
||||
9. **Plugins**.
|
||||
10. **MCP, CLI, control channel, assistant panel**.
|
||||
11. **PDF export, packaging, i18n coverage, hardening, beta**.
|
||||
|
||||
One PR per phase; each PR is reviewed before merge.
|
||||
|
||||
## 14. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Native editor is the largest unknown (IME, selection, large files, live preview). | Phase 4 starts with a spike and a written decision gate; fall back to source-only mode if live preview slips. |
|
||||
| Byte-exact round trip with extensions is easy to get subtly wrong. | Round trip is tested first (Phase 1), fuzzed, and gates the rest. |
|
||||
| Hand-rolled index may miss budget on very large vaults. | Benchmarks in `perf/` from Phase 2; Tantivy is the fallback and would need owner approval. |
|
||||
| Obsidian format drift. | Compatibility fixtures are recorded from real Obsidian output; unknown fields are always preserved. |
|
||||
| `craftrules` standards are not on disk here. | This spec follows PhotoCraft's AGENTS.md; reconcile against `craftrules` before Phase 0 merges. |
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "notescraft-cli"
|
||||
description = "NotesCraft headless CLI: info, search, links, export, commands, mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "notescraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,6 @@
|
||||
//! `NotesCraft` headless CLI: `info`, `search`, `links`, `export`, `commands`,
|
||||
//! `mcp` and the generic `run <command-id>` over the same engine.
|
||||
|
||||
fn main() {
|
||||
// TODO(phase 10): CLI subcommands over the engine.
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "notescraft"
|
||||
description = "NotesCraft desktop app (egui/eframe on wgpu, control server)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "notescraft"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,6 @@
|
||||
//! `NotesCraft` desktop app: egui/eframe on wgpu, with a TCP JSON control
|
||||
//! server bound to the local session.
|
||||
|
||||
fn main() {
|
||||
// TODO(phase 3): wire eframe, the engine Session, the control server and the MCP endpoint.
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-backlinks"
|
||||
description = "backlinks and unlinked mentions from index"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Backlinks: linked and unlinked mentions, derived from the index.
|
||||
//!
|
||||
//! Renders the backlinks pane: counts, context snippets and "link it"
|
||||
//! actions for unlinked mentions.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 2): backlinks and unlinked mentions from index.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-backlinks");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-canvas"
|
||||
description = "Obsidian-Canvas JSON model, read/write, validation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Canvas: JSON Canvas 1.0 model, tolerant read, canonical write, validation.
|
||||
//!
|
||||
//! Preserves unknown fields and validates without ever panicking.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 8): Obsidian-Canvas JSON model, read/write, validation.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-canvas");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-diagrams"
|
||||
description = "Mermaid-subset parser, layout and SVG/egui shapes (flowchart, sequence, class/state UML, ER, gantt, pie)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Diagrams: Mermaid-subset parser, layout and SVG/egui shapes.
|
||||
//!
|
||||
//! Supports flowchart/graph, sequence, class/state UML, ER, gantt, pie.
|
||||
//! Unsupported syntax yields a diagnostic; the source is never dropped.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 2): Mermaid-subset parser, layout and SVG/egui shapes (flowchart, sequence, class/state UML, ER, gantt, pie).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-diagrams");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-editor"
|
||||
description = "native egui editor widget: cursor, selection, live-preview styling, autocomplete popup, image paste"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Editor: native egui editor widget on a `ropey` buffer.
|
||||
//!
|
||||
//! Cursor, selection, live-preview styling, autocomplete popup
|
||||
//! (`[[`, `#`, `[[note#`, `[[note#^`), image paste.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 4): native egui editor widget: cursor, selection, live-preview styling, autocomplete popup, image paste.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-editor");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-engine"
|
||||
description = "Session, command registry, settings glue, undo, dirty tracking"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Engine: `Session`, the command registry, settings glue, undo, and dirty
|
||||
//! tracking.
|
||||
//!
|
||||
//! Every user-visible action is a command (id, label, menu path, shortcut,
|
||||
//! params, `enabled`, `run`). UI, CLI, control channel and MCP dispatch by id.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 3): Session, command registry, settings glue, undo, dirty tracking.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-engine");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-graph"
|
||||
description = "force-directed layout (pure data, no rendering)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Graph: hand-written Fruchterman–Reingold force layout (with grid cutoff).
|
||||
//!
|
||||
//! Pure data; rendering lives in `notescraft-ui-egui`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 6): force-directed layout (pure data, no rendering).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-graph");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-i18n"
|
||||
description = "string tables and lookup"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! i18n: string tables and lookup.
|
||||
//!
|
||||
//! Every user-visible string flows through here; an xtask coverage check
|
||||
//! enforces "zero hardcoded user strings" (English only in v1).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 3): string tables and lookup.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-i18n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-index"
|
||||
description = "in-memory BM25 full-text index, link graph, tag index, alias/path resolution"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Index: in-memory BM25 full-text index, link graph, tag index, and
|
||||
//! alias/path resolution.
|
||||
//!
|
||||
//! Built on vault open from a parallel scan, updated per file on watcher
|
||||
//! events; persisted nowhere in v1.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 2): in-memory BM25 full-text index, link graph, tag index, alias/path resolution.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-index");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-markdown"
|
||||
description = "Obsidian-flavored parser and writer, lossless round trip, AST with source spans"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Markdown: `Obsidian`-flavored `CommonMark` + GFM parser and writer.
|
||||
//!
|
||||
//! Round-trips losslessly: every input in the corpus parses to an AST with
|
||||
//! source spans, writes back to the same bytes.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 1): Obsidian-flavored parser and writer, lossless round trip, AST with source spans.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-markdown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-mcp"
|
||||
description = "MCP server tools over the engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,17 @@
|
||||
//! MCP: hand-rolled JSON-RPC server over `serde_json`.
|
||||
//!
|
||||
//! Implements `initialize`, `tools/list`, `tools/call`, `resources/list`,
|
||||
//! `resources/read`. Tools dispatch engine commands; the server holds no
|
||||
//! logic of its own.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 10): MCP server tools over the engine.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-mcp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-plugins"
|
||||
description = "wasmi plugin host and ABI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Plugins: sandboxed WebAssembly host (`wasmi`) and the ABI.
|
||||
//!
|
||||
//! Every host call returns `Result`; traps, fuel exhaustion and memory
|
||||
//! limits become errors, never panics.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 9): wasmi plugin host and ABI.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-plugins");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-templates"
|
||||
description = "daily notes and templates (date formatting, variable expansion)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Templates: daily notes and the templates folder.
|
||||
//!
|
||||
//! Moment-style date tokens (`YYYY-MM-DD` subset) and the variables
|
||||
//! `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 7): daily notes and templates (date formatting, variable expansion).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-templates");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-testkit"
|
||||
description = "fixtures: sample vaults, fuzz helpers"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Testkit: fixtures (sample vaults, fuzz helpers) shared between crates.
|
||||
//!
|
||||
//! Dev-dependency only; the `layers` rule allows it on top of L5.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 1): fixtures: sample vaults, fuzz helpers.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-testkit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-themes"
|
||||
description = "theme model, loading, validation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Themes: model, loading, and validation.
|
||||
//!
|
||||
//! Invalid values reject only that key with a diagnostic; the rest of the
|
||||
//! theme still applies.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 7): theme model, loading, validation.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-themes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-ui-egui"
|
||||
description = "app shell, panels, graph and canvas renderers, command palette, settings panel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! UI (egui): app shell, panels, renderers, command palette, settings panel.
|
||||
//!
|
||||
//! Every user string flows through `notescraft-i18n`; every panel reads
|
||||
//! from `Session`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 3): app shell, panels, graph and canvas renderers, command palette, settings panel.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-ui-egui");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-vault"
|
||||
description = "vault root, lock file, path-traversal guard, FS watcher, file read/write through craft-atomic"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Vault: on-disk location for a `NotesCraft` workspace.
|
||||
//!
|
||||
//! Owns the vault root, the lock file, the path-traversal guard, the FS
|
||||
//! watcher, and file read/write routed through `craft-atomic`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 1): vault root, lock file, path-traversal guard, FS watcher, file read/write through craft-atomic.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-vault");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "notescraft-whiteboard"
|
||||
description = "Excalidraw-compatible model, read/write, seeded hand-drawn shape generator"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Whiteboard: Excalidraw-compatible model, read/write, and a seeded
|
||||
//! hand-drawn shape generator.
|
||||
//!
|
||||
//! Reads/writes Excalidraw v2 element JSON (`type: "excalidraw"`) and
|
||||
//! preserves unknown fields.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 2): Excalidraw-compatible model, read/write, seeded hand-drawn shape generator.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-whiteboard");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Same style as the other craft apps (EffectCraft, FilmCraft): wide lines, compact small items.
|
||||
max_width = 160
|
||||
use_small_heuristics = "Max"
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
description = "Workspace tooling: verify (fmt+clippy+test), layers, ci"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Dependency layering rules (SPEC §3.1).
|
||||
//!
|
||||
//! The rule engine works on a small, metadata-independent model so it can
|
||||
//! be unit-tested; [`from_metadata`] builds that model from
|
||||
//! `cargo metadata`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where a workspace crate sits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
/// Regular layered crate.
|
||||
Layer(u8),
|
||||
/// Test tooling: may depend on anything up to L5; other crates may use
|
||||
/// it only as a dev-dependency.
|
||||
Testkit,
|
||||
/// Binaries and build tooling: exempt from the rules.
|
||||
Exempt,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
/// The numeric layer this class occupies, or `None` for exempt crates.
|
||||
fn layer(self) -> Option<u8> {
|
||||
match self {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Testkit => Some(6),
|
||||
Class::Exempt => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layering table (SPEC §3.1). Names are package names without the
|
||||
/// `notescraft-` prefix.
|
||||
pub const TABLE: &[(&str, Class)] = &[
|
||||
// L0
|
||||
("vault", Class::Layer(0)),
|
||||
("markdown", Class::Layer(0)),
|
||||
// L1
|
||||
("index", Class::Layer(1)),
|
||||
("backlinks", Class::Layer(1)),
|
||||
("canvas", Class::Layer(1)),
|
||||
("diagrams", Class::Layer(1)),
|
||||
("whiteboard", Class::Layer(1)),
|
||||
// L2
|
||||
("graph", Class::Layer(2)),
|
||||
("templates", Class::Layer(2)),
|
||||
// L3
|
||||
("plugins", Class::Layer(3)),
|
||||
("themes", Class::Layer(3)),
|
||||
// L4
|
||||
("engine", Class::Layer(4)),
|
||||
// L5
|
||||
("editor", Class::Layer(5)),
|
||||
("ui-egui", Class::Layer(5)),
|
||||
("mcp", Class::Layer(5)),
|
||||
("i18n", Class::Layer(5)),
|
||||
// Test tooling (exempt-like).
|
||||
("testkit", Class::Testkit),
|
||||
// Binaries and tooling: fully exempt.
|
||||
("notescraft", Class::Exempt),
|
||||
("notescraft-cli", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "rfd"];
|
||||
|
||||
/// First layer allowed to use UI crates (SPEC §3.1: nothing below L5).
|
||||
pub const UI_MIN_LAYER: u8 = 5;
|
||||
|
||||
/// Strip the `notescraft-` prefix; apps that already use the prefix keep
|
||||
/// their full name.
|
||||
pub fn short_name(pkg: &str) -> &str {
|
||||
pkg.strip_prefix("notescraft-").map_or(pkg, |s| if TABLE.iter().any(|(n, _)| *n == s) { s } else { pkg })
|
||||
}
|
||||
|
||||
/// Classify a package by name.
|
||||
pub fn classify(pkg: &str) -> Option<Class> {
|
||||
let s = short_name(pkg);
|
||||
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
fn is_ui_crate(name: &str) -> bool {
|
||||
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => name.starts_with(prefix),
|
||||
None => name == *p,
|
||||
})
|
||||
}
|
||||
|
||||
/// How a dependency is declared in the manifest.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DepKind {
|
||||
Normal,
|
||||
Dev,
|
||||
Build,
|
||||
}
|
||||
|
||||
/// A single dependency, as observed in `cargo metadata`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dep {
|
||||
/// Package name on the registry or workspace.
|
||||
pub name: String,
|
||||
/// Where it appears in the manifest.
|
||||
pub kind: DepKind,
|
||||
/// `true` if the dependency is a workspace member.
|
||||
pub workspace: bool,
|
||||
}
|
||||
|
||||
/// A workspace crate and its dependencies.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crate {
|
||||
pub name: String,
|
||||
pub deps: Vec<Dep>,
|
||||
}
|
||||
|
||||
/// What the rule engine can flag.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Violation {
|
||||
/// Workspace crate that the table does not know about.
|
||||
Unregistered {
|
||||
/// The offending crate name.
|
||||
krate: String,
|
||||
},
|
||||
/// Crate depends on a crate at the same or higher layer.
|
||||
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
||||
/// `notescraft-testkit` declared as anything other than a dev-dependency.
|
||||
TestkitAsNormalDep { krate: String },
|
||||
/// Crate at a layer below `UI_MIN_LAYER` depends on a UI toolkit crate.
|
||||
UiBelowL5 { krate: String, dep: String, layer: u8 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Violation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Violation::Unregistered { krate } => {
|
||||
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see SPEC §3.1)")
|
||||
}
|
||||
Violation::Upward { krate, dep, from, to, kind } => {
|
||||
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
||||
}
|
||||
Violation::TestkitAsNormalDep { krate } => {
|
||||
write!(f, "{krate}: notescraft-testkit may only be a dev-dependency")
|
||||
}
|
||||
Violation::UiBelowL5 { krate, dep, layer } => {
|
||||
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply every rule. The returned list is sorted and deduplicated for
|
||||
/// stable output.
|
||||
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
let mut out = Vec::new();
|
||||
for c in crates {
|
||||
let Some(class) = classify(&c.name) else {
|
||||
out.push(Violation::Unregistered { krate: c.name.clone() });
|
||||
continue;
|
||||
};
|
||||
if class == Class::Exempt {
|
||||
continue;
|
||||
}
|
||||
let layer = class.layer().unwrap_or(0);
|
||||
for d in &c.deps {
|
||||
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
||||
if d.name == c.name {
|
||||
continue;
|
||||
}
|
||||
if d.workspace {
|
||||
match classify(&d.name) {
|
||||
// Unregistered and exempt deps are reported on their own entry.
|
||||
None | Some(Class::Exempt) => {}
|
||||
Some(Class::Testkit) => {
|
||||
if d.kind != DepKind::Dev {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
}
|
||||
Some(dc) => {
|
||||
let to = dc.layer().unwrap_or(0);
|
||||
if to >= layer {
|
||||
out.push(Violation::Upward { krate: c.name.clone(), dep: d.name.clone(), from: layer, to, kind: d.kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(std::string::ToString::to_string);
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut out = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||
let mut deps = Vec::new();
|
||||
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||
let kind = match d["kind"].as_str() {
|
||||
Some("dev") => DepKind::Dev,
|
||||
Some("build") => DepKind::Build,
|
||||
_ => DepKind::Normal,
|
||||
};
|
||||
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||
deps.push(Dep { name: dname, kind, workspace });
|
||||
}
|
||||
out.push(Crate { name, deps });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Human-readable label for a class (used in the table output).
|
||||
pub fn describe(class: Option<Class>) -> &'static str {
|
||||
match class {
|
||||
Some(Class::Layer(0)) => "L0",
|
||||
Some(Class::Layer(1)) => "L1",
|
||||
Some(Class::Layer(2)) => "L2",
|
||||
Some(Class::Layer(3)) => "L3",
|
||||
Some(Class::Layer(4)) => "L4",
|
||||
Some(Class::Layer(5)) => "L5",
|
||||
Some(Class::Testkit) => "testkit",
|
||||
Some(Class::Exempt) => "exempt",
|
||||
Some(Class::Layer(_)) => "L?",
|
||||
None => "UNREGISTERED",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::pedantic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use DepKind::*;
|
||||
|
||||
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_downward_graph_passes() {
|
||||
let g = [
|
||||
c("notescraft-vault", &[("craft-atomic", Normal, false)]),
|
||||
c("notescraft-index", &[("notescraft-vault", Normal, true)]),
|
||||
c("notescraft-backlinks", &[("notescraft-vault", Normal, true), ("notescraft-testkit", Dev, true)]),
|
||||
c("notescraft-engine", &[("notescraft-vault", Normal, true), ("notescraft-index", Normal, true)]),
|
||||
c("notescraft-ui-egui", &[("notescraft-engine", Normal, true), ("egui", Normal, false)]),
|
||||
c("notescraft", &[("notescraft-ui-egui", Normal, true)]),
|
||||
];
|
||||
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dependency_flagged() {
|
||||
let v = check(&[c("notescraft-vault", &[("notescraft-index", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 1, .. }]), "{:?}", v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sideways_dependency_flagged() {
|
||||
let v = check(&[c("notescraft-index", &[("notescraft-backlinks", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 1, .. }]), "{:?}", v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egui_below_l5_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "rfd"] {
|
||||
let v = check(&[c("notescraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL5 { layer: 4, .. }]), "{dep}: {v:?}");
|
||||
}
|
||||
// L5 may use egui.
|
||||
assert!(check(&[c("notescraft-ui-egui", &[("egui", Normal, false)])]).is_empty());
|
||||
// Foreign crate whose name merely starts with the same letters is fine.
|
||||
assert!(check(&[c("notescraft-engine", &[("egui_extras", Normal, false)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_crate_is_error() {
|
||||
let v = check(&[c("notescraft-mystery", &[])]);
|
||||
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "notescraft-mystery"));
|
||||
assert!(v[0].to_string().contains("register"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testkit_only_as_dev_dependency() {
|
||||
let v = check(&[c("notescraft-vault", &[("notescraft-testkit", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]), "{v:?}");
|
||||
assert!(check(&[c("notescraft-vault", &[("notescraft-testkit", Dev, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_and_xtask_exempt() {
|
||||
for app in ["notescraft", "notescraft-cli", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("notescraft-ui-egui", Normal, true)])]).is_empty(), "{app}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_parsing() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
r#"{"packages":[
|
||||
{"name":"notescraft-index","dependencies":[
|
||||
{"name":"notescraft-vault","kind":null,"path":"/x/crates/vault"},
|
||||
{"name":"serde","kind":null},
|
||||
{"name":"proptest","kind":"dev"}]},
|
||||
{"name":"notescraft-vault","dependencies":[]}
|
||||
]}"#,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
.expect("parse");
|
||||
let g = from_metadata(&meta).expect("from_metadata");
|
||||
assert_eq!(g.len(), 2);
|
||||
let index = g.iter().find(|c| c.name == "notescraft-index").expect("index");
|
||||
assert!(index.deps[0].workspace);
|
||||
assert!(!index.deps[1].workspace);
|
||||
assert_eq!(index.deps[2].kind, Dev);
|
||||
assert!(check(&g).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + `serde_json`); external tools are invoked through
|
||||
//! `std::process::Command`.
|
||||
|
||||
mod layers;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
/// Usage text shown for unknown commands and when no command is given.
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
verify cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings;
|
||||
cargo test --workspace (stops at first failure, non-zero exit)
|
||||
layers enforce the crate dependency layering (SPEC §3.1)
|
||||
ci verify then layers
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("verify") => cmd_verify(),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace root (parent of the `xtask` crate).
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Build a `cargo` subcommand rooted at the workspace.
|
||||
fn cargo() -> Command {
|
||||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||
c.current_dir(root());
|
||||
c
|
||||
}
|
||||
|
||||
/// Run a command and surface a friendly error on failure.
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
}
|
||||
|
||||
/// Fetch and parse `cargo metadata --format-version 1 --no-deps`.
|
||||
fn metadata() -> Result<serde_json::Value, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
type Step = (&'static str, fn() -> Result<(), String>);
|
||||
|
||||
fn cmd_verify() -> Result<(), String> {
|
||||
let steps: [Step; 3] = [
|
||||
("fmt", || {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
("clippy", || {
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||||
}),
|
||||
("test", || {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace"]);
|
||||
run(c, "cargo test --workspace")
|
||||
}),
|
||||
];
|
||||
for (name, f) in &steps {
|
||||
eprintln!("\n=== verify: {name} ===");
|
||||
f()?;
|
||||
}
|
||||
let names: Vec<&str> = steps.iter().map(|(n, _)| *n).collect();
|
||||
println!("\nverify: all {} steps passed ({})", steps.len(), names.join(", "));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
println!("Dependency layering (SPEC §3.1)\n");
|
||||
println!("{:<32} {:<14} workspace deps", "crate", "class");
|
||||
for c in &crates {
|
||||
let ws: Vec<String> = c
|
||||
.deps
|
||||
.iter()
|
||||
.filter(|d| d.workspace)
|
||||
.map(|d| {
|
||||
let k = match d.kind {
|
||||
layers::DepKind::Normal => "",
|
||||
layers::DepKind::Dev => " (dev)",
|
||||
layers::DepKind::Build => " (build)",
|
||||
};
|
||||
format!("{}{k}", layers::short_name(&d.name))
|
||||
})
|
||||
.collect();
|
||||
let class = layers::describe(layers::classify(&c.name));
|
||||
println!("{:<32} {:<14} {}", c.name, class, ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
for v in &violations {
|
||||
println!(" - {v}");
|
||||
}
|
||||
Err(format!("{} layering violation(s)", violations.len()))
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
cmd_verify()?;
|
||||
println!();
|
||||
cmd_layers()
|
||||
}
|
||||
Reference in New Issue
Block a user