forklaunch 1.34.0

Launch faster with forklaunch
//! Admit lifecycle replacement only when its source still matches generated defaults.
use super::*;

const LIFECYCLE_FILES: &[&str] = &[
    "lib.rs", "main.rs", "build.rs", "build_assets.rs", "application.rs", "config.rs", "error.rs", "domain/mod.rs",
    "domain/types/mod.rs", "domain/types/error.rs", "domain/services/mod.rs",
    "persistence/mod.rs", "persistence/compliance_schema.rs", "bootstrapper.rs",
    "registrations.rs", "server.rs", "worker.rs", "signals.rs", "tests/smoke.rs",
    "__test__/http.rs", "Makefile", ".env.example", "domain/Cargo.toml",
];

pub(super) fn replaces(relative: &str) -> bool { LIFECYCLE_FILES.contains(&relative) }

pub(super) fn preflight(root: &Path, name: &str, kind: RustProjectKind) -> Result<()> {
    let manifest = read_manifest_value(&root.join(".forklaunch/manifest.toml"))?;
    let project = project_table(&manifest, name).context("Conversion project missing")?;
    let directory = root.join(crate_path_for_project(&manifest, name).context("Conversion crate path missing")?);
    anyhow::ensure!(directory.join("lib.rs").is_file(), "Legacy Rust project conversion requires an explicit lifecycle migration before changing kind/backend");
    let description = cargo_description(&directory.join("Cargo.toml"))?.unwrap_or_default();
    let templates = match kind {
        RustProjectKind::Service => {
            let templates = native_layout::service_templates(&directory, name, &description);
            if project.get("metadata").and_then(|m| m.get("persistence")).and_then(Value::as_str) == Some("postgresql") {
                service_database::templates(&directory, name, templates)?
            } else { templates }
        }
        RustProjectKind::Worker if directory.join("worker_hooks.rs").exists() || directory.join("consumer/worker_hooks.rs").exists() => {
            bail!("Apalis runtime changes require an explicit queue/lifecycle migration; existing application hooks are preserved");
        }
        RustProjectKind::Worker => {
            anyhow::ensure!(project.get("metadata").and_then(|m| m.get("workerService")).and_then(Value::as_bool) != Some(true),
                "Worker producer changes require an explicit queue/process migration; existing HTTP contracts are preserved");
            let templates = if let Some(backend) = worker::backend(root, name)? {
                worker::templates(root, &directory, name, &description, backend)?
            } else { rust_crate_templates(&directory, name, &description, kind) };
            let templates = native_layout::flatten(&directory, name, kind, templates);
            if contracts_layout::directory(&directory).is_some() {
                worker::consumer_contracts(&directory, name, templates)?
            } else { templates }
        }
        RustProjectKind::Library => native_layout::flatten(&directory, name, kind, rust_crate_templates(&directory, name, &description, kind)),
    };
    let templates = native_mappers::templates(&directory, directory.join("domain/mappers/health.rs").is_file(), templates)?;
    let templates = native_layout::framework_paths(root, templates);
    let cargo_template = templates.iter().find(|template| template.path == directory.join("Cargo.toml"))
        .context("Conversion Cargo template missing")?;
    let mut expected_cargo: Value = toml::from_str(&dependencies::render_for_path(&cargo_template.path, &cargo_template.content)?)?;
    let mut actual_cargo: Value = toml::from_str(&read_to_string(&cargo_template.path)?)?;
    for cargo in [&mut expected_cargo, &mut actual_cargo] {
        if let Some(package) = cargo.get_mut("package").and_then(Value::as_table_mut) { package.remove("metadata"); }
    }
    // Extra runtime dependencies (including automatically linked local libraries)
    // are explicitly retained by apply_conversion. Other customized manifest
    // sections need a deliberate migration rather than being silently replaced.
    if let Some(dependencies) = actual_cargo.get_mut("dependencies").and_then(Value::as_table_mut) {
        dependencies.retain(|name, _| expected_cargo.get("dependencies").and_then(Value::as_table).is_some_and(|known| known.contains_key(name)));
    }
    anyhow::ensure!(actual_cargo == expected_cargo,
        "Conversion would overwrite customized lifecycle manifest {}; migrate it explicitly before changing kind/backend", cargo_template.path.display());
    for relative in LIFECYCLE_FILES {
        let path = directory.join(relative);
        anyhow::ensure!(!path.exists() || templates.iter().any(|template| template.path == path),
            "Conversion would overwrite unrecognized lifecycle file {}; migrate it explicitly before changing kind/backend", path.display());
    }
    for template in templates {
        let Ok(relative) = template.path.strip_prefix(&directory) else { continue; };
        let relative = relative.to_string_lossy();
        if !replaces(&relative) || !template.path.exists() { continue; }
        anyhow::ensure!(!template.path.is_symlink(), "Conversion would replace symlinked lifecycle file {}; migrate it explicitly", template.path.display());
        let expected = dependencies::render_for_path(&template.path, &template.content)?;
        let marked = crate::core::watermark::apply_watermark(&RenderedTemplate { content: expected.clone(), ..template.clone() })?;
        let actual = read_to_string(&template.path)?;
        let normalized = native_layout::conversion_source(&template.path, &actual);
        anyhow::ensure!(
            [expected, marked].iter().any(|source| native_layout::conversion_source(&template.path, source) == normalized),
            "Conversion would overwrite customized lifecycle file {}; migrate it explicitly before changing kind/backend", template.path.display()
        );
    }
    Ok(())
}