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}
209
210impl Diagnostic {
211    pub fn new(
212        severity: DiagnosticSeverity,
213        code: impl Into<String>,
214        message: impl Into<Summary>,
215    ) -> Self {
216        Self {
217            severity,
218            code: code.into(),
219            message: message.into(),
220            location: None,
221        }
222    }
223
224    pub fn warning(code: impl Into<String>, message: impl Into<Summary>) -> Self {
225        Self::new(DiagnosticSeverity::Warning, code, message)
226    }
227
228    pub fn error(code: impl Into<String>, message: impl Into<Summary>) -> Self {
229        Self::new(DiagnosticSeverity::Error, code, message)
230    }
231
232    pub fn suggestion(code: impl Into<String>, message: impl Into<Summary>) -> Self {
233        Self::new(DiagnosticSeverity::Suggestion, code, message)
234    }
235
236    pub fn with_location(mut self, location: impl Into<String>) -> Self {
237        self.location = Some(location.into());
238        self
239    }
240
241    fn normalized(mut self) -> Self {
242        if self.location.as_deref().is_some_and(|s| s.is_empty()) {
243            self.location = None;
244        }
245        self
246    }
247}
248
249/// Renderer-visible metadata attached to a projected diff node by a rule pack.
250///
251/// Annotations are intentionally progressively typed: producers can start with
252/// a string or simple JSON value, and renderers can either display the generic
253/// value shape or add package/key-specific handling later. The package namespace
254/// keeps independently-authored plugins from colliding on common keys.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub struct Annotation {
258    pub package: String,
259    pub key: String,
260    pub value: serde_json::Value,
261}
262
263impl Annotation {
264    pub fn new(
265        package: impl Into<String>,
266        key: impl Into<String>,
267        value: serde_json::Value,
268    ) -> Self {
269        Self {
270            package: package.into(),
271            key: key.into(),
272            value,
273        }
274    }
275
276    pub fn as_str(&self) -> Option<&str> {
277        self.value.as_str()
278    }
279}
280
281/// Renderer-visible provenance for a projected diff node.
282///
283/// Most nodes have one source. Move and copy nodes use a `from` source whose
284/// path differs from the projected node path; many-to-one projections such as
285/// merges and deduplications carry multiple sources.
286#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
287#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
288pub struct Source {
289    /// Logical path of the source item.
290    pub path: String,
291    /// Snapshot side where `path` resolves.
292    pub side: Side,
293    /// Open evidence string from the rule/link that established provenance.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub evidence: Option<String>,
296    /// Open action associated with this source in the projection.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub action: Option<String>,
299}
300
301impl Source {
302    pub fn new(path: impl Into<String>, side: Side) -> Self {
303        Self {
304            path: path.into(),
305            side,
306            evidence: None,
307            action: None,
308        }
309    }
310
311    pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
312        self.evidence = Some(evidence.into());
313        self
314    }
315
316    pub fn with_action(mut self, action: impl Into<String>) -> Self {
317        self.action = Some(action.into());
318        self
319    }
320}
321
322/// A node in the projected diff tree — the durable changeset structure
323/// consumed by renderers, serializers, and bindings.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326pub struct DiffNode {
327    /// Open enum: "add", "remove", "modify", "move", "reorder",
328    /// "schema_change", etc. Plugins may define new actions.
329    pub action: String,
330
331    /// Open string: "directory", "file", "tabular", "zip_archive", etc.
332    /// No built-in types — conventions, not enforcement.
333    pub item_type: String,
334
335    /// Location within snapshot (logical path, including interior paths
336    /// like "archive.zip/>data/file.csv"). `/>` marks a decompose boundary;
337    /// a literal segment beginning with `>` is escaped as `\>`.
338    pub path: String,
339
340    /// Renderer-visible provenance for this projected node.
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub sources: Vec<Source>,
343
344    /// Optional structured one-liner describing the change. Set during
345    /// projection; renderers format each [`Segment`] by its
346    /// type. Build it with [`Summary`]'s builder, or pass a plain string —
347    /// `impl Into<Summary>` wraps it as a single [`Segment::Text`].
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub summary: Option<Summary>,
350
351    /// Open bag of semantic tags, namespaced by convention.
352    /// e.g. "binoc.column-reorder", "biobinoc.gap-change"
353    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
354    pub tags: BTreeSet<String>,
355
356    /// Child diff nodes forming the tree structure.
357    #[serde(default, skip_serializing_if = "Vec::is_empty")]
358    pub children: Vec<DiffNode>,
359
360    /// Structured payload, schema determined by item_type/action convention.
361    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
362    pub details: BTreeMap<String, serde_json::Value>,
363
364    /// Renderer-visible, structured evidence blocks. Rule packs populate
365    /// these with bounded examples while they still have domain knowledge;
366    /// renderers decide how much to display.
367    #[serde(default, skip_serializing_if = "Vec::is_empty")]
368    pub detail_blocks: Vec<DetailBlock>,
369
370    /// Renderer-visible annotations supplied by rule packs.
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub annotations: Vec<Annotation>,
373
374    /// The original item pair associated with this projected node when one is
375    /// available. Session-scoped working data: available during a live run for
376    /// rules and extractors that need to re-read source data. Callers writing
377    /// changeset output must strip this via
378    /// [`DiffNode::strip_transient`] before serializing.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub source_items: Option<ItemPair>,
381
382    /// Node-scoped diagnostics emitted during a run.
383    /// Transient: the controller hoists them into [`Changeset::diagnostics`]
384    /// at the end of the diff, then clears this field so the output shape
385    /// stays as one durable top-level diagnostics list.
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub diagnostics: Vec<Diagnostic>,
388
389    /// Published artifacts for this node. Session-scoped working data: carried
390    /// across the plugin ABI wire as descriptors (the bytes live in the shared
391    /// `data_root` cache), but not meaningful outside a session. Callers
392    /// writing changeset output must strip this via
393    /// [`DiffNode::strip_transient`] before serializing.
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub artifacts: Vec<ArtifactDescriptor>,
396}
397
398impl DiffNode {
399    pub fn new(
400        action: impl Into<String>,
401        item_type: impl Into<String>,
402        path: impl Into<String>,
403    ) -> Self {
404        Self {
405            action: action.into(),
406            item_type: item_type.into(),
407            path: path.into(),
408            sources: Vec::new(),
409            summary: None,
410            tags: BTreeSet::new(),
411            children: Vec::new(),
412            details: BTreeMap::new(),
413            detail_blocks: Vec::new(),
414            annotations: Vec::new(),
415            source_items: None,
416            diagnostics: Vec::new(),
417            artifacts: Vec::new(),
418        }
419    }
420
421    pub fn with_summary(mut self, summary: impl Into<Summary>) -> Self {
422        self.summary = Some(summary.into());
423        self
424    }
425
426    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
427        self.tags.insert(tag.into());
428        self
429    }
430
431    pub fn with_detail(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
432        self.details.insert(key.into(), value);
433        self
434    }
435
436    pub fn with_children(mut self, children: Vec<DiffNode>) -> Self {
437        self.children = children;
438        self
439    }
440
441    pub fn with_detail_block(mut self, block: DetailBlock) -> Self {
442        self.detail_blocks.push(block);
443        self
444    }
445
446    pub fn with_annotation_from(
447        mut self,
448        package: impl Into<String>,
449        key: impl Into<String>,
450        value: serde_json::Value,
451    ) -> Self {
452        self.annotate_from(package, key, value);
453        self
454    }
455
456    pub fn with_source(mut self, source: Source) -> Self {
457        self.push_source(source);
458        self
459    }
460
461    pub fn with_sources(mut self, sources: Vec<Source>) -> Self {
462        self.sources = sources;
463        self.normalize_sources();
464        self
465    }
466
467    pub fn with_source_items(mut self, items: ItemPair) -> Self {
468        self.source_items = Some(items);
469        self
470    }
471
472    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
473        self.push_diagnostic(diagnostic);
474        self
475    }
476
477    pub fn with_artifact(mut self, artifact: ArtifactDescriptor) -> Self {
478        self.artifacts.push(artifact);
479        self
480    }
481
482    pub fn push_diagnostic(&mut self, diagnostic: Diagnostic) {
483        let diagnostic = if diagnostic.location.is_none() && !self.path.is_empty() {
484            diagnostic.with_location(self.path.clone())
485        } else {
486            diagnostic
487        };
488        self.diagnostics.push(diagnostic.normalized());
489    }
490
491    pub fn push_source(&mut self, source: Source) {
492        self.sources.push(source);
493        self.normalize_sources();
494    }
495
496    pub fn primary_from_source(&self) -> Option<&Source> {
497        self.sources.iter().find(|source| source.side == Side::From)
498    }
499
500    fn normalize_sources(&mut self) {
501        self.sources.sort();
502        self.sources.dedup();
503    }
504
505    pub fn annotate_from(
506        &mut self,
507        package: impl Into<String>,
508        key: impl Into<String>,
509        value: serde_json::Value,
510    ) {
511        let package = package.into();
512        let key = key.into();
513        if let Some(existing) = self
514            .annotations
515            .iter_mut()
516            .find(|annotation| annotation.package == package && annotation.key == key)
517        {
518            existing.value = value;
519        } else {
520            self.annotations.push(Annotation::new(package, key, value));
521        }
522    }
523
524    pub fn annotation(&self, package: &str, key: &str) -> Option<&Annotation> {
525        self.annotations
526            .iter()
527            .find(|annotation| annotation.package == package && annotation.key == key)
528    }
529
530    pub fn binoc_annotation(&self, key: &str) -> Option<&Annotation> {
531        self.annotation("binoc", key)
532    }
533
534    pub fn node_count(&self) -> usize {
535        1 + self.children.iter().map(|c| c.node_count()).sum::<usize>()
536    }
537
538    pub fn all_tags(&self) -> BTreeSet<String> {
539        let mut tags = self.tags.clone();
540        for child in &self.children {
541            tags.extend(child.all_tags());
542        }
543        tags
544    }
545
546    fn drain_diagnostics_into(&mut self, target: &mut Vec<Diagnostic>) {
547        target.append(&mut self.diagnostics);
548        for child in &mut self.children {
549            child.drain_diagnostics_into(target);
550        }
551    }
552
553    /// Recursively clear session-scoped transient fields (`source_items`,
554    /// `diagnostics`, `artifacts`) on this node and all descendants.
555    ///
556    /// These fields are wire-visible so the plugin ABI can move them across
557    /// process-ready boundaries, but they are not meaningful outside a live
558    /// session and must be stripped before writing changeset output intended
559    /// for users (JSON files, renderer output, Python return values).
560    pub fn strip_transient(&mut self) {
561        self.source_items = None;
562        self.diagnostics.clear();
563        self.artifacts.clear();
564        for child in &mut self.children {
565            child.strip_transient();
566        }
567    }
568}
569
570/// Reserved run-scoped claim slot.
571///
572/// The shape is intentionally provisional pending the CFM-41 global-claim
573/// prototype. It gives renderers and serialized changesets a stable place for
574/// non-tree claims without committing the claim vocabulary yet.
575#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
576#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
577pub struct GlobalClaim {
578    /// Open claim verb such as a future global find/replace action.
579    pub verb: String,
580    /// Claim-specific structured parameters.
581    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
582    pub params: BTreeMap<String, serde_json::Value>,
583    /// Optional renderer-facing summary for the claim.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub summary: Option<Summary>,
586}
587
588impl GlobalClaim {
589    pub fn new(verb: impl Into<String>) -> Self {
590        Self {
591            verb: verb.into(),
592            params: BTreeMap::new(),
593            summary: None,
594        }
595    }
596
597    pub fn with_param(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
598        self.params.insert(key.into(), value);
599        self
600    }
601
602    pub fn with_summary(mut self, summary: impl Into<Summary>) -> Self {
603        self.summary = Some(summary.into());
604        self
605    }
606}
607
608/// Renderer-visible, bounded evidence attached to a diff node.
609#[derive(Debug, Clone, Serialize, Deserialize)]
610#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
611pub struct DetailBlock {
612    /// Stable within this node, for anchors and extract selection.
613    pub id: String,
614    /// Open, namespaced kind such as `binoc.tabular.cell_changes.v1`.
615    pub kind: String,
616    /// Short renderer-facing label.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub label: Option<String>,
619    /// Total matching items if known, including omitted examples.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub total_count: Option<u64>,
622    /// Captured examples for inline rendering.
623    #[serde(default, skip_serializing_if = "Vec::is_empty")]
624    pub examples: Vec<DetailExample>,
625    /// Named extract aspects for exhaustive retrieval.
626    #[serde(default, skip_serializing_if = "Vec::is_empty")]
627    pub extract: Vec<ExtractHint>,
628    /// Whether the producer truncated capture before exhausting candidates.
629    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
630    pub truncated: bool,
631}
632
633impl DetailBlock {
634    pub fn new(id: impl Into<String>, kind: impl Into<String>) -> Self {
635        Self {
636            id: id.into(),
637            kind: kind.into(),
638            label: None,
639            total_count: None,
640            examples: Vec::new(),
641            extract: Vec::new(),
642            truncated: false,
643        }
644    }
645
646    pub fn with_label(mut self, label: impl Into<String>) -> Self {
647        self.label = Some(label.into());
648        self
649    }
650
651    pub fn with_total_count(mut self, total_count: u64) -> Self {
652        self.total_count = Some(total_count);
653        self
654    }
655
656    pub fn with_example(mut self, example: DetailExample) -> Self {
657        self.examples.push(example);
658        self
659    }
660
661    pub fn with_extract_hint(mut self, hint: ExtractHint) -> Self {
662        self.extract.push(hint);
663        self
664    }
665}
666
667/// One bounded example inside a detail block.
668#[derive(Debug, Clone, Serialize, Deserialize)]
669#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
670pub struct DetailExample {
671    /// Structured locator such as row/column, line range, or key path.
672    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
673    pub locator: BTreeMap<String, serde_json::Value>,
674    /// Value before the change, if present.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub before: Option<ValuePreview>,
677    /// Value after the change, if present.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub after: Option<ValuePreview>,
680    /// Domain-specific structured context.
681    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
682    pub fields: BTreeMap<String, serde_json::Value>,
683}
684
685impl DetailExample {
686    pub fn new() -> Self {
687        Self {
688            locator: BTreeMap::new(),
689            before: None,
690            after: None,
691            fields: BTreeMap::new(),
692        }
693    }
694}
695
696impl Default for DetailExample {
697    fn default() -> Self {
698        Self::new()
699    }
700}
701
702/// A bounded preview of one value in a detail example.
703#[derive(Debug, Clone, Serialize, Deserialize)]
704#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
705pub struct ValuePreview {
706    pub value: serde_json::Value,
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub media_type: Option<String>,
709    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
710    pub truncated: bool,
711}
712
713/// Pointer to an extract aspect that can return exhaustive content.
714#[derive(Debug, Clone, Serialize, Deserialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct ExtractHint {
717    /// Aspect name accepted by `binoc extract`.
718    pub aspect: String,
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub label: Option<String>,
721}
722
723impl ExtractHint {
724    pub fn new(aspect: impl Into<String>) -> Self {
725        Self {
726            aspect: aspect.into(),
727            label: None,
728        }
729    }
730
731    pub fn with_label(mut self, label: impl Into<String>) -> Self {
732        self.label = Some(label.into());
733        self
734    }
735}
736
737/// A structured description of how to get from one snapshot to the next.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
740pub struct Changeset {
741    pub from_snapshot: String,
742    pub to_snapshot: String,
743    /// Run-scoped claims that do not belong to one tree node.
744    ///
745    /// Reserved for the CFM-41 global-claim prototype; empty in current engine
746    /// output.
747    #[serde(default)]
748    pub claims: Vec<GlobalClaim>,
749    pub root: Option<DiffNode>,
750    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
751    pub metadata: BTreeMap<String, String>,
752    #[serde(default, skip_serializing_if = "Vec::is_empty")]
753    pub diagnostics: Vec<Diagnostic>,
754}
755
756impl Changeset {
757    pub fn new(from: impl Into<String>, to: impl Into<String>, root: Option<DiffNode>) -> Self {
758        Self {
759            from_snapshot: from.into(),
760            to_snapshot: to.into(),
761            claims: Vec::new(),
762            root,
763            metadata: BTreeMap::new(),
764            diagnostics: Vec::new(),
765        }
766    }
767
768    pub fn node_count(&self) -> usize {
769        self.root.as_ref().map_or(0, |r| r.node_count())
770    }
771
772    pub fn push_diagnostic(&mut self, diagnostic: Diagnostic) {
773        self.diagnostics.push(diagnostic.normalized());
774    }
775
776    pub fn hoist_node_diagnostics(&mut self) {
777        if let Some(root) = self.root.as_mut() {
778            root.drain_diagnostics_into(&mut self.diagnostics);
779        }
780    }
781
782    pub fn dedupe_and_cap_diagnostics(&mut self, max_diagnostics: usize) {
783        let mut seen: BTreeSet<(String, Option<String>)> = BTreeSet::new();
784        let mut deduped = Vec::with_capacity(self.diagnostics.len().min(max_diagnostics));
785
786        for diagnostic in self.diagnostics.drain(..).map(Diagnostic::normalized) {
787            let key = (diagnostic.code.clone(), diagnostic.location.clone());
788            if seen.insert(key) {
789                deduped.push(diagnostic);
790                if deduped.len() >= max_diagnostics {
791                    break;
792                }
793            }
794        }
795
796        self.diagnostics = deduped;
797    }
798
799    /// Recursively clear session-scoped transient fields on the root and all
800    /// descendants. See [`DiffNode::strip_transient`].
801    pub fn strip_transient(&mut self) {
802        if let Some(root) = self.root.as_mut() {
803            root.strip_transient();
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn diff_node_new_creates_node_with_correct_fields() {
814        let node = DiffNode::new("modify", "file", "path/to/file.csv");
815        assert_eq!(node.action, "modify");
816        assert_eq!(node.item_type, "file");
817        assert_eq!(node.path, "path/to/file.csv");
818        assert!(node.sources.is_empty());
819        assert!(node.tags.is_empty());
820        assert!(node.children.is_empty());
821        assert!(node.details.is_empty());
822        assert!(node.detail_blocks.is_empty());
823        assert!(node.annotations.is_empty());
824    }
825
826    #[test]
827    fn diff_node_builder_methods_chain_correctly() {
828        let child = DiffNode::new("add", "file", "child.txt");
829        let node = DiffNode::new("modify", "directory", "dir")
830            .with_tag("binoc.column-reorder")
831            .with_tag("binoc.whitespace")
832            .with_detail("lines_changed", serde_json::json!(42))
833            .with_annotation_from("binoc", "note", serde_json::json!("check distribution"))
834            .with_children(vec![child])
835            .with_source(Source::new("old/dir", Side::From).with_action("move"));
836
837        assert_eq!(node.tags.len(), 2);
838        assert!(node.tags.contains("binoc.column-reorder"));
839        assert!(node.tags.contains("binoc.whitespace"));
840        assert_eq!(
841            node.details.get("lines_changed"),
842            Some(&serde_json::json!(42))
843        );
844        assert_eq!(
845            node.binoc_annotation("note")
846                .map(|annotation| &annotation.value),
847            Some(&serde_json::json!("check distribution"))
848        );
849        assert!(node.detail_blocks.is_empty());
850        assert_eq!(node.children.len(), 1);
851        assert_eq!(node.children[0].path, "child.txt");
852        assert_eq!(node.sources.len(), 1);
853        assert_eq!(node.sources[0].path, "old/dir");
854        assert_eq!(node.sources[0].side, Side::From);
855    }
856
857    #[test]
858    fn annotations_are_namespaced_and_replace_by_package_key() {
859        let mut node = DiffNode::new("modify", "file", "data.csv");
860        node.annotate_from("binoc", "note", serde_json::json!("first"));
861        node.annotate_from("binoc", "note", serde_json::json!("second"));
862        node.annotate_from("example.plugin", "note", serde_json::json!("external"));
863
864        assert_eq!(node.annotations.len(), 2);
865        assert_eq!(
866            node.binoc_annotation("note")
867                .map(|annotation| &annotation.value),
868            Some(&serde_json::json!("second"))
869        );
870        assert_eq!(
871            node.annotation("example.plugin", "note")
872                .map(|annotation| &annotation.value),
873            Some(&serde_json::json!("external"))
874        );
875    }
876
877    #[test]
878    fn node_count_leaf_returns_one() {
879        let node = DiffNode::new("add", "file", "file.txt");
880        assert_eq!(node.node_count(), 1);
881    }
882
883    #[test]
884    fn node_count_tree_returns_correct_total() {
885        let node = DiffNode::new("modify", "dir", "dir").with_children(vec![
886            DiffNode::new("add", "file", "a.txt"),
887            DiffNode::new("modify", "dir", "sub").with_children(vec![DiffNode::new(
888                "remove",
889                "file",
890                "sub/b.txt",
891            )]),
892        ]);
893        assert_eq!(node.node_count(), 4);
894    }
895
896    #[test]
897    fn all_tags_collects_from_entire_subtree() {
898        let node = DiffNode::new("modify", "dir", "dir")
899            .with_tag("root-tag")
900            .with_children(vec![
901                DiffNode::new("add", "file", "a").with_tag("child-tag"),
902                DiffNode::new("remove", "file", "b")
903                    .with_children(vec![
904                        DiffNode::new("modify", "file", "c").with_tag("grandchild-tag")
905                    ]),
906            ]);
907        let tags = node.all_tags();
908        assert_eq!(tags.len(), 3);
909        assert!(tags.contains("root-tag"));
910        assert!(tags.contains("child-tag"));
911        assert!(tags.contains("grandchild-tag"));
912    }
913
914    #[test]
915    fn serde_round_trip_preserves_equality() {
916        let node = DiffNode::new("move", "file", "new/path.csv")
917            .with_tag("binoc.move")
918            .with_detail("distance", serde_json::json!(10))
919            .with_detail_block(
920                DetailBlock::new("changed_cells", "binoc.tabular.cell_changes.v1")
921                    .with_label("Changed cells")
922                    .with_total_count(1)
923                    .with_example(DetailExample {
924                        locator: BTreeMap::from([
925                            ("row".into(), serde_json::json!(1)),
926                            ("column".into(), serde_json::json!("status")),
927                        ]),
928                        before: Some(ValuePreview {
929                            value: serde_json::json!("draft"),
930                            media_type: Some("text/plain".into()),
931                            truncated: false,
932                        }),
933                        after: Some(ValuePreview {
934                            value: serde_json::json!("published"),
935                            media_type: Some("text/plain".into()),
936                            truncated: false,
937                        }),
938                        fields: BTreeMap::new(),
939                    })
940                    .with_extract_hint(
941                        ExtractHint::new("cells_changed").with_label("All changed cells"),
942                    ),
943            )
944            .with_source(Source::new("old/path.csv", Side::From).with_action("move"));
945        let json = serde_json::to_string(&node).unwrap();
946        let restored: DiffNode = serde_json::from_str(&json).unwrap();
947        assert_eq!(node.action, restored.action);
948        assert_eq!(node.item_type, restored.item_type);
949        assert_eq!(node.path, restored.path);
950        assert_eq!(node.sources, restored.sources);
951        assert_eq!(node.tags, restored.tags);
952        assert_eq!(node.details, restored.details);
953        assert_eq!(restored.detail_blocks.len(), 1);
954        assert_eq!(restored.detail_blocks[0].examples.len(), 1);
955    }
956
957    #[test]
958    fn changeset_construction_and_node_count() {
959        let root = DiffNode::new("modify", "dir", "root").with_children(vec![
960            DiffNode::new("add", "file", "root/a.txt"),
961            DiffNode::new("remove", "file", "root/b.txt"),
962        ]);
963        let changeset = Changeset::new("v1", "v2", Some(root));
964        assert_eq!(changeset.from_snapshot, "v1");
965        assert_eq!(changeset.to_snapshot, "v2");
966        assert!(changeset.claims.is_empty());
967        assert_eq!(changeset.node_count(), 3);
968    }
969
970    #[test]
971    fn transient_fields_round_trip_through_serde() {
972        // Session-scoped transient fields (`source_items`, `artifacts`,
973        // `diagnostics`) are wire-visible so the plugin ABI can carry them
974        // across a (potentially process-isolated) boundary.
975        use crate::types::{
976            ArtifactDescriptor, ArtifactFormat, ArtifactSubject, ItemPair, ItemRef,
977        };
978
979        let artifact = ArtifactDescriptor {
980            format: ArtifactFormat::new("binoc", "tabular", 1),
981            subject: ArtifactSubject::Pair,
982            producer: "binoc.csv".into(),
983            handle: "cache/tabular-abc123".into(),
984        };
985        let source_items = ItemPair::both(
986            ItemRef {
987                logical_path: "data.csv".into(),
988                is_dir: false,
989                content_hash: None,
990                size: None,
991                media_type: None,
992                projection_hint: Default::default(),
993                handle: "/tmp/a/data.csv".into(),
994            },
995            ItemRef {
996                logical_path: "data.csv".into(),
997                is_dir: false,
998                content_hash: None,
999                size: None,
1000                media_type: None,
1001                projection_hint: Default::default(),
1002                handle: "/tmp/b/data.csv".into(),
1003            },
1004        );
1005        let child = DiffNode::new("modify", "tabular", "dir/data.csv")
1006            .with_artifact(artifact.clone())
1007            .with_source_items(source_items.clone())
1008            .with_diagnostic(Diagnostic::suggestion("binoc.demo", "Try a richer plugin"));
1009        let root = DiffNode::new("modify", "directory", "dir").with_children(vec![child]);
1010
1011        let json = serde_json::to_string(&root).unwrap();
1012        let restored: DiffNode = serde_json::from_str(&json).unwrap();
1013
1014        assert_eq!(restored.children.len(), 1);
1015        let restored_child = &restored.children[0];
1016        assert_eq!(restored_child.artifacts.len(), 1, "child artifact missing");
1017        assert_eq!(restored_child.artifacts[0].handle, artifact.handle);
1018        assert!(
1019            restored_child.source_items.is_some(),
1020            "child source_items missing"
1021        );
1022        assert_eq!(restored_child.diagnostics.len(), 1);
1023    }
1024
1025    #[test]
1026    fn hoisted_diagnostics_are_deduped_and_capped() {
1027        let mut root = DiffNode::new("modify", "directory", "");
1028        root.push_diagnostic(Diagnostic::suggestion(
1029            "binoc.binary-fallback",
1030            "Try a plugin",
1031        ));
1032        root.push_diagnostic(Diagnostic::suggestion(
1033            "binoc.binary-fallback",
1034            "Try a plugin",
1035        ));
1036        root.children = vec![
1037            DiffNode::new("modify", "file", "a.bin").with_diagnostic(Diagnostic::suggestion(
1038                "binoc.binary-fallback",
1039                "Try a plugin",
1040            )),
1041            DiffNode::new("modify", "file", "b.bin")
1042                .with_diagnostic(Diagnostic::warning("binoc.other", "Other issue")),
1043        ];
1044
1045        let mut changeset = Changeset::new("a", "b", Some(root));
1046        changeset.hoist_node_diagnostics();
1047        changeset.dedupe_and_cap_diagnostics(2);
1048
1049        assert_eq!(changeset.diagnostics.len(), 2);
1050        assert_eq!(changeset.diagnostics[0].code, "binoc.binary-fallback");
1051        assert_eq!(changeset.diagnostics[0].location, None);
1052        assert_eq!(changeset.diagnostics[1].location.as_deref(), Some("a.bin"));
1053    }
1054
1055    #[test]
1056    fn strip_transient_clears_every_descendant() {
1057        use crate::types::{ArtifactDescriptor, ArtifactFormat, ArtifactSubject};
1058        let artifact = ArtifactDescriptor {
1059            format: ArtifactFormat::new("binoc", "tabular", 1),
1060            subject: ArtifactSubject::Pair,
1061            producer: "binoc.csv".into(),
1062            handle: "h".into(),
1063        };
1064        let grandchild = DiffNode::new("modify", "tabular", "a/b/c.csv")
1065            .with_artifact(artifact)
1066            .with_diagnostic(Diagnostic::warning("binoc.test", "test"));
1067        let child = DiffNode::new("modify", "directory", "a/b").with_children(vec![grandchild]);
1068        let mut root = DiffNode::new("modify", "directory", "a").with_children(vec![child]);
1069        root.strip_transient();
1070        fn all_empty(n: &DiffNode) -> bool {
1071            n.artifacts.is_empty()
1072                && n.diagnostics.is_empty()
1073                && n.source_items.is_none()
1074                && n.children.iter().all(all_empty)
1075        }
1076        assert!(all_empty(&root));
1077    }
1078
1079    #[test]
1080    fn changeset_node_count_none_root() {
1081        let changeset = Changeset::new("v1", "v2", None);
1082        assert_eq!(changeset.node_count(), 0);
1083    }
1084}