praefectus 0.2.0

Policy-neutral, durable desktop action execution protocol
Documentation
use std::io;
use std::path::PathBuf;
use std::process::ExitCode;

use praefectus::{DenyAuthority, Engine, NativeExecutor, default_ledger_path};
use serde::Serialize;

const EXIT_USAGE: u8 = 2;
const EXIT_PROTOCOL: u8 = 3;

fn main() -> ExitCode {
    let result = run(std::env::args().skip(1).collect());
    let (value, exit) = match result {
        Ok((output, exit)) => (
            serde_json::to_value(SuccessEnvelope { ok: true, data: output }).unwrap_or_else(|_| {
                serde_json::json!({"ok":false,"error":{"code":"serialization_error","message":"failed to serialize CLI output"}})
            }),
            exit,
        ),
        Err(error) => (
            serde_json::to_value(ErrorEnvelope::new(&error))
                .unwrap_or_else(|_| serde_json::json!({"error":{"code":"serialization_error","message":"failed to serialize CLI error"}})),
            error.exit,
        ),
    };
    match serde_json::to_writer(io::stdout().lock(), &value) {
        Ok(()) => {
            println!();
            ExitCode::from(exit)
        }
        Err(error) => {
            eprintln!("praefectus: {error}");
            ExitCode::FAILURE
        }
    }
}

struct CliError {
    code: &'static str,
    message: String,
    exit: u8,
}

#[derive(Serialize)]
struct SuccessEnvelope {
    ok: bool,
    data: serde_json::Value,
}

#[derive(Serialize)]
struct ErrorEnvelope<'a> {
    ok: bool,
    error: ErrorBody<'a>,
}

#[derive(Serialize)]
struct ErrorBody<'a> {
    code: &'static str,
    message: &'a str,
}

impl<'a> From<&'a CliError> for ErrorBody<'a> {
    fn from(error: &'a CliError) -> Self {
        Self {
            code: error.code,
            message: &error.message,
        }
    }
}

impl<'a> ErrorEnvelope<'a> {
    fn new(error: &'a CliError) -> Self {
        Self {
            ok: false,
            error: error.into(),
        }
    }
}

fn run(arguments: Vec<String>) -> Result<(serde_json::Value, u8), CliError> {
    let Some(command) = arguments.first().map(String::as_str) else {
        return Err(usage("usage: praefectus execute|status|capabilities"));
    };
    let ledger = ledger_path(&arguments)?;
    match command {
        "execute" => Err(usage(
            "execute is library-only and requires a host-injected trusted AuthorityVerifier",
        )),
        "status" => {
            let operation_id = positional_arguments(&arguments)
                .into_iter()
                .next()
                .ok_or_else(|| usage("status requires an operation ID"))?;
            let engine = Engine::new(NativeExecutor::default(), ledger, DenyAuthority);
            Ok((
                serialize(
                    engine
                        .status(operation_id)
                        .map_err(|error| protocol("protocol_error", error))?,
                )?,
                0,
            ))
        }
        "capabilities" => {
            let engine = Engine::new(NativeExecutor::default(), ledger, DenyAuthority);
            Ok((
                serialize(
                    engine
                        .capabilities()
                        .map_err(|error| protocol("protocol_error", error))?,
                )?,
                0,
            ))
        }
        _ => Err(usage(format!("unknown command: {command}"))),
    }
}

fn serialize(value: impl Serialize) -> Result<serde_json::Value, CliError> {
    serde_json::to_value(value).map_err(|error| protocol("serialization_error", error))
}

fn positional_arguments(arguments: &[String]) -> Vec<&String> {
    let mut values = Vec::new();
    let mut index = 1;
    while index < arguments.len() {
        if arguments[index] == "--ledger" {
            index += 2;
        } else {
            values.push(&arguments[index]);
            index += 1;
        }
    }
    values
}

fn option_value<'a>(arguments: &'a [String], option: &str) -> Option<&'a str> {
    arguments
        .iter()
        .position(|argument| argument == option)
        .and_then(|index| arguments.get(index + 1))
        .map(String::as_str)
}

fn ledger_path(arguments: &[String]) -> Result<PathBuf, CliError> {
    if arguments.iter().any(|argument| argument == "--ledger") {
        return option_value(arguments, "--ledger")
            .map(PathBuf::from)
            .ok_or_else(|| usage("--ledger requires a path"));
    }
    Ok(default_ledger_path())
}

fn usage(message: impl Into<String>) -> CliError {
    CliError {
        code: "usage",
        message: message.into(),
        exit: EXIT_USAGE,
    }
}

fn protocol(code: &'static str, _error: impl ToString) -> CliError {
    CliError {
        code,
        message: "protocol operation failed".to_string(),
        exit: EXIT_PROTOCOL,
    }
}