Skip to main content

binoc_sdk/
ir.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4
5use crate::types::{ArtifactDescriptor, ItemPair};
6
7/// Which snapshot a [`Segment::Path`] resolves in.
8///
9/// Lets a renderer that can dereference a path — hyperlink it, shorten it
10/// against a tree, show an icon — target the correct side of the diff
11/// without understanding *why* the path appears (rename, copy,
12/// cross-reference, ...). It is a property of the value, not an encoding of
13/// any one concept. See ADR 2026-06-03-structured-summary-segments.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16#[serde(rename_all = "snake_case")]
17pub enum Side {
18    /// The "before" snapshot (a source/original path).
19    From,
20    /// The "after" snapshot (a destination/current path).
21    To,
22}
23
24/// One piece of a [`Summary`].
25///
26/// Each variant carries a value *and*, implicitly, the render-time policy
27/// for it: group an integer, format a float, leave text alone, dereference
28/// a path. Renderers format by variant; they never parse prose to recover
29/// the type of a value, because the producer never threw it away. Variants
30/// track *render behavior*, not semantics — a currency or percent is `Text`
31/// plus a number, never its own variant. See ADR
32/// 2026-06-03-structured-summary-segments.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(rename_all = "snake_case")]
36pub enum Segment {
37    /// Verbatim text: connective wording, units, punctuation, and any
38    /// value the renderer must not reinterpret. Embedded digits are never
39    /// reformatted — a number that should be grouped is a [`Segment::Uint`],
40    /// and a path that could be linked is a [`Segment::Path`].
41    Text(String),
42    /// A path or locator. Renderers may shorten or hyperlink it; `snapshot`
43    /// says which side of the diff it resolves in.
44    Path { value: String, snapshot: Side },
45    /// A non-negative count. Renderers apply digit grouping / locale.
46    Uint(u64),
47    /// A real-valued quantity. Renderers apply decimal / precision policy.
48    Float(f64),
49}
50
51/// A structured, render-ready one-line summary: an ordered list of typed
52/// [`Segment`]s.
53///
54/// Rule packs build it; renderers format
55/// each segment by its type. This replaces free-text summaries so that
56/// number and path formatting is a render-time decision the renderer makes
57/// from typed values, rather than a fragile reparse of prose. A producer
58/// that owns a concept (a rename detector) owns the *wording* — it emits the
59/// connective `Text` and the `Path`s — while the renderer owns the
60/// *typography*. See ADR 2026-06-03-structured-summary-segments.
61///
62/// The ergonomic shortcut for the common case is `impl Into<Summary>`:
63/// `with_summary("plain text")` still works and produces a single
64/// [`Segment::Text`].
65#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[serde(transparent)]
68pub struct Summary(pub Vec<Segment>);
69
70impl Summary {
71    pub fn new() -> Self {
72        Summary(Vec::new())
73    }
74
75    /// Append verbatim text, coalescing into a trailing text segment if the
76    /// summary already ends in one. Keeps the serialized form canonical so
77    /// that helpers like [`Summary::count`] which emit a count followed by
78    /// text don't leave redundant adjacent text segments on the wire.
79    pub fn text(mut self, value: impl Into<String>) -> Self {
80        let value = value.into();
81        if let Some(Segment::Text(last)) = self.0.last_mut() {
82            last.push_str(&value);
83        } else {
84            self.0.push(Segment::Text(value));
85        }
86        self
87    }
88
89    /// Append a non-negative count (renderer applies digit grouping).
90    pub fn uint(mut self, value: u64) -> Self {
91        self.0.push(Segment::Uint(value));
92        self
93    }
94
95    /// Append a counted noun: `"{n} {noun}"`, with the count as a
96    /// [`Segment::Uint`] (grouped by the renderer) and the noun pluralized
97    /// with a trailing `s` unless `n == 1`. For irregular plurals, build the
98    /// segments by hand. Example: `.count(5, "row")` -> `5 rows`.
99    pub fn count(self, n: u64, noun: &str) -> Self {
100        let suffix = if n == 1 { "" } else { "s" };
101        self.uint(n).text(format!(" {noun}{suffix}"))
102    }
103
104    /// Append a real-valued quantity (renderer applies decimal policy).
105    pub fn float(mut self, value: f64) -> Self {
106        self.0.push(Segment::Float(value));
107        self
108    }
109
110    /// Append a path/locator that resolves in `snapshot`.
111    pub fn path(mut self, value: impl Into<String>, snapshot: Side) -> Self {
112        self.0.push(Segment::Path {
113            value: value.into(),
114            snapshot,
115        });
116        self
117    }
118
119    /// Append a single segment.
120    pub fn push(&mut self, segment: Segment) {
121        self.0.push(segment);
122    }
123
124    /// Append all segments of another summary (e.g. when joining child
125    /// summaries into a trailer).
126    pub fn extend(&mut self, other: Summary) {
127        self.0.extend(other.0);
128    }
129
130    pub fn is_empty(&self) -> bool {
131        self.0.is_empty()
132    }
133
134    pub fn segments(&self) -> &[Segment] {
135        &self.0
136    }
137
138    /// Plain-text rendering with no formatting policy applied: text and path
139    /// values verbatim, numbers in bare decimal form. For consumers without a
140    /// renderer (Python bindings, machine sinks, provenance) and for internal
141    /// bookkeeping such as path-statement detection.
142    pub fn plain_text(&self) -> String {
143        self.to_string()
144    }
145
146    /// Uppercase the first character of the leading text segment, if the
147    /// summary begins with text. No-op when it begins with a number or path.
148    /// Mirrors sentence-casing of prose without scanning a built string.
149    pub fn capitalize_first(mut self) -> Self {
150        if let Some(Segment::Text(text)) = self.0.first_mut() {
151            if let Some(first) = text.get_mut(..1) {
152                first.make_ascii_uppercase();
153            }
154        }
155        self
156    }
157}
158
159impl fmt::Display for Summary {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        for segment in &self.0 {
162            match segment {
163                Segment::Text(text) => f.write_str(text)?,
164                Segment::Path { value, .. } => f.write_str(value)?,
165                Segment::Uint(value) => write!(f, "{value}")?,
166                Segment::Float(value) => write!(f, "{value}")?,
167            }
168        }
169        Ok(())
170    }
171}
172
173impl From<&str> for Summary {
174    fn from(value: &str) -> Self {
175        Summary(vec![Segment::Text(value.to_string())])
176    }
177}
178
179impl From<String> for Summary {
180    fn from(value: String) -> Self {
181        Summary(vec![Segment::Text(value)])
182    }
183}
184
185impl From<Vec<Segment>> for Summary {
186    fn from(value: Vec<Segment>) -> Self {
187        Summary(value)
188    }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193#[serde(rename_all = "snake_case")]
194pub enum DiagnosticSeverity {
195    Error,
196    Warning,
197    Suggestion,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
202pub struct Diagnostic {
203    pub severity: DiagnosticSeverity,
204    pub code: String,
205    pub message: Summary,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub location: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub extract: Option<ExtractHint>,
210}
211
212impl Diagnostic {
213    pub fn new(
214        severity: DiagnosticSeverity,
215        code: impl Into<String>,
216        message: impl Into<Summary>,
217    ) -> Self {
218        Self {
219            severity,
220            code: code.into(),
221            message: message.into(),
222            location: None,
223            extract: None,
224        }
225    }
226
227    pub fn warning(code: impl Into<String>, message: impl Into<Summary>) -> Self {
228        Self::new(DiagnosticSeverity::Warning, code, message)
229    }
230
231    pub fn error(code: impl Into<String>, message: impl Into<Summary>) -> Self {
232        Self::new(DiagnosticSeverity::Error, code, message)
233    }
234
235    pub fn suggestion(code: impl Into<String>, message: impl Into<Summary>) -> Self {
236        Self::new(DiagnosticSeverity::Suggestion, code, message)
237    }
238
239    pub fn with_location(mut self, location: impl Into<String>) -> Self {
240        self.location = Some(location.into());
241        self
242    }
243
244    pub fn with_extract_hint(mut self, hint: ExtractHint) -> Self {
245        self.extract = Some(hint);
246        self
247    }
248
249    fn normalized(mut self) -> Self {
250        if self.location.as_deref().is_some_and(|s| s.is_empty()) {
251            self.location = None;
252        }
253        self
254    }
255}
256
257/// Renderer-visible metadata attached to a projected diff node by a rule pack.
258///
259/// Annotations are intentionally progressively typed: producers can start with
260/// a string or simple JSON value, and renderers can either display the generic
261/// value shape or add package/key-specific handling later. The package namespace
262/// keeps independently-authored plugins from colliding on common keys.
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
265pub struct Annotation {
266    pub package: String,
267    pub key: String,
268    pub value: serde_json::Value,
269}
270
271impl Annotation {
272    pub fn new(
273        package: impl Into<String>,
274        key: impl Into<String>,
275        value: serde_json::Value,
276    ) -> Self {
277        Self {
278            package: package.into(),
279            key: key.into(),
280            value,
281        }
282    }
283
284    pub fn as_str(&self) -> Option<&str> {
285        self.value.as_str()
286    }
287}
288
289/// Renderer-visible provenance for a projected diff node.
290///
291/// Most nodes have one source. Move and copy nodes use a `from` source whose
292/// path differs from the projected node path; many-to-one projections such as
293/// merges and deduplications carry multiple sources.
294#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
296pub struct Source {
297    /// Logical path of the source item.
298    pub path: String,
299    /// Snapshot side where `path` resolves.
300    pub side: Side,
301    /// Open evidence string from the rule/link that established provenance.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub evidence: Option<String>,
304    /// Open action associated with this source in the projection.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub action: Option<String>,
307}
308
309impl Source {
310    pub fn new(path: impl Into<String>, side: Side) -> Self {
311        Self {
312            path: path.into(),
313            side,
314            evidence: None,
315            action: None,
316        }
317    }
318
319    pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
320        self.evidence = Some(evidence.into());
321        self
322    }
323
324    pub fn with_action(mut self, action: impl Into<String>) -> Self {
325        self.action = Some(action.into());
326        self
327    }
328}
329
330/// A node in the projected diff tree — the durable changeset structure
331/// consumed by renderers, serializers, and bindings.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
334pub struct DiffNode {
335    /// Open enum: "add", "remove", "modify", "move", "reorder",
336    /// "schema_change", etc. Plugins may define new actions.
337    pub action: String,
338
339    /// Open string: "directory", "file", "tabular", "zip_archive", etc.
340    /// No built-in types — conventions, not enforcement.
341    pub item_type: String,
342
343    /// Location within snapshot (logical path, including interior paths
344    /// like "archive.zip/>data/file.csv"). `/>` marks a decompose boundary;
345    /// a literal segment beginning with `>` is escaped as `\>`.
346    pub path: String,
347
348    /// Renderer-visible provenance for this projected node.
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub sources: Vec<Source>,
351
352    /// Optional structured one-liner describing the change. Set during
353    /// projection; renderers format each [`Segment`] by its
354    /// type. Build it with [`Summary`]'s builder, or pass a plain string —
355    /// `impl Into<Summary>` wraps it as a single [`Segment::Text`].
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub summary: Option<Summary>,
358
359    /// Open bag of semantic tags, namespaced by convention.
360    /// e.g. "binoc.column-reorder", "biobinoc.gap-change"
361    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
362    pub tags: BTreeSet<String>,
363
364    /// Child diff nodes forming the tree structure.
365    #[serde(default, skip_serializing_if = "Vec::is_empty")]
366    pub children: Vec<DiffNode>,
367
368    /// Structured payload, schema determined by item_type/action convention.
369    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
370    pub details: BTreeMap<String, serde_json::Value>,
371
372    /// Renderer-visible, structured evidence blocks. Rule packs populate
373    /// these with bounded examples while they still have domain knowledge;
374    /// renderers decide how much to display.
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub detail_blocks: Vec<DetailBlock>,
377
378    /// Renderer-visible annotations supplied by rule packs.
379    #[serde(default, skip_serializing_if = "Vec::is_empty")]
380    pub annotations: Vec<Annotation>,
381
382    /// The original item pair associated with this projected node when one is
383    /// available. Session-scoped working data: available during a live run for
384    /// rules and extractors that need to re-read source data. Callers writing
385    /// changeset output must strip this via
386    /// [`DiffNode::strip_transient`] before serializing.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub source_items: Option<ItemPair>,
389
390    /// Node-scoped diagnostics emitted during a run.
391    /// Transient: the controller hoists them into [`Changeset::diagnostics`]
392    /// at the end of the diff, then clears this field so the output shape
393    /// stays as one durable top-level diagnostics list.
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub diagnostics: Vec<Diagnostic>,
396
397    /// Published artifacts for this node. Session-scoped working data: carried
398    /// across the plugin ABI wire as descriptors (the bytes live in the shared
399    /// `data_root` cache), but not meaningful outside a session. Callers
400    /// writing changeset output must strip this via
401    /// [`DiffNode::strip_transient`] before serializing.
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub artifacts: Vec<ArtifactDescriptor>,
404}
405
406impl DiffNode {
407    pub fn new(
408        action: impl Into<String>,
409        item_type: impl Into<String>,
410        path: impl Into<String>,
411    ) -> Self {
412        Self {
413            action: action.into(),
414            item_type: item_type.into(),
415            path: path.into(),
416            sources: Vec::new(),
417            summary: None,
418            tags: BTreeSet::new(),
419            children: Vec::new(),
420            details: BTreeMap::new(),
421            detail_blocks: Vec::new(),
422            annotations: Vec::new(),
423            source_items: None,
424            diagnostics: Vec::new(),
425            artifacts: Vec::new(),
426        }
427    }
428
429    pub fn with_summary(mut self, summary: impl Into<Summary>) -> Self {
430        self.summary = Some(summary.into());
431        self
432    }
433
434    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
435        self.tags.insert(tag.into());
436        self
437    }
438
439    pub fn with_detail(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
440        self.details.insert(key.into(), value);
441        self
442    }
443
444    pub fn with_children(mut self, children: Vec<DiffNode>) -> Self {
445        self.children = children;
446        self
447    }
448
449    pub fn with_detail_block(mut self, block: DetailBlock) -> Self {
450        self.detail_blocks.push(block);
451        self
452    }
453
454    pub fn with_annotation_from(
455        mut self,
456        package: impl Into<String>,
457        key: impl Into<String>,
458        value: serde_json::Value,
459    ) -> Self {
460        self.annotate_from(package, key, value);
461        self
462    }
463
464    pub fn with_source(mut self, source: Source) -> Self {
465        self.push_source(source);
466        self
467    }
468
469    pub fn with_sources(mut self, sources: Vec<Source>) -> Self {
470        self.sources = sources;
471        self.normalize_sources();
472        self
473    }
474
475    pub fn with_source_items(mut self, items: ItemPair) -> Self {
476        self.source_items = Some(items);
477        self
478    }
479
480    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
481        self.push_diagnostic(diagnostic);
482        self
483    }
484
485    pub fn with_artifact(mut self, artifact: ArtifactDescriptor) -> Self {
486        self.artifacts.push(artifact);
487        self
488    }
489
490    pub fn push_diagnostic(&mut self, diagnostic: Diagnostic) {
491        let diagnostic = if diagnostic.location.is_none() && !self.path.is_empty() {
492            diagnostic.with_location(self.path.clone())
493        } else {
494            diagnostic
495        };
496        self.diagnostics.push(diagnostic.normalized());
497    }
498
499    pub fn push_source(&mut self, source: Source) {
500        self.sources.push(source);
501        self.normalize_sources();
502    }
503
504    pub fn primary_from_source(&self) -> Option<&Source> {
505        self.sources.iter().find(|source| source.side == Side::From)
506    }
507
508    fn normalize_sources(&mut self) {
509        self.sources.sort();
510        self.sources.dedup();
511    }
512
513    pub fn annotate_from(
514        &mut self,
515        package: impl Into<String>,
516        key: impl Into<String>,
517        value: serde_json::Value,
518    ) {
519        let package = package.into();
520        let key = key.into();
521        if let Some(existing) = self
522            .annotations
523            .iter_mut()
524            .find(|annotation| annotation.package == package && annotation.key == key)
525        {
526            existing.value = value;
527        } else {
528            self.annotations.push(Annotation::new(package, key, value));
529        }
530    }
531
532    pub fn annotation(&self, package: &str, key: &str) -> Option<&Annotation> {
533        self.annotations
534            .iter()
535            .find(|annotation| annotation.package == package && annotation.key == key)
536    }
537
538    pub fn binoc_annotation(&self, key: &str) -> Option<&Annotation> {
539        self.annotation("binoc", key)
540    }
541
542    pub fn node_count(&self) -> usize {
543        1 + self.children.iter().map(|c| c.node_count()).sum::<usize>()
544    }
545
546    pub fn all_tags(&self) -> BTreeSet<String> {
547        let mut tags = self.tags.clone();
548        for child in &self.children {
549            tags.extend(child.all_tags());
550        }
551        tags
552    }
553
554    fn drain_diagnostics_into(&mut self, target: &mut Vec<Diagnostic>) {
555        target.append(&mut self.diagnostics);
556        for child in &mut self.children {
557            child.drain_diagnostics_into(target);
558        }
559    }
560
561    /// Recursively clear session-scoped transient fields (`source_items`,
562    /// `diagnostics`, `artifacts`) on this node and all descendants.
563    ///
564    /// These fields are wire-visible so the plugin ABI can move them across
565    /// process-ready boundaries, but they are not meaningful outside a live
566    /// session and must be stripped before writing changeset output intended
567    /// for users (JSON files, renderer output, Python return values).
568    pub fn strip_transient(&mut self) {
569        self.source_items = None;
570        self.diagnostics.clear();
571        self.artifacts.clear();
572        for child in &mut self.children {
573            child.strip_transient();
574        }
575    }
576}
577
578/// Reserved run-scoped claim slot.
579///
580/// The shape is intentionally provisional pending the CFM-41 global-claim
581/// prototype. It gives renderers and serialized changesets a stable place for
582/// non-tree claims without committing the claim vocabulary yet.
583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
584#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
585pub struct GlobalClaim {
586    /// Open claim verb for a renderer- or plugin-defined run-scoped claim.
587    pub verb: String,
588    /// Claim-specific structured parameters.
589    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
590    pub params: BTreeMap<String, serde_json::Value>,
591    /// Optional renderer-facing summary for the claim.
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub summary: Option<Summary>,
594}
595
596impl GlobalClaim {
597    pub fn new(verb: impl Into<String>) -> Self {
598        Self {
599            verb: verb.into(),
600            params: BTreeMap::new(),
601            summary: None,
602        }
603    }
604
605    pub fn with_param(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
606        self.params.insert(key.into(), value);
607        self
608    }
609
610    pub fn with_summary(mut self, summary: impl Into<Summary>) -> Self {
611        self.summary = Some(summary.into());
612        self
613    }
614}
615
616/// Renderer-visible, bounded evidence attached to a diff node.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
619pub struct DetailBlock {
620    /// Stable within this node, for anchors and extract selection.
621    pub id: String,
622    /// Open, namespaced kind such as `binoc.tabular.cell_changes.v1`.
623    pub kind: String,
624    /// Short renderer-facing label.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub label: Option<String>,
627    /// Total matching items if known, including omitted examples.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub total_count: Option<u64>,
630    /// Captured examples for inline rendering.
631    #[serde(default, skip_serializing_if = "Vec::is_empty")]
632    pub examples: Vec<DetailExample>,
633    /// Named extract aspects for exhaustive retrieval.
634    #[serde(default, skip_serializing_if = "Vec::is_empty")]
635    pub extract: Vec<ExtractHint>,
636    /// Whether the producer truncated capture before exhausting candidates.
637    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
638    pub truncated: bool,
639}
640
641impl DetailBlock {
642    pub fn new(id: impl Into<String>, kind: impl Into<String>) -> Self {
643        Self {
644            id: id.into(),
645            kind: kind.into(),
646            label: None,
647            total_count: None,
648            examples: Vec::new(),
649            extract: Vec::new(),
650            truncated: false,
651        }
652    }
653
654    pub fn with_label(mut self, label: impl Into<String>) -> Self {
655        self.label = Some(label.into());
656        self
657    }
658
659    pub fn with_total_count(mut self, total_count: u64) -> Self {
660        self.total_count = Some(total_count);
661        self
662    }
663
664    pub fn with_example(mut self, example: DetailExample) -> Self {
665        self.examples.push(example);
666        self
667    }
668
669    pub fn with_extract_hint(mut self, hint: ExtractHint) -> Self {
670        self.extract.push(hint);
671        self
672    }
673}
674
675/// One bounded example inside a detail block.
676#[derive(Debug, Clone, Serialize, Deserialize)]
677#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
678pub struct DetailExample {
679    /// Structured locator such as row/column, line range, or key path.
680    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
681    pub locator: BTreeMap<String, serde_json::Value>,
682    /// Value before the change, if present.
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub before: Option<ValuePreview>,
685    /// Value after the change, if present.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub after: Option<ValuePreview>,
688    /// Domain-specific structured context.
689    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
690    pub fields: BTreeMap<String, serde_json::Value>,
691}
692
693impl DetailExample {
694    pub fn new() -> Self {
695        Self {
696            locator: BTreeMap::new(),
697            before: None,
698            after: None,
699            fields: BTreeMap::new(),
700        }
701    }
702}
703
704impl Default for DetailExample {
705    fn default() -> Self {
706        Self::new()
707    }
708}
709
710/// A bounded preview of one value in a detail example.
711#[derive(Debug, Clone, Serialize, Deserialize)]
712#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
713pub struct ValuePreview {
714    pub value: serde_json::Value,
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub media_type: Option<String>,
717    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
718    pub truncated: bool,
719}
720
721/// Pointer to an extract aspect that can return exhaustive content.
722#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
723#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
724pub struct ExtractHint {
725    /// Aspect name accepted by `binoc extract`.
726    pub aspect: String,
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub changeset_path: Option<String>,
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub label: Option<String>,
731}
732
733impl ExtractHint {
734    pub fn new(aspect: impl Into<String>) -> Self {
735        Self {
736            aspect: aspect.into(),
737            changeset_path: None,
738            label: None,
739        }
740    }
741
742    pub fn with_changeset_path(mut self, path: impl Into<String>) -> Self {
743        self.changeset_path = Some(path.into());
744        self
745    }
746
747    pub fn with_label(mut self, label: impl Into<String>) -> Self {
748        self.label = Some(label.into());
749        self
750    }
751
752    fn fill_changeset_path_if_missing(&mut self, path: &str) {
753        if self.changeset_path.is_none() {
754            self.changeset_path = Some(path.to_string());
755        }
756    }
757}
758
759/// A structured description of how to get from one snapshot to the next.
760#[derive(Debug, Clone, Serialize, Deserialize)]
761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
762pub struct Changeset {
763    pub from_snapshot: String,
764    pub to_snapshot: String,
765    /// Run-scoped claims that do not belong to one tree node.
766    ///
767    /// Reserved for the CFM-41 global-claim prototype; empty in current engine
768    /// output.
769    #[serde(default)]
770    pub claims: Vec<GlobalClaim>,
771    pub root: Option<DiffNode>,
772    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
773    pub metadata: BTreeMap<String, String>,
774    #[serde(default, skip_serializing_if = "Vec::is_empty")]
775    pub diagnostics: Vec<Diagnostic>,
776}
777
778impl Changeset {
779    pub fn new(from: impl Into<String>, to: impl Into<String>, root: Option<DiffNode>) -> Self {
780        Self {
781            from_snapshot: from.into(),
782            to_snapshot: to.into(),
783            claims: Vec::new(),
784            root,
785            metadata: BTreeMap::new(),
786            diagnostics: Vec::new(),
787        }
788    }
789
790    pub fn node_count(&self) -> usize {
791        self.root.as_ref().map_or(0, |r| r.node_count())
792    }
793
794    pub fn push_diagnostic(&mut self, diagnostic: Diagnostic) {
795        self.diagnostics.push(diagnostic.normalized());
796    }
797
798    pub fn hoist_node_diagnostics(&mut self) {
799        if let Some(root) = self.root.as_mut() {
800            root.drain_diagnostics_into(&mut self.diagnostics);
801        }
802    }
803
804    pub fn dedupe_and_cap_diagnostics(&mut self, max_diagnostics: usize) {
805        let mut seen: BTreeSet<(String, Option<String>)> = BTreeSet::new();
806        let mut deduped = Vec::with_capacity(self.diagnostics.len().min(max_diagnostics));
807
808        for diagnostic in self.diagnostics.drain(..).map(Diagnostic::normalized) {
809            let key = (diagnostic.code.clone(), diagnostic.location.clone());
810            if seen.insert(key) {
811                deduped.push(diagnostic);
812                if deduped.len() >= max_diagnostics {
813                    break;
814                }
815            }
816        }
817
818        self.diagnostics = deduped;
819    }
820
821    pub fn fill_missing_extract_hint_paths(&mut self, path: impl AsRef<str>) {
822        let path = path.as_ref();
823        for diagnostic in &mut self.diagnostics {
824            diagnostic.fill_changeset_path_if_missing(path);
825        }
826        if let Some(root) = self.root.as_mut() {
827            root.fill_changeset_path_if_missing(path);
828        }
829    }
830
831    /// Recursively clear session-scoped transient fields on the root and all
832    /// descendants. See [`DiffNode::strip_transient`].
833    pub fn strip_transient(&mut self) {
834        if let Some(root) = self.root.as_mut() {
835            root.strip_transient();
836        }
837    }
838}
839
840impl Diagnostic {
841    fn fill_changeset_path_if_missing(&mut self, path: &str) {
842        if let Some(extract) = self.extract.as_mut() {
843            extract.fill_changeset_path_if_missing(path);
844        }
845    }
846}
847
848impl DetailBlock {
849    fn fill_changeset_path_if_missing(&mut self, path: &str) {
850        for extract in &mut self.extract {
851            extract.fill_changeset_path_if_missing(path);
852        }
853    }
854}
855
856impl DiffNode {
857    fn fill_changeset_path_if_missing(&mut self, path: &str) {
858        for detail_block in &mut self.detail_blocks {
859            detail_block.fill_changeset_path_if_missing(path);
860        }
861        for diagnostic in &mut self.diagnostics {
862            diagnostic.fill_changeset_path_if_missing(path);
863        }
864        for child in &mut self.children {
865            child.fill_changeset_path_if_missing(path);
866        }
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn diff_node_new_creates_node_with_correct_fields() {
876        let node = DiffNode::new("modify", "file", "path/to/file.csv");
877        assert_eq!(node.action, "modify");
878        assert_eq!(node.item_type, "file");
879        assert_eq!(node.path, "path/to/file.csv");
880        assert!(node.sources.is_empty());
881        assert!(node.tags.is_empty());
882        assert!(node.children.is_empty());
883        assert!(node.details.is_empty());
884        assert!(node.detail_blocks.is_empty());
885        assert!(node.annotations.is_empty());
886    }
887
888    #[test]
889    fn diff_node_builder_methods_chain_correctly() {
890        let child = DiffNode::new("add", "file", "child.txt");
891        let node = DiffNode::new("modify", "directory", "dir")
892            .with_tag("binoc.column-reorder")
893            .with_tag("binoc.whitespace")
894            .with_detail("lines_changed", serde_json::json!(42))
895            .with_annotation_from("binoc", "note", serde_json::json!("check distribution"))
896            .with_children(vec![child])
897            .with_source(Source::new("old/dir", Side::From).with_action("move"));
898
899        assert_eq!(node.tags.len(), 2);
900        assert!(node.tags.contains("binoc.column-reorder"));
901        assert!(node.tags.contains("binoc.whitespace"));
902        assert_eq!(
903            node.details.get("lines_changed"),
904            Some(&serde_json::json!(42))
905        );
906        assert_eq!(
907            node.binoc_annotation("note")
908                .map(|annotation| &annotation.value),
909            Some(&serde_json::json!("check distribution"))
910        );
911        assert!(node.detail_blocks.is_empty());
912        assert_eq!(node.children.len(), 1);
913        assert_eq!(node.children[0].path, "child.txt");
914        assert_eq!(node.sources.len(), 1);
915        assert_eq!(node.sources[0].path, "old/dir");
916        assert_eq!(node.sources[0].side, Side::From);
917    }
918
919    #[test]
920    fn annotations_are_namespaced_and_replace_by_package_key() {
921        let mut node = DiffNode::new("modify", "file", "data.csv");
922        node.annotate_from("binoc", "note", serde_json::json!("first"));
923        node.annotate_from("binoc", "note", serde_json::json!("second"));
924        node.annotate_from("example.plugin", "note", serde_json::json!("external"));
925
926        assert_eq!(node.annotations.len(), 2);
927        assert_eq!(
928            node.binoc_annotation("note")
929                .map(|annotation| &annotation.value),
930            Some(&serde_json::json!("second"))
931        );
932        assert_eq!(
933            node.annotation("example.plugin", "note")
934                .map(|annotation| &annotation.value),
935            Some(&serde_json::json!("external"))
936        );
937    }
938
939    #[test]
940    fn node_count_leaf_returns_one() {
941        let node = DiffNode::new("add", "file", "file.txt");
942        assert_eq!(node.node_count(), 1);
943    }
944
945    #[test]
946    fn node_count_tree_returns_correct_total() {
947        let node = DiffNode::new("modify", "dir", "dir").with_children(vec![
948            DiffNode::new("add", "file", "a.txt"),
949            DiffNode::new("modify", "dir", "sub").with_children(vec![DiffNode::new(
950                "remove",
951                "file",
952                "sub/b.txt",
953            )]),
954        ]);
955        assert_eq!(node.node_count(), 4);
956    }
957
958    #[test]
959    fn all_tags_collects_from_entire_subtree() {
960        let node = DiffNode::new("modify", "dir", "dir")
961            .with_tag("root-tag")
962            .with_children(vec![
963                DiffNode::new("add", "file", "a").with_tag("child-tag"),
964                DiffNode::new("remove", "file", "b")
965                    .with_children(vec![
966                        DiffNode::new("modify", "file", "c").with_tag("grandchild-tag")
967                    ]),
968            ]);
969        let tags = node.all_tags();
970        assert_eq!(tags.len(), 3);
971        assert!(tags.contains("root-tag"));
972        assert!(tags.contains("child-tag"));
973        assert!(tags.contains("grandchild-tag"));
974    }
975
976    #[test]
977    fn serde_round_trip_preserves_equality() {
978        let node = DiffNode::new("move", "file", "new/path.csv")
979            .with_tag("binoc.move")
980            .with_detail("distance", serde_json::json!(10))
981            .with_detail_block(
982                DetailBlock::new("changed_cells", "binoc.tabular.cell_changes.v1")
983                    .with_label("Changed cells")
984                    .with_total_count(1)
985                    .with_example(DetailExample {
986                        locator: BTreeMap::from([
987                            ("row".into(), serde_json::json!(1)),
988                            ("column".into(), serde_json::json!("status")),
989                        ]),
990                        before: Some(ValuePreview {
991                            value: serde_json::json!("draft"),
992                            media_type: Some("text/plain".into()),
993                            truncated: false,
994                        }),
995                        after: Some(ValuePreview {
996                            value: serde_json::json!("published"),
997                            media_type: Some("text/plain".into()),
998                            truncated: false,
999                        }),
1000                        fields: BTreeMap::new(),
1001                    })
1002                    .with_extract_hint(
1003                        ExtractHint::new("cells_changed").with_label("All changed cells"),
1004                    ),
1005            )
1006            .with_source(Source::new("old/path.csv", Side::From).with_action("move"));
1007        let json = serde_json::to_string(&node).unwrap();
1008        let restored: DiffNode = serde_json::from_str(&json).unwrap();
1009        assert_eq!(node.action, restored.action);
1010        assert_eq!(node.item_type, restored.item_type);
1011        assert_eq!(node.path, restored.path);
1012        assert_eq!(node.sources, restored.sources);
1013        assert_eq!(node.tags, restored.tags);
1014        assert_eq!(node.details, restored.details);
1015        assert_eq!(restored.detail_blocks.len(), 1);
1016        assert_eq!(restored.detail_blocks[0].examples.len(), 1);
1017    }
1018
1019    #[test]
1020    fn changeset_construction_and_node_count() {
1021        let root = DiffNode::new("modify", "dir", "root").with_children(vec![
1022            DiffNode::new("add", "file", "root/a.txt"),
1023            DiffNode::new("remove", "file", "root/b.txt"),
1024        ]);
1025        let changeset = Changeset::new("v1", "v2", Some(root));
1026        assert_eq!(changeset.from_snapshot, "v1");
1027        assert_eq!(changeset.to_snapshot, "v2");
1028        assert!(changeset.claims.is_empty());
1029        assert_eq!(changeset.node_count(), 3);
1030    }
1031
1032    #[test]
1033    fn transient_fields_round_trip_through_serde() {
1034        // Session-scoped transient fields (`source_items`, `artifacts`,
1035        // `diagnostics`) are wire-visible so the plugin ABI can carry them
1036        // across a (potentially process-isolated) boundary.
1037        use crate::types::{
1038            ArtifactDescriptor, ArtifactFormat, ArtifactSubject, ItemPair, ItemRef,
1039        };
1040
1041        let artifact = ArtifactDescriptor {
1042            format: ArtifactFormat::new("binoc", "tabular", 1),
1043            subject: ArtifactSubject::Pair,
1044            producer: "binoc.csv".into(),
1045            handle: "cache/tabular-abc123".into(),
1046        };
1047        let source_items = ItemPair::both(
1048            ItemRef {
1049                logical_path: "data.csv".into(),
1050                is_dir: false,
1051                content_hash: None,
1052                size: None,
1053                media_type: None,
1054                projection_hint: Default::default(),
1055                tabular_parse: None,
1056                handle: "/tmp/a/data.csv".into(),
1057            },
1058            ItemRef {
1059                logical_path: "data.csv".into(),
1060                is_dir: false,
1061                content_hash: None,
1062                size: None,
1063                media_type: None,
1064                projection_hint: Default::default(),
1065                tabular_parse: None,
1066                handle: "/tmp/b/data.csv".into(),
1067            },
1068        );
1069        let child = DiffNode::new("modify", "tabular", "dir/data.csv")
1070            .with_artifact(artifact.clone())
1071            .with_source_items(source_items.clone())
1072            .with_diagnostic(
1073                Diagnostic::suggestion("binoc.demo", "Try a richer plugin")
1074                    .with_extract_hint(ExtractHint::new("content")),
1075            );
1076        let root = DiffNode::new("modify", "directory", "dir").with_children(vec![child]);
1077
1078        let json = serde_json::to_string(&root).unwrap();
1079        let restored: DiffNode = serde_json::from_str(&json).unwrap();
1080
1081        assert_eq!(restored.children.len(), 1);
1082        let restored_child = &restored.children[0];
1083        assert_eq!(restored_child.artifacts.len(), 1, "child artifact missing");
1084        assert_eq!(restored_child.artifacts[0].handle, artifact.handle);
1085        assert!(
1086            restored_child.source_items.is_some(),
1087            "child source_items missing"
1088        );
1089        assert_eq!(restored_child.diagnostics.len(), 1);
1090        assert_eq!(
1091            restored_child.diagnostics[0]
1092                .extract
1093                .as_ref()
1094                .map(|hint| hint.aspect.as_str()),
1095            Some("content")
1096        );
1097    }
1098
1099    #[test]
1100    fn hoisted_diagnostics_are_deduped_and_capped() {
1101        let mut root = DiffNode::new("modify", "directory", "");
1102        root.push_diagnostic(Diagnostic::suggestion(
1103            "binoc.binary-fallback",
1104            "Try a plugin",
1105        ));
1106        root.push_diagnostic(Diagnostic::suggestion(
1107            "binoc.binary-fallback",
1108            "Try a plugin",
1109        ));
1110        root.children = vec![
1111            DiffNode::new("modify", "file", "a.bin").with_diagnostic(Diagnostic::suggestion(
1112                "binoc.binary-fallback",
1113                "Try a plugin",
1114            )),
1115            DiffNode::new("modify", "file", "b.bin")
1116                .with_diagnostic(Diagnostic::warning("binoc.other", "Other issue")),
1117        ];
1118
1119        let mut changeset = Changeset::new("a", "b", Some(root));
1120        changeset.hoist_node_diagnostics();
1121        changeset.dedupe_and_cap_diagnostics(2);
1122
1123        assert_eq!(changeset.diagnostics.len(), 2);
1124        assert_eq!(changeset.diagnostics[0].code, "binoc.binary-fallback");
1125        assert_eq!(changeset.diagnostics[0].location, None);
1126        assert_eq!(changeset.diagnostics[1].location.as_deref(), Some("a.bin"));
1127    }
1128
1129    #[test]
1130    fn strip_transient_clears_every_descendant() {
1131        use crate::types::{ArtifactDescriptor, ArtifactFormat, ArtifactSubject};
1132        let artifact = ArtifactDescriptor {
1133            format: ArtifactFormat::new("binoc", "tabular", 1),
1134            subject: ArtifactSubject::Pair,
1135            producer: "binoc.csv".into(),
1136            handle: "h".into(),
1137        };
1138        let grandchild = DiffNode::new("modify", "tabular", "a/b/c.csv")
1139            .with_artifact(artifact)
1140            .with_diagnostic(Diagnostic::warning("binoc.test", "test"));
1141        let child = DiffNode::new("modify", "directory", "a/b").with_children(vec![grandchild]);
1142        let mut root = DiffNode::new("modify", "directory", "a").with_children(vec![child]);
1143        root.strip_transient();
1144        fn all_empty(n: &DiffNode) -> bool {
1145            n.artifacts.is_empty()
1146                && n.diagnostics.is_empty()
1147                && n.source_items.is_none()
1148                && n.children.iter().all(all_empty)
1149        }
1150        assert!(all_empty(&root));
1151    }
1152
1153    #[test]
1154    fn changeset_node_count_none_root() {
1155        let changeset = Changeset::new("v1", "v2", None);
1156        assert_eq!(changeset.node_count(), 0);
1157    }
1158
1159    #[test]
1160    fn fill_missing_extract_hint_paths_updates_nested_hints_only_when_missing() {
1161        let child = DiffNode::new("modify", "file", "child.csv")
1162            .with_detail_block(
1163                DetailBlock::new("cells", "binoc.tabular.cell_changes.v1")
1164                    .with_extract_hint(ExtractHint::new("cells_changed")),
1165            )
1166            .with_diagnostic(
1167                Diagnostic::warning("binoc.child", "child diagnostic")
1168                    .with_extract_hint(ExtractHint::new("content")),
1169            );
1170        let root = DiffNode::new("modify", "file", "root.csv")
1171            .with_detail_block(
1172                DetailBlock::new("rows", "binoc.tabular.row_changes.v1").with_extract_hint(
1173                    ExtractHint::new("rows_changed").with_changeset_path("already-set.json"),
1174                ),
1175            )
1176            .with_children(vec![child]);
1177        let mut changeset = Changeset::new("v1", "v2", Some(root));
1178        changeset.push_diagnostic(
1179            Diagnostic::warning("binoc.root", "top diagnostic")
1180                .with_extract_hint(ExtractHint::new("content")),
1181        );
1182
1183        changeset.fill_missing_extract_hint_paths("changeset.json");
1184
1185        assert_eq!(
1186            changeset.diagnostics[0]
1187                .extract
1188                .as_ref()
1189                .and_then(|hint| hint.changeset_path.as_deref()),
1190            Some("changeset.json")
1191        );
1192        let root = changeset.root.as_ref().unwrap();
1193        assert_eq!(
1194            root.detail_blocks[0].extract[0].changeset_path.as_deref(),
1195            Some("already-set.json")
1196        );
1197        let child = &root.children[0];
1198        assert_eq!(
1199            child.detail_blocks[0].extract[0].changeset_path.as_deref(),
1200            Some("changeset.json")
1201        );
1202        assert_eq!(
1203            child.diagnostics[0]
1204                .extract
1205                .as_ref()
1206                .and_then(|hint| hint.changeset_path.as_deref()),
1207            Some("changeset.json")
1208        );
1209    }
1210}