Skip to main content

legalis_diff/
lib.rs

1#![allow(clippy::manual_clamp)]
2
3//! Legalis-Diff: Statute diffing and change detection for Legalis-RS.
4//!
5//! This crate provides tools for detecting and analyzing changes between
6//! statute versions:
7//! - Structural diff between statutes
8//! - Change categorization
9//! - Impact analysis
10//! - Amendment tracking
11//! - Multiple output formats (JSON, HTML, Markdown)
12//!
13//! # Quick Start
14//!
15//! ```
16//! use legalis_core::{Statute, Effect, EffectType, Condition, ComparisonOp};
17//! use legalis_diff::{diff, summarize};
18//!
19//! // Create two versions of a statute
20//! let old = Statute::new(
21//!     "benefit-123",
22//!     "Senior Tax Credit",
23//!     Effect::new(EffectType::Grant, "Tax credit granted"),
24//! ).with_precondition(Condition::Age {
25//!     operator: ComparisonOp::GreaterOrEqual,
26//!     value: 65,
27//! });
28//!
29//! let mut new = old.clone();
30//! new.preconditions[0] = Condition::Age {
31//!     operator: ComparisonOp::GreaterOrEqual,
32//!     value: 60, // Lowered age requirement
33//! };
34//!
35//! // Compute the diff
36//! let diff_result = diff(&old, &new).unwrap();
37//!
38//! // Check impact
39//! assert!(diff_result.impact.affects_eligibility);
40//!
41//! // Generate summary
42//! let summary = summarize(&diff_result);
43//! println!("{}", summary);
44//! ```
45
46use legalis_core::{Condition, Statute};
47use serde::{Deserialize, Serialize};
48use thiserror::Error;
49
50pub mod adaptive;
51pub mod advanced_cache;
52pub mod advanced_visual;
53pub mod algorithms;
54pub mod analysis;
55pub mod api;
56pub mod audit;
57pub mod cloud;
58pub mod collaborative;
59pub mod collaborative_review;
60pub mod compliance;
61pub mod cross_jurisdiction;
62pub mod distributed;
63pub mod dsl;
64pub mod enterprise;
65pub mod export;
66pub mod export_plugins;
67pub mod formats;
68pub mod fuzzy;
69pub mod git;
70pub mod gpu;
71pub mod integration;
72pub mod integration_examples;
73pub mod legal_domain;
74pub mod legislative_history;
75pub mod llm;
76pub mod machine_readable;
77pub mod merge;
78pub mod ml;
79pub mod ml_advanced;
80pub mod multilingual;
81pub mod nlp;
82pub mod optimization;
83pub mod parallel;
84pub mod patterns;
85pub mod plugins;
86pub mod quantum;
87pub mod realtime;
88pub mod recommendation;
89pub mod rollback;
90pub mod scripting;
91pub mod security;
92pub mod semantic;
93pub mod simd;
94pub mod statistics;
95pub mod streaming;
96pub mod templates;
97pub mod time_travel;
98pub mod timeline;
99pub mod timeseries;
100pub mod validation;
101pub mod vcs;
102pub mod vcs_integration;
103pub mod visual;
104
105/// Errors during diff operations.
106#[derive(Debug, Error)]
107pub enum DiffError {
108    #[error("Cannot compare statutes with different IDs: {0} vs {1}")]
109    IdMismatch(String, String),
110
111    #[error("Invalid comparison: {0}")]
112    InvalidComparison(String),
113
114    #[error("Empty statute provided: {0}")]
115    EmptyStatute(String),
116
117    #[error("Version conflict: {old_version} -> {new_version}")]
118    VersionConflict { old_version: u32, new_version: u32 },
119
120    #[error("Merge conflict detected at {location}: {description}")]
121    MergeConflict {
122        location: String,
123        description: String,
124    },
125
126    #[error("Unsupported operation: {0}")]
127    UnsupportedOperation(String),
128
129    #[error("Serialization error: {0}")]
130    SerializationError(String),
131}
132
133/// Result type for diff operations.
134pub type DiffResult<T> = Result<T, DiffError>;
135
136/// A diff between two statutes.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct StatuteDiff {
139    /// Statute ID
140    pub statute_id: String,
141    /// Version comparison (if available)
142    pub version_info: Option<VersionInfo>,
143    /// List of changes
144    pub changes: Vec<Change>,
145    /// Impact assessment
146    pub impact: ImpactAssessment,
147}
148
149/// Version information for the diff.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct VersionInfo {
152    pub old_version: Option<u32>,
153    pub new_version: Option<u32>,
154}
155
156/// A single change.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Change {
159    /// Type of change
160    pub change_type: ChangeType,
161    /// What was changed
162    pub target: ChangeTarget,
163    /// Description of the change
164    pub description: String,
165    /// Old value (if applicable)
166    pub old_value: Option<String>,
167    /// New value (if applicable)
168    pub new_value: Option<String>,
169}
170
171/// Types of changes.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
173pub enum ChangeType {
174    /// Something was added
175    Added,
176    /// Something was removed
177    Removed,
178    /// Something was modified
179    Modified,
180    /// Order was changed
181    Reordered,
182}
183
184/// What was changed.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub enum ChangeTarget {
187    Title,
188    Precondition { index: usize },
189    Effect,
190    DiscretionLogic,
191    Metadata { key: String },
192}
193
194impl std::fmt::Display for ChangeTarget {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        match self {
197            Self::Title => write!(f, "Title"),
198            Self::Precondition { index } => write!(f, "Precondition #{}", index + 1),
199            Self::Effect => write!(f, "Effect"),
200            Self::DiscretionLogic => write!(f, "Discretion Logic"),
201            Self::Metadata { key } => write!(f, "Metadata[{}]", key),
202        }
203    }
204}
205
206/// Impact assessment of changes.
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct ImpactAssessment {
209    /// Overall severity
210    pub severity: Severity,
211    /// Whether the change affects eligibility criteria
212    pub affects_eligibility: bool,
213    /// Whether the change affects the outcome
214    pub affects_outcome: bool,
215    /// Whether discretion requirements changed
216    pub discretion_changed: bool,
217    /// Detailed impact notes
218    pub notes: Vec<String>,
219}
220
221/// Severity of changes.
222#[derive(
223    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
224)]
225pub enum Severity {
226    /// No significant impact
227    #[default]
228    None,
229    /// Minor changes (typos, clarifications)
230    Minor,
231    /// Moderate changes (adjusted thresholds)
232    Moderate,
233    /// Major changes (new requirements, different outcomes)
234    Major,
235    /// Breaking changes (complete restructure)
236    Breaking,
237}
238
239/// Computes the diff between two statutes.
240///
241/// # Examples
242///
243/// ```
244/// use legalis_core::{Statute, Effect, EffectType, Condition, ComparisonOp};
245/// use legalis_diff::diff;
246///
247/// let old = Statute::new(
248///     "tax-credit",
249///     "Tax Credit for Seniors",
250///     Effect::new(EffectType::Grant, "Tax credit granted"),
251/// ).with_precondition(Condition::Age {
252///     operator: ComparisonOp::GreaterOrEqual,
253///     value: 65,
254/// });
255///
256/// let mut new = old.clone();
257/// new.title = "Enhanced Tax Credit for Seniors".to_string();
258///
259/// let result = diff(&old, &new).unwrap();
260/// assert_eq!(result.changes.len(), 1);
261/// assert!(result.changes[0].description.contains("Title"));
262/// ```
263///
264/// # Errors
265///
266/// Returns [`DiffError::IdMismatch`] if the statute IDs don't match.
267pub fn diff(old: &Statute, new: &Statute) -> DiffResult<StatuteDiff> {
268    if old.id != new.id {
269        return Err(DiffError::IdMismatch(old.id.clone(), new.id.clone()));
270    }
271
272    let mut changes = Vec::new();
273    let mut impact = ImpactAssessment::default();
274
275    // Check title
276    if old.title != new.title {
277        changes.push(Change {
278            change_type: ChangeType::Modified,
279            target: ChangeTarget::Title,
280            description: "Title was modified".to_string(),
281            old_value: Some(old.title.clone()),
282            new_value: Some(new.title.clone()),
283        });
284        impact.severity = impact.severity.max(Severity::Minor);
285    }
286
287    // Check preconditions
288    diff_preconditions(
289        &old.preconditions,
290        &new.preconditions,
291        &mut changes,
292        &mut impact,
293    );
294
295    // Check effect
296    if old.effect != new.effect {
297        changes.push(Change {
298            change_type: ChangeType::Modified,
299            target: ChangeTarget::Effect,
300            description: "Effect was modified".to_string(),
301            old_value: Some(format!("{:?}", old.effect)),
302            new_value: Some(format!("{:?}", new.effect)),
303        });
304        impact.affects_outcome = true;
305        impact.severity = impact.severity.max(Severity::Major);
306        impact
307            .notes
308            .push("Outcome of statute application changed".to_string());
309    }
310
311    // Check discretion logic
312    match (&old.discretion_logic, &new.discretion_logic) {
313        (None, Some(logic)) => {
314            changes.push(Change {
315                change_type: ChangeType::Added,
316                target: ChangeTarget::DiscretionLogic,
317                description: "Discretion logic was added".to_string(),
318                old_value: None,
319                new_value: Some(logic.clone()),
320            });
321            impact.discretion_changed = true;
322            impact.severity = impact.severity.max(Severity::Major);
323            impact.notes.push("Human judgment now required".to_string());
324        }
325        (Some(old_logic), None) => {
326            changes.push(Change {
327                change_type: ChangeType::Removed,
328                target: ChangeTarget::DiscretionLogic,
329                description: "Discretion logic was removed".to_string(),
330                old_value: Some(old_logic.clone()),
331                new_value: None,
332            });
333            impact.discretion_changed = true;
334            impact.severity = impact.severity.max(Severity::Major);
335            impact
336                .notes
337                .push("Human judgment no longer required - now deterministic".to_string());
338        }
339        (Some(old_logic), Some(new_logic)) if old_logic != new_logic => {
340            changes.push(Change {
341                change_type: ChangeType::Modified,
342                target: ChangeTarget::DiscretionLogic,
343                description: "Discretion logic was modified".to_string(),
344                old_value: Some(old_logic.clone()),
345                new_value: Some(new_logic.clone()),
346            });
347            impact.discretion_changed = true;
348            impact.severity = impact.severity.max(Severity::Moderate);
349        }
350        _ => {}
351    }
352
353    Ok(StatuteDiff {
354        statute_id: old.id.clone(),
355        version_info: None,
356        changes,
357        impact,
358    })
359}
360
361fn diff_preconditions(
362    old: &[Condition],
363    new: &[Condition],
364    changes: &mut Vec<Change>,
365    impact: &mut ImpactAssessment,
366) {
367    let old_len = old.len();
368    let new_len = new.len();
369
370    // Check for added/removed conditions
371    if new_len > old_len {
372        for (i, cond) in new.iter().enumerate().skip(old_len) {
373            changes.push(Change {
374                change_type: ChangeType::Added,
375                target: ChangeTarget::Precondition { index: i },
376                description: format!("New precondition added at position {}", i + 1),
377                old_value: None,
378                new_value: Some(format!("{:?}", cond)),
379            });
380        }
381        impact.affects_eligibility = true;
382        impact.severity = impact.severity.max(Severity::Major);
383        impact
384            .notes
385            .push("New eligibility conditions added".to_string());
386    } else if old_len > new_len {
387        for (i, cond) in old.iter().enumerate().skip(new_len) {
388            changes.push(Change {
389                change_type: ChangeType::Removed,
390                target: ChangeTarget::Precondition { index: i },
391                description: format!("Precondition removed from position {}", i + 1),
392                old_value: Some(format!("{:?}", cond)),
393                new_value: None,
394            });
395        }
396        impact.affects_eligibility = true;
397        impact.severity = impact.severity.max(Severity::Major);
398        impact
399            .notes
400            .push("Eligibility conditions removed".to_string());
401    }
402
403    // Check for modified conditions
404    for i in 0..old_len.min(new_len) {
405        if old[i] != new[i] {
406            changes.push(Change {
407                change_type: ChangeType::Modified,
408                target: ChangeTarget::Precondition { index: i },
409                description: format!("Precondition {} was modified", i + 1),
410                old_value: Some(format!("{:?}", old[i])),
411                new_value: Some(format!("{:?}", new[i])),
412            });
413            impact.affects_eligibility = true;
414            impact.severity = impact.severity.max(Severity::Moderate);
415        }
416    }
417}
418
419/// Summary of changes for display.
420///
421/// # Examples
422///
423/// ```
424/// use legalis_core::{Statute, Effect, EffectType, Condition, ComparisonOp};
425/// use legalis_diff::{diff, summarize};
426///
427/// let old = Statute::new(
428///     "benefit-123",
429///     "Old Title",
430///     Effect::new(EffectType::Grant, "Benefit granted"),
431/// );
432///
433/// let new = old.clone().with_precondition(Condition::Age {
434///     operator: ComparisonOp::GreaterOrEqual,
435///     value: 18,
436/// });
437///
438/// let diff_result = diff(&old, &new).unwrap();
439/// let summary = summarize(&diff_result);
440///
441/// assert!(summary.contains("benefit-123"));
442/// assert!(summary.contains("Severity"));
443/// ```
444pub fn summarize(diff: &StatuteDiff) -> String {
445    let mut summary = format!("Diff for statute '{}'\n", diff.statute_id);
446    summary.push_str(&format!("Severity: {:?}\n", diff.impact.severity));
447    summary.push_str(&format!("Changes: {}\n\n", diff.changes.len()));
448
449    for change in &diff.changes {
450        summary.push_str(&format!(
451            "  [{:?}] {}: {}\n",
452            change.change_type, change.target, change.description
453        ));
454    }
455
456    if !diff.impact.notes.is_empty() {
457        summary.push_str("\nImpact Notes:\n");
458        for note in &diff.impact.notes {
459            summary.push_str(&format!("  - {}\n", note));
460        }
461    }
462
463    summary
464}
465
466/// Filters changes by type from a diff.
467///
468/// # Examples
469///
470/// ```
471/// use legalis_core::{Statute, Effect, EffectType, Condition, ComparisonOp};
472/// use legalis_diff::{diff, filter_changes_by_type, ChangeType};
473///
474/// let old = Statute::new("law", "Old", Effect::new(EffectType::Grant, "Benefit"));
475/// let new = old.clone()
476///     .with_precondition(Condition::Age {
477///         operator: ComparisonOp::GreaterOrEqual,
478///         value: 18,
479///     });
480///
481/// let diff_result = diff(&old, &new).unwrap();
482/// let added = filter_changes_by_type(&diff_result, ChangeType::Added);
483///
484/// assert_eq!(added.len(), 1);
485/// ```
486pub fn filter_changes_by_type(diff: &StatuteDiff, change_type: ChangeType) -> Vec<Change> {
487    diff.changes
488        .iter()
489        .filter(|c| c.change_type == change_type)
490        .cloned()
491        .collect()
492}
493
494/// Checks if a diff contains any breaking changes.
495///
496/// Breaking changes include:
497/// - Effect modifications
498/// - Precondition additions (tightens eligibility)
499/// - Changes in discretion requirements
500///
501/// # Examples
502///
503/// ```
504/// use legalis_core::{Statute, Effect, EffectType};
505/// use legalis_diff::{diff, has_breaking_changes};
506///
507/// let old = Statute::new("law", "Title", Effect::new(EffectType::Grant, "Benefit"));
508/// let mut new = old.clone();
509/// new.effect = Effect::new(EffectType::Revoke, "Revoke benefit"); // Breaking change!
510///
511/// let diff_result = diff(&old, &new).unwrap();
512/// assert!(has_breaking_changes(&diff_result));
513/// ```
514pub fn has_breaking_changes(diff: &StatuteDiff) -> bool {
515    use crate::Severity;
516
517    diff.impact.severity >= Severity::Major
518        || diff.impact.affects_outcome
519        || diff.impact.discretion_changed
520}
521
522/// Counts the number of changes by target type.
523///
524/// # Examples
525///
526/// ```
527/// use legalis_core::{Statute, Effect, EffectType, Condition, ComparisonOp};
528/// use legalis_diff::{diff, count_changes_by_target};
529///
530/// let old = Statute::new("law", "Old Title", Effect::new(EffectType::Grant, "Benefit"));
531/// let mut new = old.clone();
532/// new.title = "New Title".to_string();
533///
534/// let diff_result = diff(&old, &new).unwrap();
535/// let counts = count_changes_by_target(&diff_result);
536///
537/// assert!(counts.contains_key("Title"));
538/// ```
539pub fn count_changes_by_target(diff: &StatuteDiff) -> std::collections::HashMap<String, usize> {
540    use std::collections::HashMap;
541
542    let mut counts: HashMap<String, usize> = HashMap::new();
543
544    for change in &diff.changes {
545        let key = match &change.target {
546            ChangeTarget::Title => "Title".to_string(),
547            ChangeTarget::Precondition { .. } => "Precondition".to_string(),
548            ChangeTarget::Effect => "Effect".to_string(),
549            ChangeTarget::DiscretionLogic => "DiscretionLogic".to_string(),
550            ChangeTarget::Metadata { .. } => "Metadata".to_string(),
551        };
552        *counts.entry(key).or_insert(0) += 1;
553    }
554
555    counts
556}
557
558/// Computes diffs for a sequence of statute versions.
559///
560/// Returns a vector of diffs, where each diff represents the changes from
561/// one version to the next.
562///
563/// # Examples
564///
565/// ```
566/// use legalis_core::{Statute, Effect, EffectType};
567/// use legalis_diff::diff_sequence;
568///
569/// let v1 = Statute::new("law", "Version 1", Effect::new(EffectType::Grant, "Benefit"));
570/// let v2 = Statute::new("law", "Version 2", Effect::new(EffectType::Grant, "Benefit"));
571/// let v3 = Statute::new("law", "Version 3", Effect::new(EffectType::Grant, "Benefit"));
572///
573/// let versions = vec![v1, v2, v3];
574/// let diffs = diff_sequence(&versions).unwrap();
575///
576/// // Should have 2 diffs for 3 versions (v1->v2, v2->v3)
577/// assert_eq!(diffs.len(), 2);
578/// ```
579///
580/// # Errors
581///
582/// Returns [`DiffError::IdMismatch`] if any statutes have different IDs.
583pub fn diff_sequence(versions: &[Statute]) -> DiffResult<Vec<StatuteDiff>> {
584    if versions.len() < 2 {
585        return Ok(Vec::new());
586    }
587
588    let mut diffs = Vec::new();
589    for i in 0..versions.len() - 1 {
590        diffs.push(diff(&versions[i], &versions[i + 1])?);
591    }
592
593    Ok(diffs)
594}
595
596/// Detailed summary with confidence scores for each aspect of the diff.
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct DetailedSummary {
599    /// The statute ID.
600    pub statute_id: String,
601    /// Overall confidence score (0.0 to 1.0).
602    pub overall_confidence: f64,
603    /// Number of changes detected.
604    pub change_count: usize,
605    /// Severity level.
606    pub severity: Severity,
607    /// Summary text.
608    pub summary_text: String,
609    /// Confidence in change detection (0.0 to 1.0).
610    pub change_detection_confidence: f64,
611    /// Confidence in impact assessment (0.0 to 1.0).
612    pub impact_assessment_confidence: f64,
613    /// Key insights from the analysis.
614    pub insights: Vec<String>,
615}
616
617/// Creates a detailed summary with confidence scores.
618///
619/// This provides more information than the basic `summarize` function,
620/// including confidence metrics and analytical insights.
621///
622/// # Examples
623///
624/// ```
625/// use legalis_core::{Statute, Effect, EffectType, Condition, ComparisonOp};
626/// use legalis_diff::{diff, detailed_summary};
627///
628/// let old = Statute::new("law", "Title", Effect::new(EffectType::Grant, "Benefit"));
629/// let mut new = old.clone();
630/// new.title = "New Title".to_string();
631///
632/// let diff_result = diff(&old, &new).unwrap();
633/// let summary = detailed_summary(&diff_result);
634///
635/// assert_eq!(summary.statute_id, "law");
636/// assert!(summary.overall_confidence > 0.0);
637/// ```
638pub fn detailed_summary(diff: &StatuteDiff) -> DetailedSummary {
639    let mut insights = Vec::new();
640    let change_count = diff.changes.len();
641
642    // Calculate change detection confidence
643    let change_detection_confidence = if change_count == 0 {
644        1.0 // High confidence in no changes
645    } else {
646        0.95 // High confidence in detected changes
647    };
648
649    // Calculate impact assessment confidence based on severity and flags
650    let impact_assessment_confidence = if diff.impact.affects_outcome
651        || diff.impact.affects_eligibility
652        || diff.impact.discretion_changed
653    {
654        0.9 // High confidence in significant impact
655    } else if diff.impact.severity >= Severity::Moderate {
656        0.85
657    } else if diff.impact.severity == Severity::Minor {
658        0.8
659    } else {
660        0.95 // Very high confidence in no impact
661    };
662
663    // Generate insights
664    if diff.impact.affects_eligibility {
665        insights
666            .push("This change affects who is eligible for the statute's provisions.".to_string());
667    }
668
669    if diff.impact.affects_outcome {
670        insights.push("This change modifies the outcome or effect of the statute.".to_string());
671    }
672
673    if diff.impact.discretion_changed {
674        insights.push("Discretionary judgment requirements have been modified.".to_string());
675    }
676
677    // Analyze change patterns
678    let added_count = diff
679        .changes
680        .iter()
681        .filter(|c| c.change_type == ChangeType::Added)
682        .count();
683    let removed_count = diff
684        .changes
685        .iter()
686        .filter(|c| c.change_type == ChangeType::Removed)
687        .count();
688    let modified_count = diff
689        .changes
690        .iter()
691        .filter(|c| c.change_type == ChangeType::Modified)
692        .count();
693
694    if added_count > 0 {
695        insights.push(format!("{} new element(s) added.", added_count));
696    }
697    if removed_count > 0 {
698        insights.push(format!("{} element(s) removed.", removed_count));
699    }
700    if modified_count > 0 {
701        insights.push(format!("{} element(s) modified.", modified_count));
702    }
703
704    // Calculate overall confidence
705    let overall_confidence = (change_detection_confidence + impact_assessment_confidence) / 2.0;
706
707    // Build summary text
708    let summary_text = summarize(diff);
709
710    DetailedSummary {
711        statute_id: diff.statute_id.clone(),
712        overall_confidence,
713        change_count,
714        severity: diff.impact.severity,
715        summary_text,
716        change_detection_confidence,
717        impact_assessment_confidence,
718        insights,
719    }
720}
721
722/// Compares only the preconditions of two statutes.
723///
724/// This is useful when you only need to check for eligibility criteria changes
725/// without analyzing the entire statute.
726///
727/// # Examples
728///
729/// ```
730/// use legalis_core::{Statute, Effect, EffectType, Condition, ComparisonOp};
731/// use legalis_diff::diff_preconditions_only;
732///
733/// let old = Statute::new("law", "Title", Effect::new(EffectType::Grant, "Benefit"))
734///     .with_precondition(Condition::Age {
735///         operator: ComparisonOp::GreaterOrEqual,
736///         value: 65,
737///     });
738///
739/// let new = Statute::new("law", "Title", Effect::new(EffectType::Grant, "Benefit"))
740///     .with_precondition(Condition::Age {
741///         operator: ComparisonOp::GreaterOrEqual,
742///         value: 60,
743///     });
744///
745/// let changes = diff_preconditions_only(&old, &new).unwrap();
746/// assert!(!changes.is_empty());
747/// ```
748///
749/// # Errors
750///
751/// Returns [`DiffError::IdMismatch`] if the statute IDs don't match.
752pub fn diff_preconditions_only(old: &Statute, new: &Statute) -> DiffResult<Vec<Change>> {
753    if old.id != new.id {
754        return Err(DiffError::IdMismatch(old.id.clone(), new.id.clone()));
755    }
756
757    let mut changes = Vec::new();
758    let mut impact = ImpactAssessment::default();
759
760    diff_preconditions(
761        &old.preconditions,
762        &new.preconditions,
763        &mut changes,
764        &mut impact,
765    );
766
767    Ok(changes)
768}
769
770/// Compares only the effect of two statutes.
771///
772/// # Examples
773///
774/// ```
775/// use legalis_core::{Statute, Effect, EffectType};
776/// use legalis_diff::diff_effect_only;
777///
778/// let old = Statute::new("law", "Title", Effect::new(EffectType::Grant, "Old benefit"));
779/// let new = Statute::new("law", "Title", Effect::new(EffectType::Grant, "New benefit"));
780///
781/// let change = diff_effect_only(&old, &new).unwrap();
782/// assert!(change.is_some());
783/// ```
784///
785/// # Errors
786///
787/// Returns [`DiffError::IdMismatch`] if the statute IDs don't match.
788pub fn diff_effect_only(old: &Statute, new: &Statute) -> DiffResult<Option<Change>> {
789    if old.id != new.id {
790        return Err(DiffError::IdMismatch(old.id.clone(), new.id.clone()));
791    }
792
793    if old.effect != new.effect {
794        Ok(Some(Change {
795            change_type: ChangeType::Modified,
796            target: ChangeTarget::Effect,
797            description: "Effect was modified".to_string(),
798            old_value: Some(format!("{:?}", old.effect)),
799            new_value: Some(format!("{:?}", new.effect)),
800        }))
801    } else {
802        Ok(None)
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use legalis_core::{ComparisonOp, Effect, EffectType};
810
811    fn test_statute() -> Statute {
812        Statute::new(
813            "test-statute",
814            "Test Statute",
815            Effect::new(EffectType::Grant, "Test effect"),
816        )
817        .with_precondition(Condition::Age {
818            operator: ComparisonOp::GreaterOrEqual,
819            value: 18,
820        })
821    }
822
823    fn empty_statute() -> Statute {
824        Statute::new(
825            "empty-statute",
826            "Empty Statute",
827            Effect::new(EffectType::Grant, "Empty effect"),
828        )
829    }
830
831    #[test]
832    fn test_no_changes() {
833        let statute = test_statute();
834        let result = diff(&statute, &statute).unwrap();
835        assert!(result.changes.is_empty());
836        assert_eq!(result.impact.severity, Severity::None);
837    }
838
839    #[test]
840    fn test_identical_statutes() {
841        let statute1 = test_statute();
842        let statute2 = test_statute();
843        let result = diff(&statute1, &statute2).unwrap();
844        assert!(result.changes.is_empty());
845        assert_eq!(result.impact.severity, Severity::None);
846        assert!(!result.impact.affects_eligibility);
847        assert!(!result.impact.affects_outcome);
848    }
849
850    #[test]
851    fn test_empty_statutes() {
852        let statute1 = empty_statute();
853        let statute2 = empty_statute();
854        let result = diff(&statute1, &statute2).unwrap();
855        assert!(result.changes.is_empty());
856        assert_eq!(result.impact.severity, Severity::None);
857    }
858
859    #[test]
860    fn test_empty_to_populated() {
861        let old = empty_statute();
862        let mut new = old.clone();
863        new.preconditions.push(Condition::Age {
864            operator: ComparisonOp::GreaterOrEqual,
865            value: 18,
866        });
867
868        let result = diff(&old, &new).unwrap();
869        assert_eq!(result.changes.len(), 1);
870        assert!(result.impact.affects_eligibility);
871        assert!(matches!(result.changes[0].change_type, ChangeType::Added));
872    }
873
874    #[test]
875    fn test_title_change() {
876        let old = test_statute();
877        let mut new = old.clone();
878        new.title = "Modified Title".to_string();
879
880        let result = diff(&old, &new).unwrap();
881        assert_eq!(result.changes.len(), 1);
882        assert!(matches!(result.changes[0].target, ChangeTarget::Title));
883    }
884
885    #[test]
886    fn test_precondition_added() {
887        let old = test_statute();
888        let mut new = old.clone();
889        new.preconditions.push(Condition::Income {
890            operator: ComparisonOp::LessOrEqual,
891            value: 5000000,
892        });
893
894        let result = diff(&old, &new).unwrap();
895        assert!(result.impact.affects_eligibility);
896        assert!(result.impact.severity >= Severity::Major);
897    }
898
899    #[test]
900    fn test_precondition_removed() {
901        let mut old = test_statute();
902        old.preconditions.push(Condition::Income {
903            operator: ComparisonOp::LessOrEqual,
904            value: 5000000,
905        });
906        let new = test_statute(); // Has only the age condition
907
908        let result = diff(&old, &new).unwrap();
909        assert!(result.impact.affects_eligibility);
910        assert!(result.impact.severity >= Severity::Major);
911        let has_removed = result
912            .changes
913            .iter()
914            .any(|c| matches!(c.change_type, ChangeType::Removed));
915        assert!(has_removed);
916    }
917
918    #[test]
919    fn test_precondition_modified() {
920        let old = test_statute();
921        let mut new = old.clone();
922        new.preconditions[0] = Condition::Age {
923            operator: ComparisonOp::GreaterOrEqual,
924            value: 21,
925        };
926
927        let result = diff(&old, &new).unwrap();
928        assert!(result.impact.affects_eligibility);
929        assert!(!result.changes.is_empty());
930        let has_modified = result
931            .changes
932            .iter()
933            .any(|c| matches!(c.change_type, ChangeType::Modified));
934        assert!(has_modified);
935    }
936
937    #[test]
938    fn test_effect_change() {
939        let old = test_statute();
940        let mut new = old.clone();
941        new.effect = Effect::new(EffectType::Revoke, "Revoke instead");
942
943        let result = diff(&old, &new).unwrap();
944        assert!(result.impact.affects_outcome);
945        assert_eq!(result.impact.severity, Severity::Major);
946    }
947
948    #[test]
949    fn test_discretion_added() {
950        let old = test_statute();
951        let new = old
952            .clone()
953            .with_discretion("Consider special circumstances");
954
955        let result = diff(&old, &new).unwrap();
956        assert!(result.impact.discretion_changed);
957    }
958
959    #[test]
960    fn test_discretion_removed() {
961        let old = test_statute().with_discretion("Consider special circumstances");
962        let new = test_statute();
963
964        let result = diff(&old, &new).unwrap();
965        assert!(result.impact.discretion_changed);
966        assert!(result.impact.severity >= Severity::Major);
967    }
968
969    #[test]
970    fn test_discretion_modified() {
971        let old = test_statute().with_discretion("Consider special circumstances");
972        let new = test_statute().with_discretion("Consider different circumstances");
973
974        let result = diff(&old, &new).unwrap();
975        assert!(result.impact.discretion_changed);
976    }
977
978    #[test]
979    fn test_multiple_changes() {
980        let old = test_statute();
981        let mut new = old.clone();
982        new.title = "New Title".to_string();
983        new.preconditions.push(Condition::Income {
984            operator: ComparisonOp::LessOrEqual,
985            value: 5000000,
986        });
987        new.effect = Effect::new(EffectType::Obligation, "New effect");
988
989        let result = diff(&old, &new).unwrap();
990        assert!(result.changes.len() >= 3);
991        assert!(result.impact.affects_eligibility);
992        assert!(result.impact.affects_outcome);
993        assert_eq!(result.impact.severity, Severity::Major);
994    }
995
996    #[test]
997    fn test_id_mismatch_error() {
998        let old = test_statute();
999        let mut new = test_statute();
1000        new.id = "different-id".to_string();
1001
1002        let result = diff(&old, &new);
1003        assert!(matches!(result, Err(DiffError::IdMismatch(_, _))));
1004    }
1005
1006    #[test]
1007    fn test_summarize() {
1008        let old = test_statute();
1009        let mut new = old.clone();
1010        new.title = "Modified Title".to_string();
1011
1012        let result = diff(&old, &new).unwrap();
1013        let summary = summarize(&result);
1014
1015        assert!(summary.contains("test-statute"));
1016        assert!(summary.contains("Modified"));
1017    }
1018
1019    #[test]
1020    fn test_all_preconditions_removed() {
1021        let old = test_statute();
1022        let mut new = old.clone();
1023        new.preconditions.clear();
1024
1025        let result = diff(&old, &new).unwrap();
1026        assert!(result.impact.affects_eligibility);
1027        assert!(result.impact.severity >= Severity::Major);
1028    }
1029
1030    #[test]
1031    fn test_version_info_present() {
1032        let old = test_statute();
1033        let new = test_statute();
1034
1035        let mut result = diff(&old, &new).unwrap();
1036        result.version_info = Some(VersionInfo {
1037            old_version: Some(1),
1038            new_version: Some(2),
1039        });
1040
1041        assert!(result.version_info.is_some());
1042        assert_eq!(result.version_info.as_ref().unwrap().old_version, Some(1));
1043        assert_eq!(result.version_info.as_ref().unwrap().new_version, Some(2));
1044    }
1045
1046    #[test]
1047    fn test_detailed_summary() {
1048        let old = test_statute();
1049        let mut new = old.clone();
1050        new.title = "Modified Title".to_string();
1051
1052        let diff_result = diff(&old, &new).unwrap();
1053        let summary = detailed_summary(&diff_result);
1054
1055        assert_eq!(summary.statute_id, "test-statute");
1056        assert!(summary.overall_confidence > 0.0);
1057        assert!(summary.overall_confidence <= 1.0);
1058        assert_eq!(summary.change_count, 1);
1059        assert!(!summary.insights.is_empty());
1060    }
1061
1062    #[test]
1063    fn test_diff_preconditions_only() {
1064        let old = test_statute();
1065        let mut new = old.clone();
1066        new.preconditions[0] = Condition::Age {
1067            operator: ComparisonOp::GreaterOrEqual,
1068            value: 21,
1069        };
1070
1071        let changes = diff_preconditions_only(&old, &new).unwrap();
1072        assert!(!changes.is_empty());
1073        assert!(matches!(
1074            changes[0].target,
1075            ChangeTarget::Precondition { .. }
1076        ));
1077    }
1078
1079    #[test]
1080    fn test_diff_effect_only() {
1081        let old = test_statute();
1082        let mut new = old.clone();
1083        new.effect = Effect::new(EffectType::Revoke, "Different effect");
1084
1085        let change = diff_effect_only(&old, &new).unwrap();
1086        assert!(change.is_some());
1087        let c = change.unwrap();
1088        assert!(matches!(c.target, ChangeTarget::Effect));
1089    }
1090
1091    #[test]
1092    fn test_diff_effect_only_no_change() {
1093        let old = test_statute();
1094        let new = old.clone();
1095
1096        let change = diff_effect_only(&old, &new).unwrap();
1097        assert!(change.is_none());
1098    }
1099}