Skip to main content

type_bridge_migration/
loader.rs

1//! Native Rust sidecar loader for migration files.
2//!
3//! Reads `NNNN_<name>.json` sidecar files that the generator writes beside
4//! the corresponding `NNNN_<name>.py` source files.  The sidecar carries the
5//! serde [`MigrationSpec`] produced from the same op list as the `.py` — so
6//! the Rust CLI can hydrate a [`MigrationGraph`] without importing Python.
7//!
8//! The loader is pure `std::fs` + `serde_json`; it opens no TypeDB
9//! transaction and has no dependency on `type_bridge_orm` (invariant 7).
10
11use std::path::{Path, PathBuf};
12
13use crate::checksum::migration_file_checksum;
14use crate::error::{MigrationError, Result};
15use crate::spec::{MigrationGraph, MigrationSpec};
16
17/// Load the sidecar spec for a given `.py` migration path.
18///
19/// Derives the sidecar path by replacing the `.py` extension with `.json`
20/// (same stem, sibling file).
21///
22/// - If the `.json` sibling **does not exist** → `Ok(None)`.  The caller
23///   should fall back to the trusted-import Python path for this file.
24/// - If it **exists** → read, deserialize, and return `Ok(Some(spec))`.
25/// - If it exists but is malformed → `Err(MigrationError::Loader { .. })`.
26///
27/// # Errors
28///
29/// Returns [`MigrationError::Loader`] when the sidecar exists but cannot be
30/// read or deserialized.
31pub fn load_sidecar(py_path: &Path) -> Result<Option<MigrationSpec>> {
32    let json_path = py_path.with_extension("json");
33    if !json_path.exists() {
34        return Ok(None);
35    }
36    let content = std::fs::read_to_string(&json_path).map_err(|err| MigrationError::Loader {
37        message: format!("failed to read sidecar {}: {err}", json_path.display()),
38    })?;
39    let spec: MigrationSpec =
40        serde_json::from_str(&content).map_err(|err| MigrationError::Loader {
41            message: format!("failed to parse sidecar {}: {err}", json_path.display()),
42        })?;
43    Ok(Some(spec))
44}
45
46/// Walk `dir` and load all `NNNN_*.json` sidecar files into a sorted
47/// [`MigrationGraph`].
48///
49/// Only files whose stems match the four-digit prefix pattern
50/// (`[0-9][0-9][0-9][0-9]_*`) and whose extension is `.json` are loaded.
51/// `.py` files and any other non-matching files are skipped.  The resulting
52/// [`MigrationGraph`] is sorted by file stem (lexicographic / discovery
53/// order), which matches Python's `discover()` sort.
54///
55/// This is the dir-native loader consumed by the Rust CLI (sub-plan 08).
56/// It does not invoke Python, does not `exec_module`, and opens no
57/// transaction.
58///
59/// # Errors
60///
61/// Returns [`MigrationError::Loader`] when the directory cannot be read or
62/// a matching sidecar file cannot be read or deserialized.
63pub fn load_dir(dir: &Path) -> Result<MigrationGraph> {
64    let read_dir = std::fs::read_dir(dir).map_err(|err| MigrationError::Loader {
65        message: format!("failed to read migrations dir {}: {err}", dir.display()),
66    })?;
67
68    let mut entries: Vec<(String, PathBuf)> = Vec::new();
69
70    for entry in read_dir {
71        let entry = entry.map_err(|err| MigrationError::Loader {
72            message: format!("failed to iterate migrations dir {}: {err}", dir.display()),
73        })?;
74        let path = entry.path();
75
76        // Only consider `.json` files.
77        if path.extension().and_then(|e| e.to_str()) != Some("json") {
78            continue;
79        }
80
81        // The stem must match `NNNN_*` (four digits then underscore).
82        let stem = match path.file_stem().and_then(|s| s.to_str()) {
83            Some(s) => s.to_owned(),
84            None => continue,
85        };
86
87        if !is_migration_stem(&stem) {
88            continue;
89        }
90
91        entries.push((stem, path));
92    }
93
94    // Sort by stem for stable discovery order.
95    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
96
97    let mut migrations = Vec::with_capacity(entries.len());
98    for (stem, path) in entries {
99        let content = std::fs::read_to_string(&path).map_err(|err| MigrationError::Loader {
100            message: format!("failed to read sidecar {}: {err}", path.display()),
101        })?;
102        let spec: MigrationSpec =
103            serde_json::from_str(&content).map_err(|err| MigrationError::Loader {
104                message: format!(
105                    "failed to parse sidecar {} (stem={stem}): {err}",
106                    path.display()
107                ),
108            })?;
109        migrations.push(spec);
110    }
111
112    Ok(MigrationGraph { migrations })
113}
114
115/// Walk `dir` and load all `NNNN_*.json` sidecar files into a sorted
116/// [`MigrationGraph`], then validate that each sidecar's embedded checksum
117/// agrees with the current `.py` text.
118///
119/// This is the **checked** variant of [`load_dir`], intended for use by the
120/// Rust CLI whenever it is the execution source.  The check guards against
121/// sidecar drift: if a developer hand-edits the `.py` after the sidecar was
122/// generated, the sidecar's `checksum` field will disagree with the fresh
123/// `.py` text, and this function returns an error rather than silently
124/// executing a stale sidecar.
125///
126/// # Invariant
127///
128/// The `.py` text is the sole checksum source (sub-plan 04/07 invariant).
129/// The sidecar carries a copy of that checksum so the drift guard can compare
130/// without importing Python; if the `.py` file is absent for a given sidecar
131/// the check is skipped (legacy sidecar-only migration; no `.py` to compare).
132///
133/// # Errors
134///
135/// Returns [`MigrationError::Loader`] when:
136/// - The directory or a sidecar cannot be read (same as [`load_dir`]).
137/// - A sidecar's embedded `checksum` disagrees with the recomputed `.py` text
138///   checksum — "sidecar drift: regenerate the migration" (D6 guard).
139pub fn load_dir_checked(dir: &Path) -> Result<MigrationGraph> {
140    let read_dir = std::fs::read_dir(dir).map_err(|err| MigrationError::Loader {
141        message: format!("failed to read migrations dir {}: {err}", dir.display()),
142    })?;
143
144    let mut entries: Vec<(String, PathBuf)> = Vec::new();
145
146    for entry in read_dir {
147        let entry = entry.map_err(|err| MigrationError::Loader {
148            message: format!("failed to iterate migrations dir {}: {err}", dir.display()),
149        })?;
150        let path = entry.path();
151
152        if path.extension().and_then(|e| e.to_str()) != Some("json") {
153            continue;
154        }
155
156        let stem = match path.file_stem().and_then(|s| s.to_str()) {
157            Some(s) => s.to_owned(),
158            None => continue,
159        };
160
161        if !is_migration_stem(&stem) {
162            continue;
163        }
164
165        entries.push((stem, path));
166    }
167
168    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
169
170    let mut migrations = Vec::with_capacity(entries.len());
171    for (stem, json_path) in entries {
172        let content =
173            std::fs::read_to_string(&json_path).map_err(|err| MigrationError::Loader {
174                message: format!("failed to read sidecar {}: {err}", json_path.display()),
175            })?;
176        let spec: MigrationSpec =
177            serde_json::from_str(&content).map_err(|err| MigrationError::Loader {
178                message: format!(
179                    "failed to parse sidecar {} (stem={stem}): {err}",
180                    json_path.display()
181                ),
182            })?;
183
184        // D6 drift guard: recompute the .py text checksum and compare to the
185        // sidecar's embedded value.  The .py text is the sole checksum source
186        // (04/07 invariant); the sidecar is a generated cache.  If the .py
187        // was hand-edited after the sidecar was written, the checksums will
188        // diverge and we reject the stale sidecar rather than executing it.
189        if let Some(sidecar_checksum) = &spec.checksum {
190            let py_path = json_path.with_extension("py");
191            if py_path.exists() {
192                let py_text =
193                    std::fs::read_to_string(&py_path).map_err(|err| MigrationError::Loader {
194                        message: format!(
195                            "failed to read .py for drift check {}: {err}",
196                            py_path.display()
197                        ),
198                    })?;
199                let computed = migration_file_checksum(&py_text);
200                if computed != *sidecar_checksum {
201                    return Err(MigrationError::Loader {
202                        message: format!(
203                            "sidecar drift detected for {stem}: the .py file has been \
204                             modified since the sidecar was generated \
205                             (sidecar checksum={sidecar_checksum}, \
206                             current .py checksum={computed}). \
207                             Regenerate the migration to sync the sidecar."
208                        ),
209                    });
210                }
211            }
212        }
213
214        migrations.push(spec);
215    }
216
217    Ok(MigrationGraph { migrations })
218}
219
220/// Return `true` if the stem matches the migration naming convention:
221/// four ASCII digits followed by an underscore and at least one more character.
222fn is_migration_stem(stem: &str) -> bool {
223    let bytes = stem.as_bytes();
224    if bytes.len() < 6 {
225        return false;
226    }
227    bytes[..4].iter().all(|b| b.is_ascii_digit()) && bytes[4] == b'_'
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::spec::{MigrationSpec, OperationSpec};
234
235    /// Build a minimal but valid `MigrationSpec` for testing.
236    fn make_spec(name: &str) -> MigrationSpec {
237        MigrationSpec {
238            app_label: "test_app".to_string(),
239            name: name.to_string(),
240            dependencies: vec![],
241            operations: vec![OperationSpec::RunTypeql {
242                forward: format!("define attribute {name}, value string;"),
243                reverse: None,
244            }],
245            checksum: Some("abc123".to_string()),
246            reversible: false,
247        }
248    }
249
250    /// Write a `MigrationSpec` to a `.json` file in `dir` using the given stem.
251    fn write_sidecar(dir: &Path, stem: &str, spec: &MigrationSpec) {
252        let json = serde_json::to_string(spec).unwrap();
253        std::fs::write(dir.join(format!("{stem}.json")), json).unwrap();
254    }
255
256    /// Write an empty `.py` file in `dir` using the given stem.
257    fn write_py(dir: &Path, stem: &str) {
258        std::fs::write(dir.join(format!("{stem}.py")), b"class Migration: pass\n").unwrap();
259    }
260
261    // ── load_sidecar ──────────────────────────────────────────────────────────
262
263    #[test]
264    fn load_sidecar_returns_some_for_valid_json() {
265        let tmp = tempfile::tempdir().unwrap();
266        let spec = make_spec("0001_initial");
267        write_sidecar(tmp.path(), "0001_initial", &spec);
268        write_py(tmp.path(), "0001_initial");
269
270        let py_path = tmp.path().join("0001_initial.py");
271        let result = load_sidecar(&py_path).unwrap();
272
273        assert_eq!(result, Some(spec));
274    }
275
276    #[test]
277    fn load_sidecar_returns_none_when_no_json_sibling() {
278        let tmp = tempfile::tempdir().unwrap();
279        write_py(tmp.path(), "0001_initial");
280
281        let py_path = tmp.path().join("0001_initial.py");
282        let result = load_sidecar(&py_path).unwrap();
283
284        assert_eq!(result, None);
285    }
286
287    #[test]
288    fn load_sidecar_returns_error_on_malformed_json() {
289        let tmp = tempfile::tempdir().unwrap();
290        std::fs::write(tmp.path().join("0001_initial.json"), b"{ not valid json }").unwrap();
291        write_py(tmp.path(), "0001_initial");
292
293        let py_path = tmp.path().join("0001_initial.py");
294        let result = load_sidecar(&py_path);
295
296        assert!(result.is_err(), "expected Err on malformed JSON");
297        let err = result.unwrap_err();
298        assert!(
299            matches!(err, MigrationError::Loader { .. }),
300            "expected MigrationError::Loader, got: {err:?}"
301        );
302    }
303
304    // ── load_dir ─────────────────────────────────────────────────────────────
305
306    #[test]
307    fn load_dir_loads_sidecars_and_skips_bare_py() {
308        let tmp = tempfile::tempdir().unwrap();
309
310        // Two sidecar-bearing migrations.
311        let spec1 = make_spec("0001_initial");
312        let spec2 = make_spec("0002_add_attr");
313        write_sidecar(tmp.path(), "0001_initial", &spec1);
314        write_py(tmp.path(), "0001_initial");
315        write_sidecar(tmp.path(), "0002_add_attr", &spec2);
316        write_py(tmp.path(), "0002_add_attr");
317
318        // One legacy .py with NO sidecar — must be skipped by load_dir.
319        write_py(tmp.path(), "0003_legacy");
320
321        let graph = load_dir(tmp.path()).unwrap();
322
323        assert_eq!(
324            graph.migrations.len(),
325            2,
326            "expected exactly two specs from the two sidecars"
327        );
328        assert_eq!(
329            graph.migrations[0], spec1,
330            "first spec should be 0001_initial"
331        );
332        assert_eq!(
333            graph.migrations[1], spec2,
334            "second spec should be 0002_add_attr"
335        );
336    }
337
338    #[test]
339    fn load_dir_sorts_by_stem() {
340        let tmp = tempfile::tempdir().unwrap();
341
342        // Write in reverse order to verify sort is applied.
343        let spec2 = make_spec("0002_b");
344        let spec1 = make_spec("0001_a");
345        write_sidecar(tmp.path(), "0002_b", &spec2);
346        write_sidecar(tmp.path(), "0001_a", &spec1);
347
348        let graph = load_dir(tmp.path()).unwrap();
349
350        assert_eq!(graph.migrations.len(), 2);
351        assert_eq!(graph.migrations[0].name, "0001_a");
352        assert_eq!(graph.migrations[1].name, "0002_b");
353    }
354
355    #[test]
356    fn load_dir_integration_smoke_sidecar_and_no_sidecar() {
357        // Integration smoke: dir with one sidecar-bearing .py+.json pair and one
358        // legacy .py-only file; load_dir returns MigrationGraph with exactly the
359        // one sidecar spec, confirming prefer-sidecar / fall-back-to-None seam
360        // for the pure-Rust (CLI) consumption path.
361        let tmp = tempfile::tempdir().unwrap();
362
363        let spec = make_spec("0001_initial");
364        write_sidecar(tmp.path(), "0001_initial", &spec);
365        write_py(tmp.path(), "0001_initial");
366
367        // Legacy: .py only, no sidecar.
368        write_py(tmp.path(), "0002_legacy");
369
370        let graph = load_dir(tmp.path()).unwrap();
371
372        assert_eq!(
373            graph.migrations.len(),
374            1,
375            "load_dir must load only JSON sidecars; the bare .py must not appear"
376        );
377        assert_eq!(graph.migrations[0], spec);
378    }
379
380    // ── load_dir_checked (D6 drift guard) ────────────────────────────────────
381
382    /// Build a `MigrationSpec` whose `checksum` is computed over a real `.py`
383    /// text body using `migration_file_checksum`, so the drift guard accepts it.
384    fn make_spec_with_real_checksum(name: &str, py_text: &str) -> MigrationSpec {
385        use crate::checksum::migration_file_checksum;
386        MigrationSpec {
387            app_label: "test_app".to_string(),
388            name: name.to_string(),
389            dependencies: vec![],
390            operations: vec![OperationSpec::RunTypeql {
391                forward: format!("define attribute {name}, value string;"),
392                reverse: None,
393            }],
394            checksum: Some(migration_file_checksum(py_text)),
395            reversible: false,
396        }
397    }
398
399    #[test]
400    fn load_dir_checked_accepts_matching_checksum() {
401        // A sidecar whose embedded checksum was computed from the same .py text
402        // that is on disk must be accepted by the drift guard.
403        let tmp = tempfile::tempdir().unwrap();
404        let py_text = "class Migration: pass\n";
405
406        let spec = make_spec_with_real_checksum("0001_initial", py_text);
407        write_sidecar(tmp.path(), "0001_initial", &spec);
408        std::fs::write(tmp.path().join("0001_initial.py"), py_text.as_bytes()).unwrap();
409
410        let graph = load_dir_checked(tmp.path()).unwrap();
411        assert_eq!(graph.migrations.len(), 1);
412        assert_eq!(graph.migrations[0].name, "0001_initial");
413    }
414
415    #[test]
416    fn load_dir_checked_rejects_stale_sidecar() {
417        // If the .py is hand-edited AFTER the sidecar was generated, the
418        // embedded checksum will disagree with the current .py text.  The
419        // drift guard must reject the sidecar rather than silently executing it.
420        let tmp = tempfile::tempdir().unwrap();
421        let original_py_text = "class Migration: pass\n";
422        let mutated_py_text = "class Migration: pass\n# hand-edited after sidecar generation\n";
423
424        // Sidecar checksum reflects the ORIGINAL .py text.
425        let spec = make_spec_with_real_checksum("0001_initial", original_py_text);
426        write_sidecar(tmp.path(), "0001_initial", &spec);
427
428        // Write the MUTATED .py text to disk — the sidecar is now stale.
429        std::fs::write(
430            tmp.path().join("0001_initial.py"),
431            mutated_py_text.as_bytes(),
432        )
433        .unwrap();
434
435        let result = load_dir_checked(tmp.path());
436        assert!(
437            result.is_err(),
438            "load_dir_checked must reject a stale sidecar"
439        );
440        let err = result.unwrap_err();
441        assert!(
442            matches!(err, MigrationError::Loader { .. }),
443            "expected MigrationError::Loader, got {err:?}"
444        );
445        // The error message must guide the developer to regenerate.
446        let msg = err.to_string();
447        assert!(
448            msg.contains("sidecar drift") || msg.contains("regenerate"),
449            "error message should mention sidecar drift or regenerate; got: {msg}"
450        );
451    }
452
453    #[test]
454    fn load_dir_checked_skips_drift_check_when_no_py_file() {
455        // When there is no .py file beside the sidecar (sidecar-only migration),
456        // the drift check is skipped — the sidecar is loaded as-is.
457        let tmp = tempfile::tempdir().unwrap();
458
459        // Write a sidecar with an arbitrary checksum; no .py companion.
460        let spec = make_spec("0001_initial");
461        write_sidecar(tmp.path(), "0001_initial", &spec);
462        // Deliberately do NOT write a .py file.
463
464        let graph = load_dir_checked(tmp.path()).unwrap();
465        assert_eq!(
466            graph.migrations.len(),
467            1,
468            "sidecar without .py companion must still be loaded"
469        );
470    }
471
472    #[test]
473    fn load_dir_checked_skips_drift_check_when_no_checksum_in_sidecar() {
474        // A sidecar with no `checksum` field cannot be drift-checked; it is
475        // accepted unconditionally (same policy as load_dir).
476        let tmp = tempfile::tempdir().unwrap();
477        let py_text = "class Migration: pass\n";
478
479        let mut spec = make_spec("0001_initial");
480        spec.checksum = None; // no checksum embedded
481        write_sidecar(tmp.path(), "0001_initial", &spec);
482        std::fs::write(tmp.path().join("0001_initial.py"), py_text.as_bytes()).unwrap();
483
484        let graph = load_dir_checked(tmp.path()).unwrap();
485        assert_eq!(graph.migrations.len(), 1);
486    }
487}