prax_migrate/
resolution.rs

1//! Migration resolution system.
2//!
3//! This module provides mechanisms to handle migration conflicts and issues:
4//! - Checksum mismatches (when migrations are modified after being applied)
5//! - Conflict resolution (when multiple migrations conflict)
6//! - Baseline migrations (mark existing schema as a baseline)
7//! - Renamed migrations (map old IDs to new ones)
8//! - Skipped migrations (intentionally skip certain migrations)
9//!
10//! # Example
11//!
12//! ```rust,ignore
13//! use prax_migrate::resolution::{ResolutionConfig, Resolution, ResolutionAction};
14//!
15//! let mut resolutions = ResolutionConfig::new();
16//!
17//! // Accept a modified migration's new checksum
18//! resolutions.add(Resolution::accept_checksum(
19//!     "20240101_create_users",
20//!     "old_checksum",
21//!     "new_checksum",
22//!     "Fixed typo in column name"
23//! ));
24//!
25//! // Skip a migration entirely
26//! resolutions.add(Resolution::skip(
27//!     "20240102_add_legacy_table",
28//!     "Table already exists from legacy system"
29//! ));
30//!
31//! // Mark a migration as baseline (applied without running)
32//! resolutions.add(Resolution::baseline(
33//!     "20240103_initial_schema",
34//!     "Schema was imported from existing database"
35//! ));
36//!
37//! // Save resolutions to file
38//! resolutions.save("migrations/resolutions.toml").await?;
39//! ```
40
41use std::collections::HashMap;
42use std::path::{Path, PathBuf};
43
44use chrono::{DateTime, Utc};
45use serde::{Deserialize, Serialize};
46
47use crate::error::{MigrateResult, MigrationError};
48
49/// Configuration for managing migration resolutions.
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct ResolutionConfig {
52    /// Map of migration ID to resolution.
53    #[serde(default)]
54    pub resolutions: HashMap<String, Resolution>,
55    /// When the config was last modified.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub last_modified: Option<DateTime<Utc>>,
58    /// Path to the resolution file.
59    #[serde(skip)]
60    pub file_path: Option<PathBuf>,
61}
62
63impl ResolutionConfig {
64    /// Create a new empty resolution config.
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Load resolutions from a file.
70    pub async fn load(path: impl AsRef<Path>) -> MigrateResult<Self> {
71        let path = path.as_ref();
72
73        if !path.exists() {
74            return Ok(Self {
75                file_path: Some(path.to_path_buf()),
76                ..Default::default()
77            });
78        }
79
80        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
81            MigrationError::ResolutionFile(format!("Failed to read resolution file: {}", e))
82        })?;
83
84        let mut config: Self = toml::from_str(&content).map_err(|e| {
85            MigrationError::ResolutionFile(format!("Failed to parse resolution file: {}", e))
86        })?;
87
88        config.file_path = Some(path.to_path_buf());
89        Ok(config)
90    }
91
92    /// Save resolutions to file.
93    pub async fn save(&self, path: impl AsRef<Path>) -> MigrateResult<()> {
94        let path = path.as_ref();
95
96        // Ensure parent directory exists
97        if let Some(parent) = path.parent() {
98            tokio::fs::create_dir_all(parent).await.map_err(|e| {
99                MigrationError::ResolutionFile(format!("Failed to create directory: {}", e))
100            })?;
101        }
102
103        let mut config = self.clone();
104        config.last_modified = Some(Utc::now());
105
106        let content = toml::to_string_pretty(&config).map_err(|e| {
107            MigrationError::ResolutionFile(format!("Failed to serialize resolutions: {}", e))
108        })?;
109
110        // Add header comment
111        let content = format!(
112            "# Prax Migration Resolutions\n\
113             # This file tracks intentional changes to migrations\n\
114             # Do not edit manually unless you know what you're doing\n\n\
115             {}",
116            content
117        );
118
119        tokio::fs::write(path, content).await.map_err(|e| {
120            MigrationError::ResolutionFile(format!("Failed to write resolution file: {}", e))
121        })?;
122
123        Ok(())
124    }
125
126    /// Add a resolution.
127    pub fn add(&mut self, resolution: Resolution) {
128        self.resolutions
129            .insert(resolution.migration_id.clone(), resolution);
130    }
131
132    /// Remove a resolution.
133    pub fn remove(&mut self, migration_id: &str) -> Option<Resolution> {
134        self.resolutions.remove(migration_id)
135    }
136
137    /// Get a resolution for a migration.
138    pub fn get(&self, migration_id: &str) -> Option<&Resolution> {
139        self.resolutions.get(migration_id)
140    }
141
142    /// Check if a migration has a resolution.
143    pub fn has_resolution(&self, migration_id: &str) -> bool {
144        self.resolutions.contains_key(migration_id)
145    }
146
147    /// Get all resolutions of a specific type.
148    pub fn by_action(&self, action: ResolutionAction) -> Vec<&Resolution> {
149        self.resolutions
150            .values()
151            .filter(|r| std::mem::discriminant(&r.action) == std::mem::discriminant(&action))
152            .collect()
153    }
154
155    /// Get all skipped migrations.
156    pub fn skipped(&self) -> Vec<&str> {
157        self.resolutions
158            .values()
159            .filter_map(|r| {
160                if matches!(r.action, ResolutionAction::Skip) {
161                    Some(r.migration_id.as_str())
162                } else {
163                    None
164                }
165            })
166            .collect()
167    }
168
169    /// Get all baseline migrations.
170    pub fn baselines(&self) -> Vec<&str> {
171        self.resolutions
172            .values()
173            .filter_map(|r| {
174                if matches!(r.action, ResolutionAction::Baseline) {
175                    Some(r.migration_id.as_str())
176                } else {
177                    None
178                }
179            })
180            .collect()
181    }
182
183    /// Check if a migration should be skipped.
184    pub fn should_skip(&self, migration_id: &str) -> bool {
185        self.get(migration_id)
186            .map(|r| matches!(r.action, ResolutionAction::Skip))
187            .unwrap_or(false)
188    }
189
190    /// Check if a migration is a baseline.
191    pub fn is_baseline(&self, migration_id: &str) -> bool {
192        self.get(migration_id)
193            .map(|r| matches!(r.action, ResolutionAction::Baseline))
194            .unwrap_or(false)
195    }
196
197    /// Check if a checksum mismatch is accepted.
198    pub fn accepts_checksum(
199        &self,
200        migration_id: &str,
201        old_checksum: &str,
202        new_checksum: &str,
203    ) -> bool {
204        self.get(migration_id)
205            .map(|r| {
206                if let ResolutionAction::AcceptChecksum {
207                    from_checksum,
208                    to_checksum,
209                } = &r.action
210                {
211                    from_checksum == old_checksum && to_checksum == new_checksum
212                } else {
213                    false
214                }
215            })
216            .unwrap_or(false)
217    }
218
219    /// Get the renamed migration ID if this migration was renamed.
220    pub fn get_renamed(&self, old_id: &str) -> Option<&str> {
221        self.resolutions.values().find_map(|r| {
222            if let ResolutionAction::Rename { from_id } = &r.action
223                && from_id == old_id
224            {
225                return Some(r.migration_id.as_str());
226            }
227            None
228        })
229    }
230
231    /// Validate all resolutions.
232    pub fn validate(&self) -> MigrateResult<Vec<ResolutionWarning>> {
233        let mut warnings = Vec::new();
234
235        for (id, resolution) in &self.resolutions {
236            // Check for duplicate rename targets
237            if let ResolutionAction::Rename { from_id } = &resolution.action {
238                let count = self
239                    .resolutions
240                    .values()
241                    .filter(|r| {
242                        if let ResolutionAction::Rename { from_id: other } = &r.action {
243                            other == from_id
244                        } else {
245                            false
246                        }
247                    })
248                    .count();
249
250                if count > 1 {
251                    warnings.push(ResolutionWarning::DuplicateRename {
252                        migration_id: id.clone(),
253                        from_id: from_id.clone(),
254                    });
255                }
256            }
257
258            // Check for expired resolutions
259            if let Some(expires_at) = resolution.expires_at
260                && expires_at < Utc::now()
261            {
262                warnings.push(ResolutionWarning::Expired {
263                    migration_id: id.clone(),
264                    expired_at: expires_at,
265                });
266            }
267        }
268
269        Ok(warnings)
270    }
271
272    /// Merge another config into this one.
273    pub fn merge(&mut self, other: ResolutionConfig) {
274        for (id, resolution) in other.resolutions {
275            self.resolutions.entry(id).or_insert(resolution);
276        }
277    }
278
279    /// Count resolutions by type.
280    pub fn count_by_type(&self) -> ResolutionCounts {
281        let mut counts = ResolutionCounts::default();
282
283        for resolution in self.resolutions.values() {
284            match &resolution.action {
285                ResolutionAction::AcceptChecksum { .. } => counts.checksum_accepted += 1,
286                ResolutionAction::Skip => counts.skipped += 1,
287                ResolutionAction::Baseline => counts.baseline += 1,
288                ResolutionAction::Rename { .. } => counts.renamed += 1,
289                ResolutionAction::ResolveConflict { .. } => counts.conflicts_resolved += 1,
290                ResolutionAction::ForceApply => counts.force_applied += 1,
291            }
292        }
293
294        counts
295    }
296}
297
298/// A single migration resolution.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct Resolution {
301    /// The migration ID this resolution applies to.
302    pub migration_id: String,
303    /// The action to take.
304    pub action: ResolutionAction,
305    /// Human-readable reason for this resolution.
306    pub reason: String,
307    /// When this resolution was created.
308    pub created_at: DateTime<Utc>,
309    /// Who created this resolution.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub created_by: Option<String>,
312    /// When this resolution expires (optional).
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub expires_at: Option<DateTime<Utc>>,
315    /// Additional metadata.
316    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
317    pub metadata: HashMap<String, String>,
318}
319
320impl Resolution {
321    /// Create a resolution to accept a checksum change.
322    pub fn accept_checksum(
323        migration_id: impl Into<String>,
324        from_checksum: impl Into<String>,
325        to_checksum: impl Into<String>,
326        reason: impl Into<String>,
327    ) -> Self {
328        Self {
329            migration_id: migration_id.into(),
330            action: ResolutionAction::AcceptChecksum {
331                from_checksum: from_checksum.into(),
332                to_checksum: to_checksum.into(),
333            },
334            reason: reason.into(),
335            created_at: Utc::now(),
336            created_by: None,
337            expires_at: None,
338            metadata: HashMap::new(),
339        }
340    }
341
342    /// Create a resolution to skip a migration.
343    pub fn skip(migration_id: impl Into<String>, reason: impl Into<String>) -> Self {
344        Self {
345            migration_id: migration_id.into(),
346            action: ResolutionAction::Skip,
347            reason: reason.into(),
348            created_at: Utc::now(),
349            created_by: None,
350            expires_at: None,
351            metadata: HashMap::new(),
352        }
353    }
354
355    /// Create a resolution to mark a migration as baseline.
356    pub fn baseline(migration_id: impl Into<String>, reason: impl Into<String>) -> Self {
357        Self {
358            migration_id: migration_id.into(),
359            action: ResolutionAction::Baseline,
360            reason: reason.into(),
361            created_at: Utc::now(),
362            created_by: None,
363            expires_at: None,
364            metadata: HashMap::new(),
365        }
366    }
367
368    /// Create a resolution to rename a migration.
369    pub fn rename(
370        new_id: impl Into<String>,
371        old_id: impl Into<String>,
372        reason: impl Into<String>,
373    ) -> Self {
374        Self {
375            migration_id: new_id.into(),
376            action: ResolutionAction::Rename {
377                from_id: old_id.into(),
378            },
379            reason: reason.into(),
380            created_at: Utc::now(),
381            created_by: None,
382            expires_at: None,
383            metadata: HashMap::new(),
384        }
385    }
386
387    /// Create a resolution to resolve a conflict.
388    pub fn resolve_conflict(
389        migration_id: impl Into<String>,
390        conflicting_ids: Vec<String>,
391        strategy: ConflictStrategy,
392        reason: impl Into<String>,
393    ) -> Self {
394        Self {
395            migration_id: migration_id.into(),
396            action: ResolutionAction::ResolveConflict {
397                conflicting_ids,
398                strategy,
399            },
400            reason: reason.into(),
401            created_at: Utc::now(),
402            created_by: None,
403            expires_at: None,
404            metadata: HashMap::new(),
405        }
406    }
407
408    /// Create a resolution to force apply a migration.
409    pub fn force_apply(migration_id: impl Into<String>, reason: impl Into<String>) -> Self {
410        Self {
411            migration_id: migration_id.into(),
412            action: ResolutionAction::ForceApply,
413            reason: reason.into(),
414            created_at: Utc::now(),
415            created_by: None,
416            expires_at: None,
417            metadata: HashMap::new(),
418        }
419    }
420
421    /// Set who created this resolution.
422    pub fn with_author(mut self, author: impl Into<String>) -> Self {
423        self.created_by = Some(author.into());
424        self
425    }
426
427    /// Set when this resolution expires.
428    pub fn with_expiration(mut self, expires_at: DateTime<Utc>) -> Self {
429        self.expires_at = Some(expires_at);
430        self
431    }
432
433    /// Add metadata to the resolution.
434    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
435        self.metadata.insert(key.into(), value.into());
436        self
437    }
438
439    /// Check if this resolution has expired.
440    pub fn is_expired(&self) -> bool {
441        self.expires_at.map(|e| e < Utc::now()).unwrap_or(false)
442    }
443}
444
445/// The action to take for a resolution.
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(tag = "type", rename_all = "snake_case")]
448pub enum ResolutionAction {
449    /// Accept a checksum change (migration was intentionally modified).
450    AcceptChecksum {
451        /// The old checksum.
452        from_checksum: String,
453        /// The new checksum.
454        to_checksum: String,
455    },
456    /// Skip this migration entirely.
457    Skip,
458    /// Mark this migration as a baseline (applied without running).
459    Baseline,
460    /// Rename a migration (map old ID to new ID).
461    Rename {
462        /// The old migration ID.
463        from_id: String,
464    },
465    /// Resolve a conflict between migrations.
466    ResolveConflict {
467        /// The conflicting migration IDs.
468        conflicting_ids: Vec<String>,
469        /// The strategy to resolve the conflict.
470        strategy: ConflictStrategy,
471    },
472    /// Force apply this migration even if it would normally be blocked.
473    ForceApply,
474}
475
476/// Strategy for resolving migration conflicts.
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479pub enum ConflictStrategy {
480    /// Keep this migration, skip the conflicting ones.
481    KeepThis,
482    /// Merge the conflicting migrations into this one.
483    Merge,
484    /// Apply this migration first, then the conflicting ones.
485    ApplyFirst,
486    /// Apply this migration last, after the conflicting ones.
487    ApplyLast,
488}
489
490/// Warning from resolution validation.
491#[derive(Debug, Clone)]
492pub enum ResolutionWarning {
493    /// Multiple resolutions try to rename from the same ID.
494    DuplicateRename {
495        migration_id: String,
496        from_id: String,
497    },
498    /// A resolution has expired.
499    Expired {
500        migration_id: String,
501        expired_at: DateTime<Utc>,
502    },
503    /// A baseline resolution references a migration that doesn't exist.
504    BaselineNotFound { migration_id: String },
505    /// A rename resolution's source migration doesn't exist in history.
506    RenameSourceNotFound {
507        migration_id: String,
508        from_id: String,
509    },
510}
511
512impl std::fmt::Display for ResolutionWarning {
513    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
514        match self {
515            Self::DuplicateRename {
516                migration_id,
517                from_id,
518            } => {
519                write!(
520                    f,
521                    "Multiple resolutions rename from '{}' (found in '{}')",
522                    from_id, migration_id
523                )
524            }
525            Self::Expired {
526                migration_id,
527                expired_at,
528            } => {
529                write!(
530                    f,
531                    "Resolution for '{}' expired at {}",
532                    migration_id, expired_at
533                )
534            }
535            Self::BaselineNotFound { migration_id } => {
536                write!(
537                    f,
538                    "Baseline migration '{}' not found in migration files",
539                    migration_id
540                )
541            }
542            Self::RenameSourceNotFound {
543                migration_id,
544                from_id,
545            } => {
546                write!(
547                    f,
548                    "Rename source '{}' not found in history (target: '{}')",
549                    from_id, migration_id
550                )
551            }
552        }
553    }
554}
555
556/// Counts of resolutions by type.
557#[derive(Debug, Clone, Default)]
558pub struct ResolutionCounts {
559    /// Number of accepted checksum changes.
560    pub checksum_accepted: usize,
561    /// Number of skipped migrations.
562    pub skipped: usize,
563    /// Number of baseline migrations.
564    pub baseline: usize,
565    /// Number of renamed migrations.
566    pub renamed: usize,
567    /// Number of resolved conflicts.
568    pub conflicts_resolved: usize,
569    /// Number of force-applied migrations.
570    pub force_applied: usize,
571}
572
573impl ResolutionCounts {
574    /// Get total number of resolutions.
575    pub fn total(&self) -> usize {
576        self.checksum_accepted
577            + self.skipped
578            + self.baseline
579            + self.renamed
580            + self.conflicts_resolved
581            + self.force_applied
582    }
583}
584
585/// Builder for creating resolutions interactively.
586pub struct ResolutionBuilder {
587    migration_id: String,
588    action: Option<ResolutionAction>,
589    reason: Option<String>,
590    created_by: Option<String>,
591    expires_at: Option<DateTime<Utc>>,
592    metadata: HashMap<String, String>,
593}
594
595impl ResolutionBuilder {
596    /// Create a new resolution builder.
597    pub fn new(migration_id: impl Into<String>) -> Self {
598        Self {
599            migration_id: migration_id.into(),
600            action: None,
601            reason: None,
602            created_by: None,
603            expires_at: None,
604            metadata: HashMap::new(),
605        }
606    }
607
608    /// Set the action to accept a checksum change.
609    pub fn accept_checksum(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
610        self.action = Some(ResolutionAction::AcceptChecksum {
611            from_checksum: from.into(),
612            to_checksum: to.into(),
613        });
614        self
615    }
616
617    /// Set the action to skip.
618    pub fn skip(mut self) -> Self {
619        self.action = Some(ResolutionAction::Skip);
620        self
621    }
622
623    /// Set the action to baseline.
624    pub fn baseline(mut self) -> Self {
625        self.action = Some(ResolutionAction::Baseline);
626        self
627    }
628
629    /// Set the action to rename.
630    pub fn rename_from(mut self, old_id: impl Into<String>) -> Self {
631        self.action = Some(ResolutionAction::Rename {
632            from_id: old_id.into(),
633        });
634        self
635    }
636
637    /// Set the action to resolve conflict.
638    pub fn resolve_conflict(
639        mut self,
640        conflicting: Vec<String>,
641        strategy: ConflictStrategy,
642    ) -> Self {
643        self.action = Some(ResolutionAction::ResolveConflict {
644            conflicting_ids: conflicting,
645            strategy,
646        });
647        self
648    }
649
650    /// Set the action to force apply.
651    pub fn force_apply(mut self) -> Self {
652        self.action = Some(ResolutionAction::ForceApply);
653        self
654    }
655
656    /// Set the reason.
657    pub fn reason(mut self, reason: impl Into<String>) -> Self {
658        self.reason = Some(reason.into());
659        self
660    }
661
662    /// Set the author.
663    pub fn author(mut self, author: impl Into<String>) -> Self {
664        self.created_by = Some(author.into());
665        self
666    }
667
668    /// Set the expiration.
669    pub fn expires(mut self, at: DateTime<Utc>) -> Self {
670        self.expires_at = Some(at);
671        self
672    }
673
674    /// Add metadata.
675    pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
676        self.metadata.insert(key.into(), value.into());
677        self
678    }
679
680    /// Build the resolution.
681    pub fn build(self) -> MigrateResult<Resolution> {
682        let action = self.action.ok_or_else(|| {
683            MigrationError::ResolutionFile("Resolution action is required".to_string())
684        })?;
685
686        let reason = self.reason.ok_or_else(|| {
687            MigrationError::ResolutionFile("Resolution reason is required".to_string())
688        })?;
689
690        Ok(Resolution {
691            migration_id: self.migration_id,
692            action,
693            reason,
694            created_at: Utc::now(),
695            created_by: self.created_by,
696            expires_at: self.expires_at,
697            metadata: self.metadata,
698        })
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn test_resolution_accept_checksum() {
708        let resolution = Resolution::accept_checksum(
709            "20240101_create_users",
710            "abc123",
711            "def456",
712            "Fixed typo in column name",
713        );
714
715        assert_eq!(resolution.migration_id, "20240101_create_users");
716        assert!(matches!(
717            resolution.action,
718            ResolutionAction::AcceptChecksum { .. }
719        ));
720    }
721
722    #[test]
723    fn test_resolution_skip() {
724        let resolution = Resolution::skip(
725            "20240102_add_legacy_table",
726            "Already exists from legacy system",
727        );
728
729        assert_eq!(resolution.migration_id, "20240102_add_legacy_table");
730        assert!(matches!(resolution.action, ResolutionAction::Skip));
731    }
732
733    #[test]
734    fn test_resolution_baseline() {
735        let resolution =
736            Resolution::baseline("20240103_initial_schema", "Imported from existing database");
737
738        assert!(matches!(resolution.action, ResolutionAction::Baseline));
739    }
740
741    #[test]
742    fn test_resolution_rename() {
743        let resolution = Resolution::rename(
744            "20240104_new_name",
745            "20240104_old_name",
746            "Renamed for clarity",
747        );
748
749        if let ResolutionAction::Rename { from_id } = &resolution.action {
750            assert_eq!(from_id, "20240104_old_name");
751        } else {
752            panic!("Expected Rename action");
753        }
754    }
755
756    #[test]
757    fn test_resolution_config() {
758        let mut config = ResolutionConfig::new();
759
760        config.add(Resolution::skip("migration_1", "Skip reason"));
761        config.add(Resolution::baseline("migration_2", "Baseline reason"));
762
763        assert!(config.has_resolution("migration_1"));
764        assert!(config.should_skip("migration_1"));
765        assert!(config.is_baseline("migration_2"));
766        assert!(!config.should_skip("migration_2"));
767    }
768
769    #[test]
770    fn test_resolution_accepts_checksum() {
771        let mut config = ResolutionConfig::new();
772
773        config.add(Resolution::accept_checksum(
774            "migration_1",
775            "old_hash",
776            "new_hash",
777            "Fixed typo",
778        ));
779
780        assert!(config.accepts_checksum("migration_1", "old_hash", "new_hash"));
781        assert!(!config.accepts_checksum("migration_1", "wrong", "new_hash"));
782        assert!(!config.accepts_checksum("migration_2", "old_hash", "new_hash"));
783    }
784
785    #[test]
786    fn test_resolution_skipped_list() {
787        let mut config = ResolutionConfig::new();
788
789        config.add(Resolution::skip("skip_1", "Reason 1"));
790        config.add(Resolution::skip("skip_2", "Reason 2"));
791        config.add(Resolution::baseline("baseline_1", "Reason 3"));
792
793        let skipped = config.skipped();
794        assert_eq!(skipped.len(), 2);
795        assert!(skipped.contains(&"skip_1"));
796        assert!(skipped.contains(&"skip_2"));
797    }
798
799    #[test]
800    fn test_resolution_builder() {
801        let resolution = ResolutionBuilder::new("migration_1")
802            .skip()
803            .reason("Testing")
804            .author("Test User")
805            .meta("ticket", "JIRA-123")
806            .build()
807            .unwrap();
808
809        assert_eq!(resolution.migration_id, "migration_1");
810        assert!(matches!(resolution.action, ResolutionAction::Skip));
811        assert_eq!(resolution.reason, "Testing");
812        assert_eq!(resolution.created_by, Some("Test User".to_string()));
813        assert_eq!(
814            resolution.metadata.get("ticket"),
815            Some(&"JIRA-123".to_string())
816        );
817    }
818
819    #[test]
820    fn test_resolution_counts() {
821        let mut config = ResolutionConfig::new();
822
823        config.add(Resolution::skip("m1", "r"));
824        config.add(Resolution::skip("m2", "r"));
825        config.add(Resolution::baseline("m3", "r"));
826        config.add(Resolution::accept_checksum("m4", "a", "b", "r"));
827
828        let counts = config.count_by_type();
829        assert_eq!(counts.skipped, 2);
830        assert_eq!(counts.baseline, 1);
831        assert_eq!(counts.checksum_accepted, 1);
832        assert_eq!(counts.total(), 4);
833    }
834
835    #[test]
836    fn test_resolution_expiration() {
837        let expired = Resolution::skip("migration", "reason")
838            .with_expiration(Utc::now() - chrono::Duration::hours(1));
839
840        assert!(expired.is_expired());
841
842        let valid = Resolution::skip("migration", "reason")
843            .with_expiration(Utc::now() + chrono::Duration::hours(1));
844
845        assert!(!valid.is_expired());
846    }
847
848    #[test]
849    fn test_get_renamed() {
850        let mut config = ResolutionConfig::new();
851        config.add(Resolution::rename("new_id", "old_id", "Renamed"));
852
853        assert_eq!(config.get_renamed("old_id"), Some("new_id"));
854        assert_eq!(config.get_renamed("unknown"), None);
855    }
856}