blue-lang-cli 0.0.180

The blue command line: run, fmt, ast, erase, check.
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use blue_lang_proc::receipt::Tapes;
use blue_lang_proc::receipt::{canonical, Replay};
use blue_lang_proc::trace::{crash_hash, tape_from_json, tape_to_json};
use blue_lang_proc::Pid;
use blue_lang_runtime::pipeline::{Run, RunError};
use blue_lang_runtime::{Account, Hold, Receipt};
use serde_json::{json, Value as Json};

use crate::config::BlueConfig;
use crate::{invoke, CliError, Invocation, Invoked};

pub const FORMAT: &str = "blue-run-record";
pub const VERSION: u64 = 1;
pub const EXTENSION: &str = "run.json";

pub enum Taping {
    Off,
    Record,
    Replay(Tapes),
}

pub struct Inputs<'a> {
    pub file: &'a Path,
    pub inputs: &'a [String],
    pub allow: &'a [String],
    pub args: &'a [String],
    pub receipt_steps: bool,
}

fn tapes_to_json(tapes: &Tapes) -> Json {
    Json::Object(
        tapes
            .iter()
            .map(|(pid, t)| (pid.0.to_string(), tape_to_json(t)))
            .collect(),
    )
}

fn tapes_from_json(j: &Json) -> Result<Tapes, String> {
    j.as_object()
        .ok_or("the tapes are not an object of process tapes")?
        .iter()
        .map(|(pid, t)| {
            let pid = pid
                .parse()
                .map_err(|_| format!("`{pid}` is not a process id"))?;
            Ok((Pid(pid), tape_from_json(t)?))
        })
        .collect()
}

fn hash(j: &Json) -> String {
    format!("b3:{}", crash_hash(j))
}

fn inputs_json(given: &Inputs<'_>, source: &str) -> Json {
    json!({
        "allow": given.allow,
        "args": given.args,
        "clock": "virtual",
        "file": given.file.display().to_string(),
        "inputs": given.inputs,
        "receipt_steps": given.receipt_steps,
        "source": source,
    })
}

fn output_hash(ran: &Result<Run, RunError>) -> String {
    let (kind, text) = blue_lang_runtime::receipt::exit_of(ran);
    let value = ran.as_ref().ok().map(|r| crate::render(&r.value));
    hash(&json!({"exit": {"kind": kind, "text": text}, "value": value}))
}

fn receipt_of(
    hold: &Hold,
    account: &Account,
    ran: &Result<Run, RunError>,
    inputs: &Json,
) -> Receipt {
    let mut receipt =
        blue_lang_runtime::receipt::of_run(hold, account, blue_lang_runtime::receipt::exit_of(ran));
    let tapes = account.host_io.taped().map(|t| t.tapes).unwrap_or_default();
    receipt.replay = Some(Replay {
        inputs: hash(inputs),
        output: output_hash(ran),
        tape: hash(&tapes_to_json(&tapes)),
    });
    receipt
}

fn name_of(receipt: &Receipt) -> String {
    let file = blue_lang_runtime::receipt::name_of(&receipt.bytes());
    format!(
        "b3:{}",
        file.trim_end_matches(blue_lang_runtime::receipt::EXTENSION)
            .trim_end_matches('.')
    )
}

pub fn write(dir: &Path, invoked: &Invoked, given: &Inputs<'_>) {
    let Some(hold) = invoked.hold.as_ref() else {
        return;
    };
    let inputs = inputs_json(given, &invoked.src);
    let receipt = receipt_of(hold, &invoked.account, &invoked.ran, &inputs);
    let tapes = invoked
        .account
        .host_io
        .taped()
        .map(|t| t.tapes)
        .unwrap_or_default();
    let named = name_of(&receipt);
    let record = json!({
        "format": FORMAT,
        "version": VERSION,
        "receipt": named,
        "run": inputs,
        "tapes": tapes_to_json(&tapes),
    });
    let written = blue_lang_runtime::receipt::write(dir, &receipt).and_then(|_| {
        let hex = named.trim_start_matches("b3:");
        std::fs::write(
            dir.join(format!("{hex}.{EXTENSION}")),
            canonical(&record).into_bytes(),
        )
    });
    if let Err(e) = written {
        eprintln!(
            "blue: the run's record could not be written to {}: {e}",
            dir.display()
        );
    }
}

struct Recorded {
    file: PathBuf,
    source: String,
    inputs: Vec<String>,
    allow: Vec<String>,
    args: Vec<String>,
    receipt_steps: bool,
    tapes: Tapes,
}

fn strings(j: &Json, key: &str) -> Result<Vec<String>, String> {
    j.get(key)
        .and_then(Json::as_array)
        .ok_or_else(|| format!("the record's run has no `{key}`"))?
        .iter()
        .map(|v| {
            v.as_str()
                .map(str::to_string)
                .ok_or_else(|| format!("`{key}` holds {v}, not a string"))
        })
        .collect()
}

fn load(path: &Path, receipt: &str) -> Result<Recorded, String> {
    let shown = path.display();
    let bytes = std::fs::read(path).map_err(|e| format!("{shown}: {e}"))?;
    let j: Json = serde_json::from_slice(&bytes).map_err(|e| format!("{shown}: not JSON: {e}"))?;
    if j.get("format").and_then(Json::as_str) != Some(FORMAT) {
        return Err(format!("{shown}: not a {FORMAT}"));
    }
    if j.get("version").and_then(Json::as_u64) != Some(VERSION) {
        return Err(format!(
            "{shown}: {FORMAT} version {}; this blue reads version {VERSION}",
            j["version"]
        ));
    }
    if j.get("receipt").and_then(Json::as_str) != Some(receipt) {
        return Err(format!(
            "{shown}: records the run of {}, not of {receipt}",
            j["receipt"]
        ));
    }
    let run = j.get("run").ok_or_else(|| format!("{shown}: no `run`"))?;
    let text = |k: &str| {
        run.get(k)
            .and_then(Json::as_str)
            .map(str::to_string)
            .ok_or_else(|| format!("{shown}: the record's run has no `{k}`"))
    };
    Ok(Recorded {
        file: PathBuf::from(text("file")?),
        source: text("source")?,
        inputs: strings(run, "inputs")?,
        allow: strings(run, "allow")?,
        args: strings(run, "args")?,
        receipt_steps: run
            .get("receipt_steps")
            .and_then(Json::as_bool)
            .unwrap_or(false),
        tapes: tapes_from_json(j.get("tapes").unwrap_or(&Json::Null))
            .map_err(|e| format!("{shown}: its tapes: {e}"))?,
    })
}

pub fn replay(cfg: &BlueConfig, receipt_path: &Path) -> Result<ExitCode, CliError> {
    let original = blue_lang_runtime::receipt::verify(receipt_path).map_err(CliError::Fmt)?;
    let hex = original.trim_start_matches("b3:");
    let record_path = receipt_path
        .parent()
        .unwrap_or_else(|| Path::new("."))
        .join(format!("{hex}.{EXTENSION}"));
    let recorded = load(&record_path, &original).map_err(|e| {
        CliError::Fmt(format!(
            "{e}\nblue: only a run recorded with `blue run --receipts DIR --record` replays from its receipt"
        ))
    })?;
    let scratch = std::env::temp_dir().join(format!("blue-replay-{}-{hex}", std::process::id()));
    let given = Inputs {
        file: &recorded.file,
        inputs: &recorded.inputs,
        allow: &recorded.allow,
        args: &recorded.args,
        receipt_steps: recorded.receipt_steps,
    };
    let taped: usize = recorded.tapes.values().map(Vec::len).sum();
    let invoked = invoke(
        cfg,
        Invocation {
            file: &recorded.file,
            src: Some(recorded.source.clone()),
            inputs: &recorded.inputs,
            allow: &recorded.allow,
            receipts: Some(&scratch),
            receipt_steps: recorded.receipt_steps,
            no_memo: true,
            args: recorded.args.clone(),
            taping: Taping::Replay(recorded.tapes.clone()),
        },
    );
    let _ = std::fs::remove_dir_all(&scratch);
    let invoked = invoked?;
    let hold = invoked
        .hold
        .as_ref()
        .ok_or_else(|| CliError::Fmt("the replay ran under no hold".into()))?;
    let receipt = receipt_of(
        hold,
        &invoked.account,
        &invoked.ran,
        &inputs_json(&given, &invoked.src),
    );
    let replayed = name_of(&receipt);
    let consumed = invoked.account.host_io.taped();
    println!("recorded: {original}");
    println!("replayed: {replayed}");
    if let Some(t) = &consumed {
        println!("tape: {} of {taped} host answers read", t.entries);
        if let Some(d) = &t.diverged {
            println!("diverged: {d}");
        }
    }
    if replayed == original {
        println!("reproduced: the replayed run's receipt is the recorded run's");
        Ok(ExitCode::SUCCESS)
    } else {
        println!("not reproduced: the replayed run's receipt differs from the recorded run's");
        println!(
            "replayed receipt: {}",
            String::from_utf8_lossy(&receipt.bytes())
        );
        Ok(ExitCode::FAILURE)
    }
}