1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{MigrationDependencySpec, MigrationGraph};
8
9type MigrationKey = (String, String);
10type MigrationBackedge = (MigrationKey, MigrationKey);
11
12const CYCLE_PATH_SEPARATOR: &str = " -> ";
13const MAX_CYCLE_DIAGNOSTIC_PATH_BYTES: usize = 16 * 1024 * 1024;
16const MAX_CYCLE_DIAGNOSTIC_PATHS: usize = 65_536;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AppliedMigrationRecord {
21 pub app_label: String,
23 pub name: String,
25 pub checksum: String,
27 #[serde(default)]
29 pub applied_at: Option<String>,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ValidationCode {
36 EmptyAppLabel,
38 EmptyName,
40 DuplicateMigration,
42 DuplicateMigrationNumber,
44 SelfDependency,
46 MissingDependency,
48 DependencyCycle,
50 DuplicateAppliedRecord,
52 UnknownAppliedMigration,
54 AppliedDependencyMissing,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct MigrationValidationError {
61 pub code: ValidationCode,
63 pub app_label: String,
65 pub name: String,
67 pub message: String,
69}
70
71pub fn validate_graph(
73 graph: &MigrationGraph,
74 applied: &[AppliedMigrationRecord],
75) -> Vec<MigrationValidationError> {
76 let mut errors = Vec::new();
77 let graph_keys = validate_loaded_migrations(graph, &mut errors);
78 validate_dependencies(graph, &graph_keys, &mut errors);
79 validate_cycles(graph, &graph_keys, &mut errors);
80 validate_applied_records(graph, applied, &graph_keys, &mut errors);
81 errors
82}
83
84fn validate_loaded_migrations(
85 graph: &MigrationGraph,
86 errors: &mut Vec<MigrationValidationError>,
87) -> BTreeSet<MigrationKey> {
88 let mut keys = BTreeSet::new();
89 let mut seen_keys = BTreeSet::new();
90 let mut numbers: BTreeMap<(String, String), String> = BTreeMap::new();
91
92 for migration in &graph.migrations {
93 if migration.app_label.is_empty() {
94 errors.push(error(
95 ValidationCode::EmptyAppLabel,
96 &migration.app_label,
97 &migration.name,
98 format!("Migration {} has an empty app label", migration.name),
99 ));
100 }
101 if migration.name.is_empty() {
102 errors.push(error(
103 ValidationCode::EmptyName,
104 &migration.app_label,
105 &migration.name,
106 format!("Migration {} has an empty name", migration.app_label),
107 ));
108 }
109
110 let key = (migration.app_label.clone(), migration.name.clone());
111 if !seen_keys.insert(key.clone()) {
112 errors.push(error(
113 ValidationCode::DuplicateMigration,
114 &migration.app_label,
115 &migration.name,
116 format!(
117 "Migration {}.{} is defined more than once",
118 migration.app_label, migration.name
119 ),
120 ));
121 }
122 keys.insert(key);
123
124 if let Some(number) = migration_number(&migration.name) {
125 let number_key = (migration.app_label.clone(), number);
126 if let Some(existing_name) = numbers.get(&number_key) {
127 if existing_name != &migration.name {
128 errors.push(error(
129 ValidationCode::DuplicateMigrationNumber,
130 &migration.app_label,
131 &migration.name,
132 format!(
133 "Migration {}.{} uses the same number as {}.{}",
134 migration.app_label, migration.name, migration.app_label, existing_name
135 ),
136 ));
137 }
138 } else {
139 numbers.insert(number_key, migration.name.clone());
140 }
141 }
142 }
143
144 keys
145}
146
147fn validate_dependencies(
148 graph: &MigrationGraph,
149 graph_keys: &BTreeSet<MigrationKey>,
150 errors: &mut Vec<MigrationValidationError>,
151) {
152 for migration in &graph.migrations {
153 let migration_key = (migration.app_label.clone(), migration.name.clone());
154 for dependency in &migration.dependencies {
155 let dependency_key = dependency_key(dependency);
156 if dependency_key == migration_key {
157 errors.push(error(
158 ValidationCode::SelfDependency,
159 &migration.app_label,
160 &migration.name,
161 format!(
162 "Migration {}.{} depends on itself",
163 migration.app_label, migration.name
164 ),
165 ));
166 } else if !graph_keys.contains(&dependency_key) {
167 errors.push(error(
168 ValidationCode::MissingDependency,
169 &migration.app_label,
170 &migration.name,
171 format!(
172 "Migration {}.{} depends on {}.{} which does not exist",
173 migration.app_label,
174 migration.name,
175 dependency.app_label,
176 dependency.migration_name
177 ),
178 ));
179 }
180 }
181 }
182}
183
184fn validate_cycles(
185 graph: &MigrationGraph,
186 graph_keys: &BTreeSet<MigrationKey>,
187 errors: &mut Vec<MigrationValidationError>,
188) {
189 let mut adjacency: BTreeMap<MigrationKey, Vec<MigrationKey>> = BTreeMap::new();
190 for migration in &graph.migrations {
191 let key = (migration.app_label.clone(), migration.name.clone());
192 let dependencies = migration
193 .dependencies
194 .iter()
195 .map(dependency_key)
196 .filter(|dependency| graph_keys.contains(dependency))
197 .collect();
198 adjacency.insert(key, dependencies);
199 }
200
201 let mut traversal = CycleTraversal::new();
202
203 for key in adjacency.keys() {
204 detect_cycle(key, &adjacency, &mut traversal, errors);
205 }
206}
207
208fn detect_cycle(
209 key: &MigrationKey,
210 adjacency: &BTreeMap<MigrationKey, Vec<MigrationKey>>,
211 traversal: &mut CycleTraversal,
212 errors: &mut Vec<MigrationValidationError>,
213) {
214 match traversal.state.get(key) {
215 Some(VisitState::Done) => return,
216 Some(VisitState::Visiting) => {
217 report_cycle(key, traversal, errors);
218 return;
219 }
220 None => {}
221 }
222
223 traversal.state.insert(key.clone(), VisitState::Visiting);
224 traversal
225 .stack_positions
226 .insert(key.clone(), traversal.stack.len());
227 traversal.stack.push(key.clone());
228 let mut frames = Vec::new();
233 frames.push(TraversalFrame {
234 key: key.clone(),
235 next_dependency: 0,
236 });
237
238 while let Some(frame) = frames.last_mut() {
239 let dependency = adjacency
240 .get(&frame.key)
241 .and_then(|dependencies| dependencies.get(frame.next_dependency))
242 .cloned();
243 if let Some(dependency) = dependency {
244 frame.next_dependency += 1;
245 match traversal.state.get(&dependency) {
246 Some(VisitState::Done) => {}
247 Some(VisitState::Visiting) => {
248 report_cycle(&dependency, traversal, errors);
249 }
250 None => {
251 traversal
252 .state
253 .insert(dependency.clone(), VisitState::Visiting);
254 traversal
255 .stack_positions
256 .insert(dependency.clone(), traversal.stack.len());
257 traversal.stack.push(dependency.clone());
258 frames.push(TraversalFrame {
259 key: dependency,
260 next_dependency: 0,
261 });
262 }
263 }
264 continue;
265 }
266
267 let Some(completed) = frames.pop() else {
268 break;
269 };
270 debug_assert_eq!(traversal.stack.last(), Some(&completed.key));
271 traversal.stack.pop();
272 traversal.stack_positions.remove(&completed.key);
273 traversal.state.insert(completed.key, VisitState::Done);
274 }
275}
276
277fn report_cycle(
278 key: &MigrationKey,
279 traversal: &mut CycleTraversal,
280 errors: &mut Vec<MigrationValidationError>,
281) {
282 if traversal.diagnostic_budget.exhausted {
283 return;
284 }
285 let Some(&start) = traversal.stack_positions.get(key) else {
286 return;
287 };
288 let Some(current) = traversal.stack.last() else {
289 return;
290 };
291 if !traversal
295 .reported_backedges
296 .insert((current.clone(), key.clone()))
297 {
298 return;
299 }
300
301 let cycle_nodes = &traversal.stack[start..];
302 let path_bytes = cycle_path_bytes(cycle_nodes, key);
303 if !traversal.diagnostic_budget.reserve(path_bytes) {
304 errors.push(error(
305 ValidationCode::DependencyCycle,
306 &key.0,
307 &key.1,
308 format!(
309 "Migration dependency cycle detected; additional cycle paths omitted after the \
310 bounded {MAX_CYCLE_DIAGNOSTIC_PATH_BYTES}-byte / \
311 {MAX_CYCLE_DIAGNOSTIC_PATHS}-path diagnostic budget was exhausted"
312 ),
313 ));
314 return;
315 }
316
317 let mut message_path = String::with_capacity(path_bytes);
318 for cycle_key in cycle_nodes {
319 push_migration_key(&mut message_path, cycle_key);
320 message_path.push_str(CYCLE_PATH_SEPARATOR);
321 }
322 push_migration_key(&mut message_path, key);
323 if !traversal.reported_paths.insert(message_path.clone()) {
327 return;
328 }
329 errors.push(error(
330 ValidationCode::DependencyCycle,
331 &key.0,
332 &key.1,
333 format!("Migration dependency cycle detected: {message_path}"),
334 ));
335}
336
337fn cycle_path_bytes(cycle_nodes: &[MigrationKey], repeated_key: &MigrationKey) -> usize {
338 cycle_nodes.iter().fold(
339 migration_key_display_bytes(repeated_key),
340 |path_bytes, key| {
341 path_bytes
342 .saturating_add(migration_key_display_bytes(key))
343 .saturating_add(CYCLE_PATH_SEPARATOR.len())
344 },
345 )
346}
347
348fn migration_key_display_bytes(key: &MigrationKey) -> usize {
349 key.0.len().saturating_add(1).saturating_add(key.1.len())
350}
351
352fn push_migration_key(output: &mut String, key: &MigrationKey) {
353 output.push_str(&key.0);
354 output.push('.');
355 output.push_str(&key.1);
356}
357
358fn validate_applied_records(
359 graph: &MigrationGraph,
360 applied: &[AppliedMigrationRecord],
361 graph_keys: &BTreeSet<MigrationKey>,
362 errors: &mut Vec<MigrationValidationError>,
363) {
364 let mut applied_keys = BTreeSet::new();
365 for record in applied {
366 let key = (record.app_label.clone(), record.name.clone());
367 if !applied_keys.insert(key.clone()) {
368 errors.push(error(
369 ValidationCode::DuplicateAppliedRecord,
370 &record.app_label,
371 &record.name,
372 format!(
373 "Applied migration {}.{} is recorded more than once",
374 record.app_label, record.name
375 ),
376 ));
377 }
378 if !graph_keys.contains(&key) {
379 errors.push(error(
380 ValidationCode::UnknownAppliedMigration,
381 &record.app_label,
382 &record.name,
383 format!(
384 "Applied migration {}.{} is not present in the loaded graph",
385 record.app_label, record.name
386 ),
387 ));
388 }
389 }
390
391 for migration in &graph.migrations {
392 let key = (migration.app_label.clone(), migration.name.clone());
393 if !applied_keys.contains(&key) {
394 continue;
395 }
396 for dependency in &migration.dependencies {
397 let dependency_key = dependency_key(dependency);
398 if graph_keys.contains(&dependency_key) && !applied_keys.contains(&dependency_key) {
399 errors.push(error(
400 ValidationCode::AppliedDependencyMissing,
401 &migration.app_label,
402 &migration.name,
403 format!(
404 "Applied migration {}.{} depends on unapplied migration {}.{}",
405 migration.app_label,
406 migration.name,
407 dependency.app_label,
408 dependency.migration_name
409 ),
410 ));
411 }
412 }
413 }
414}
415
416fn dependency_key(dependency: &MigrationDependencySpec) -> MigrationKey {
417 (
418 dependency.app_label.clone(),
419 dependency.migration_name.clone(),
420 )
421}
422
423fn migration_number(name: &str) -> Option<String> {
424 let bytes = name.as_bytes();
425 if bytes.len() < 5 || bytes[4] != b'_' {
426 return None;
427 }
428 let prefix = &bytes[..4];
429 if prefix.iter().all(u8::is_ascii_digit) {
430 Some(name[..4].to_string())
431 } else {
432 None
433 }
434}
435
436fn error(
437 code: ValidationCode,
438 app_label: &str,
439 name: &str,
440 message: String,
441) -> MigrationValidationError {
442 MigrationValidationError {
443 code,
444 app_label: app_label.to_string(),
445 name: name.to_string(),
446 message,
447 }
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451enum VisitState {
452 Visiting,
453 Done,
454}
455
456#[derive(Debug)]
457struct TraversalFrame {
458 key: MigrationKey,
459 next_dependency: usize,
460}
461
462#[derive(Debug)]
463struct CycleTraversal {
464 state: BTreeMap<MigrationKey, VisitState>,
465 stack: Vec<MigrationKey>,
466 stack_positions: BTreeMap<MigrationKey, usize>,
467 reported_backedges: BTreeSet<MigrationBackedge>,
468 reported_paths: BTreeSet<String>,
469 diagnostic_budget: CycleDiagnosticBudget,
470}
471
472impl CycleTraversal {
473 fn new() -> Self {
474 Self {
475 state: BTreeMap::new(),
476 stack: Vec::new(),
477 stack_positions: BTreeMap::new(),
478 reported_backedges: BTreeSet::new(),
479 reported_paths: BTreeSet::new(),
480 diagnostic_budget: CycleDiagnosticBudget::new(),
481 }
482 }
483}
484
485#[derive(Debug)]
486struct CycleDiagnosticBudget {
487 remaining_path_bytes: usize,
488 remaining_paths: usize,
489 exhausted: bool,
490}
491
492impl CycleDiagnosticBudget {
493 fn new() -> Self {
494 Self {
495 remaining_path_bytes: MAX_CYCLE_DIAGNOSTIC_PATH_BYTES,
496 remaining_paths: MAX_CYCLE_DIAGNOSTIC_PATHS,
497 exhausted: false,
498 }
499 }
500
501 fn reserve(&mut self, path_bytes: usize) -> bool {
502 if self.exhausted || self.remaining_paths == 0 || path_bytes > self.remaining_path_bytes {
503 self.exhausted = true;
504 return false;
505 }
506 self.remaining_path_bytes -= path_bytes;
507 self.remaining_paths -= 1;
508 true
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::{MigrationGraph, MigrationSpec};
516
517 fn migration(name: &str, dependencies: Vec<(&str, &str)>) -> MigrationSpec {
518 MigrationSpec {
519 app_label: "app".to_string(),
520 name: name.to_string(),
521 dependencies: dependencies
522 .into_iter()
523 .map(|(app_label, migration_name)| MigrationDependencySpec {
524 app_label: app_label.to_string(),
525 migration_name: migration_name.to_string(),
526 })
527 .collect(),
528 operations: Vec::new(),
529 checksum: Some(format!("{name}-checksum")),
530 source_sha256: None,
531 reversible: true,
532 }
533 }
534
535 fn graph(migrations: Vec<MigrationSpec>) -> MigrationGraph {
536 MigrationGraph { migrations }
537 }
538
539 fn applied(name: &str) -> AppliedMigrationRecord {
540 AppliedMigrationRecord {
541 app_label: "app".to_string(),
542 name: name.to_string(),
543 checksum: format!("{name}-checksum"),
544 applied_at: Some("2026-06-05T00:00:00".to_string()),
545 }
546 }
547
548 fn codes(errors: &[MigrationValidationError]) -> Vec<ValidationCode> {
549 errors.iter().map(|error| error.code).collect()
550 }
551
552 #[test]
553 fn valid_graph_has_no_errors() {
554 let graph = graph(vec![
555 migration("0001_initial", vec![]),
556 migration("0002_next", vec![("app", "0001_initial")]),
557 ]);
558
559 assert_eq!(
560 validate_graph(&graph, &[applied("0001_initial")]),
561 Vec::new()
562 );
563 }
564
565 #[test]
566 fn duplicate_migration_identity_is_reported() {
567 let graph = graph(vec![
568 migration("0001_initial", vec![]),
569 migration("0001_initial", vec![]),
570 ]);
571
572 assert_eq!(
573 codes(&validate_graph(&graph, &[])),
574 vec![ValidationCode::DuplicateMigration]
575 );
576 }
577
578 #[test]
579 fn duplicate_migration_number_is_reported() {
580 let graph = graph(vec![
581 migration("0001_initial", vec![]),
582 migration("0001_second", vec![]),
583 ]);
584
585 assert_eq!(
586 codes(&validate_graph(&graph, &[])),
587 vec![ValidationCode::DuplicateMigrationNumber]
588 );
589 }
590
591 #[test]
592 fn missing_dependency_is_reported() {
593 let graph = graph(vec![migration("0002_next", vec![("app", "0001_initial")])]);
594
595 assert_eq!(
596 codes(&validate_graph(&graph, &[])),
597 vec![ValidationCode::MissingDependency]
598 );
599 }
600
601 #[test]
602 fn self_dependency_is_reported() {
603 let graph = graph(vec![migration(
604 "0001_initial",
605 vec![("app", "0001_initial")],
606 )]);
607
608 assert_eq!(
609 codes(&validate_graph(&graph, &[])),
610 vec![
611 ValidationCode::SelfDependency,
612 ValidationCode::DependencyCycle
613 ]
614 );
615 }
616
617 #[test]
618 fn dependency_cycle_is_reported() {
619 let graph = graph(vec![
620 migration("0001_initial", vec![("app", "0002_next")]),
621 migration("0002_next", vec![("app", "0001_initial")]),
622 ]);
623
624 assert_eq!(
625 codes(&validate_graph(&graph, &[])),
626 vec![ValidationCode::DependencyCycle]
627 );
628 }
629
630 #[test]
631 fn small_cycle_diagnostic_preserves_the_exact_deterministic_path() {
632 let graph = graph(vec![
633 migration("0001_initial", vec![("app", "0002_next")]),
634 migration("0002_next", vec![("app", "0001_initial")]),
635 ]);
636
637 assert_eq!(
638 validate_graph(&graph, &[]),
639 vec![MigrationValidationError {
640 code: ValidationCode::DependencyCycle,
641 app_label: "app".to_owned(),
642 name: "0001_initial".to_owned(),
643 message: "Migration dependency cycle detected: app.0001_initial -> \
644 app.0002_next -> app.0001_initial"
645 .to_owned(),
646 }],
647 );
648 }
649
650 #[test]
651 fn overlapping_cycles_preserve_each_released_deterministic_path() {
652 let graph = graph(vec![
653 migration("0001_a", vec![("app", "0002_b"), ("app", "0003_c")]),
654 migration("0002_b", vec![("app", "0001_a")]),
655 migration("0003_c", vec![("app", "0001_a")]),
656 ]);
657
658 assert_eq!(
659 validate_graph(&graph, &[]),
660 vec![
661 MigrationValidationError {
662 code: ValidationCode::DependencyCycle,
663 app_label: "app".to_owned(),
664 name: "0001_a".to_owned(),
665 message: "Migration dependency cycle detected: app.0001_a -> app.0002_b -> \
666 app.0001_a"
667 .to_owned(),
668 },
669 MigrationValidationError {
670 code: ValidationCode::DependencyCycle,
671 app_label: "app".to_owned(),
672 name: "0001_a".to_owned(),
673 message: "Migration dependency cycle detected: app.0001_a -> app.0003_c -> \
674 app.0001_a"
675 .to_owned(),
676 },
677 ],
678 );
679 }
680
681 #[test]
682 fn duplicate_dependency_edges_do_not_duplicate_a_released_cycle_path() {
683 let graph = graph(vec![
684 migration("0001_a", vec![("app", "0002_b"), ("app", "0002_b")]),
685 migration("0002_b", vec![("app", "0001_a")]),
686 ]);
687
688 assert_eq!(
689 validate_graph(&graph, &[]),
690 vec![MigrationValidationError {
691 code: ValidationCode::DependencyCycle,
692 app_label: "app".to_owned(),
693 name: "0001_a".to_owned(),
694 message: "Migration dependency cycle detected: app.0001_a -> app.0002_b -> \
695 app.0001_a"
696 .to_owned(),
697 }],
698 );
699 }
700
701 #[test]
702 fn directory_ceiling_disconnected_graph_remains_near_linear() {
703 const NODE_COUNT: usize = 65_536;
704 let migrations = (0..NODE_COUNT)
705 .map(|index| migration(&format!("node_{index:05}"), vec![]))
706 .collect();
707 let started = std::time::Instant::now();
708
709 let errors = validate_graph(&graph(migrations), &[]);
710
711 assert!(errors.is_empty());
712 assert!(started.elapsed() < std::time::Duration::from_secs(10));
713 }
714
715 #[test]
716 fn directory_ceiling_scale_cycle_returns_typed_validation_without_recursion() {
717 const NODE_COUNT: usize = 65_536;
718 let migrations = (0..NODE_COUNT)
719 .map(|index| {
720 let name = format!("node_{index:05}");
721 let dependency = format!("node_{:05}", (index + 1) % NODE_COUNT);
722 migration(&name, vec![("app", &dependency)])
723 })
724 .collect();
725 let started = std::time::Instant::now();
726
727 let errors = validate_graph(&graph(migrations), &[]);
728
729 assert_eq!(errors.len(), 1);
730 assert_eq!(errors[0].code, ValidationCode::DependencyCycle);
731 assert_eq!(errors[0].app_label, "app");
732 assert_eq!(errors[0].name, "node_00000");
733 assert!(
734 errors[0].message.starts_with(
735 "Migration dependency cycle detected: app.node_00000 -> app.node_00001"
736 )
737 );
738 assert!(
739 errors[0]
740 .message
741 .ends_with("app.node_65535 -> app.node_00000")
742 );
743 assert!(started.elapsed() < std::time::Duration::from_secs(10));
744 }
745
746 #[test]
747 fn overlapping_deep_backedges_are_amortized_at_the_directory_ceiling() {
748 const NODE_COUNT: usize = 65_536;
749 let migrations = (0..NODE_COUNT)
750 .map(|index| {
751 let name = format!("node_{index:05}");
752 let dependencies = if index + 1 < NODE_COUNT {
753 vec![("app", format!("node_{:05}", index + 1))]
754 } else {
755 std::iter::once(("app", format!("node_{:05}", NODE_COUNT - 2)))
756 .chain(
757 (0..NODE_COUNT - 2).map(|target| ("app", format!("node_{target:05}"))),
758 )
759 .collect()
760 };
761 MigrationSpec {
762 app_label: "app".to_owned(),
763 name: name.clone(),
764 dependencies: dependencies
765 .into_iter()
766 .map(|(app_label, migration_name)| MigrationDependencySpec {
767 app_label: app_label.to_owned(),
768 migration_name,
769 })
770 .collect(),
771 operations: Vec::new(),
772 checksum: Some(format!("{name}-checksum")),
773 source_sha256: None,
774 reversible: true,
775 }
776 })
777 .collect();
778 let started = std::time::Instant::now();
779
780 let errors = validate_graph(&graph(migrations), &[]);
781
782 assert!(errors.len() > 2);
783 assert!(
784 errors
785 .iter()
786 .all(|error| error.code == ValidationCode::DependencyCycle)
787 );
788 assert_eq!(
789 errors[0].message,
790 "Migration dependency cycle detected: app.node_65534 -> app.node_65535 -> \
791 app.node_65534"
792 );
793 assert!(
794 errors[1].message.starts_with(
795 "Migration dependency cycle detected: app.node_00000 -> app.node_00001"
796 )
797 );
798 assert_eq!(
799 errors.last().map(|error| error.message.as_str()),
800 Some(
801 "Migration dependency cycle detected; additional cycle paths omitted after the \
802 bounded 16777216-byte / 65536-path diagnostic budget was exhausted"
803 )
804 );
805 let rendered_cycle_bytes = errors
806 .iter()
807 .filter_map(|error| {
808 error
809 .message
810 .strip_prefix("Migration dependency cycle detected: ")
811 .map(str::len)
812 })
813 .sum::<usize>();
814 assert!(rendered_cycle_bytes <= MAX_CYCLE_DIAGNOSTIC_PATH_BYTES);
815 assert!(started.elapsed() < std::time::Duration::from_secs(10));
816 }
817
818 #[test]
819 fn unknown_applied_record_is_reported() {
820 let graph = graph(vec![migration("0001_initial", vec![])]);
821
822 assert_eq!(
823 codes(&validate_graph(&graph, &[applied("0002_unknown")])),
824 vec![ValidationCode::UnknownAppliedMigration]
825 );
826 }
827
828 #[test]
829 fn applied_dependency_gap_is_reported() {
830 let graph = graph(vec![
831 migration("0001_initial", vec![]),
832 migration("0002_next", vec![("app", "0001_initial")]),
833 ]);
834
835 assert_eq!(
836 codes(&validate_graph(&graph, &[applied("0002_next")])),
837 vec![ValidationCode::AppliedDependencyMissing]
838 );
839 }
840}