1use std::path::{Path, PathBuf};
12
13use crate::checksum::migration_file_checksum;
14use crate::error::{MigrationError, Result};
15use crate::spec::{MigrationGraph, MigrationSpec};
16
17pub 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
46pub 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 if path.extension().and_then(|e| e.to_str()) != Some("json") {
78 continue;
79 }
80
81 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 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
115pub 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 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
220fn 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 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 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 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 #[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 #[test]
307 fn load_dir_loads_sidecars_and_skips_bare_py() {
308 let tmp = tempfile::tempdir().unwrap();
309
310 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 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 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 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 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 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 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 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 let spec = make_spec_with_real_checksum("0001_initial", original_py_text);
426 write_sidecar(tmp.path(), "0001_initial", &spec);
427
428 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 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 let tmp = tempfile::tempdir().unwrap();
458
459 let spec = make_spec("0001_initial");
461 write_sidecar(tmp.path(), "0001_initial", &spec);
462 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 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; 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}