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/// The raw-pointer signature is the stable C ABI; callers must return exactly
39/// a pointer/length pair obtained from this module.
40#[no_mangle]
41#[allow(clippy::not_unsafe_ptr_arg_deref)]
42pub extern "C" fn pd_free(ptr: *mut u8, len: u32) {
43  if ptr.is_null() || len == 0 {
44    return;
45  }
46  unsafe { dealloc(ptr, Layout::from_size_align_unchecked(len as usize, 1)) }
47}
48
49fn read_arg(ptr: *const u8, len: u32) -> String {
50  if ptr.is_null() || len == 0 {
51    return String::new();
52  }
53  let bytes = unsafe { core::slice::from_raw_parts(ptr, len as usize) };
54  String::from_utf8_lossy(bytes).into_owned()
55}
56
57fn pack(s: String) -> u64 {
58  let boxed = s.into_bytes().into_boxed_slice();
59  let len = boxed.len() as u64;
60  let ptr = Box::leak(boxed).as_mut_ptr() as u32 as u64;
61  (ptr << 32) | len
62}
63
64fn ok(value: serde_json::Value) -> u64 {
65  pack(json!({ "Ok": value }).to_string())
66}
67
68fn err(message: impl std::fmt::Display) -> u64 {
69  pack(json!({ "Error": { "message": message.to_string() } }).to_string())
70}
71
72fn doc_value(doc: &ReviewDocument) -> serde_json::Value {
73  serde_json::to_value(doc).expect("document serializes")
74}
75
76// --------------------------------------------------------------------- api
77
78/// `meta` = `{"repo": "...", "base": {"name","sha"}, "head": {"name","sha"}}`.
79/// Returns a fresh empty review document.
80#[no_mangle]
81pub extern "C" fn pd_new_document(meta_ptr: *const u8, meta_len: u32) -> u64 {
82  #[derive(serde::Deserialize)]
83  struct Meta {
84    repo: String,
85    base: RefInfo,
86    head: RefInfo,
87  }
88  match serde_json::from_str::<Meta>(&read_arg(meta_ptr, meta_len)) {
89    Ok(m) => ok(doc_value(&ReviewDocument::new(m.repo, m.base, m.head))),
90    Err(e) => err(format!("invalid meta: {e}")),
91  }
92}
93
94/// Parse, validate, and normalize a stored document (the load path).
95#[no_mangle]
96pub extern "C" fn pd_parse_document(doc_ptr: *const u8, doc_len: u32) -> u64 {
97  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
98    Ok(doc) => ok(doc_value(&doc)),
99    Err(e) => err(e),
100  }
101}
102
103/// Insert or replace (by id) one comment. Returns the updated document.
104#[no_mangle]
105pub extern "C" fn pd_upsert_comment(doc_ptr: *const u8, doc_len: u32, comment_ptr: *const u8, comment_len: u32) -> u64 {
106  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
107    Ok(d) => d,
108    Err(e) => return err(e),
109  };
110  let comment: Comment = match serde_json::from_str(&read_arg(comment_ptr, comment_len)) {
111    Ok(c) => c,
112    Err(e) => return err(format!("invalid comment: {e}")),
113  };
114  match doc.upsert(comment) {
115    Ok(()) => ok(doc_value(&doc)),
116    Err(e) => err(e),
117  }
118}
119
120/// Delete a comment by id (the id arrives as a plain UTF-8 string, not JSON).
121/// Returns the updated document; deleting a missing id is not an error.
122#[no_mangle]
123pub extern "C" fn pd_delete_comment(doc_ptr: *const u8, doc_len: u32, id_ptr: *const u8, id_len: u32) -> u64 {
124  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
125    Ok(d) => d,
126    Err(e) => return err(e),
127  };
128  doc.delete(&read_arg(id_ptr, id_len));
129  ok(doc_value(&doc))
130}
131
132/// Set, replace, or clear the review verdict. `verdict` is either the
133/// single-key union — `{"Approved": {"at": "…"}}` / `{"ChangesRequired":
134/// {"at": "…"}}` — or the literal `null` to return the review to
135/// in-progress. Returns the updated document.
136#[no_mangle]
137pub extern "C" fn pd_set_verdict(doc_ptr: *const u8, doc_len: u32, verdict_ptr: *const u8, verdict_len: u32) -> u64 {
138  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
139    Ok(d) => d,
140    Err(e) => return err(e),
141  };
142  let verdict: Option<Verdict> = match serde_json::from_str(&read_arg(verdict_ptr, verdict_len)) {
143    Ok(v) => v,
144    Err(e) => return err(format!("invalid verdict: {e}")),
145  };
146  match doc.set_verdict(verdict) {
147    Ok(()) => ok(doc_value(&doc)),
148    Err(e) => err(e),
149  }
150}
151
152/// Merge an imported document into the current one (union by id, newer
153/// `updated_at` wins). Returns the merged document.
154#[no_mangle]
155pub extern "C" fn pd_merge(doc_ptr: *const u8, doc_len: u32, incoming_ptr: *const u8, incoming_len: u32) -> u64 {
156  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
157    Ok(d) => d,
158    Err(e) => return err(e),
159  };
160  let incoming = match ReviewDocument::parse(&read_arg(incoming_ptr, incoming_len)) {
161    Ok(d) => d,
162    Err(e) => return err(format!("import rejected: {e}")),
163  };
164  doc.merge(&incoming);
165  ok(doc_value(&doc))
166}
167
168#[no_mangle]
169pub extern "C" fn pd_export_json(doc_ptr: *const u8, doc_len: u32) -> u64 {
170  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
171    Ok(doc) => ok(serde_json::Value::String(export::to_json(&doc))),
172    Err(e) => err(e),
173  }
174}
175
176#[no_mangle]
177pub extern "C" fn pd_export_markdown(doc_ptr: *const u8, doc_len: u32) -> u64 {
178  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
179    Ok(doc) => ok(serde_json::Value::String(export::to_markdown(&doc))),
180    Err(e) => err(e),
181  }
182}
183
184#[no_mangle]
185pub extern "C" fn pd_export_csv(doc_ptr: *const u8, doc_len: u32) -> u64 {
186  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
187    Ok(doc) => ok(serde_json::Value::String(export::to_csv(&doc))),
188    Err(e) => err(e),
189  }
190}
191
192/// Diff a contiguous commit sub-range from the page's embedded snapshots.
193/// `snapshots` is a `RangeSnapshots` JSON; `params` is
194/// `{"from": <boundary index>, "to": <boundary index>, "context": N}` with
195/// `from < to` (commit `k` alone is `from = k - 1, to = k`). `Ok` carries
196/// the `FileDiff` array — the same shape the build-time parser emits.
197#[no_mangle]
198pub extern "C" fn pd_range_diff(snap_ptr: *const u8, snap_len: u32, params_ptr: *const u8, params_len: u32) -> u64 {
199  #[derive(serde::Deserialize)]
200  #[serde(deny_unknown_fields)]
201  struct Params {
202    from: usize,
203    to: usize,
204    context: usize,
205  }
206  let snap: packdiff_dto::snapshot::RangeSnapshots = match serde_json::from_str(&read_arg(snap_ptr, snap_len)) {
207    Ok(s) => s,
208    Err(e) => return err(format!("invalid snapshots: {e}")),
209  };
210  let params: Params = match serde_json::from_str(&read_arg(params_ptr, params_len)) {
211    Ok(p) => p,
212    Err(e) => return err(format!("invalid params: {e}")),
213  };
214  match packdiff_dto::snapshot::range_diff(&snap, params.from, params.to, params.context) {
215    Ok(files) => ok(serde_json::to_value(files).expect("FileDiff serializes: no non-string keys")),
216    Err(e) => err(e),
217  }
218}
219
220/// Unchanged lines shared by a file's two endpoint snapshots — the page's
221/// expand-context data. `snapshots` is a `RangeSnapshots` JSON; `params` is
222/// `{"old_path": "...", "new_path": "...", "old_start": N, "new_start": N,
223/// "count": N}` (1-based starts; paths differ for renames). `Ok` carries a
224/// `Line` array of `Ctx` entries, clamped at either file's end; a region
225/// that is not identical at both endpoints is rejected.
226#[no_mangle]
227pub extern "C" fn pd_context_slice(snap_ptr: *const u8, snap_len: u32, params_ptr: *const u8, params_len: u32) -> u64 {
228  #[derive(serde::Deserialize)]
229  #[serde(deny_unknown_fields)]
230  struct Params {
231    old_path: String,
232    new_path: String,
233    old_start: u32,
234    new_start: u32,
235    count: u32,
236  }
237  let snap: packdiff_dto::snapshot::RangeSnapshots = match serde_json::from_str(&read_arg(snap_ptr, snap_len)) {
238    Ok(s) => s,
239    Err(e) => return err(format!("invalid snapshots: {e}")),
240  };
241  let params: Params = match serde_json::from_str(&read_arg(params_ptr, params_len)) {
242    Ok(p) => p,
243    Err(e) => return err(format!("invalid params: {e}")),
244  };
245  match packdiff_dto::snapshot::context_slice(
246    &snap,
247    &params.old_path,
248    &params.new_path,
249    params.old_start,
250    params.new_start,
251    params.count,
252  ) {
253    Ok(lines) => ok(serde_json::to_value(lines).expect("Line serializes: no non-string keys")),
254    Err(e) => err(e),
255  }
256}
257
258/// Render markdown to safe HTML (the subset in `packdiff_dto::markdown`).
259/// The input is the raw markdown text — a plain UTF-8 string, not JSON —
260/// and `Ok` carries the HTML string. Never fails on any input.
261#[no_mangle]
262pub extern "C" fn pd_markdown_html(text_ptr: *const u8, text_len: u32) -> u64 {
263  ok(serde_json::Value::String(packdiff_dto::markdown::to_html(&read_arg(text_ptr, text_len))))
264}
265
266/// Highlight a contiguous source-line run. `lines` is a JSON string array;
267/// `Ok` carries an HTML string array, or `null` for an unknown path.
268#[no_mangle]
269pub extern "C" fn pd_highlight_lines(path_ptr: *const u8, path_len: u32, lines_ptr: *const u8, lines_len: u32) -> u64 {
270  let path = read_arg(path_ptr, path_len);
271  let lines: Vec<String> = match serde_json::from_str(&read_arg(lines_ptr, lines_len)) {
272    Ok(lines) => lines,
273    Err(e) => return err(format!("invalid lines: {e}")),
274  };
275  let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
276  ok(serde_json::to_value(packdiff_dto::highlight::highlight_lines(&path, &refs)).expect("highlighted lines serialize"))
277}
278
279/// Highlight a typed diff hunk with independent old/new lexer streams.
280/// `lines` is a JSON `Line`-union array; success has the same fallback shape
281/// as [`pd_highlight_lines`].
282#[no_mangle]
283pub extern "C" fn pd_highlight_hunk(path_ptr: *const u8, path_len: u32, lines_ptr: *const u8, lines_len: u32) -> u64 {
284  let path = read_arg(path_ptr, path_len);
285  let lines: Vec<packdiff_dto::diff::Line> = match serde_json::from_str(&read_arg(lines_ptr, lines_len)) {
286    Ok(lines) => lines,
287    Err(e) => return err(format!("invalid hunk lines: {e}")),
288  };
289  ok(serde_json::to_value(packdiff_dto::highlight::highlight_hunk(&path, &lines)).expect("highlighted hunk serializes"))
290}
291
292/// `meta` as in [`pd_new_document`]; returns the legacy SHA-pinned
293/// localStorage key string. Pages now key state by the content-fingerprint
294/// `review_id` and call this once to migrate pre-`review_id` state.
295#[no_mangle]
296pub extern "C" fn pd_storage_key(meta_ptr: *const u8, meta_len: u32) -> u64 {
297  #[derive(serde::Deserialize)]
298  struct Meta {
299    repo: String,
300    base: RefInfo,
301    head: RefInfo,
302  }
303  match serde_json::from_str::<Meta>(&read_arg(meta_ptr, meta_len)) {
304    Ok(m) => ok(serde_json::Value::String(storage_key(&m.repo, &m.base.sha, &m.head.sha))),
305    Err(e) => err(format!("invalid meta: {e}")),
306  }
307}