use std::fs;
use std::path::{Component, Path};
use crate::author::build::AuthoredMigration;
use crate::error::MigrationError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExistingArtifactPolicy {
ValidateIdentical,
Fail,
}
pub fn write_authored_migration(
migrations_dir: &Path,
authored: &AuthoredMigration,
policy: ExistingArtifactPolicy,
) -> crate::Result<()> {
for artifact in &authored.files {
validate_relative_path(&artifact.relative_path)?;
let path = migrations_dir.join(&artifact.relative_path);
if !path.exists() {
continue;
}
if artifact.relative_path == "snapshots/__init__.py" {
continue;
}
match policy {
ExistingArtifactPolicy::Fail => {
return Err(MigrationError::AuthoringInput {
message: format!(
"artifact {} already exists in {}",
artifact.relative_path,
migrations_dir.display()
),
});
}
ExistingArtifactPolicy::ValidateIdentical => {
let existing = fs::read(&path).map_err(|e| io_error(&path, &e))?;
if existing != artifact.contents {
return Err(MigrationError::AuthoringInput {
message: format!(
"artifact {} already exists with different contents; \
snapshots and checked artifacts are immutable",
artifact.relative_path
),
});
}
}
}
}
for artifact in &authored.files {
let path = migrations_dir.join(&artifact.relative_path);
if path.exists() {
continue;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| io_error(parent, &e))?;
}
fs::write(&path, &artifact.contents).map_err(|e| io_error(&path, &e))?;
}
Ok(())
}
fn validate_relative_path(relative_path: &str) -> crate::Result<()> {
let path = Path::new(relative_path);
let escapes = path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
if escapes {
return Err(MigrationError::AuthoringInput {
message: format!(
"artifact path {relative_path:?} must stay inside the migrations directory"
),
});
}
Ok(())
}
fn io_error(path: &Path, error: &std::io::Error) -> MigrationError {
MigrationError::Loader {
message: format!(
"failed writing authored migration at {}: {error}",
path.display()
),
}
}
#[cfg(test)]
mod tests {
use crate::author::build::AuthoredArtifact;
use crate::spec::MigrationSpec;
use super::*;
fn authored(files: Vec<(&str, &[u8])>) -> AuthoredMigration {
AuthoredMigration {
migration_name: "0001_initial".to_string(),
python_source: String::new(),
spec: MigrationSpec {
app_label: "migrations".to_string(),
name: "0001_initial".to_string(),
dependencies: vec![],
operations: vec![],
checksum: None,
source_sha256: None,
reversible: true,
},
files: files
.into_iter()
.map(|(path, contents)| AuthoredArtifact {
relative_path: path.to_string(),
contents: contents.to_vec(),
})
.collect(),
}
}
#[test]
fn writes_and_is_idempotent_under_validate_identical() {
let dir = tempfile::tempdir().expect("tempdir");
let migration = authored(vec![
("0001_initial.py", b"py".as_slice()),
("snapshots/__init__.py", b"# marker\n".as_slice()),
("snapshots/v0001/schema.tql", b"define".as_slice()),
]);
write_authored_migration(
dir.path(),
&migration,
ExistingArtifactPolicy::ValidateIdentical,
)
.expect("first write succeeds");
write_authored_migration(
dir.path(),
&migration,
ExistingArtifactPolicy::ValidateIdentical,
)
.expect("identical rewrite succeeds");
assert_eq!(
fs::read(dir.path().join("snapshots/v0001/schema.tql")).expect("file present"),
b"define"
);
}
#[test]
fn drifted_existing_artifact_blocks_the_whole_write() {
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(dir.path().join("snapshots/v0001")).expect("mkdir");
fs::write(dir.path().join("snapshots/v0001/schema.tql"), b"other").expect("seed");
let migration = authored(vec![
("0001_initial.py", b"py".as_slice()),
("snapshots/v0001/schema.tql", b"define".as_slice()),
]);
let error = write_authored_migration(
dir.path(),
&migration,
ExistingArtifactPolicy::ValidateIdentical,
)
.expect_err("drifted snapshot must collide");
assert!(matches!(error, MigrationError::AuthoringInput { .. }));
assert!(!dir.path().join("0001_initial.py").exists());
}
#[test]
fn fail_policy_rejects_any_existing_artifact() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("0001_initial.py"), b"py").expect("seed");
let migration = authored(vec![("0001_initial.py", b"py".as_slice())]);
let error = write_authored_migration(dir.path(), &migration, ExistingArtifactPolicy::Fail)
.expect_err("existing artifact must fail");
assert!(matches!(error, MigrationError::AuthoringInput { .. }));
}
#[test]
fn existing_snapshots_package_marker_is_left_untouched() {
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(dir.path().join("snapshots")).expect("mkdir");
fs::write(dir.path().join("snapshots/__init__.py"), b"# custom\n").expect("seed");
let migration = authored(vec![
("snapshots/__init__.py", b"# marker\n".as_slice()),
("snapshots/v0001/schema.tql", b"define".as_slice()),
]);
write_authored_migration(
dir.path(),
&migration,
ExistingArtifactPolicy::ValidateIdentical,
)
.expect("write succeeds");
assert_eq!(
fs::read(dir.path().join("snapshots/__init__.py")).expect("marker present"),
b"# custom\n"
);
}
#[test]
fn authored_output_passes_checked_loading_validation_and_planning() {
use std::collections::BTreeMap;
use type_bridge_orm::_schema::info::{AttributeSchemaEntry, EntitySchemaEntry, SchemaInfo};
use type_bridge_orm::ValueType;
use crate::author::build::{
AuthorMigrationRequest, MigrationMetadata, PositionedOperations, SnapshotContext,
author_migration,
};
use crate::checksum::migration_file_checksum;
use crate::graph::validate_graph;
use crate::plan::plan;
let mut target = SchemaInfo::default();
target.attributes.insert(
"name".to_string(),
AttributeSchemaEntry::new("name", ValueType::String),
);
target.entities.insert(
"person".to_string(),
EntitySchemaEntry {
type_name: "person".to_string(),
is_abstract: false,
parent_type: None,
owned_attributes: vec![],
plays_cardinalities: BTreeMap::new(),
doc: None,
meta: BTreeMap::new(),
},
);
let authored = author_migration(&AuthorMigrationRequest {
base: SchemaInfo::default(),
target,
metadata: MigrationMetadata {
app_label: "migrations".to_string(),
name: "0001_initial".to_string(),
dependencies: vec![],
generated_at: "2026-07-13T00:00:00+00:00".to_string(),
type_bridge_version: "1.5.7".to_string(),
type_bridge_core_version: "1.5.7".to_string(),
},
snapshot: SnapshotContext {
version: "v0001".to_string(),
previous_version: None,
},
extra_operations: PositionedOperations::default(),
attribute_renames: vec![],
})
.expect("authoring should succeed")
.expect("changes must author");
let dir = tempfile::tempdir().expect("tempdir");
write_authored_migration(
dir.path(),
&authored,
ExistingArtifactPolicy::ValidateIdentical,
)
.expect("write succeeds");
let graph = crate::loader::load_dir_checked(dir.path()).expect("checked load succeeds");
assert_eq!(graph.migrations.len(), 1);
assert_eq!(
graph.migrations[0].checksum.as_deref(),
Some(migration_file_checksum(&authored.python_source).as_str())
);
assert!(validate_graph(&graph, &[]).is_empty());
let execution_plan = plan(&graph, &[], None).expect("planning succeeds");
assert_eq!(execution_plan.to_apply.len(), 1);
assert_eq!(execution_plan.to_apply[0].steps.len(), 2);
}
#[test]
fn escaping_paths_are_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let migration = authored(vec![("../escape.py", b"py".as_slice())]);
let error = write_authored_migration(
dir.path(),
&migration,
ExistingArtifactPolicy::ValidateIdentical,
)
.expect_err("path escape must be rejected");
assert!(matches!(error, MigrationError::AuthoringInput { .. }));
}
}