mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 00:44:27 -06:00
* Zotero 1: range fields and custom document properties round-trip Citation managers' ADDIN fields (Zotero, Mendeley, EndNote) are now kept as range fields: FieldStart/FieldEnd markers with the result as ordinary, formatted content between them, possibly spanning paragraphs (a Zotero bibliography). Before, the result was flattened to plain text and a multi-paragraph field lost its end. - doc: InlineObject::FieldStart/FieldEnd, Document::field_ranges, balance_field_ranges; CustomProp and custom_props with get/set/remove. - docx: read ADDIN fields as ranges (field codes up to 2 MiB), write them back as begin/instr/separate…end; orphan markers are dropped on save so files always hold whole fields. docProps/custom.xml is parsed and regenerated (simple types as text, vectors/blobs verbatim) instead of passed through, so Zotero's ZOTERO_PREF_n settings are editable. - engine: file.properties reads and sets custom properties. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 2: wire-protocol client and document bridge New crate wordcraft-zotero (L5) speaks Zotero's published word-processor protocol (port 23116; 32-bit transaction id + big-endian length frames, JSON payloads, ERR: replies): - client: sends an integration command (addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs) and answers Zotero's calls until Document_complete or the connection closes (cancelled dialog). Answers go through a callback so the app can apply them on its UI thread; run_on_session answers directly. - Bridge: implements Zotero's Document_*/Field_* calls on a Session. Fields are stored as Word's plugin stores them (ADDIN ZOTERO_ range fields; prefs in ZOTERO_PREF_n, with Zotero's ReferenceMark field type kept as Word's Field in the file), so documents stay Word-compatible. Rich text (RTF or HTML) keeps citation formatting; bibliographies get a Bibliography style from setBibliographyStyle; note styles create footnotes/endnotes, and deleting a lone note citation removes its note; placeholder links convert to fields. One transaction = one undo step. Field ids stay stable through inserts and deletes. - Tests: a fake Zotero over a real socket, every call, docx round trip, hostile arguments for every method, random frames (proptest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 3: Zotero in the app and the CLI, tested with the real Zotero - ui.zotero.<command> (addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs), ui.zotero.status and ui.zotero.answer. The socket runs on a background thread; each of Zotero's calls is answered on the UI thread against the session, and Zotero's questions become a dialog the session waits on. Web builds report that Zotero needs the desktop app. - wordcraft-cli zotero <command> <file> [--save] [--trace] [--port]. - Bridge: a selection Zotero makes to show a citation is put back when the session ends. - ui_shot: {"wait": ms} keeps frames running for background work. - docs/zotero.md. Checked against Zotero 10.0.6: refresh of a citation and bibliography (APA), its "modified citation" question answered in the app's dialog, RTF results with italics, Bibliography style, prefs round trip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 4: switching between in-text and note styles moves citations Document_convert and Field_convert now move a citation between the text, footnotes and endnotes, as Zotero asks when the document's style changes between an in-text and a note style: into a new note at the same place, or back into the text where the note mark was (removing a note that held only the citation; keeping one with other text). Field ids survive the move, formatting is kept, and each Zotero session stays one undo step. Checked against Zotero 10.0.6: unlink citations (removeCodes, with its confirmation question) and a refresh after switching the document to Chicago notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 5: Zotero tab in the ribbon A Zotero tab (before Help) with Add/Edit Citation, Add Note, Add/Edit Bibliography, Refresh, Document Preferences and Unlink Citations. The buttons are disabled while a Zotero command is running and in the web build. Three new icons drawn in code (document preferences, unlink, add note); the others reuse the References tab's. Labels and status messages translated (zh-hans, zh-hant, ja). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero: Add/Edit Bibliography inserted nothing Zotero reads a newly inserted bibliography field back before filling it, and unlinks a field whose text is empty, taking it for a bibliography the user deleted. Our new fields were empty, so Add/Edit Bibliography ended with Field_removeCode and nothing in the document. New fields now hold "{Citation}" until Zotero writes the result. Found by tracing the session with Zotero 10.0.6; regression test added. wordcraft-cli zotero takes --cmd (run before the Zotero command, e.g. caret.docEnd) to reproduce sessions from a given caret position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero: log each command's outcome and Zotero's calls to the app log Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cargo.lock: wordcraft-zotero at the workspace version (0.4.0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
339 lines
12 KiB
Rust
339 lines
12 KiB
Rust
//! Dependency layering rules (plan/architecture.md §3).
|
|
//!
|
|
//! 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),
|
|
/// Layer 0, and additionally may depend on no workspace crate at all.
|
|
Standalone,
|
|
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
|
/// purposes); other crates may use it only as a dev-dependency.
|
|
Testkit,
|
|
/// Binaries and build tooling: exempt from the rules.
|
|
Exempt,
|
|
}
|
|
|
|
impl Class {
|
|
fn layer(self) -> Option<u8> {
|
|
match self {
|
|
Class::Layer(l) => Some(l),
|
|
Class::Standalone => Some(0),
|
|
Class::Testkit => Some(6),
|
|
Class::Exempt => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The layering table. Names are package names without the `wordcraft-`
|
|
/// prefix.
|
|
pub const TABLE: &[(&str, Class)] = &[
|
|
("geom", Class::Layer(0)),
|
|
("control-key", Class::Standalone),
|
|
("doc", Class::Layer(1)),
|
|
("fonts", Class::Layer(1)),
|
|
("proof", Class::Layer(1)),
|
|
("layout", Class::Layer(2)),
|
|
("docx", Class::Layer(2)),
|
|
("formats", Class::Layer(2)),
|
|
("render", Class::Layer(3)),
|
|
("pdf", Class::Layer(3)),
|
|
("engine", Class::Layer(4)),
|
|
("mcp", Class::Layer(5)),
|
|
("zotero", Class::Layer(5)),
|
|
("ui-egui", Class::Layer(6)),
|
|
("testkit", Class::Testkit),
|
|
// apps and tooling
|
|
("wordcraft", Class::Exempt),
|
|
("cli", Class::Exempt),
|
|
("web", Class::Exempt),
|
|
("xtask", Class::Exempt),
|
|
];
|
|
|
|
/// Explicit orderings *within* a layer (earlier may be used by later).
|
|
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
|
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
|
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
|
|
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["doc", "fonts"]];
|
|
|
|
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
|
let (from, to) = (short_name(from), short_name(to));
|
|
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
|
|
(Some(f), Some(t)) => t < f,
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
/// External crates that constitute a UI toolkit / windowing dependency.
|
|
/// Entries ending in `*` are prefixes.
|
|
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
|
|
|
/// First layer allowed to use UI crates.
|
|
pub const UI_MIN_LAYER: u8 = 6;
|
|
|
|
pub fn short_name(pkg: &str) -> &str {
|
|
pkg.strip_prefix("wordcraft-").unwrap_or(pkg)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DepKind {
|
|
Normal,
|
|
Dev,
|
|
Build,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Dep {
|
|
pub name: String,
|
|
pub kind: DepKind,
|
|
/// `true` if the dependency is a workspace member.
|
|
pub workspace: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Crate {
|
|
pub name: String,
|
|
pub deps: Vec<Dep>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Violation {
|
|
Unregistered { krate: String },
|
|
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
|
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
|
TestkitAsNormalDep { krate: String },
|
|
UiBelowL6 { 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 plan/architecture.md §3)")
|
|
}
|
|
Violation::Upward { krate, dep, from, to, kind } => {
|
|
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
|
}
|
|
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
|
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
|
|
}
|
|
Violation::TestkitAsNormalDep { krate } => {
|
|
write!(f, "{krate}: wordcraft-testkit may only be a dev-dependency")
|
|
}
|
|
Violation::UiBelowL6 { krate, dep, layer } => {
|
|
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check all rules. Returns violations sorted 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 {
|
|
if class == Class::Standalone {
|
|
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
|
continue;
|
|
}
|
|
match classify(&d.name) {
|
|
// Unregistered deps are reported on their own entry.
|
|
None => {}
|
|
Some(Class::Testkit) => {
|
|
if d.kind != DepKind::Dev {
|
|
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
|
}
|
|
}
|
|
Some(dc) => {
|
|
let to = dc.layer().unwrap_or(u8::MAX);
|
|
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
|
out.push(Violation::Upward {
|
|
krate: c.name.clone(),
|
|
dep: d.name.clone(),
|
|
from: layer,
|
|
to: if to == u8::MAX { 7 } else { to },
|
|
kind: d.kind,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
|
out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
|
}
|
|
}
|
|
}
|
|
out.sort_by_key(|v| v.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)
|
|
}
|
|
|
|
pub fn describe(class: Option<Class>) -> String {
|
|
match class {
|
|
Some(Class::Layer(l)) => format!("L{l}"),
|
|
Some(Class::Standalone) => "L0 standalone".into(),
|
|
Some(Class::Testkit) => "testkit".into(),
|
|
Some(Class::Exempt) => "exempt".into(),
|
|
None => "UNREGISTERED".into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
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() }
|
|
}
|
|
use DepKind::*;
|
|
|
|
#[test]
|
|
fn clean_downward_graph_passes() {
|
|
let g = [
|
|
c("wordcraft-geom", &[("kurbo", Normal, false)]),
|
|
c("wordcraft-doc", &[("wordcraft-geom", Normal, true)]),
|
|
c("wordcraft-engine", &[("wordcraft-doc", Normal, true), ("wordcraft-testkit", Dev, true)]),
|
|
c("wordcraft-ui-egui", &[("wordcraft-engine", Normal, true), ("egui", Normal, false)]),
|
|
c("wordcraft-cli", &[("wordcraft-ui-egui", Normal, true)]),
|
|
];
|
|
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn upward_dependency_flagged() {
|
|
let v = check(&[c("wordcraft-doc", &[("wordcraft-engine", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 4, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn sideways_dependency_flagged() {
|
|
let v = check(&[c("wordcraft-layout", &[("wordcraft-docx", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn l0_foundation_chain_allowed_one_way() {
|
|
assert!(check(&[c("wordcraft-doc", &[("wordcraft-geom", Normal, true)])]).is_empty());
|
|
let v = check(&[c("wordcraft-geom", &[("wordcraft-doc", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 1, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn self_dev_dependency_ignored() {
|
|
assert!(check(&[c("wordcraft-doc", &[("wordcraft-doc", Dev, true)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn upward_dev_dependency_flagged() {
|
|
let v = check(&[c("wordcraft-geom", &[("wordcraft-doc", Dev, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn ui_crates_below_l6_flagged() {
|
|
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
|
let v = check(&[c("wordcraft-engine", &[(dep, Normal, false)])]);
|
|
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 4, .. }]), "{dep}");
|
|
}
|
|
assert!(check(&[c("wordcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
|
assert!(!check(&[c("wordcraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unregistered_crate_is_error() {
|
|
let v = check(&[c("wordcraft-mystery", &[])]);
|
|
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "wordcraft-mystery"));
|
|
assert!(v[0].to_string().contains("register"));
|
|
}
|
|
|
|
#[test]
|
|
fn testkit_only_as_dev_dependency() {
|
|
let v = check(&[c("wordcraft-render", &[("wordcraft-testkit", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
|
assert!(check(&[c("wordcraft-render", &[("wordcraft-testkit", Dev, true)])]).is_empty());
|
|
// testkit itself may use anything up to L5 but not L6 crates.
|
|
assert!(check(&[c("wordcraft-testkit", &[("wordcraft-engine", Normal, true)])]).is_empty());
|
|
assert!(!check(&[c("wordcraft-testkit", &[("wordcraft-ui-egui", Normal, true)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn apps_and_xtask_exempt() {
|
|
for app in ["wordcraft", "wordcraft-cli", "wordcraft-web", "xtask"] {
|
|
assert!(check(&[c(app, &[("egui", Normal, false), ("wordcraft-ui-egui", Normal, true)])]).is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_parsing() {
|
|
let meta: Value = serde_json::from_str(
|
|
r#"{"packages":[
|
|
{"name":"wordcraft-doc","dependencies":[
|
|
{"name":"wordcraft-geom","kind":null,"path":"/x/crates/geom"},
|
|
{"name":"serde","kind":null},
|
|
{"name":"proptest","kind":"dev"}]},
|
|
{"name":"wordcraft-geom","dependencies":[]}
|
|
]}"#,
|
|
)
|
|
.unwrap();
|
|
let g = from_metadata(&meta).unwrap();
|
|
assert_eq!(g.len(), 2);
|
|
let doc = g.iter().find(|c| c.name == "wordcraft-doc").unwrap();
|
|
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
|
assert_eq!(doc.deps[2].kind, Dev);
|
|
assert!(check(&g).is_empty());
|
|
}
|
|
}
|