1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct ResolutionConfig {
52 #[serde(default)]
54 pub resolutions: HashMap<String, Resolution>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub last_modified: Option<DateTime<Utc>>,
58 #[serde(skip)]
60 pub file_path: Option<PathBuf>,
61}
62
63impl ResolutionConfig {
64 pub fn new() -> Self {
66 Self::default()
67 }
68
69 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 pub async fn save(&self, path: impl AsRef<Path>) -> MigrateResult<()> {
94 let path = path.as_ref();
95
96 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 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 pub fn add(&mut self, resolution: Resolution) {
128 self.resolutions
129 .insert(resolution.migration_id.clone(), resolution);
130 }
131
132 pub fn remove(&mut self, migration_id: &str) -> Option<Resolution> {
134 self.resolutions.remove(migration_id)
135 }
136
137 pub fn get(&self, migration_id: &str) -> Option<&Resolution> {
139 self.resolutions.get(migration_id)
140 }
141
142 pub fn has_resolution(&self, migration_id: &str) -> bool {
144 self.resolutions.contains_key(migration_id)
145 }
146
147 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 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 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 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 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 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 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 if from_id == old_id {
224 return Some(r.migration_id.as_str());
225 }
226 }
227 None
228 })
229 }
230
231 pub fn validate(&self) -> MigrateResult<Vec<ResolutionWarning>> {
233 let mut warnings = Vec::new();
234
235 for (id, resolution) in &self.resolutions {
236 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 if let Some(expires_at) = resolution.expires_at {
260 if expires_at < Utc::now() {
261 warnings.push(ResolutionWarning::Expired {
262 migration_id: id.clone(),
263 expired_at: expires_at,
264 });
265 }
266 }
267 }
268
269 Ok(warnings)
270 }
271
272 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct Resolution {
301 pub migration_id: String,
303 pub action: ResolutionAction,
305 pub reason: String,
307 pub created_at: DateTime<Utc>,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub created_by: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub expires_at: Option<DateTime<Utc>>,
315 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
317 pub metadata: HashMap<String, String>,
318}
319
320impl Resolution {
321 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 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 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 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 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 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 pub fn with_author(mut self, author: impl Into<String>) -> Self {
423 self.created_by = Some(author.into());
424 self
425 }
426
427 pub fn with_expiration(mut self, expires_at: DateTime<Utc>) -> Self {
429 self.expires_at = Some(expires_at);
430 self
431 }
432
433 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 pub fn is_expired(&self) -> bool {
441 self.expires_at.map(|e| e < Utc::now()).unwrap_or(false)
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(tag = "type", rename_all = "snake_case")]
448pub enum ResolutionAction {
449 AcceptChecksum {
451 from_checksum: String,
453 to_checksum: String,
455 },
456 Skip,
458 Baseline,
460 Rename {
462 from_id: String,
464 },
465 ResolveConflict {
467 conflicting_ids: Vec<String>,
469 strategy: ConflictStrategy,
471 },
472 ForceApply,
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479pub enum ConflictStrategy {
480 KeepThis,
482 Merge,
484 ApplyFirst,
486 ApplyLast,
488}
489
490#[derive(Debug, Clone)]
492pub enum ResolutionWarning {
493 DuplicateRename {
495 migration_id: String,
496 from_id: String,
497 },
498 Expired {
500 migration_id: String,
501 expired_at: DateTime<Utc>,
502 },
503 BaselineNotFound { migration_id: String },
505 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#[derive(Debug, Clone, Default)]
558pub struct ResolutionCounts {
559 pub checksum_accepted: usize,
561 pub skipped: usize,
563 pub baseline: usize,
565 pub renamed: usize,
567 pub conflicts_resolved: usize,
569 pub force_applied: usize,
571}
572
573impl ResolutionCounts {
574 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
585pub 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 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 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 pub fn skip(mut self) -> Self {
619 self.action = Some(ResolutionAction::Skip);
620 self
621 }
622
623 pub fn baseline(mut self) -> Self {
625 self.action = Some(ResolutionAction::Baseline);
626 self
627 }
628
629 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 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 pub fn force_apply(mut self) -> Self {
652 self.action = Some(ResolutionAction::ForceApply);
653 self
654 }
655
656 pub fn reason(mut self, reason: impl Into<String>) -> Self {
658 self.reason = Some(reason.into());
659 self
660 }
661
662 pub fn author(mut self, author: impl Into<String>) -> Self {
664 self.created_by = Some(author.into());
665 self
666 }
667
668 pub fn expires(mut self, at: DateTime<Utc>) -> Self {
670 self.expires_at = Some(at);
671 self
672 }
673
674 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 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}