simit 0.17.7

Semver-aware git commit helper for Rust projects
Documentation
use std::fs;
use std::path::PathBuf;
use std::process::Command;

use anyhow::{Context, Result, bail};

use crate::cargo;
use crate::cli::{WingetAction, WingetCommand, WingetSubmitArgs};
use crate::config::{ProjectConfig, WingetConfig};
use crate::registry::{self, FeatureStatus};

pub fn run(command: WingetCommand) -> Result<()> {
    match command.action {
        WingetAction::Submit(args) => submit(args),
    }
}

pub(crate) fn submit(args: WingetSubmitArgs) -> Result<()> {
    validate_version(&args.version)?;
    let resolved = resolve(&args)?;
    validate_download_repo(&resolved.download_repo)?;
    let url = args
        .url
        .clone()
        .unwrap_or_else(|| release_url(&resolved, &args.version));
    let token_env = args
        .token_env
        .as_deref()
        .unwrap_or(resolved.token_secret.as_str());
    if token_env.is_empty() {
        bail!("Winget token environment variable name is empty");
    }
    let token = std::env::var(token_env)
        .with_context(|| format!("reading Winget GitHub token from ${token_env}"))?;
    if token.is_empty() {
        bail!("Winget token environment variable ${token_env} is empty");
    }

    let wingetcreate = resolve_wingetcreate(&args)?;
    let command_display = format!(
        "{} {} update {} --version {} --urls {}|x64 --token ${} --submit",
        args.wine,
        wingetcreate.display(),
        resolved.package_id,
        args.version,
        url,
        token_env
    );
    if args.dry_run {
        println!("{command_display}");
        return Ok(());
    }

    let mut command = Command::new(&args.wine);
    command
        .arg(&wingetcreate)
        .arg("update")
        .arg(&resolved.package_id)
        .arg("--version")
        .arg(&args.version)
        .arg("--urls")
        .arg(format!("{url}|x64"))
        .arg("--token")
        .arg(token)
        .arg("--submit")
        .env("WINEDEBUG", "-all")
        .env("TERM", "xterm");
    if args.wingetcreate.is_none() {
        let wineprefix = wingetcreate
            .parent()
            .unwrap_or_else(|| std::path::Path::new("."))
            .join("wine");
        command.env("WINEPREFIX", wineprefix);
    }
    let status = command
        .status()
        .with_context(|| format!("running {}", args.wine))?;
    if !status.success() {
        bail!("wingetcreate update failed");
    }
    registry::touch_current_project_or_warn([("winget", FeatureStatus::Managed)]);
    Ok(())
}

fn resolve_wingetcreate(args: &WingetSubmitArgs) -> Result<PathBuf> {
    if let Some(path) = &args.wingetcreate {
        return Ok(path.as_std_path().to_path_buf());
    }
    let work_dir = args
        .work_dir
        .as_ref()
        .map(|path| path.as_std_path().to_path_buf())
        .unwrap_or_else(|| PathBuf::from("target/simit-winget"));
    if args.dry_run {
        return Ok(work_dir.join("wingetcreate.exe"));
    }
    fs::create_dir_all(&work_dir).with_context(|| format!("creating {}", work_dir.display()))?;
    let wingetcreate = work_dir.join("wingetcreate.exe");
    if wingetcreate.is_file() {
        return Ok(wingetcreate);
    }
    let url = match &args.wingetcreate_url {
        Some(url) => url.clone(),
        None => latest_wingetcreate_url()?,
    };
    let status = Command::new("curl")
        .arg("-fsSL")
        .arg("-o")
        .arg(&wingetcreate)
        .arg(&url)
        .status()
        .with_context(|| "downloading wingetcreate.exe with curl")?;
    if !status.success() {
        bail!("downloading wingetcreate.exe failed");
    }
    Ok(wingetcreate)
}

fn latest_wingetcreate_url() -> Result<String> {
    let output = Command::new("curl")
        .arg("-fsSL")
        .arg("https://api.github.com/repos/microsoft/winget-create/releases/latest")
        .output()
        .with_context(|| "fetching latest winget-create release with curl")?;
    if !output.status.success() {
        bail!("fetching latest winget-create release failed");
    }
    let json: serde_json::Value =
        serde_json::from_slice(&output.stdout).context("parsing winget-create release JSON")?;
    json.get("assets")
        .and_then(|assets| assets.as_array())
        .and_then(|assets| {
            assets.iter().find_map(|asset| {
                (asset.get("name").and_then(|name| name.as_str()) == Some("wingetcreate.exe"))
                    .then(|| {
                        asset
                            .get("browser_download_url")
                            .and_then(|url| url.as_str())
                    })
                    .flatten()
            })
        })
        .map(str::to_owned)
        .context("latest winget-create release did not contain wingetcreate.exe")
}

fn resolve(args: &WingetSubmitArgs) -> Result<WingetConfig> {
    let metadata = cargo::metadata_for_current_dir()?;
    let workspace_root = metadata.workspace_root.as_std_path();
    let cfg = ProjectConfig::load(workspace_root)?;
    let configured = cfg.winget.as_ref();
    let package_id = args
        .package_id
        .clone()
        .or_else(|| configured.map(|winget| winget.package_id.clone()))
        .context("winget.package_id not set; provide --package-id or [winget].package_id")?;
    let download_repo = args
        .download_repo
        .clone()
        .or_else(|| configured.map(|winget| winget.download_repo.clone()))
        .context(
            "winget.download_repo not set; provide --download-repo or [winget].download_repo",
        )?;
    let zip_archive = args
        .zip_archive
        .clone()
        .or_else(|| configured.map(|winget| winget.zip_archive.clone()))
        .context("winget.zip_archive not set; provide --zip-archive or [winget].zip_archive")?;
    let token_secret = args
        .token_env
        .clone()
        .or_else(|| configured.map(|winget| winget.token_secret.clone()))
        .unwrap_or_else(|| "WINGET_PAT".to_owned());
    Ok(WingetConfig {
        package_id,
        download_repo,
        zip_archive,
        token_secret,
    })
}

fn release_url(resolved: &WingetConfig, version: &str) -> String {
    let zip = resolved.zip_archive.replace("{version}", version);
    format!(
        "https://codeberg.org/{}/releases/download/{}/{}",
        resolved.download_repo, version, zip
    )
}

fn validate_version(version: &str) -> Result<()> {
    if version.starts_with('v')
        || version.is_empty()
        || !version
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '+' | '~' | '_' | '-'))
    {
        bail!("version must match [0-9A-Za-z.+~_-]+ without a leading v, got: {version}");
    }
    Ok(())
}

fn validate_download_repo(value: &str) -> Result<()> {
    if value.split('/').count() != 2 || value.split('/').any(str::is_empty) {
        bail!("winget.download_repo must be OWNER/REPO");
    }
    Ok(())
}