runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! `runtime release` — list / view / create.

use std::io::Write;

use anyhow::{anyhow, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};

use crate::client;
use crate::commands::issue::resolve_repo;
use crate::editor;
use crate::output::{render_record, render_table, FormatChoice};

#[derive(Debug, Subcommand)]
pub enum ReleaseCmd {
    List(ListArgs),
    View(ViewArgs),
    Create(CreateArgs),
}

#[derive(Debug, Args)]
pub struct ListArgs {
    pub repo: String,
    #[arg(long, default_value_t = false)]
    pub include_drafts: bool,
    #[arg(long, default_value_t = false)]
    pub json: bool,
    #[arg(long, default_value_t = false)]
    pub tsv: bool,
}

#[derive(Debug, Args)]
pub struct ViewArgs {
    pub repo: String,
    pub tag: String,
    #[arg(long, default_value_t = false)]
    pub json: bool,
    #[arg(long, default_value_t = false)]
    pub tsv: bool,
}

#[derive(Debug, Args)]
pub struct CreateArgs {
    pub repo: String,
    #[arg(long)]
    pub tag: String,
    #[arg(long)]
    pub name: String,
    #[arg(long)]
    pub body: Option<String>,
    #[arg(long, default_value_t = false)]
    pub draft: bool,
    #[arg(long, default_value_t = false)]
    pub prerelease: bool,
}

pub fn run(cmd: ReleaseCmd) -> Result<()> {
    match cmd {
        ReleaseCmd::List(a) => list(a),
        ReleaseCmd::View(a) => view(a),
        ReleaseCmd::Create(a) => create(a),
    }
}

fn list(args: ListArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
    let data = cli.execute(
        LIST_RELEASES,
        json!({ "id": repo_id, "drafts": args.include_drafts }),
    )?;
    let releases = data["repository"]["releases"]
        .as_array()
        .cloned()
        .unwrap_or_default();
    let rows = release_rows(&releases);
    let fmt = FormatChoice {
        json: args.json,
        tsv: args.tsv,
    }
    .resolve();
    std::io::stdout().lock().write_all(
        render_table(
            &["tag", "name", "draft", "prerelease", "created"],
            &rows,
            fmt,
        )
        .as_bytes(),
    )?;
    Ok(())
}

fn view(args: ViewArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
    let data = cli.execute(LIST_RELEASES, json!({ "id": repo_id, "drafts": true }))?;
    let releases = data["repository"]["releases"]
        .as_array()
        .cloned()
        .unwrap_or_default();
    let r = releases
        .iter()
        .find(|r| r["tagName"].as_str() == Some(&args.tag))
        .ok_or_else(|| anyhow!("no release tagged `{}`", args.tag))?;
    let pairs = release_record_pairs(r, &args.tag);
    let fmt = FormatChoice {
        json: args.json,
        tsv: args.tsv,
    }
    .resolve();
    std::io::stdout()
        .lock()
        .write_all(render_record(&pairs, fmt).as_bytes())?;
    Ok(())
}

fn create(args: CreateArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
    let body = match args.body {
        Some(b) => b,
        None => editor::compose("")?,
    };
    let data = cli.execute(
        CREATE_RELEASE,
        json!({
            "input": {
                "repositoryId": repo_id,
                "tagName": args.tag,
                "name": args.name,
                "body": body,
                "draft": args.draft,
                "prerelease": args.prerelease,
            }
        }),
    )?;
    let tag = data["createRelease"]["tagName"]
        .as_str()
        .unwrap_or(&args.tag)
        .to_string();
    writeln!(
        std::io::stdout().lock(),
        "Created release `{tag}` in {}",
        args.repo,
    )?;
    Ok(())
}

fn release_rows(releases: &[Value]) -> Vec<Vec<String>> {
    releases
        .iter()
        .map(|r| {
            vec![
                r["tagName"].as_str().unwrap_or("").to_string(),
                r["name"].as_str().unwrap_or("").to_string(),
                r["draft"].as_bool().unwrap_or(false).to_string(),
                r["prerelease"].as_bool().unwrap_or(false).to_string(),
                r["createdAt"].as_str().unwrap_or("").to_string(),
            ]
        })
        .collect()
}

fn release_record_pairs(release: &Value, tag: &str) -> Vec<(&'static str, String)> {
    vec![
        ("Tag", tag.to_string()),
        ("Name", release["name"].as_str().unwrap_or("").to_string()),
        (
            "Draft",
            release["draft"].as_bool().unwrap_or(false).to_string(),
        ),
        (
            "Prerelease",
            release["prerelease"].as_bool().unwrap_or(false).to_string(),
        ),
        (
            "Created",
            release["createdAt"].as_str().unwrap_or("").to_string(),
        ),
        ("Body", release["body"].as_str().unwrap_or("").to_string()),
    ]
}

const LIST_RELEASES: &str = "query($id: UUID!, $drafts: Boolean!) { \
    repository(id: $id) { \
        releases(includeDraft: $drafts) { \
            id tagName name body draft prerelease createdAt \
        } \
    } \
}";

const CREATE_RELEASE: &str = "mutation($input: CreateReleaseInput!) { \
    createRelease(input: $input) { id tagName } \
}";

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

    #[test]
    fn release_rows_format_flags_and_defaults() {
        let releases = vec![
            json!({
                "tagName": "v1.0.0",
                "name": "One",
                "draft": false,
                "prerelease": true,
                "createdAt": "2026-06-18T01:00:00Z"
            }),
            json!({}),
        ];

        assert_eq!(
            release_rows(&releases),
            vec![
                vec!["v1.0.0", "One", "false", "true", "2026-06-18T01:00:00Z"],
                vec!["", "", "false", "false", ""],
            ]
        );
    }

    #[test]
    fn release_record_pairs_use_requested_tag_and_release_fields() {
        let release = json!({
            "tagName": "ignored",
            "name": "Runtime 1.0",
            "draft": true,
            "prerelease": false,
            "createdAt": "2026-06-18T02:00:00Z",
            "body": "ship it"
        });

        assert_eq!(
            release_record_pairs(&release, "v1.0.0"),
            vec![
                ("Tag", "v1.0.0".to_string()),
                ("Name", "Runtime 1.0".to_string()),
                ("Draft", "true".to_string()),
                ("Prerelease", "false".to_string()),
                ("Created", "2026-06-18T02:00:00Z".to_string()),
                ("Body", "ship it".to_string()),
            ]
        );
    }
}