forjar 1.4.2

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! FJ-3604..FJ-3606: Nix flake, GitHub Action, Debian, RPM generators + helpers.

use crate::core::types::DistConfig;
use std::path::Path;

// ── FJ-3604: Nix Flake ──

pub fn generate_nix(dist: &DistConfig) -> String {
    let desc = if dist.description.is_empty() {
        &dist.binary
    } else {
        &dist.description
    };

    let mut system_blocks = String::new();
    for t in &dist.targets {
        // Skip musl for Nix (uses system libc)
        if t.libc.as_deref() == Some("musl") {
            continue;
        }
        let nix_system = match (t.os.as_str(), t.arch.as_str()) {
            ("linux", "x86_64") => "x86_64-linux",
            ("linux", "aarch64") => "aarch64-linux",
            ("darwin", "x86_64") => "x86_64-darwin",
            ("darwin", "aarch64") => "aarch64-darwin",
            _ => continue,
        };
        let url = format!(
            "https://github.com/{}/releases/download/v${{version}}/{}",
            dist.repo,
            t.asset.replace("{version}", "${version}")
        );
        system_blocks.push_str(&format!(
            r#"          "{nix_system}" = pkgs.fetchurl {{
            url = "{url}";
            sha256 = "PLACEHOLDER_CHECKSUM";
          }};
"#
        ));
    }

    format!(
        r#"# Generated by forjar dist (do not edit)
{{
  description = "{desc}";

  inputs = {{
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  }};

  outputs = {{ self, nixpkgs, flake-utils }}:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${{system}};
        version = "VERSION";
        src = {{
{system_blocks}        }}.${{system}} or (throw "unsupported system: ${{system}}");
      in {{
        packages.default = pkgs.stdenv.mkDerivation {{
          pname = "{binary}";
          inherit version;
          inherit src;
          sourceRoot = ".";
          unpackPhase = "tar xzf $src";
          installPhase = ''
            mkdir -p $out/bin
            cp {binary} $out/bin/
          '';
        }};
      }}
    );
}}
"#,
        binary = dist.binary,
    )
}

// ── FJ-3605: GitHub Actions ──

pub fn generate_github_action(dist: &DistConfig) -> String {
    let desc = if dist.description.is_empty() {
        format!("Install {} CLI", dist.binary)
    } else {
        format!("Install {}", dist.description)
    };

    // Build arch→target mapping for the action
    let mut target_cases = String::new();
    for t in &dist.targets {
        if t.os != "linux" {
            continue;
        }
        // Prefer gnu over musl for GHA (Ubuntu runners)
        if t.libc.as_deref() == Some("musl") {
            continue;
        }
        let rust_triple = to_rust_triple(t);
        let arch_pattern = match t.arch.as_str() {
            "x86_64" => "x86_64",
            "aarch64" => "aarch64",
            _ => continue,
        };
        target_cases.push_str(&format!(
            r#"          {arch_pattern})  TARGET="{rust_triple}" ;;
"#
        ));
    }

    format!(
        r#"# Generated by forjar dist (do not edit)
name: Setup {binary}
description: "{desc}"
inputs:
  version:
    description: "Version to install (default: latest)"
    required: false
    default: "latest"
runs:
  using: composite
  steps:
    - name: Install {binary}
      shell: bash
      run: |
        VERSION="${{{{ inputs.version }}}}"
        if [ "$VERSION" = "latest" ]; then
          VERSION=$(curl -sSf https://api.github.com/repos/{repo}/releases/latest | grep tag_name | cut -d'"' -f4)
        fi
        ARCH=$(uname -m)
        case "$ARCH" in
{target_cases}          *) echo "Unsupported architecture: $ARCH"; exit 1 ;;
        esac
        VERSION_NUM="${{VERSION#v}}"
        curl -sSfL "https://github.com/{repo}/releases/download/${{VERSION}}/{binary}-${{VERSION_NUM}}-${{TARGET}}.tar.gz" | tar xz
        sudo mv {binary} /usr/local/bin/
        {binary} --version
"#,
        binary = dist.binary,
        repo = dist.repo,
    )
}

// ── FJ-3606: Debian Package ──

pub fn generate_deb(dist: &DistConfig, dir: &Path) -> Result<(), String> {
    std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;

    let control = format!(
        r#"Package: {binary}
Version: VERSION-1
Section: utils
Priority: optional
Architecture: amd64
Maintainer: {maintainer}
Description: {description}
Homepage: {homepage}
"#,
        binary = dist.binary,
        maintainer = if dist.maintainer.is_empty() {
            "Unknown"
        } else {
            &dist.maintainer
        },
        description = if dist.description.is_empty() {
            &dist.binary
        } else {
            &dist.description
        },
        homepage = dist.homepage,
    );
    write_artifact(&dir.join("control"), &control)?;

    let install = format!("{} usr/local/bin\n", dist.binary);
    write_artifact(&dir.join("install"), &install)?;

    let copyright = format!(
        r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: {binary}
Source: {homepage}

Files: *
Copyright: {maintainer}
License: {license}
"#,
        binary = dist.binary,
        homepage = dist.homepage,
        maintainer = if dist.maintainer.is_empty() {
            "Unknown"
        } else {
            &dist.maintainer
        },
        license = if dist.license.is_empty() {
            "MIT"
        } else {
            &dist.license
        },
    );
    write_artifact(&dir.join("copyright"), &copyright)?;

    let rules = r#"#!/usr/bin/make -f
%:
	dh $@

override_dh_auto_build:
	@true
"#;
    write_artifact(&dir.join("rules"), rules)?;

    Ok(())
}

// ── FJ-3606: RPM Spec ──

pub fn generate_rpm(dist: &DistConfig) -> String {
    // Pick the first linux/x86_64/gnu target for Source0
    let source_asset = dist
        .targets
        .iter()
        .find(|t| t.os == "linux" && t.arch == "x86_64" && t.libc.as_deref() != Some("musl"))
        .map(|t| t.asset.as_str())
        .unwrap_or("BINARY.tar.gz");

    format!(
        r#"# Generated by forjar dist (do not edit)
Name:    {binary}
Version: VERSION
Release: 1
Summary: {description}
License: {license}
URL:     {homepage}
Source0: {source_asset}

%description
{description}

%install
mkdir -p %{{buildroot}}/usr/local/bin
cp {binary} %{{buildroot}}/usr/local/bin/

%files
/usr/local/bin/{binary}
"#,
        binary = dist.binary,
        description = if dist.description.is_empty() {
            &dist.binary
        } else {
            &dist.description
        },
        license = if dist.license.is_empty() {
            "MIT"
        } else {
            &dist.license
        },
        homepage = dist.homepage,
        source_asset = source_asset,
    )
}

// ── Helpers ──

/// Write content to a file, creating parent directories.
pub fn write_artifact(path: &Path, content: &str) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
    }
    std::fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
}

/// Convert binary name to Ruby class name (e.g., "my-tool" → "MyTool").
pub fn to_class_name(name: &str) -> String {
    name.split('-')
        .map(|part| {
            let mut chars = part.chars();
            match chars.next() {
                Some(c) => {
                    let upper: String = c.to_uppercase().collect();
                    format!("{}{}", upper, chars.collect::<String>())
                }
                None => String::new(),
            }
        })
        .collect()
}

/// Convert a DistBinaryTarget to a Rust target triple.
pub fn to_rust_triple(t: &crate::core::types::DistBinaryTarget) -> String {
    let os_part = match t.os.as_str() {
        "darwin" => "apple-darwin",
        "linux" => {
            let libc = t.libc.as_deref().unwrap_or("gnu");
            &format!("unknown-linux-{libc}")
        }
        other => other,
    };
    // Leak is fine for static-ish format strings in a CLI
    format!("{}-{}", t.arch, os_part)
}