Skip to main content

differential_schema/
lib.rs

1//! The frozen JSON contract for differential reading plans.
2//!
3//! This crate is the product boundary: every consumer (shadow-branch stack, TUI,
4//! forge review) depends on these types and nothing else. Consumer conveniences
5//! must not leak in here.
6//!
7//! Contract rules:
8//! - `schema_version` is 1. Readers must reject versions they do not know.
9//! - Deserialisation tolerates unknown fields, so additive changes are non-breaking.
10//! - `groups`/`reading_plan` are `null` when the grouping stage has not run. That is
11//!   distinct from `[]`, which would mean "grouping ran and produced nothing" and is
12//!   always a bug. `generator.stages` states exactly which stages produced the document.
13//! - Optional fields serialise as explicit `null`, never omitted.
14
15use serde::{Deserialize, Serialize};
16
17pub const SCHEMA_VERSION: u32 = 1;
18
19/// The one JSON document: a grouped, ordered reading plan for a diff.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct PlanDocument {
22    pub schema_version: u32,
23    pub generator: Generator,
24    pub source: Source,
25    pub stats: Stats,
26    pub files: Vec<FileEntry>,
27    pub hunks: Vec<HunkEntry>,
28    pub classes: Vec<ClassEntry>,
29    /// `None` until the grouping stage runs. `Some(vec![])` is a bug, not a state.
30    pub groups: Option<Vec<Group>>,
31    /// `None` until the grouping stage runs; ordered foundation-first once present.
32    pub reading_plan: Option<Vec<ReadingStep>>,
33    pub audit: Audit,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct Generator {
38    pub tool: String,
39    pub version: String,
40    /// Pipeline stages that actually ran, in order: "enumerate", "classify",
41    /// "group", "order".
42    pub stages: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct Source {
47    pub kind: SourceKind,
48    /// Fully resolved commit sha.
49    pub base: String,
50    /// Fully resolved commit sha.
51    pub head: String,
52    pub remote: Option<Remote>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum SourceKind {
58    Commit,
59    Range,
60    Mr,
61    Pr,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct Remote {
66    pub forge: String,
67    pub project: String,
68    pub id: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct Stats {
73    pub files: u32,
74    pub hunks: u32,
75    pub classes: u32,
76    pub binary_files: u32,
77    pub submodules: u32,
78}
79
80/// One changed file in the canonical (`--no-renames`) view. A rename therefore
81/// appears as a D entry plus an A entry; the rename-detected view annotates both.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct FileEntry {
84    pub path: String,
85    pub disposition: Disposition,
86    /// New-side mode ("100644", "100755", "120000", "160000"); `None` on deletion.
87    pub mode: Option<String>,
88    /// Old-side mode when it differs from `mode`, and on deletion.
89    pub old_mode: Option<String>,
90    /// On the A side of a detected rename: where the content came from.
91    pub old_path: Option<String>,
92    /// On the D side of a detected rename: where the content went. Together with
93    /// `old_path` this makes "moved and modified" addressable from both ends.
94    pub new_path: Option<String>,
95    /// Similarity score 0-100 from git's rename detection. Present on both sides of
96    /// a detected rename. Below ~95 the change is a modification, not a relocation,
97    /// and must never be treated as skim-eligible.
98    pub rename_similarity: Option<u8>,
99    /// Binary files carry zero hunks; content is tracked by object id only.
100    pub binary: bool,
101    pub submodule: Option<SubmoduleChange>,
102    /// Hint for the noise tier. Computed (builtin list, gitattributes, repo config),
103    /// never claimed by a model.
104    pub generated: bool,
105    pub generated_by: Option<GeneratedBy>,
106    /// Ids into `hunks`, in file order.
107    pub hunk_ids: Vec<String>,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub enum Disposition {
112    A,
113    D,
114    M,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct SubmoduleChange {
119    pub old: Option<String>,
120    pub new: Option<String>,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum GeneratedBy {
126    /// Matched the built-in lockfile/artefact list.
127    Builtin,
128    /// Declared by the repo via a gitattributes attribute (e.g. linguist-generated).
129    Attr,
130    /// Matched a glob in the repo's `.differential.toml`.
131    Config,
132}
133
134/// One canonical hunk from `git diff -U0 --no-renames`. Ids are positional
135/// (`h0..hN` in enumeration order) and do NOT survive regeneration; `digest` does.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct HunkEntry {
138    pub id: String,
139    pub file: String,
140    pub old_start: u32,
141    pub old_count: u32,
142    pub new_start: u32,
143    pub new_count: u32,
144    /// Shape class id into `classes`.
145    pub class: String,
146    /// Exact content hash of the hunk (removed ++ added bytes, un-normalised).
147    /// The stable anchor for comments and review state across regenerations.
148    pub digest: String,
149    /// `\ No newline at end of file` on the old side.
150    pub nonl_old: bool,
151    /// `\ No newline at end of file` on the new side.
152    pub nonl_new: bool,
153    /// Position in the forge's rename-detected diff, for posting comments.
154    pub forge_position: ForgePosition,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct ForgePosition {
159    /// Line in the new file; `None` for deletion-only hunks.
160    pub new_line: Option<u32>,
161    /// Line in the old file; `None` for insertion-only hunks.
162    pub old_line: Option<u32>,
163}
164
165/// A shape class: hunks whose diff text is identical after normalising away
166/// identifiers and literals on BOTH sides. Ids `C0..Cn`, numbered by descending
167/// member count. 100% hunk coverage is by construction.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct ClassEntry {
170    pub id: String,
171    pub hunk_ids: Vec<String>,
172    /// The member a reviewer reads to verify the whole class.
173    pub exemplar: String,
174    /// True iff, after erasing identifiers and literals, the removed and added
175    /// lines match — a structure-free substitution. Computed, never claimed.
176    pub pure_substitution: bool,
177}
178
179/// A merged, labelled group of shape classes. Produced by the grouping stage.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct Group {
182    pub id: String,
183    pub label: String,
184    pub description: String,
185    pub reason: String,
186    pub effort: Effort,
187    /// `None` until the ordering stage runs — role is an ordering-stage output.
188    pub role: Option<Role>,
189    pub class_ids: Vec<String>,
190    /// Group ids this group depends on (it consumes what they define).
191    pub depends_on: Vec<String>,
192    /// Position in the foundation-first ordering.
193    pub rank: u32,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198pub enum Effort {
199    /// Read every hunk.
200    Close,
201    /// Read one exemplar per shape class; trust the rest.
202    Skim,
203    /// Generated content: folded entirely, no exemplars to read.
204    Noise,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "lowercase")]
209pub enum Role {
210    Foundation,
211    Consumer,
212    Mechanical,
213    Noise,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct ReadingStep {
218    pub group: String,
219    pub action: ReadAction,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "lowercase")]
224pub enum ReadAction {
225    /// Read every hunk in the group.
226    Read,
227    /// Read one hunk per shape class.
228    Exemplars,
229    /// Remaining members of already-verified shapes.
230    Skip,
231    /// Noise group: collapsed entirely.
232    Fold,
233}
234
235/// Structural audit. The first four fields exist for every document; the rest are
236/// `null` until the grouping stage runs.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct Audit {
239    /// "n/n" — files reconstructed byte-exactly from base + hunks.
240    pub applier_exact: String,
241    /// "pass" — built-from-hunks tree equals the head tree.
242    pub tree_assertion: String,
243    pub hunks_carried: u32,
244    /// Independent `@@` recount computed from git output, not from bookkeeping.
245    pub recount: u32,
246    pub coverage: Option<f64>,
247    pub classes_missing: Option<u32>,
248    pub classes_duplicated: Option<Vec<String>>,
249    pub classes_hallucinated: Option<Vec<String>>,
250    /// Hunks a reviewer actually reads (close + exemplars). The honest number.
251    pub read_hunks: Option<u32>,
252    /// Hunks never opened (skim remainders + folded noise). The genuine saving.
253    pub skipped_hunks: Option<u32>,
254}
255
256#[derive(Debug)]
257pub enum SchemaError {
258    UnsupportedVersion { found: u32 },
259    Json(serde_json::Error),
260}
261
262impl std::fmt::Display for SchemaError {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        match self {
265            SchemaError::UnsupportedVersion { found } => write!(
266                f,
267                "unsupported schema_version {found} (this reader understands {SCHEMA_VERSION})"
268            ),
269            SchemaError::Json(e) => write!(f, "invalid plan document: {e}"),
270        }
271    }
272}
273
274impl std::error::Error for SchemaError {
275    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
276        match self {
277            SchemaError::Json(e) => Some(e),
278            _ => None,
279        }
280    }
281}
282
283impl From<serde_json::Error> for SchemaError {
284    fn from(e: serde_json::Error) -> Self {
285        SchemaError::Json(e)
286    }
287}
288
289impl PlanDocument {
290    /// Parse and enforce the version gate. Use this instead of raw serde_json.
291    pub fn from_json(s: &str) -> Result<Self, SchemaError> {
292        #[derive(Deserialize)]
293        struct VersionProbe {
294            schema_version: u32,
295        }
296        let probe: VersionProbe = serde_json::from_str(s)?;
297        if probe.schema_version != SCHEMA_VERSION {
298            return Err(SchemaError::UnsupportedVersion {
299                found: probe.schema_version,
300            });
301        }
302        Ok(serde_json::from_str(s)?)
303    }
304
305    pub fn to_json_pretty(&self) -> Result<String, SchemaError> {
306        Ok(serde_json::to_string_pretty(self)?)
307    }
308
309    pub fn to_json(&self) -> Result<String, SchemaError> {
310        Ok(serde_json::to_string(self)?)
311    }
312}