Skip to main content

packdiff_wasm/
lib.rs

1//! WASM ABI over `packdiff-dto` — the comment engine the generated HTML page
2//! calls into. Every model mutation and export the browser performs goes
3//! through here; the page's JavaScript is only a view layer.
4//!
5//! ABI (no wasm-bindgen, empty import object, works from `file://`):
6//!
7//! - Strings cross the boundary as `(ptr: u32, len: u32)` UTF-8 buffers.
8//!   JS allocates inputs with [`pd_alloc`], writes, calls, then frees them
9//!   with [`pd_free`].
10//! - Every API function returns a packed `u64` = `(ptr << 32) | len` naming a
11//!   fresh UTF-8 buffer the CALLER must free with [`pd_free`] after copying.
12//! - Every returned buffer is a single-key union document (§ house style):
13//!   `{ "Ok": <result> }` on success or `{ "Error": { "message": "…" } }` on
14//!   failure. The `Ok` payload is a document object for mutations and a plain
15//!   string for exports/keys.
16//!
17//! Purity: the module has no clock and no entropy — comment ids and RFC 3339
18//! timestamps come in from JS as part of the comment JSON.
19
20use std::alloc::{alloc, dealloc, Layout};
21
22use packdiff_dto::review::{Comment, ReviewDocument, Verdict};
23use packdiff_dto::{export, storage_key, RefInfo};
24use serde_json::json;
25
26// ------------------------------------------------------------------ memory
27
28/// Allocate `len` bytes for the caller to write an input buffer into.
29#[no_mangle]
30pub extern "C" fn pd_alloc(len: u32) -> *mut u8 {
31  if len == 0 {
32    return core::ptr::null_mut();
33  }
34  unsafe { alloc(Layout::from_size_align_unchecked(len as usize, 1)) }
35}
36
37/// Free a buffer previously returned by [`pd_alloc`] or by any API function.
38#[no_mangle]
39pub extern "C" fn pd_free(ptr: *mut u8, len: u32) {
40  if ptr.is_null() || len == 0 {
41    return;
42  }
43  unsafe { dealloc(ptr, Layout::from_size_align_unchecked(len as usize, 1)) }
44}
45
46fn read_arg(ptr: *const u8, len: u32) -> String {
47  if ptr.is_null() || len == 0 {
48    return String::new();
49  }
50  let bytes = unsafe { core::slice::from_raw_parts(ptr, len as usize) };
51  String::from_utf8_lossy(bytes).into_owned()
52}
53
54fn pack(s: String) -> u64 {
55  let boxed = s.into_bytes().into_boxed_slice();
56  let len = boxed.len() as u64;
57  let ptr = Box::leak(boxed).as_mut_ptr() as u32 as u64;
58  (ptr << 32) | len
59}
60
61fn ok(value: serde_json::Value) -> u64 {
62  pack(json!({ "Ok": value }).to_string())
63}
64
65fn err(message: impl std::fmt::Display) -> u64 {
66  pack(json!({ "Error": { "message": message.to_string() } }).to_string())
67}
68
69fn doc_value(doc: &ReviewDocument) -> serde_json::Value {
70  serde_json::to_value(doc).expect("document serializes")
71}
72
73// --------------------------------------------------------------------- api
74
75/// `meta` = `{"repo": "...", "base": {"name","sha"}, "head": {"name","sha"}}`.
76/// Returns a fresh empty review document.
77#[no_mangle]
78pub extern "C" fn pd_new_document(meta_ptr: *const u8, meta_len: u32) -> u64 {
79  #[derive(serde::Deserialize)]
80  struct Meta {
81    repo: String,
82    base: RefInfo,
83    head: RefInfo,
84  }
85  match serde_json::from_str::<Meta>(&read_arg(meta_ptr, meta_len)) {
86    Ok(m) => ok(doc_value(&ReviewDocument::new(m.repo, m.base, m.head))),
87    Err(e) => err(format!("invalid meta: {e}")),
88  }
89}
90
91/// Parse, validate, and normalize a stored document (the load path).
92#[no_mangle]
93pub extern "C" fn pd_parse_document(doc_ptr: *const u8, doc_len: u32) -> u64 {
94  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
95    Ok(doc) => ok(doc_value(&doc)),
96    Err(e) => err(e),
97  }
98}
99
100/// Insert or replace (by id) one comment. Returns the updated document.
101#[no_mangle]
102pub extern "C" fn pd_upsert_comment(doc_ptr: *const u8, doc_len: u32, comment_ptr: *const u8, comment_len: u32) -> u64 {
103  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
104    Ok(d) => d,
105    Err(e) => return err(e),
106  };
107  let comment: Comment = match serde_json::from_str(&read_arg(comment_ptr, comment_len)) {
108    Ok(c) => c,
109    Err(e) => return err(format!("invalid comment: {e}")),
110  };
111  match doc.upsert(comment) {
112    Ok(()) => ok(doc_value(&doc)),
113    Err(e) => err(e),
114  }
115}
116
117/// Delete a comment by id (the id arrives as a plain UTF-8 string, not JSON).
118/// Returns the updated document; deleting a missing id is not an error.
119#[no_mangle]
120pub extern "C" fn pd_delete_comment(doc_ptr: *const u8, doc_len: u32, id_ptr: *const u8, id_len: u32) -> u64 {
121  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
122    Ok(d) => d,
123    Err(e) => return err(e),
124  };
125  doc.delete(&read_arg(id_ptr, id_len));
126  ok(doc_value(&doc))
127}
128
129/// Set, replace, or clear the review verdict. `verdict` is either the
130/// single-key union — `{"Approved": {"at": "…"}}` / `{"ChangesRequired":
131/// {"at": "…"}}` — or the literal `null` to return the review to
132/// in-progress. Returns the updated document.
133#[no_mangle]
134pub extern "C" fn pd_set_verdict(doc_ptr: *const u8, doc_len: u32, verdict_ptr: *const u8, verdict_len: u32) -> u64 {
135  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
136    Ok(d) => d,
137    Err(e) => return err(e),
138  };
139  let verdict: Option<Verdict> = match serde_json::from_str(&read_arg(verdict_ptr, verdict_len)) {
140    Ok(v) => v,
141    Err(e) => return err(format!("invalid verdict: {e}")),
142  };
143  match doc.set_verdict(verdict) {
144    Ok(()) => ok(doc_value(&doc)),
145    Err(e) => err(e),
146  }
147}
148
149/// Merge an imported document into the current one (union by id, newer
150/// `updated_at` wins). Returns the merged document.
151#[no_mangle]
152pub extern "C" fn pd_merge(doc_ptr: *const u8, doc_len: u32, incoming_ptr: *const u8, incoming_len: u32) -> u64 {
153  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
154    Ok(d) => d,
155    Err(e) => return err(e),
156  };
157  let incoming = match ReviewDocument::parse(&read_arg(incoming_ptr, incoming_len)) {
158    Ok(d) => d,
159    Err(e) => return err(format!("import rejected: {e}")),
160  };
161  doc.merge(&incoming);
162  ok(doc_value(&doc))
163}
164
165#[no_mangle]
166pub extern "C" fn pd_export_json(doc_ptr: *const u8, doc_len: u32) -> u64 {
167  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
168    Ok(doc) => ok(serde_json::Value::String(export::to_json(&doc))),
169    Err(e) => err(e),
170  }
171}
172
173#[no_mangle]
174pub extern "C" fn pd_export_markdown(doc_ptr: *const u8, doc_len: u32) -> u64 {
175  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
176    Ok(doc) => ok(serde_json::Value::String(export::to_markdown(&doc))),
177    Err(e) => err(e),
178  }
179}
180
181#[no_mangle]
182pub extern "C" fn pd_export_csv(doc_ptr: *const u8, doc_len: u32) -> u64 {
183  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
184    Ok(doc) => ok(serde_json::Value::String(export::to_csv(&doc))),
185    Err(e) => err(e),
186  }
187}
188
189/// Diff a contiguous commit sub-range from the page's embedded snapshots.
190/// `snapshots` is a `RangeSnapshots` JSON; `params` is
191/// `{"from": <boundary index>, "to": <boundary index>, "context": N}` with
192/// `from < to` (commit `k` alone is `from = k - 1, to = k`). `Ok` carries
193/// the `FileDiff` array — the same shape the build-time parser emits.
194#[no_mangle]
195pub extern "C" fn pd_range_diff(snap_ptr: *const u8, snap_len: u32, params_ptr: *const u8, params_len: u32) -> u64 {
196  #[derive(serde::Deserialize)]
197  #[serde(deny_unknown_fields)]
198  struct Params {
199    from: usize,
200    to: usize,
201    context: usize,
202  }
203  let snap: packdiff_dto::snapshot::RangeSnapshots = match serde_json::from_str(&read_arg(snap_ptr, snap_len)) {
204    Ok(s) => s,
205    Err(e) => return err(format!("invalid snapshots: {e}")),
206  };
207  let params: Params = match serde_json::from_str(&read_arg(params_ptr, params_len)) {
208    Ok(p) => p,
209    Err(e) => return err(format!("invalid params: {e}")),
210  };
211  match packdiff_dto::snapshot::range_diff(&snap, params.from, params.to, params.context) {
212    Ok(files) => ok(serde_json::to_value(files).expect("FileDiff serializes: no non-string keys")),
213    Err(e) => err(e),
214  }
215}
216
217/// Unchanged lines shared by a file's two endpoint snapshots — the page's
218/// expand-context data. `snapshots` is a `RangeSnapshots` JSON; `params` is
219/// `{"old_path": "...", "new_path": "...", "old_start": N, "new_start": N,
220/// "count": N}` (1-based starts; paths differ for renames). `Ok` carries a
221/// `Line` array of `Ctx` entries, clamped at either file's end; a region
222/// that is not identical at both endpoints is rejected.
223#[no_mangle]
224pub extern "C" fn pd_context_slice(snap_ptr: *const u8, snap_len: u32, params_ptr: *const u8, params_len: u32) -> u64 {
225  #[derive(serde::Deserialize)]
226  #[serde(deny_unknown_fields)]
227  struct Params {
228    old_path: String,
229    new_path: String,
230    old_start: u32,
231    new_start: u32,
232    count: u32,
233  }
234  let snap: packdiff_dto::snapshot::RangeSnapshots = match serde_json::from_str(&read_arg(snap_ptr, snap_len)) {
235    Ok(s) => s,
236    Err(e) => return err(format!("invalid snapshots: {e}")),
237  };
238  let params: Params = match serde_json::from_str(&read_arg(params_ptr, params_len)) {
239    Ok(p) => p,
240    Err(e) => return err(format!("invalid params: {e}")),
241  };
242  match packdiff_dto::snapshot::context_slice(
243    &snap,
244    &params.old_path,
245    &params.new_path,
246    params.old_start,
247    params.new_start,
248    params.count,
249  ) {
250    Ok(lines) => ok(serde_json::to_value(lines).expect("Line serializes: no non-string keys")),
251    Err(e) => err(e),
252  }
253}
254
255/// Render markdown to safe HTML (the subset in `packdiff_dto::markdown`).
256/// The input is the raw markdown text — a plain UTF-8 string, not JSON —
257/// and `Ok` carries the HTML string. Never fails on any input.
258#[no_mangle]
259pub extern "C" fn pd_markdown_html(text_ptr: *const u8, text_len: u32) -> u64 {
260  ok(serde_json::Value::String(packdiff_dto::markdown::to_html(&read_arg(text_ptr, text_len))))
261}
262
263/// `meta` as in [`pd_new_document`]; returns the legacy SHA-pinned
264/// localStorage key string. Pages now key state by the content-fingerprint
265/// `review_id` and call this once to migrate pre-`review_id` state.
266#[no_mangle]
267pub extern "C" fn pd_storage_key(meta_ptr: *const u8, meta_len: u32) -> u64 {
268  #[derive(serde::Deserialize)]
269  struct Meta {
270    repo: String,
271    base: RefInfo,
272    head: RefInfo,
273  }
274  match serde_json::from_str::<Meta>(&read_arg(meta_ptr, meta_len)) {
275    Ok(m) => ok(serde_json::Value::String(storage_key(&m.repo, &m.base.sha, &m.head.sha))),
276    Err(e) => err(format!("invalid meta: {e}")),
277  }
278}