Skip to main content

agm_core/diff/
fields.rs

1//! Field-level diff within a single node, and severity classification.
2
3use serde::{Deserialize, Serialize};
4
5use crate::model::fields::FieldValue;
6use crate::model::node::Node;
7
8use super::{ChangeKind, ChangeSeverity};
9
10/// A serializable snapshot of a field value for display in reports.
11///
12/// Unlike `FieldValue` from the model (which has `Scalar`, `List`, `Block`),
13/// this type adds representation for complex fields (code blocks, verify
14/// checks, agent context, etc.) that are serialized as JSON strings.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(untagged)]
17pub enum FieldValueSnapshot {
18    /// A single scalar string.
19    Scalar(String),
20    /// A list of strings.
21    List(Vec<String>),
22    /// A block of text.
23    Block(String),
24    /// Complex structured value serialized to JSON string for display.
25    Complex(String),
26}
27
28impl FieldValueSnapshot {
29    /// Converts a `FieldValue` to a snapshot.
30    pub(crate) fn from_field_value(v: &FieldValue) -> Self {
31        match v {
32            FieldValue::Scalar(s) => Self::Scalar(s.clone()),
33            FieldValue::List(l) => Self::List(l.clone()),
34            FieldValue::Block(b) => Self::Block(b.clone()),
35        }
36    }
37
38    /// Creates a scalar snapshot from a string.
39    pub(crate) fn from_str_val(s: &str) -> Self {
40        Self::Scalar(s.to_owned())
41    }
42
43    /// Creates a complex snapshot by serializing a value to a JSON string.
44    pub(crate) fn from_complex<T: Serialize>(v: &T) -> Self {
45        Self::Complex(serde_json::to_string(v).unwrap_or_default())
46    }
47}
48
49/// A change to a single field within a node.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct FieldChange {
52    pub field: String,
53    pub kind: ChangeKind,
54    pub severity: ChangeSeverity,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub old_value: Option<FieldValueSnapshot>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub new_value: Option<FieldValueSnapshot>,
59}
60
61/// Classifies the severity of a change to a given field.
62///
63/// See the severity classification table in section 4.1 of the plan.
64#[must_use]
65pub(crate) fn classify_severity(field_name: &str, kind: ChangeKind) -> ChangeSeverity {
66    match (field_name, kind) {
67        // Identity & structure
68        ("node_type" | "type", _) => ChangeSeverity::Breaking,
69
70        // Required fields
71        ("summary", _) => ChangeSeverity::Minor,
72
73        // Control fields
74        ("priority" | "stability" | "confidence" | "status", ChangeKind::Added) => {
75            ChangeSeverity::Info
76        }
77        ("priority" | "stability" | "confidence" | "status", _) => ChangeSeverity::Minor,
78
79        // Relationship: depends
80        ("depends", ChangeKind::Removed) => ChangeSeverity::Breaking,
81        ("depends", ChangeKind::Added) => ChangeSeverity::Minor,
82        ("depends", ChangeKind::Modified) => ChangeSeverity::Minor,
83
84        // Relationship: related_to, see_also
85        ("related_to" | "see_also", _) => ChangeSeverity::Info,
86
87        // Relationship: replaces, conflicts
88        ("replaces" | "conflicts", _) => ChangeSeverity::Minor,
89
90        // Operational fields: items
91        ("items", _) => ChangeSeverity::Minor,
92
93        // Operational fields: steps (removed = breaking, added = minor)
94        ("steps", ChangeKind::Removed) => ChangeSeverity::Breaking,
95        ("steps", _) => ChangeSeverity::Minor,
96
97        // Operational fields: fields, input, output (removed = breaking)
98        ("fields" | "input" | "output", ChangeKind::Removed) => ChangeSeverity::Breaking,
99        ("fields" | "input" | "output", _) => ChangeSeverity::Minor,
100
101        // Explanatory fields
102        ("detail" | "examples" | "notes", _) => ChangeSeverity::Info,
103        ("rationale" | "tradeoffs" | "resolution", _) => ChangeSeverity::Info,
104
105        // Executable fields
106        ("code" | "code_blocks", ChangeKind::Removed) => ChangeSeverity::Breaking,
107        ("code" | "code_blocks", _) => ChangeSeverity::Minor,
108        ("verify", _) => ChangeSeverity::Minor,
109        ("agent_context", ChangeKind::Added) => ChangeSeverity::Info,
110        ("agent_context", _) => ChangeSeverity::Minor,
111        ("target", ChangeKind::Removed) => ChangeSeverity::Breaking,
112        ("target", _) => ChangeSeverity::Minor,
113
114        // Execution state fields
115        (
116            "execution_status" | "executed_by" | "executed_at" | "execution_log" | "retry_count",
117            _,
118        ) => ChangeSeverity::Info,
119
120        // Orchestration fields
121        ("parallel_groups", ChangeKind::Removed) => ChangeSeverity::Breaking,
122        ("parallel_groups", _) => ChangeSeverity::Minor,
123
124        // Memory fields
125        ("memory", ChangeKind::Added) => ChangeSeverity::Info,
126        ("memory", _) => ChangeSeverity::Minor,
127
128        // Context fields: scope
129        ("scope", ChangeKind::Added) => ChangeSeverity::Info,
130        ("scope", _) => ChangeSeverity::Minor,
131
132        // Context fields: applies_when
133        ("applies_when", ChangeKind::Added) => ChangeSeverity::Info,
134        ("applies_when", _) => ChangeSeverity::Minor,
135
136        // Context fields: valid_from, valid_until
137        ("valid_from" | "valid_until", _) => ChangeSeverity::Info,
138
139        // Context fields: tags, keywords
140        ("tags" | "keywords", _) => ChangeSeverity::Info,
141
142        // Context fields: aliases
143        ("aliases", ChangeKind::Added) => ChangeSeverity::Info,
144        ("aliases", _) => ChangeSeverity::Minor,
145
146        // Extension / unknown fields
147        _ => ChangeSeverity::Info,
148    }
149}
150
151/// Compares all fields of two nodes and returns a list of changes.
152///
153/// Handles typed fields (node_type, summary, priority, etc.) and
154/// generic extra_fields. Delegates relationship fields to relation::diff_relation_field.
155#[must_use]
156pub(crate) fn diff_all_fields(left: &Node, right: &Node) -> Vec<FieldChange> {
157    let mut changes = Vec::new();
158
159    // node_type
160    if left.node_type != right.node_type {
161        changes.push(FieldChange {
162            field: "type".to_owned(),
163            kind: ChangeKind::Modified,
164            severity: classify_severity("node_type", ChangeKind::Modified),
165            old_value: Some(FieldValueSnapshot::from_str_val(
166                &left.node_type.to_string(),
167            )),
168            new_value: Some(FieldValueSnapshot::from_str_val(
169                &right.node_type.to_string(),
170            )),
171        });
172    }
173
174    // summary
175    if left.summary != right.summary {
176        changes.push(FieldChange {
177            field: "summary".to_owned(),
178            kind: ChangeKind::Modified,
179            severity: classify_severity("summary", ChangeKind::Modified),
180            old_value: Some(FieldValueSnapshot::from_str_val(&left.summary)),
181            new_value: Some(FieldValueSnapshot::from_str_val(&right.summary)),
182        });
183    }
184
185    // Optional scalar: priority
186    diff_opt_display(
187        &mut changes,
188        "priority",
189        left.priority.as_ref().map(|v| v.to_string()),
190        right.priority.as_ref().map(|v| v.to_string()),
191    );
192
193    // stability
194    diff_opt_display(
195        &mut changes,
196        "stability",
197        left.stability.as_ref().map(|v| v.to_string()),
198        right.stability.as_ref().map(|v| v.to_string()),
199    );
200
201    // confidence
202    diff_opt_display(
203        &mut changes,
204        "confidence",
205        left.confidence.as_ref().map(|v| v.to_string()),
206        right.confidence.as_ref().map(|v| v.to_string()),
207    );
208
209    // status
210    diff_opt_display(
211        &mut changes,
212        "status",
213        left.status.as_ref().map(|v| v.to_string()),
214        right.status.as_ref().map(|v| v.to_string()),
215    );
216
217    // Relationship fields (ordered)
218    diff_opt_list_field(
219        &mut changes,
220        "depends",
221        left.depends.as_deref(),
222        right.depends.as_deref(),
223    );
224
225    // Relationship fields (unordered)
226    diff_opt_list_field(
227        &mut changes,
228        "related_to",
229        left.related_to.as_deref(),
230        right.related_to.as_deref(),
231    );
232    diff_opt_list_field(
233        &mut changes,
234        "replaces",
235        left.replaces.as_deref(),
236        right.replaces.as_deref(),
237    );
238    diff_opt_list_field(
239        &mut changes,
240        "conflicts",
241        left.conflicts.as_deref(),
242        right.conflicts.as_deref(),
243    );
244    diff_opt_list_field(
245        &mut changes,
246        "see_also",
247        left.see_also.as_deref(),
248        right.see_also.as_deref(),
249    );
250
251    // Operational list fields
252    diff_opt_list_field(
253        &mut changes,
254        "items",
255        left.items.as_deref(),
256        right.items.as_deref(),
257    );
258    diff_opt_list_field(
259        &mut changes,
260        "steps",
261        left.steps.as_deref(),
262        right.steps.as_deref(),
263    );
264    diff_opt_list_field(
265        &mut changes,
266        "fields",
267        left.fields.as_deref(),
268        right.fields.as_deref(),
269    );
270    diff_opt_list_field(
271        &mut changes,
272        "input",
273        left.input.as_deref(),
274        right.input.as_deref(),
275    );
276    diff_opt_list_field(
277        &mut changes,
278        "output",
279        left.output.as_deref(),
280        right.output.as_deref(),
281    );
282
283    // Explanatory scalar fields
284    diff_opt_scalar(
285        &mut changes,
286        "detail",
287        left.detail.as_deref(),
288        right.detail.as_deref(),
289    );
290    diff_opt_scalar(
291        &mut changes,
292        "examples",
293        left.examples.as_deref(),
294        right.examples.as_deref(),
295    );
296    diff_opt_scalar(
297        &mut changes,
298        "notes",
299        left.notes.as_deref(),
300        right.notes.as_deref(),
301    );
302
303    // Explanatory list fields
304    diff_opt_list_field(
305        &mut changes,
306        "rationale",
307        left.rationale.as_deref(),
308        right.rationale.as_deref(),
309    );
310    diff_opt_list_field(
311        &mut changes,
312        "tradeoffs",
313        left.tradeoffs.as_deref(),
314        right.tradeoffs.as_deref(),
315    );
316    diff_opt_list_field(
317        &mut changes,
318        "resolution",
319        left.resolution.as_deref(),
320        right.resolution.as_deref(),
321    );
322
323    // Executable scalar
324    diff_opt_scalar(
325        &mut changes,
326        "target",
327        left.target.as_deref(),
328        right.target.as_deref(),
329    );
330
331    // Executable complex: code
332    diff_opt_complex(
333        &mut changes,
334        "code",
335        left.code.as_ref(),
336        right.code.as_ref(),
337    );
338
339    // Executable complex: code_blocks
340    diff_opt_complex(
341        &mut changes,
342        "code_blocks",
343        left.code_blocks.as_ref(),
344        right.code_blocks.as_ref(),
345    );
346
347    // Executable complex: verify
348    diff_opt_complex(
349        &mut changes,
350        "verify",
351        left.verify.as_ref(),
352        right.verify.as_ref(),
353    );
354
355    // Executable complex: agent_context
356    diff_opt_complex(
357        &mut changes,
358        "agent_context",
359        left.agent_context.as_ref(),
360        right.agent_context.as_ref(),
361    );
362
363    // Execution state
364    diff_opt_display(
365        &mut changes,
366        "execution_status",
367        left.execution_status.as_ref().map(|v| v.to_string()),
368        right.execution_status.as_ref().map(|v| v.to_string()),
369    );
370    diff_opt_scalar(
371        &mut changes,
372        "executed_by",
373        left.executed_by.as_deref(),
374        right.executed_by.as_deref(),
375    );
376    diff_opt_scalar(
377        &mut changes,
378        "executed_at",
379        left.executed_at.as_deref(),
380        right.executed_at.as_deref(),
381    );
382    diff_opt_scalar(
383        &mut changes,
384        "execution_log",
385        left.execution_log.as_deref(),
386        right.execution_log.as_deref(),
387    );
388    diff_opt_display(
389        &mut changes,
390        "retry_count",
391        left.retry_count.map(|v| v.to_string()),
392        right.retry_count.map(|v| v.to_string()),
393    );
394
395    // Orchestration complex
396    diff_opt_complex(
397        &mut changes,
398        "parallel_groups",
399        left.parallel_groups.as_ref(),
400        right.parallel_groups.as_ref(),
401    );
402
403    // Memory complex
404    diff_opt_complex(
405        &mut changes,
406        "memory",
407        left.memory.as_ref(),
408        right.memory.as_ref(),
409    );
410
411    // Context list fields
412    diff_opt_list_field(
413        &mut changes,
414        "scope",
415        left.scope.as_deref(),
416        right.scope.as_deref(),
417    );
418    diff_opt_scalar(
419        &mut changes,
420        "applies_when",
421        left.applies_when.as_deref(),
422        right.applies_when.as_deref(),
423    );
424    diff_opt_scalar(
425        &mut changes,
426        "valid_from",
427        left.valid_from.as_deref(),
428        right.valid_from.as_deref(),
429    );
430    diff_opt_scalar(
431        &mut changes,
432        "valid_until",
433        left.valid_until.as_deref(),
434        right.valid_until.as_deref(),
435    );
436    diff_opt_list_field(
437        &mut changes,
438        "tags",
439        left.tags.as_deref(),
440        right.tags.as_deref(),
441    );
442    diff_opt_list_field(
443        &mut changes,
444        "aliases",
445        left.aliases.as_deref(),
446        right.aliases.as_deref(),
447    );
448    diff_opt_list_field(
449        &mut changes,
450        "keywords",
451        left.keywords.as_deref(),
452        right.keywords.as_deref(),
453    );
454
455    // Extension (extra_fields)
456    diff_extra_fields(&mut changes, &left.extra_fields, &right.extra_fields);
457
458    changes
459}
460
461// ---------------------------------------------------------------------------
462// Helpers
463// ---------------------------------------------------------------------------
464
465fn diff_opt_scalar(
466    changes: &mut Vec<FieldChange>,
467    field: &str,
468    left: Option<&str>,
469    right: Option<&str>,
470) {
471    match (left, right) {
472        (None, None) => {}
473        (None, Some(r)) => changes.push(FieldChange {
474            field: field.to_owned(),
475            kind: ChangeKind::Added,
476            severity: classify_severity(field, ChangeKind::Added),
477            old_value: None,
478            new_value: Some(FieldValueSnapshot::Scalar(r.to_owned())),
479        }),
480        (Some(l), None) => changes.push(FieldChange {
481            field: field.to_owned(),
482            kind: ChangeKind::Removed,
483            severity: classify_severity(field, ChangeKind::Removed),
484            old_value: Some(FieldValueSnapshot::Scalar(l.to_owned())),
485            new_value: None,
486        }),
487        (Some(l), Some(r)) if l != r => changes.push(FieldChange {
488            field: field.to_owned(),
489            kind: ChangeKind::Modified,
490            severity: classify_severity(field, ChangeKind::Modified),
491            old_value: Some(FieldValueSnapshot::Scalar(l.to_owned())),
492            new_value: Some(FieldValueSnapshot::Scalar(r.to_owned())),
493        }),
494        _ => {}
495    }
496}
497
498fn diff_opt_display(
499    changes: &mut Vec<FieldChange>,
500    field: &str,
501    left: Option<String>,
502    right: Option<String>,
503) {
504    diff_opt_scalar(changes, field, left.as_deref(), right.as_deref());
505}
506
507fn diff_opt_list_field(
508    changes: &mut Vec<FieldChange>,
509    field: &str,
510    left: Option<&[String]>,
511    right: Option<&[String]>,
512) {
513    let left_slice = left.unwrap_or_default();
514    let right_slice = right.unwrap_or_default();
515
516    // If both are absent, no change
517    if left.is_none() && right.is_none() {
518        return;
519    }
520
521    let sub_changes = super::relation::diff_relation_field(field, left_slice, right_slice);
522    changes.extend(sub_changes);
523}
524
525fn diff_opt_complex<T: Serialize + PartialEq>(
526    changes: &mut Vec<FieldChange>,
527    field: &str,
528    left: Option<&T>,
529    right: Option<&T>,
530) {
531    match (left, right) {
532        (None, None) => {}
533        (None, Some(r)) => changes.push(FieldChange {
534            field: field.to_owned(),
535            kind: ChangeKind::Added,
536            severity: classify_severity(field, ChangeKind::Added),
537            old_value: None,
538            new_value: Some(FieldValueSnapshot::from_complex(r)),
539        }),
540        (Some(l), None) => changes.push(FieldChange {
541            field: field.to_owned(),
542            kind: ChangeKind::Removed,
543            severity: classify_severity(field, ChangeKind::Removed),
544            old_value: Some(FieldValueSnapshot::from_complex(l)),
545            new_value: None,
546        }),
547        (Some(l), Some(r)) if l != r => {
548            let old_json = serde_json::to_string(l).unwrap_or_default();
549            let new_json = serde_json::to_string(r).unwrap_or_default();
550            if old_json != new_json {
551                changes.push(FieldChange {
552                    field: field.to_owned(),
553                    kind: ChangeKind::Modified,
554                    severity: classify_severity(field, ChangeKind::Modified),
555                    old_value: Some(FieldValueSnapshot::Complex(old_json)),
556                    new_value: Some(FieldValueSnapshot::Complex(new_json)),
557                });
558            }
559        }
560        _ => {}
561    }
562}
563
564fn diff_extra_fields(
565    changes: &mut Vec<FieldChange>,
566    left: &std::collections::BTreeMap<String, FieldValue>,
567    right: &std::collections::BTreeMap<String, FieldValue>,
568) {
569    // Keys only in left -> Removed
570    for (key, val) in left {
571        if !right.contains_key(key) {
572            changes.push(FieldChange {
573                field: key.clone(),
574                kind: ChangeKind::Removed,
575                severity: ChangeSeverity::Info,
576                old_value: Some(FieldValueSnapshot::from_field_value(val)),
577                new_value: None,
578            });
579        }
580    }
581
582    // Keys only in right -> Added
583    for (key, val) in right {
584        if !left.contains_key(key) {
585            changes.push(FieldChange {
586                field: key.clone(),
587                kind: ChangeKind::Added,
588                severity: ChangeSeverity::Info,
589                old_value: None,
590                new_value: Some(FieldValueSnapshot::from_field_value(val)),
591            });
592        }
593    }
594
595    // Keys in both but different values -> Modified
596    for (key, left_val) in left {
597        if let Some(right_val) = right.get(key) {
598            if left_val != right_val {
599                changes.push(FieldChange {
600                    field: key.clone(),
601                    kind: ChangeKind::Modified,
602                    severity: ChangeSeverity::Info,
603                    old_value: Some(FieldValueSnapshot::from_field_value(left_val)),
604                    new_value: Some(FieldValueSnapshot::from_field_value(right_val)),
605                });
606            }
607        }
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Tests
613// ---------------------------------------------------------------------------
614
615#[cfg(test)]
616mod tests {
617    use crate::model::fields::{FieldValue, NodeType, Priority, Span};
618    use crate::model::node::Node;
619
620    use super::*;
621
622    fn minimal_node() -> Node {
623        Node {
624            id: "test.node".to_owned(),
625            node_type: NodeType::Facts,
626            summary: "a test node".to_owned(),
627            span: Span::new(1, 1),
628            ..Default::default()
629        }
630    }
631
632    #[test]
633    fn test_classify_severity_type_modified_returns_breaking() {
634        assert_eq!(
635            classify_severity("node_type", ChangeKind::Modified),
636            ChangeSeverity::Breaking
637        );
638    }
639
640    #[test]
641    fn test_classify_severity_summary_modified_returns_minor() {
642        assert_eq!(
643            classify_severity("summary", ChangeKind::Modified),
644            ChangeSeverity::Minor
645        );
646    }
647
648    #[test]
649    fn test_classify_severity_detail_modified_returns_info() {
650        assert_eq!(
651            classify_severity("detail", ChangeKind::Modified),
652            ChangeSeverity::Info
653        );
654    }
655
656    #[test]
657    fn test_classify_severity_depends_removed_returns_breaking() {
658        assert_eq!(
659            classify_severity("depends", ChangeKind::Removed),
660            ChangeSeverity::Breaking
661        );
662    }
663
664    #[test]
665    fn test_classify_severity_steps_removed_returns_breaking() {
666        assert_eq!(
667            classify_severity("steps", ChangeKind::Removed),
668            ChangeSeverity::Breaking
669        );
670    }
671
672    #[test]
673    fn test_classify_severity_code_removed_returns_breaking() {
674        assert_eq!(
675            classify_severity("code", ChangeKind::Removed),
676            ChangeSeverity::Breaking
677        );
678    }
679
680    #[test]
681    fn test_classify_severity_tags_added_returns_info() {
682        assert_eq!(
683            classify_severity("tags", ChangeKind::Added),
684            ChangeSeverity::Info
685        );
686    }
687
688    #[test]
689    fn test_classify_severity_unknown_field_returns_info() {
690        assert_eq!(
691            classify_severity("some_unknown_field", ChangeKind::Modified),
692            ChangeSeverity::Info
693        );
694    }
695
696    #[test]
697    fn test_diff_all_fields_identical_returns_empty() {
698        let node = minimal_node();
699        let changes = diff_all_fields(&node, &node);
700        assert!(changes.is_empty());
701    }
702
703    #[test]
704    fn test_diff_all_fields_type_changed_returns_breaking() {
705        let left = minimal_node();
706        let mut right = left.clone();
707        right.node_type = NodeType::Rules;
708        let changes = diff_all_fields(&left, &right);
709        assert_eq!(changes.len(), 1);
710        assert_eq!(changes[0].field, "type");
711        assert_eq!(changes[0].severity, ChangeSeverity::Breaking);
712    }
713
714    #[test]
715    fn test_diff_all_fields_summary_changed_returns_minor() {
716        let left = minimal_node();
717        let mut right = left.clone();
718        right.summary = "different summary".to_owned();
719        let changes = diff_all_fields(&left, &right);
720        assert_eq!(changes.len(), 1);
721        assert_eq!(changes[0].field, "summary");
722        assert_eq!(changes[0].severity, ChangeSeverity::Minor);
723    }
724
725    #[test]
726    fn test_diff_all_fields_priority_added_returns_info() {
727        let left = minimal_node();
728        let mut right = left.clone();
729        right.priority = Some(Priority::High);
730        let changes = diff_all_fields(&left, &right);
731        assert_eq!(changes.len(), 1);
732        assert_eq!(changes[0].field, "priority");
733        assert_eq!(changes[0].kind, ChangeKind::Added);
734        assert_eq!(changes[0].severity, ChangeSeverity::Info);
735    }
736
737    #[test]
738    fn test_diff_all_fields_priority_removed_returns_minor() {
739        let mut left = minimal_node();
740        left.priority = Some(Priority::High);
741        let right = minimal_node();
742        let changes = diff_all_fields(&left, &right);
743        assert_eq!(changes.len(), 1);
744        assert_eq!(changes[0].field, "priority");
745        assert_eq!(changes[0].kind, ChangeKind::Removed);
746        assert_eq!(changes[0].severity, ChangeSeverity::Minor);
747    }
748
749    #[test]
750    fn test_diff_all_fields_extra_field_added_returns_info() {
751        let left = minimal_node();
752        let mut right = left.clone();
753        right.extra_fields.insert(
754            "custom_key".to_owned(),
755            FieldValue::Scalar("val".to_owned()),
756        );
757        let changes = diff_all_fields(&left, &right);
758        assert_eq!(changes.len(), 1);
759        assert_eq!(changes[0].field, "custom_key");
760        assert_eq!(changes[0].kind, ChangeKind::Added);
761        assert_eq!(changes[0].severity, ChangeSeverity::Info);
762    }
763
764    #[test]
765    fn test_diff_all_fields_extra_field_removed_returns_info() {
766        let mut left = minimal_node();
767        left.extra_fields.insert(
768            "custom_key".to_owned(),
769            FieldValue::Scalar("val".to_owned()),
770        );
771        let right = minimal_node();
772        let changes = diff_all_fields(&left, &right);
773        assert_eq!(changes.len(), 1);
774        assert_eq!(changes[0].field, "custom_key");
775        assert_eq!(changes[0].kind, ChangeKind::Removed);
776        assert_eq!(changes[0].severity, ChangeSeverity::Info);
777    }
778
779    #[test]
780    fn test_diff_all_fields_code_block_changed_returns_minor() {
781        use crate::model::code::{CodeAction, CodeBlock};
782        let left = minimal_node();
783        let mut right = left.clone();
784        right.code = Some(CodeBlock {
785            lang: Some("rust".to_owned()),
786            target: None,
787            action: CodeAction::Full,
788            body: "fn main() {}".to_owned(),
789            anchor: None,
790            old: None,
791        });
792        let changes = diff_all_fields(&left, &right);
793        assert_eq!(changes.len(), 1);
794        assert_eq!(changes[0].field, "code");
795        assert_eq!(changes[0].kind, ChangeKind::Added);
796        // added code -> Minor
797        assert_eq!(changes[0].severity, ChangeSeverity::Minor);
798    }
799
800    #[test]
801    fn test_field_value_snapshot_from_field_value_scalar() {
802        let fv = FieldValue::Scalar("hello".to_owned());
803        let snap = FieldValueSnapshot::from_field_value(&fv);
804        assert_eq!(snap, FieldValueSnapshot::Scalar("hello".to_owned()));
805    }
806
807    #[test]
808    fn test_field_value_snapshot_from_field_value_list() {
809        let fv = FieldValue::List(vec!["a".to_owned(), "b".to_owned()]);
810        let snap = FieldValueSnapshot::from_field_value(&fv);
811        assert_eq!(
812            snap,
813            FieldValueSnapshot::List(vec!["a".to_owned(), "b".to_owned()])
814        );
815    }
816
817    #[test]
818    fn test_field_value_snapshot_from_complex_serializes_json() {
819        #[derive(Serialize)]
820        struct Simple {
821            key: &'static str,
822        }
823        let val = Simple { key: "value" };
824        let snap = FieldValueSnapshot::from_complex(&val);
825        assert!(matches!(snap, FieldValueSnapshot::Complex(_)));
826        if let FieldValueSnapshot::Complex(s) = snap {
827            assert!(s.contains("key"));
828            assert!(s.contains("value"));
829        }
830    }
831}