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);
10
11/// Applied migration record as loaded from migration state.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct AppliedMigrationRecord {
14    /// Application or migration package label.
15    pub app_label: String,
16    /// Migration file stem, such as `0001_initial`.
17    pub name: String,
18    /// Checksum recorded when this migration was applied.
19    pub checksum: String,
20    /// Optional application timestamp carried through the Python compatibility DTO.
21    #[serde(default)]
22    pub applied_at: Option<String>,
23}
24
25/// Stable validation error code for machine-readable assertions.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ValidationCode {
29    /// Migration app label is empty.
30    EmptyAppLabel,
31    /// Migration name is empty.
32    EmptyName,
33    /// More than one loaded migration has the same app/name identity.
34    DuplicateMigration,
35    /// More than one migration in an app uses the same numeric prefix.
36    DuplicateMigrationNumber,
37    /// A migration depends on itself.
38    SelfDependency,
39    /// A dependency target is not present in the loaded graph.
40    MissingDependency,
41    /// A dependency cycle was detected.
42    DependencyCycle,
43    /// More than one applied record has the same app/name identity.
44    DuplicateAppliedRecord,
45    /// Applied state references a migration not present in the loaded graph.
46    UnknownAppliedMigration,
47    /// An applied migration has an unapplied dependency.
48    AppliedDependencyMissing,
49}
50
51/// Structured migration validation error.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct MigrationValidationError {
54    /// Stable validation code.
55    pub code: ValidationCode,
56    /// App label associated with the failing migration when available.
57    pub app_label: String,
58    /// Migration name associated with the failing migration when available.
59    pub name: String,
60    /// Human-readable validation message.
61    pub message: String,
62}
63
64/// Validate graph and applied-state consistency, returning all deterministic errors.
65pub fn validate_graph(
66    graph: &MigrationGraph,
67    applied: &[AppliedMigrationRecord],
68) -> Vec<MigrationValidationError> {
69    let mut errors = Vec::new();
70    let graph_keys = validate_loaded_migrations(graph, &mut errors);
71    validate_dependencies(graph, &graph_keys, &mut errors);
72    validate_cycles(graph, &graph_keys, &mut errors);
73    validate_applied_records(graph, applied, &graph_keys, &mut errors);
74    errors
75}
76
77fn validate_loaded_migrations(
78    graph: &MigrationGraph,
79    errors: &mut Vec<MigrationValidationError>,
80) -> BTreeSet<MigrationKey> {
81    let mut keys = BTreeSet::new();
82    let mut seen_keys = BTreeSet::new();
83    let mut numbers: BTreeMap<(String, String), String> = BTreeMap::new();
84
85    for migration in &graph.migrations {
86        if migration.app_label.is_empty() {
87            errors.push(error(
88                ValidationCode::EmptyAppLabel,
89                &migration.app_label,
90                &migration.name,
91                format!("Migration {} has an empty app label", migration.name),
92            ));
93        }
94        if migration.name.is_empty() {
95            errors.push(error(
96                ValidationCode::EmptyName,
97                &migration.app_label,
98                &migration.name,
99                format!("Migration {} has an empty name", migration.app_label),
100            ));
101        }
102
103        let key = (migration.app_label.clone(), migration.name.clone());
104        if !seen_keys.insert(key.clone()) {
105            errors.push(error(
106                ValidationCode::DuplicateMigration,
107                &migration.app_label,
108                &migration.name,
109                format!(
110                    "Migration {}.{} is defined more than once",
111                    migration.app_label, migration.name
112                ),
113            ));
114        }
115        keys.insert(key);
116
117        if let Some(number) = migration_number(&migration.name) {
118            let number_key = (migration.app_label.clone(), number);
119            if let Some(existing_name) = numbers.get(&number_key) {
120                if existing_name != &migration.name {
121                    errors.push(error(
122                        ValidationCode::DuplicateMigrationNumber,
123                        &migration.app_label,
124                        &migration.name,
125                        format!(
126                            "Migration {}.{} uses the same number as {}.{}",
127                            migration.app_label, migration.name, migration.app_label, existing_name
128                        ),
129                    ));
130                }
131            } else {
132                numbers.insert(number_key, migration.name.clone());
133            }
134        }
135    }
136
137    keys
138}
139
140fn validate_dependencies(
141    graph: &MigrationGraph,
142    graph_keys: &BTreeSet<MigrationKey>,
143    errors: &mut Vec<MigrationValidationError>,
144) {
145    for migration in &graph.migrations {
146        let migration_key = (migration.app_label.clone(), migration.name.clone());
147        for dependency in &migration.dependencies {
148            let dependency_key = dependency_key(dependency);
149            if dependency_key == migration_key {
150                errors.push(error(
151                    ValidationCode::SelfDependency,
152                    &migration.app_label,
153                    &migration.name,
154                    format!(
155                        "Migration {}.{} depends on itself",
156                        migration.app_label, migration.name
157                    ),
158                ));
159            } else if !graph_keys.contains(&dependency_key) {
160                errors.push(error(
161                    ValidationCode::MissingDependency,
162                    &migration.app_label,
163                    &migration.name,
164                    format!(
165                        "Migration {}.{} depends on {}.{} which does not exist",
166                        migration.app_label,
167                        migration.name,
168                        dependency.app_label,
169                        dependency.migration_name
170                    ),
171                ));
172            }
173        }
174    }
175}
176
177fn validate_cycles(
178    graph: &MigrationGraph,
179    graph_keys: &BTreeSet<MigrationKey>,
180    errors: &mut Vec<MigrationValidationError>,
181) {
182    let mut adjacency: BTreeMap<MigrationKey, Vec<MigrationKey>> = BTreeMap::new();
183    for migration in &graph.migrations {
184        let key = (migration.app_label.clone(), migration.name.clone());
185        let dependencies = migration
186            .dependencies
187            .iter()
188            .map(dependency_key)
189            .filter(|dependency| graph_keys.contains(dependency))
190            .collect();
191        adjacency.insert(key, dependencies);
192    }
193
194    let mut state: BTreeMap<MigrationKey, VisitState> = BTreeMap::new();
195    let mut stack = Vec::new();
196    let mut reported = BTreeSet::new();
197
198    for key in adjacency.keys() {
199        detect_cycle(
200            key,
201            &adjacency,
202            &mut state,
203            &mut stack,
204            &mut reported,
205            errors,
206        );
207    }
208}
209
210fn detect_cycle(
211    key: &MigrationKey,
212    adjacency: &BTreeMap<MigrationKey, Vec<MigrationKey>>,
213    state: &mut BTreeMap<MigrationKey, VisitState>,
214    stack: &mut Vec<MigrationKey>,
215    reported: &mut BTreeSet<String>,
216    errors: &mut Vec<MigrationValidationError>,
217) {
218    match state.get(key) {
219        Some(VisitState::Done) => return,
220        Some(VisitState::Visiting) => {
221            report_cycle(key, stack, reported, errors);
222            return;
223        }
224        None => {}
225    }
226
227    state.insert(key.clone(), VisitState::Visiting);
228    stack.push(key.clone());
229
230    if let Some(dependencies) = adjacency.get(key) {
231        for dependency in dependencies {
232            detect_cycle(dependency, adjacency, state, stack, reported, errors);
233        }
234    }
235
236    stack.pop();
237    state.insert(key.clone(), VisitState::Done);
238}
239
240fn report_cycle(
241    key: &MigrationKey,
242    stack: &[MigrationKey],
243    reported: &mut BTreeSet<String>,
244    errors: &mut Vec<MigrationValidationError>,
245) {
246    let Some(start) = stack.iter().position(|stack_key| stack_key == key) else {
247        return;
248    };
249    let mut cycle = stack[start..].to_vec();
250    cycle.push(key.clone());
251    let message_path = cycle
252        .iter()
253        .map(|(app_label, name)| format!("{app_label}.{name}"))
254        .collect::<Vec<_>>()
255        .join(" -> ");
256    if reported.insert(message_path.clone()) {
257        errors.push(error(
258            ValidationCode::DependencyCycle,
259            &key.0,
260            &key.1,
261            format!("Migration dependency cycle detected: {message_path}"),
262        ));
263    }
264}
265
266fn validate_applied_records(
267    graph: &MigrationGraph,
268    applied: &[AppliedMigrationRecord],
269    graph_keys: &BTreeSet<MigrationKey>,
270    errors: &mut Vec<MigrationValidationError>,
271) {
272    let mut applied_keys = BTreeSet::new();
273    for record in applied {
274        let key = (record.app_label.clone(), record.name.clone());
275        if !applied_keys.insert(key.clone()) {
276            errors.push(error(
277                ValidationCode::DuplicateAppliedRecord,
278                &record.app_label,
279                &record.name,
280                format!(
281                    "Applied migration {}.{} is recorded more than once",
282                    record.app_label, record.name
283                ),
284            ));
285        }
286        if !graph_keys.contains(&key) {
287            errors.push(error(
288                ValidationCode::UnknownAppliedMigration,
289                &record.app_label,
290                &record.name,
291                format!(
292                    "Applied migration {}.{} is not present in the loaded graph",
293                    record.app_label, record.name
294                ),
295            ));
296        }
297    }
298
299    for migration in &graph.migrations {
300        let key = (migration.app_label.clone(), migration.name.clone());
301        if !applied_keys.contains(&key) {
302            continue;
303        }
304        for dependency in &migration.dependencies {
305            let dependency_key = dependency_key(dependency);
306            if graph_keys.contains(&dependency_key) && !applied_keys.contains(&dependency_key) {
307                errors.push(error(
308                    ValidationCode::AppliedDependencyMissing,
309                    &migration.app_label,
310                    &migration.name,
311                    format!(
312                        "Applied migration {}.{} depends on unapplied migration {}.{}",
313                        migration.app_label,
314                        migration.name,
315                        dependency.app_label,
316                        dependency.migration_name
317                    ),
318                ));
319            }
320        }
321    }
322}
323
324fn dependency_key(dependency: &MigrationDependencySpec) -> MigrationKey {
325    (
326        dependency.app_label.clone(),
327        dependency.migration_name.clone(),
328    )
329}
330
331fn migration_number(name: &str) -> Option<String> {
332    let bytes = name.as_bytes();
333    if bytes.len() < 5 || bytes[4] != b'_' {
334        return None;
335    }
336    let prefix = &bytes[..4];
337    if prefix.iter().all(u8::is_ascii_digit) {
338        Some(name[..4].to_string())
339    } else {
340        None
341    }
342}
343
344fn error(
345    code: ValidationCode,
346    app_label: &str,
347    name: &str,
348    message: String,
349) -> MigrationValidationError {
350    MigrationValidationError {
351        code,
352        app_label: app_label.to_string(),
353        name: name.to_string(),
354        message,
355    }
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359enum VisitState {
360    Visiting,
361    Done,
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::{MigrationGraph, MigrationSpec};
368
369    fn migration(name: &str, dependencies: Vec<(&str, &str)>) -> MigrationSpec {
370        MigrationSpec {
371            app_label: "app".to_string(),
372            name: name.to_string(),
373            dependencies: dependencies
374                .into_iter()
375                .map(|(app_label, migration_name)| MigrationDependencySpec {
376                    app_label: app_label.to_string(),
377                    migration_name: migration_name.to_string(),
378                })
379                .collect(),
380            operations: Vec::new(),
381            checksum: Some(format!("{name}-checksum")),
382            reversible: true,
383        }
384    }
385
386    fn graph(migrations: Vec<MigrationSpec>) -> MigrationGraph {
387        MigrationGraph { migrations }
388    }
389
390    fn applied(name: &str) -> AppliedMigrationRecord {
391        AppliedMigrationRecord {
392            app_label: "app".to_string(),
393            name: name.to_string(),
394            checksum: format!("{name}-checksum"),
395            applied_at: Some("2026-06-05T00:00:00".to_string()),
396        }
397    }
398
399    fn codes(errors: &[MigrationValidationError]) -> Vec<ValidationCode> {
400        errors.iter().map(|error| error.code).collect()
401    }
402
403    #[test]
404    fn valid_graph_has_no_errors() {
405        let graph = graph(vec![
406            migration("0001_initial", vec![]),
407            migration("0002_next", vec![("app", "0001_initial")]),
408        ]);
409
410        assert_eq!(
411            validate_graph(&graph, &[applied("0001_initial")]),
412            Vec::new()
413        );
414    }
415
416    #[test]
417    fn duplicate_migration_identity_is_reported() {
418        let graph = graph(vec![
419            migration("0001_initial", vec![]),
420            migration("0001_initial", vec![]),
421        ]);
422
423        assert_eq!(
424            codes(&validate_graph(&graph, &[])),
425            vec![ValidationCode::DuplicateMigration]
426        );
427    }
428
429    #[test]
430    fn duplicate_migration_number_is_reported() {
431        let graph = graph(vec![
432            migration("0001_initial", vec![]),
433            migration("0001_second", vec![]),
434        ]);
435
436        assert_eq!(
437            codes(&validate_graph(&graph, &[])),
438            vec![ValidationCode::DuplicateMigrationNumber]
439        );
440    }
441
442    #[test]
443    fn missing_dependency_is_reported() {
444        let graph = graph(vec![migration("0002_next", vec![("app", "0001_initial")])]);
445
446        assert_eq!(
447            codes(&validate_graph(&graph, &[])),
448            vec![ValidationCode::MissingDependency]
449        );
450    }
451
452    #[test]
453    fn self_dependency_is_reported() {
454        let graph = graph(vec![migration(
455            "0001_initial",
456            vec![("app", "0001_initial")],
457        )]);
458
459        assert_eq!(
460            codes(&validate_graph(&graph, &[])),
461            vec![
462                ValidationCode::SelfDependency,
463                ValidationCode::DependencyCycle
464            ]
465        );
466    }
467
468    #[test]
469    fn dependency_cycle_is_reported() {
470        let graph = graph(vec![
471            migration("0001_initial", vec![("app", "0002_next")]),
472            migration("0002_next", vec![("app", "0001_initial")]),
473        ]);
474
475        assert_eq!(
476            codes(&validate_graph(&graph, &[])),
477            vec![ValidationCode::DependencyCycle]
478        );
479    }
480
481    #[test]
482    fn unknown_applied_record_is_reported() {
483        let graph = graph(vec![migration("0001_initial", vec![])]);
484
485        assert_eq!(
486            codes(&validate_graph(&graph, &[applied("0002_unknown")])),
487            vec![ValidationCode::UnknownAppliedMigration]
488        );
489    }
490
491    #[test]
492    fn applied_dependency_gap_is_reported() {
493        let graph = graph(vec![
494            migration("0001_initial", vec![]),
495            migration("0002_next", vec![("app", "0001_initial")]),
496        ]);
497
498        assert_eq!(
499            codes(&validate_graph(&graph, &[applied("0002_next")])),
500            vec![ValidationCode::AppliedDependencyMissing]
501        );
502    }
503}