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 std::collections::BTreeMap;
618
619    use crate::model::fields::{FieldValue, NodeType, Priority, Span};
620    use crate::model::node::Node;
621
622    use super::*;
623
624    fn minimal_node() -> Node {
625        Node {
626            id: "test.node".to_owned(),
627            node_type: NodeType::Facts,
628            summary: "a test node".to_owned(),
629            priority: None,
630            stability: None,
631            confidence: None,
632            status: None,
633            depends: None,
634            related_to: None,
635            replaces: None,
636            conflicts: None,
637            see_also: None,
638            items: None,
639            steps: None,
640            fields: None,
641            input: None,
642            output: None,
643            detail: None,
644            rationale: None,
645            tradeoffs: None,
646            resolution: None,
647            examples: None,
648            notes: None,
649            code: None,
650            code_blocks: None,
651            verify: None,
652            agent_context: None,
653            target: None,
654            execution_status: None,
655            executed_by: None,
656            executed_at: None,
657            execution_log: None,
658            retry_count: None,
659            parallel_groups: None,
660            memory: None,
661            scope: None,
662            applies_when: None,
663            valid_from: None,
664            valid_until: None,
665            tags: None,
666            aliases: None,
667            keywords: None,
668            extra_fields: BTreeMap::new(),
669            span: Span::new(1, 1),
670        }
671    }
672
673    #[test]
674    fn test_classify_severity_type_modified_returns_breaking() {
675        assert_eq!(
676            classify_severity("node_type", ChangeKind::Modified),
677            ChangeSeverity::Breaking
678        );
679    }
680
681    #[test]
682    fn test_classify_severity_summary_modified_returns_minor() {
683        assert_eq!(
684            classify_severity("summary", ChangeKind::Modified),
685            ChangeSeverity::Minor
686        );
687    }
688
689    #[test]
690    fn test_classify_severity_detail_modified_returns_info() {
691        assert_eq!(
692            classify_severity("detail", ChangeKind::Modified),
693            ChangeSeverity::Info
694        );
695    }
696
697    #[test]
698    fn test_classify_severity_depends_removed_returns_breaking() {
699        assert_eq!(
700            classify_severity("depends", ChangeKind::Removed),
701            ChangeSeverity::Breaking
702        );
703    }
704
705    #[test]
706    fn test_classify_severity_steps_removed_returns_breaking() {
707        assert_eq!(
708            classify_severity("steps", ChangeKind::Removed),
709            ChangeSeverity::Breaking
710        );
711    }
712
713    #[test]
714    fn test_classify_severity_code_removed_returns_breaking() {
715        assert_eq!(
716            classify_severity("code", ChangeKind::Removed),
717            ChangeSeverity::Breaking
718        );
719    }
720
721    #[test]
722    fn test_classify_severity_tags_added_returns_info() {
723        assert_eq!(
724            classify_severity("tags", ChangeKind::Added),
725            ChangeSeverity::Info
726        );
727    }
728
729    #[test]
730    fn test_classify_severity_unknown_field_returns_info() {
731        assert_eq!(
732            classify_severity("some_unknown_field", ChangeKind::Modified),
733            ChangeSeverity::Info
734        );
735    }
736
737    #[test]
738    fn test_diff_all_fields_identical_returns_empty() {
739        let node = minimal_node();
740        let changes = diff_all_fields(&node, &node);
741        assert!(changes.is_empty());
742    }
743
744    #[test]
745    fn test_diff_all_fields_type_changed_returns_breaking() {
746        let left = minimal_node();
747        let mut right = left.clone();
748        right.node_type = NodeType::Rules;
749        let changes = diff_all_fields(&left, &right);
750        assert_eq!(changes.len(), 1);
751        assert_eq!(changes[0].field, "type");
752        assert_eq!(changes[0].severity, ChangeSeverity::Breaking);
753    }
754
755    #[test]
756    fn test_diff_all_fields_summary_changed_returns_minor() {
757        let left = minimal_node();
758        let mut right = left.clone();
759        right.summary = "different summary".to_owned();
760        let changes = diff_all_fields(&left, &right);
761        assert_eq!(changes.len(), 1);
762        assert_eq!(changes[0].field, "summary");
763        assert_eq!(changes[0].severity, ChangeSeverity::Minor);
764    }
765
766    #[test]
767    fn test_diff_all_fields_priority_added_returns_info() {
768        let left = minimal_node();
769        let mut right = left.clone();
770        right.priority = Some(Priority::High);
771        let changes = diff_all_fields(&left, &right);
772        assert_eq!(changes.len(), 1);
773        assert_eq!(changes[0].field, "priority");
774        assert_eq!(changes[0].kind, ChangeKind::Added);
775        assert_eq!(changes[0].severity, ChangeSeverity::Info);
776    }
777
778    #[test]
779    fn test_diff_all_fields_priority_removed_returns_minor() {
780        let mut left = minimal_node();
781        left.priority = Some(Priority::High);
782        let right = minimal_node();
783        let changes = diff_all_fields(&left, &right);
784        assert_eq!(changes.len(), 1);
785        assert_eq!(changes[0].field, "priority");
786        assert_eq!(changes[0].kind, ChangeKind::Removed);
787        assert_eq!(changes[0].severity, ChangeSeverity::Minor);
788    }
789
790    #[test]
791    fn test_diff_all_fields_extra_field_added_returns_info() {
792        let left = minimal_node();
793        let mut right = left.clone();
794        right.extra_fields.insert(
795            "custom_key".to_owned(),
796            FieldValue::Scalar("val".to_owned()),
797        );
798        let changes = diff_all_fields(&left, &right);
799        assert_eq!(changes.len(), 1);
800        assert_eq!(changes[0].field, "custom_key");
801        assert_eq!(changes[0].kind, ChangeKind::Added);
802        assert_eq!(changes[0].severity, ChangeSeverity::Info);
803    }
804
805    #[test]
806    fn test_diff_all_fields_extra_field_removed_returns_info() {
807        let mut left = minimal_node();
808        left.extra_fields.insert(
809            "custom_key".to_owned(),
810            FieldValue::Scalar("val".to_owned()),
811        );
812        let right = minimal_node();
813        let changes = diff_all_fields(&left, &right);
814        assert_eq!(changes.len(), 1);
815        assert_eq!(changes[0].field, "custom_key");
816        assert_eq!(changes[0].kind, ChangeKind::Removed);
817        assert_eq!(changes[0].severity, ChangeSeverity::Info);
818    }
819
820    #[test]
821    fn test_diff_all_fields_code_block_changed_returns_minor() {
822        use crate::model::code::{CodeAction, CodeBlock};
823        let left = minimal_node();
824        let mut right = left.clone();
825        right.code = Some(CodeBlock {
826            lang: Some("rust".to_owned()),
827            target: None,
828            action: CodeAction::Full,
829            body: "fn main() {}".to_owned(),
830            anchor: None,
831            old: None,
832        });
833        let changes = diff_all_fields(&left, &right);
834        assert_eq!(changes.len(), 1);
835        assert_eq!(changes[0].field, "code");
836        assert_eq!(changes[0].kind, ChangeKind::Added);
837        // added code -> Minor
838        assert_eq!(changes[0].severity, ChangeSeverity::Minor);
839    }
840
841    #[test]
842    fn test_field_value_snapshot_from_field_value_scalar() {
843        let fv = FieldValue::Scalar("hello".to_owned());
844        let snap = FieldValueSnapshot::from_field_value(&fv);
845        assert_eq!(snap, FieldValueSnapshot::Scalar("hello".to_owned()));
846    }
847
848    #[test]
849    fn test_field_value_snapshot_from_field_value_list() {
850        let fv = FieldValue::List(vec!["a".to_owned(), "b".to_owned()]);
851        let snap = FieldValueSnapshot::from_field_value(&fv);
852        assert_eq!(
853            snap,
854            FieldValueSnapshot::List(vec!["a".to_owned(), "b".to_owned()])
855        );
856    }
857
858    #[test]
859    fn test_field_value_snapshot_from_complex_serializes_json() {
860        #[derive(Serialize)]
861        struct Simple {
862            key: &'static str,
863        }
864        let val = Simple { key: "value" };
865        let snap = FieldValueSnapshot::from_complex(&val);
866        assert!(matches!(snap, FieldValueSnapshot::Complex(_)));
867        if let FieldValueSnapshot::Complex(s) = snap {
868            assert!(s.contains("key"));
869            assert!(s.contains("value"));
870        }
871    }
872}