fission-command-release 0.6.1

Release content, store, auth, beta, and review workflows for the fission command
Documentation
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;

#[derive(Debug, Deserialize, Default)]
struct WorkflowRootToml {
    #[serde(default)]
    release_workflows: BTreeMap<String, ReleaseWorkflowToml>,
}

#[derive(Debug, Deserialize, Serialize, Default)]
struct ReleaseWorkflowToml {
    #[serde(default)]
    commands: Vec<String>,
}

#[derive(Debug, Serialize)]
struct WorkflowReceipt {
    schema_version: u32,
    workflow: String,
    status: String,
    dry_run: bool,
    commands: Vec<WorkflowCommandReceipt>,
}

#[derive(Debug, Serialize)]
struct WorkflowCommandReceipt {
    index: usize,
    command: String,
    argv: Vec<String>,
    status: String,
    exit_code: Option<i32>,
}

pub(super) fn list(project_dir: &Path, json: bool) -> Result<()> {
    let workflows = read_workflows(project_dir)?;
    if json {
        println!(
            "{}",
            serde_json::to_string_pretty(&workflows.release_workflows)?
        );
    } else if workflows.release_workflows.is_empty() {
        println!("No [release_workflows.<name>] entries configured");
    } else {
        for (name, workflow) in workflows.release_workflows {
            println!("{name}: {} command(s)", workflow.commands.len());
            for command in workflow.commands {
                println!("  {command}");
            }
        }
    }
    Ok(())
}

pub(super) fn run(project_dir: &Path, name: &str, dry_run: bool, json: bool) -> Result<()> {
    let workflows = read_workflows(project_dir)?;
    let workflow = workflows
        .release_workflows
        .get(name)
        .with_context(|| format!("release workflow `{name}` is not configured"))?;
    if workflow.commands.is_empty() {
        bail!("release workflow `{name}` has no commands");
    }
    let exe = env::current_exe().context("failed to resolve current fission executable")?;
    let mut receipt = WorkflowReceipt {
        schema_version: 1,
        workflow: name.to_string(),
        status: "passed".to_string(),
        dry_run,
        commands: Vec::new(),
    };
    for (index, command) in workflow.commands.iter().enumerate() {
        let mut argv = split_command(command)?;
        if argv.is_empty() {
            continue;
        }
        if !has_project_dir(&argv) {
            argv.push("--project-dir".to_string());
            argv.push(project_dir.display().to_string());
        }
        if dry_run {
            receipt.commands.push(WorkflowCommandReceipt {
                index,
                command: command.clone(),
                argv,
                status: "dry-run".to_string(),
                exit_code: None,
            });
            continue;
        }
        let status = Command::new(&exe)
            .args(&argv)
            .status()
            .with_context(|| format!("failed to run release workflow command `{command}`"))?;
        let success = status.success();
        receipt.commands.push(WorkflowCommandReceipt {
            index,
            command: command.clone(),
            argv,
            status: if success { "passed" } else { "failed" }.to_string(),
            exit_code: status.code(),
        });
        if !success {
            receipt.status = "failed".to_string();
            write_workflow_receipt(project_dir, &receipt)?;
            if json {
                println!("{}", serde_json::to_string_pretty(&receipt)?);
            }
            bail!("release workflow `{name}` failed at command {}", index + 1);
        }
    }
    write_workflow_receipt(project_dir, &receipt)?;
    if json {
        println!("{}", serde_json::to_string_pretty(&receipt)?);
    } else if dry_run {
        println!(
            "Release workflow `{name}` dry run: {} command(s)",
            receipt.commands.len()
        );
        for command in &receipt.commands {
            println!("  {}", command.argv.join(" "));
        }
    } else {
        println!("Release workflow `{name}` completed");
    }
    Ok(())
}

fn read_workflows(project_dir: &Path) -> Result<WorkflowRootToml> {
    let path = project_dir.join("fission.toml");
    let text =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
}

fn write_workflow_receipt(project_dir: &Path, receipt: &WorkflowReceipt) -> Result<()> {
    let path = project_dir
        .join("target/fission/release-workflows")
        .join(format!("{}.json", receipt.workflow));
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, serde_json::to_vec_pretty(receipt)?)
        .with_context(|| format!("failed to write {}", path.display()))
}

fn has_project_dir(argv: &[String]) -> bool {
    argv.iter()
        .any(|arg| arg == "--project-dir" || arg.starts_with("--project-dir="))
}

fn split_command(command: &str) -> Result<Vec<String>> {
    let mut args = Vec::new();
    let mut current = String::new();
    let mut chars = command.chars().peekable();
    let mut quote = None;
    while let Some(ch) = chars.next() {
        match (quote, ch) {
            (Some(q), c) if c == q => quote = None,
            (Some(_), '\\') => {
                if let Some(next) = chars.next() {
                    current.push(next);
                }
            }
            (Some(_), c) => current.push(c),
            (None, '\'' | '"') => quote = Some(ch),
            (None, c) if c.is_whitespace() => {
                if !current.is_empty() {
                    args.push(std::mem::take(&mut current));
                }
            }
            (None, c) => current.push(c),
        }
    }
    if let Some(q) = quote {
        bail!("unterminated {q} quote in workflow command `{command}`");
    }
    if !current.is_empty() {
        args.push(current);
    }
    Ok(args)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn split_command_keeps_quoted_values() {
        let args =
            split_command("release-config push --provider play-store --locales 'en-US,fr-FR'")
                .unwrap();
        assert_eq!(
            args,
            vec![
                "release-config",
                "push",
                "--provider",
                "play-store",
                "--locales",
                "en-US,fr-FR"
            ]
        );
    }

    #[test]
    fn project_dir_detection_accepts_split_or_equals() {
        assert!(has_project_dir(&[
            "--project-dir".to_string(),
            ".".to_string()
        ]));
        assert!(has_project_dir(&["--project-dir=.".to_string()]));
        assert!(!has_project_dir(&["release-config".to_string()]));
    }
}