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