1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4
5use crate::types::{ArtifactDescriptor, ItemPair};
6
7#[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 From,
20 To,
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(rename_all = "snake_case")]
36pub enum Segment {
37 Text(String),
42 Path { value: String, snapshot: Side },
45 Uint(u64),
47 Float(f64),
49}
50
51#[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 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 pub fn uint(mut self, value: u64) -> Self {
91 self.0.push(Segment::Uint(value));
92 self
93 }
94
95 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 pub fn float(mut self, value: f64) -> Self {
106 self.0.push(Segment::Float(value));
107 self
108 }
109
110 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 pub fn push(&mut self, segment: Segment) {
121 self.0.push(segment);
122 }
123
124 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 pub fn plain_text(&self) -> String {
143 self.to_string()
144 }
145
146 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#[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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
287#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
288pub struct Source {
289 pub path: String,
291 pub side: Side,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub evidence: Option<String>,
296 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326pub struct DiffNode {
327 pub action: String,
330
331 pub item_type: String,
334
335 pub path: String,
339
340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
342 pub sources: Vec<Source>,
343
344 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub summary: Option<Summary>,
350
351 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
354 pub tags: BTreeSet<String>,
355
356 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub children: Vec<DiffNode>,
359
360 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
362 pub details: BTreeMap<String, serde_json::Value>,
363
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
368 pub detail_blocks: Vec<DetailBlock>,
369
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
372 pub annotations: Vec<Annotation>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub source_items: Option<ItemPair>,
381
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
387 pub diagnostics: Vec<Diagnostic>,
388
389 #[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
576#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
577pub struct GlobalClaim {
578 pub verb: String,
580 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
582 pub params: BTreeMap<String, serde_json::Value>,
583 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
610#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
611pub struct DetailBlock {
612 pub id: String,
614 pub kind: String,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub label: Option<String>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub total_count: Option<u64>,
622 #[serde(default, skip_serializing_if = "Vec::is_empty")]
624 pub examples: Vec<DetailExample>,
625 #[serde(default, skip_serializing_if = "Vec::is_empty")]
627 pub extract: Vec<ExtractHint>,
628 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
669#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
670pub struct DetailExample {
671 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
673 pub locator: BTreeMap<String, serde_json::Value>,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub before: Option<ValuePreview>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub after: Option<ValuePreview>,
680 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct ExtractHint {
717 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#[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 #[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 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 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}