Skip to content
use std::{error::Error, fs, process::Command};

use super::new::{self, Bump};

pub fn run(requested_bump: Bump) -> Result<(), Box<dyn Error>> {
    let bump = match requested_bump {
        Bump::Auto => auto_bump()?,
        explicit => explicit,
    };
    let old_version = new::current_version()?;
    let mut version = old_version;
    version.bump(bump);
    let version = version.to_string();
    let docker_image = if std::path::Path::new("Dockerfile").is_file() {
        Some(docker_image_name()?)
    } else {
        None
    };

    // Match the Fig release task: bump first, build and push the image, then
    // commit all release changes and push the current branch.
    update_manifest_version(&version)?;
    if let Some(image) = &docker_image {
        run_command(
            "docker",
            &[
                "buildx",
                "build",
                "--provenance=false",
                "--push",
                "-t",
                &format!("{image}:{version}"),
                "-t",
                &format!("{image}:latest"),
                ".",
            ],
        )?;
    }
    run_command("git", &["add", "."])?;
    run_command(
        "git",
        &["commit", "-m", &format!("chore: release {version}")],
    )?;
    run_command("git", &["push"])?;

    if let Some(image) = docker_image {
        println!("bumped Cargo.toml to {version}; released {image}:{version}");
    } else {
        println!("bumped Cargo.toml to {version}; skipped Docker build (no Dockerfile found)");
    }
    Ok(())
}

fn docker_image_name() -> Result<String, Box<dyn Error>> {
    let dockerfile = fs::read_to_string("Dockerfile")?;
    remote_image_name(&dockerfile)
        .ok_or_else(|| "Dockerfile must define an image name with LABEL remote=<image>".into())
}

fn remote_image_name(dockerfile: &str) -> Option<String> {
    let mut remote = None;
    let mut instruction = String::new();

    for line in dockerfile.lines() {
        let line = line.trim();
        if instruction.is_empty() && (line.is_empty() || line.starts_with('#')) {
            continue;
        }

        let continued = line.ends_with('\\');
        instruction.push_str(line.trim_end_matches('\\'));
        instruction.push(' ');
        if continued {
            continue;
        }

        let mut tokens = instruction.split_whitespace();
        if tokens
            .next()
            .is_some_and(|directive| directive.eq_ignore_ascii_case("LABEL"))
        {
            for token in tokens {
                if let Some(("remote", value)) = token.split_once('=') {
                    remote = Some(value.trim_matches(['"', '\'']).to_owned());
                }
            }
        }
        instruction.clear();
    }

    remote.filter(|image: &String| !image.is_empty())
}

fn auto_bump() -> Result<Bump, Box<dyn Error>> {
    let repository = git2::Repository::discover(".")?;
    let commit = repository.head()?.peel_to_commit()?;
    Ok(infer_bump(commit.message().unwrap_or_default()))
}

fn infer_bump(message: &str) -> Bump {
    let (header, body) = message.split_once('\n').unwrap_or((message, ""));
    let header = header.trim();
    let prefix = header.split_once(':').map_or(header, |(prefix, _)| prefix);
    if prefix.ends_with('!')
        || body
            .lines()
            .any(|line| line.starts_with("BREAKING CHANGE:"))
    {
        return Bump::Major;
    }

    let kind = prefix
        .trim_end_matches('!')
        .split_once('(')
        .map_or(prefix.trim_end_matches('!'), |(kind, _)| kind);
    match kind {
        "feat" | "perf" => Bump::Minor,
        // An automatic release always advances the version. Fixes, chores,
        // and unrecognized commit styles default to patch.
        _ => Bump::Patch,
    }
}

fn update_manifest_version(version: &str) -> Result<(), Box<dyn Error>> {
    let path = std::path::Path::new("Cargo.toml");
    let manifest = fs::read_to_string(path)?;
    let mut in_package = false;
    let mut updated = false;
    let mut lines = Vec::new();

    for line in manifest.lines() {
        if line.trim() == "[package]" {
            in_package = true;
        } else if line.trim_start().starts_with('[') {
            in_package = false;
        }

        if in_package && line.trim_start().starts_with("version =") && !updated {
            let indentation = &line[..line.len() - line.trim_start().len()];
            lines.push(format!("{indentation}version = \"{version}\""));
            updated = true;
        } else {
            lines.push(line.to_owned());
        }
    }

    if !updated {
        return Err("Cargo.toml has no [package].version".into());
    }

    let mut contents = lines.join("\n");
    if manifest.ends_with('\n') {
        contents.push('\n');
    }
    let temporary_path = path.with_extension("toml.tmp");
    fs::write(&temporary_path, contents)?;
    fs::rename(temporary_path, path)?;
    Ok(())
}

fn run_command(program: &str, args: &[&str]) -> Result<(), Box<dyn Error>> {
    let status = Command::new(program).args(args).status()?;
    if !status.success() {
        return Err(format!("{program} exited with status {status}").into());
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{Bump, infer_bump, remote_image_name};

    #[test]
    fn automatic_bump_uses_conventional_commit_type() {
        assert_eq!(infer_bump("feat(ui): add a menu"), Bump::Minor);
        assert_eq!(infer_bump("perf: cache lookup"), Bump::Minor);
        assert_eq!(infer_bump("fix: correct parsing"), Bump::Patch);
        assert_eq!(infer_bump("chore: release 1.2.3"), Bump::Patch);
    }

    #[test]
    fn automatic_bump_detects_breaking_changes() {
        assert_eq!(infer_bump("feat!: remove old API"), Bump::Major);
        assert_eq!(
            infer_bump("feat: update API\n\nBREAKING CHANGE: callers must migrate"),
            Bump::Major
        );
    }

    #[test]
    fn docker_image_name_comes_from_remote_label() {
        assert_eq!(
            remote_image_name("FROM scratch\nLABEL remote=\"silenloc/fig\"\n").as_deref(),
            Some("silenloc/fig")
        );
        assert_eq!(
            remote_image_name("FROM scratch\nLABEL maintainer=team remote='silenloc/app'\n")
                .as_deref(),
            Some("silenloc/app")
        );
        assert_eq!(remote_image_name("FROM scratch\n"), None);
    }
}