runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! `runtime run` — action-run listing / view / logs / cancel.

use std::io::Write;
use std::thread::sleep;
use std::time::Duration;

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

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

#[derive(Debug, Subcommand)]
pub enum RunCmd {
    /// List action runs for a repository.
    List(ListArgs),
    /// View a single run.
    View(ViewArgs),
    /// Print the run's log buffer.
    Logs(LogsArgs),
    /// Cancel a queued or running action run.
    Cancel(IdArgs),
}

#[derive(Debug, Args)]
pub struct ListArgs {
    pub repo: String,
    #[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 id: String,
    #[arg(long, default_value_t = false)]
    pub json: bool,
    #[arg(long, default_value_t = false)]
    pub tsv: bool,
}

#[derive(Debug, Args)]
pub struct LogsArgs {
    pub id: String,
    /// Poll for new log content until the run completes.
    #[arg(long, default_value_t = false)]
    pub follow: bool,
}

#[derive(Debug, Args)]
pub struct IdArgs {
    pub id: String,
}

pub fn run(cmd: RunCmd) -> Result<()> {
    match cmd {
        RunCmd::List(a) => list(a),
        RunCmd::View(a) => view(a),
        RunCmd::Logs(a) => logs(a),
        RunCmd::Cancel(a) => cancel(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_RUNS, json!({ "id": repo_id }))?;
    let runs = data["actionRuns"].as_array().cloned().unwrap_or_default();
    let rows = run_rows(&runs);
    let fmt = FormatChoice {
        json: args.json,
        tsv: args.tsv,
    }
    .resolve();
    let out = render_table(&["id", "status", "workflow", "job", "started"], &rows, fmt);
    std::io::stdout().lock().write_all(out.as_bytes())?;
    Ok(())
}

fn view(args: ViewArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    let data = cli.execute(VIEW_RUN, json!({ "id": args.id }))?;
    let r = &data["actionRun"];
    if r.is_null() {
        return Err(anyhow!("no run with id {}", args.id));
    }
    let pairs = run_record_pairs(r, &args.id);
    let fmt = FormatChoice {
        json: args.json,
        tsv: args.tsv,
    }
    .resolve();
    std::io::stdout()
        .lock()
        .write_all(render_record(&pairs, fmt).as_bytes())?;
    Ok(())
}

fn logs(args: LogsArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    let mut printed: usize = 0;
    loop {
        let data = cli.execute(LOG_RUN, json!({ "id": args.id }))?;
        let buf = data["actionRun"]["log"].as_str().unwrap_or("");
        let status = data["actionRun"]["status"]
            .as_str()
            .unwrap_or("")
            .to_string();
        if let Some(chunk) = next_log_chunk(buf, printed) {
            std::io::stdout().lock().write_all(chunk.as_bytes())?;
            printed = buf.len();
        }
        if !args.follow {
            break;
        }
        if is_terminal_log_status(&status) {
            break;
        }
        sleep(Duration::from_millis(750));
    }
    Ok(())
}

fn cancel(args: IdArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    cli.execute(CANCEL_RUN, json!({ "id": args.id }))?;
    writeln!(std::io::stdout().lock(), "Cancelled run {}", args.id)?;
    Ok(())
}

fn is_terminal_log_status(status: &str) -> bool {
    matches!(
        status,
        "SUCCESS" | "SUCCEEDED" | "FAILURE" | "FAILED" | "CANCELLED" | "CANCELED" | "SKIPPED"
    )
}

fn run_rows(runs: &[Value]) -> Vec<Vec<String>> {
    runs.iter()
        .map(|r| {
            vec![
                r["id"].as_str().unwrap_or("").to_string(),
                r["status"].as_str().unwrap_or("").to_string(),
                r["workflowName"].as_str().unwrap_or("").to_string(),
                r["jobName"].as_str().unwrap_or("").to_string(),
                r["startedAt"].as_str().unwrap_or("").to_string(),
            ]
        })
        .collect()
}

fn run_record_pairs(run: &Value, id: &str) -> Vec<(&'static str, String)> {
    vec![
        ("Run", id.to_string()),
        ("Status", run["status"].as_str().unwrap_or("").to_string()),
        (
            "Workflow",
            run["workflowName"].as_str().unwrap_or("").to_string(),
        ),
        ("Job", run["jobName"].as_str().unwrap_or("").to_string()),
        (
            "Commit",
            run["commitOid"].as_str().unwrap_or("").to_string(),
        ),
        (
            "Triggered by",
            run["triggeredByUsername"]
                .as_str()
                .unwrap_or("")
                .to_string(),
        ),
        ("Queued", run["queuedAt"].as_str().unwrap_or("").to_string()),
        (
            "Started",
            run["startedAt"].as_str().unwrap_or("").to_string(),
        ),
        (
            "Completed",
            run["completedAt"].as_str().unwrap_or("").to_string(),
        ),
    ]
}

fn next_log_chunk(buf: &str, printed: usize) -> Option<&str> {
    (buf.len() > printed).then(|| &buf[printed..])
}

const LIST_RUNS: &str = "query($id: UUID!) { \
    actionRuns(repositoryId: $id) { \
        id status workflowName jobName startedAt completedAt \
    } \
}";

const VIEW_RUN: &str = "query($id: UUID!) { \
    actionRun(id: $id) { \
        id status workflowName jobName commitOid triggeredByUsername \
        queuedAt startedAt completedAt \
    } \
}";

const LOG_RUN: &str = "query($id: UUID!) { actionRun(id: $id) { status log } }";
const CANCEL_RUN: &str = "mutation($id: UUID!) { cancelActionRun(id: $id) { id status } }";

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

    #[test]
    fn terminal_log_statuses_match_api_and_legacy_values() {
        for status in [
            "SUCCESS",
            "SUCCEEDED",
            "FAILURE",
            "FAILED",
            "CANCELLED",
            "CANCELED",
            "SKIPPED",
        ] {
            assert!(
                is_terminal_log_status(status),
                "{status} should stop following logs"
            );
        }

        for status in ["QUEUED", "RUNNING", "IN_PROGRESS", ""] {
            assert!(
                !is_terminal_log_status(status),
                "{status:?} should keep following logs"
            );
        }
    }

    #[test]
    fn run_rows_and_record_pairs_format_values_with_defaults() {
        let runs = vec![
            json!({
                "id": "run-1",
                "status": "RUNNING",
                "workflowName": "ci",
                "jobName": "test",
                "startedAt": "2026-06-18T01:00:00Z"
            }),
            json!({}),
        ];

        assert_eq!(
            run_rows(&runs),
            vec![
                vec![
                    "run-1".to_string(),
                    "RUNNING".to_string(),
                    "ci".to_string(),
                    "test".to_string(),
                    "2026-06-18T01:00:00Z".to_string(),
                ],
                vec![
                    String::new(),
                    String::new(),
                    String::new(),
                    String::new(),
                    String::new(),
                ],
            ]
        );

        let run = json!({
            "status": "SUCCESS",
            "workflowName": "release",
            "jobName": "publish",
            "commitOid": "abc123",
            "triggeredByUsername": "bri",
            "queuedAt": "2026-06-18T00:59:00Z",
            "startedAt": "2026-06-18T01:00:00Z",
            "completedAt": "2026-06-18T01:05:00Z"
        });

        assert_eq!(
            run_record_pairs(&run, "run-2"),
            vec![
                ("Run", "run-2".to_string()),
                ("Status", "SUCCESS".to_string()),
                ("Workflow", "release".to_string()),
                ("Job", "publish".to_string()),
                ("Commit", "abc123".to_string()),
                ("Triggered by", "bri".to_string()),
                ("Queued", "2026-06-18T00:59:00Z".to_string()),
                ("Started", "2026-06-18T01:00:00Z".to_string()),
                ("Completed", "2026-06-18T01:05:00Z".to_string()),
            ]
        );
        assert_eq!(
            run_record_pairs(&json!({}), "run-3")[0],
            ("Run", "run-3".to_string())
        );
    }

    #[test]
    fn next_log_chunk_returns_only_unprinted_suffix() {
        assert_eq!(next_log_chunk("hello world", 6), Some("world"));
        assert_eq!(next_log_chunk("hello", 5), None);
        assert_eq!(next_log_chunk("", 0), None);
    }
}