Skip to main content

type_bridge_migration/
graph.rs

1//! Pure migration graph validation.
2
3use 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 = " -> ";
13// Bound aggregate diagnostic construction independently of graph density while
14// retaining exact released paths for ordinary migration histories.
15const MAX_CYCLE_DIAGNOSTIC_PATH_BYTES: usize = 16 * 1024 * 1024;
16const MAX_CYCLE_DIAGNOSTIC_PATHS: usize = 65_536;
17
18/// Applied migration record as loaded from migration state.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AppliedMigrationRecord {
21    /// Application or migration package label.
22    pub app_label: String,
23    /// Migration file stem, such as `0001_initial`.
24    pub name: String,
25    /// Checksum recorded when this migration was applied.
26    pub checksum: String,
27    /// Optional application timestamp carried through the Python compatibility DTO.
28    #[serde(default)]
29    pub applied_at: Option<String>,
30}
31
32/// Stable validation error code for machine-readable assertions.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ValidationCode {
36    /// Migration app label is empty.
37    EmptyAppLabel,
38    /// Migration name is empty.
39    EmptyName,
40    /// More than one loaded migration has the same app/name identity.
41    DuplicateMigration,
42    /// More than one migration in an app uses the same numeric prefix.
43    DuplicateMigrationNumber,
44    /// A migration depends on itself.
45    SelfDependency,
46    /// A dependency target is not present in the loaded graph.
47    MissingDependency,
48    /// A dependency cycle was detected.
49    DependencyCycle,
50    /// More than one applied record has the same app/name identity.
51    DuplicateAppliedRecord,
52    /// Applied state references a migration not present in the loaded graph.
53    UnknownAppliedMigration,
54    /// An applied migration has an unapplied dependency.
55    AppliedDependencyMissing,
56}
57
58/// Structured migration validation error.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct MigrationValidationError {
61    /// Stable validation code.
62    pub code: ValidationCode,
63    /// App label associated with the failing migration when available.
64    pub app_label: String,
65    /// Migration name associated with the failing migration when available.
66    pub name: String,
67    /// Human-readable validation message.
68    pub message: String,
69}
70
71/// Validate graph and applied-state consistency, returning all deterministic errors.
72pub 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    // A node enters the frame stack only from the unseen state, so the
229    // retained traversal is strictly bounded by the finite adjacency map.
230    // Grow per component: reserving the entire graph for every disconnected
231    // root would itself make an otherwise edgeless graph quadratic.
232    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    // A node is discovered only once, so its active DFS path is fixed. The
292    // current-node/ancestor pair therefore identifies the released path
293    // exactly and filters only duplicate dependency edges.
294    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    // Released V1 deduplicated the rendered path, rather than the graph-node
324    // identity. Retain that final check for unusual component spellings that
325    // render to the same dotted path.
326    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}