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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
296pub struct Source {
297 pub path: String,
299 pub side: Side,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub evidence: Option<String>,
304 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
333#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
334pub struct DiffNode {
335 pub action: String,
338
339 pub item_type: String,
342
343 pub path: String,
347
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub sources: Vec<Source>,
351
352 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub summary: Option<Summary>,
358
359 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
362 pub tags: BTreeSet<String>,
363
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 pub children: Vec<DiffNode>,
367
368 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
370 pub details: BTreeMap<String, serde_json::Value>,
371
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
376 pub detail_blocks: Vec<DetailBlock>,
377
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
380 pub annotations: Vec<Annotation>,
381
382 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub source_items: Option<ItemPair>,
389
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 pub diagnostics: Vec<Diagnostic>,
396
397 #[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
584#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
585pub struct GlobalClaim {
586 pub verb: String,
588 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
590 pub params: BTreeMap<String, serde_json::Value>,
591 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
618#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
619pub struct DetailBlock {
620 pub id: String,
622 pub kind: String,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub label: Option<String>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub total_count: Option<u64>,
630 #[serde(default, skip_serializing_if = "Vec::is_empty")]
632 pub examples: Vec<DetailExample>,
633 #[serde(default, skip_serializing_if = "Vec::is_empty")]
635 pub extract: Vec<ExtractHint>,
636 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
677#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
678pub struct DetailExample {
679 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
681 pub locator: BTreeMap<String, serde_json::Value>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub before: Option<ValuePreview>,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub after: Option<ValuePreview>,
688 #[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
723#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
724pub struct ExtractHint {
725 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#[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 #[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 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 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}