Skip to main content

packdiff_dto/
lib.rs

1//! # packdiff-dto — the data model
2//!
3//! This crate is the single source of truth for every piece of data packdiff
4//! handles. It is **pure logic**: no filesystem, no subprocess, no clock, no
5//! randomness — those all live in the callers (`packdiff-cli` natively,
6//! `packdiff-wasm` in the browser). That is what lets the *same* compiled
7//! semantics run on both sides of the tool.
8//!
9//! Two document families:
10//!
11//! - [`diff::DiffDocument`] — the **immutable build artifact**: everything the
12//!   CLI extracted from git (refs, commits, per-file hunks and lines). It is
13//!   produced once per render and embedded/consumed read-only.
14//! - [`review::ReviewDocument`] — the **mutable review state**: the comments a
15//!   human leaves on the page. It lives in the browser's localStorage, and
16//!   every mutation (upsert, delete, merge/import) goes through this crate via
17//!   WASM — the page's JavaScript never edits the document itself.
18//!
19//! Schema rules:
20//!
21//! - JSON field names are `snake_case`; union variants are `CamelCase` and
22//!   encode as single-key objects — `{ "VariantName": { ...payload } }` — with
23//!   no discriminator field.
24//! - Every struct strict-rejects unknown fields (`deny_unknown_fields`): a
25//!   typo or a drifted producer is a hard, loud error, never a silently
26//!   ignored key.
27//! - Both documents carry `schema_version` ([`SCHEMA_VERSION`]). This is the
28//!   one deliberate opt-in to long-term compatibility (review documents live
29//!   in users' localStorage): parsers reject documents from a *newer* schema
30//!   than they understand and accept older ones. v2 (verdict + per-comment
31//!   resolution) reads v1 documents — the new fields default to absent; a v1
32//!   reader rejects v2 documents loudly, by design.
33//! - Determinism: same inputs → byte-identical outputs. Ordering is always
34//!   explicit ([`review::ReviewDocument::sort`]), timestamps and ids are
35//!   caller-supplied, and exports are stable.
36
37pub mod diff;
38pub mod export;
39pub mod highlight;
40pub mod markdown;
41pub mod review;
42pub mod snapshot;
43
44/// Version stamped into (and required of) every document this crate touches.
45/// One generation covers both document families: v2 added the review
46/// verdict and per-comment resolution; diff documents are shape-identical
47/// to v1.
48pub const SCHEMA_VERSION: u32 = 2;
49
50/// Tool identifier stamped into documents.
51pub const TOOL: &str = "packdiff";
52
53/// A named ref pinned to the commit it resolved to at build time.
54#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct RefInfo {
57  /// What the user asked for: branch, tag, or SHA — kept verbatim so pages
58  /// and exports show the name the reviewer thinks in.
59  pub name: String,
60  /// The full commit SHA the name resolved to; pins the document to an exact
61  /// state even if the ref later moves.
62  pub sha: String,
63}
64
65/// Errors surfaced by any model operation. Typed so callers branch on
66/// variants; the WASM ABI ships them as `{ "Error": { "message": ... } }`.
67#[derive(Debug, PartialEq, Eq, thiserror::Error)]
68pub enum ModelError {
69  /// Input was not valid JSON for the expected shape.
70  #[error("invalid JSON: {0}")]
71  Json(String),
72  /// Document is from a newer schema than this build understands.
73  #[error("unsupported schema_version {found} (this build supports up to {supported})")]
74  UnsupportedSchema {
75    /// The `schema_version` the document claims.
76    found: u32,
77    /// The newest `schema_version` this build can read.
78    supported: u32,
79  },
80  /// A comment failed validation.
81  #[error("invalid comment: {0}")]
82  InvalidComment(String),
83  /// A review verdict failed validation.
84  #[error("invalid verdict: {0}")]
85  InvalidVerdict(String),
86  /// A commit-range request did not match the snapshot store (bad boundary
87  /// indices, or a boundary referencing a blob the store does not carry).
88  #[error("invalid snapshot range: {0}")]
89  InvalidRange(String),
90}
91
92/// The LEGACY localStorage key a review document was filed under, exposed to
93/// the page as `pd_storage_key` and consulted once per load to migrate old
94/// state. Pages now key state by `review_id` — a render-time fingerprint of
95/// the canonical diff content (see `cli/src/render.rs` and docs/PAGE.md) —
96/// so an identical regenerated diff keeps its comments. Carrying comments
97/// across a CHANGED diff remains export → import, where
98/// [`review::ReviewDocument::merge`] applies.
99pub fn storage_key(repo: &str, base_sha: &str, head_sha: &str) -> String {
100  fn short(sha: &str) -> &str {
101    &sha[..sha.len().min(12)]
102  }
103  // The `v1` here names a localStorage NAMESPACE, deliberately decoupled
104  // from SCHEMA_VERSION: documents self-describe their schema, and the
105  // migration must keep finding the keys old pages wrote. Interpolating
106  // SCHEMA_VERSION would silently orphan every existing store on a bump.
107  format!("packdiff:v1:{}:{}..{}", repo, short(base_sha), short(head_sha))
108}
109
110#[cfg(test)]
111mod tests {
112  use super::*;
113
114  #[test]
115  fn storage_key_pins_repo_and_short_shas() {
116    let key =
117      storage_key("myrepo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
118    assert_eq!(key, "packdiff:v1:myrepo:aaaaaaaaaaaa..bbbbbbbbbbbb");
119  }
120
121  #[test]
122  fn storage_key_tolerates_short_shas() {
123    assert_eq!(storage_key("r", "abc", "def"), "packdiff:v1:r:abc..def");
124  }
125}