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};
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/// Merge an imported document into the current one (union by id, newer
130/// `updated_at` wins). Returns the merged document.
131#[no_mangle]
132pub extern "C" fn pd_merge(doc_ptr: *const u8, doc_len: u32, incoming_ptr: *const u8, incoming_len: u32) -> u64 {
133  let mut doc = match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
134    Ok(d) => d,
135    Err(e) => return err(e),
136  };
137  let incoming = match ReviewDocument::parse(&read_arg(incoming_ptr, incoming_len)) {
138    Ok(d) => d,
139    Err(e) => return err(format!("import rejected: {e}")),
140  };
141  doc.merge(&incoming);
142  ok(doc_value(&doc))
143}
144
145#[no_mangle]
146pub extern "C" fn pd_export_json(doc_ptr: *const u8, doc_len: u32) -> u64 {
147  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
148    Ok(doc) => ok(serde_json::Value::String(export::to_json(&doc))),
149    Err(e) => err(e),
150  }
151}
152
153#[no_mangle]
154pub extern "C" fn pd_export_markdown(doc_ptr: *const u8, doc_len: u32) -> u64 {
155  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
156    Ok(doc) => ok(serde_json::Value::String(export::to_markdown(&doc))),
157    Err(e) => err(e),
158  }
159}
160
161#[no_mangle]
162pub extern "C" fn pd_export_csv(doc_ptr: *const u8, doc_len: u32) -> u64 {
163  match ReviewDocument::parse(&read_arg(doc_ptr, doc_len)) {
164    Ok(doc) => ok(serde_json::Value::String(export::to_csv(&doc))),
165    Err(e) => err(e),
166  }
167}
168
169/// Diff a contiguous commit sub-range from the page's embedded snapshots.
170/// `snapshots` is a `RangeSnapshots` JSON; `params` is
171/// `{"from": <boundary index>, "to": <boundary index>, "context": N}` with
172/// `from < to` (commit `k` alone is `from = k - 1, to = k`). `Ok` carries
173/// the `FileDiff` array — the same shape the build-time parser emits.
174#[no_mangle]
175pub extern "C" fn pd_range_diff(snap_ptr: *const u8, snap_len: u32, params_ptr: *const u8, params_len: u32) -> u64 {
176  #[derive(serde::Deserialize)]
177  #[serde(deny_unknown_fields)]
178  struct Params {
179    from: usize,
180    to: usize,
181    context: usize,
182  }
183  let snap: packdiff_dto::snapshot::RangeSnapshots = match serde_json::from_str(&read_arg(snap_ptr, snap_len)) {
184    Ok(s) => s,
185    Err(e) => return err(format!("invalid snapshots: {e}")),
186  };
187  let params: Params = match serde_json::from_str(&read_arg(params_ptr, params_len)) {
188    Ok(p) => p,
189    Err(e) => return err(format!("invalid params: {e}")),
190  };
191  match packdiff_dto::snapshot::range_diff(&snap, params.from, params.to, params.context) {
192    Ok(files) => ok(serde_json::to_value(files).expect("FileDiff serializes: no non-string keys")),
193    Err(e) => err(e),
194  }
195}
196
197/// Render markdown to safe HTML (the subset in `packdiff_dto::markdown`).
198/// The input is the raw markdown text — a plain UTF-8 string, not JSON —
199/// and `Ok` carries the HTML string. Never fails on any input.
200#[no_mangle]
201pub extern "C" fn pd_markdown_html(text_ptr: *const u8, text_len: u32) -> u64 {
202  ok(serde_json::Value::String(packdiff_dto::markdown::to_html(&read_arg(text_ptr, text_len))))
203}
204
205/// `meta` as in [`pd_new_document`]; returns the localStorage key string.
206#[no_mangle]
207pub extern "C" fn pd_storage_key(meta_ptr: *const u8, meta_len: u32) -> u64 {
208  #[derive(serde::Deserialize)]
209  struct Meta {
210    repo: String,
211    base: RefInfo,
212    head: RefInfo,
213  }
214  match serde_json::from_str::<Meta>(&read_arg(meta_ptr, meta_len)) {
215    Ok(m) => ok(serde_json::Value::String(storage_key(&m.repo, &m.base.sha, &m.head.sha))),
216    Err(e) => err(format!("invalid meta: {e}")),
217  }
218}