Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle src cob external.rs
//! # External Collaborative Objects
//!
//! This module provides an interface for external helper programs to provide
//! the evaluation logic of Collaborative Objects (COBs).
//!
//! An external COB is one that relies on an external program (so called
//! "helper", is an executable file, for example a script, or a binary), that
//! implements the evaluation logic for that particular COB:
//! Whenever an operation is to be applied to an external COB, the helper is
//! invoked with the current state of the COB and the operation to be applied.
//! It then returns the new state of the COB, according to its internal logic.
//! This concept is borrowed from Git, which supports [remote helpers] and
//! [credential helpers].
//!
//! External COBs must be based on JSON, that is, the COB itself and associated
//! actions must serialize to and deserialize from JSON.
//! Further, the helper must be able to communicate to Radicle using
//! [JSON Lines] (see further details below).
//!
//! # Invocation
//!
//! The helper is invoked by Radicle without command line arguments.
//! In the future, more arguments might be added.
//!
//! # Helper Protocol
//!
//! Radicle and the helper communicate back and forth [JSON Lines] via standard
//! streams.
//!
//!  1. The helper must read and process at least one JSON Line (containing one
//!     operation) from standard input, which represents the operation to be
//!     applied to the COB, along with possible concurrent operations.
//!  2. The helper must write the new state of COB to standard output in a JSON
//!     Line.
//!  3. The helper may read additional JSON Lines from standard input, these are
//!     to be applied "on top of" the previous operations.
//!  4. The helper must reply with the state of the COB in a JSON Line after
//!     processing each operation.
//!  5. The helper must exit with a status code of zero on success, and a
//!     non-zero status code on failure.
//!  6. The helper may write to standard error for logging and debugging
//!     purposes.
//!
//! # Syntax of Operations
//!
//! The operations sent from Radicle to the helper are of the following shape:
//!
//! ```json
//! {
//!     "title": "Operation as sent to helper"
//!     "type": "object",
//!     "properties": {
//!         "concurrent": {
//!             "type": "array",
//!             "items": {
//!                 "$ref": "#/definitions/radicle::cob::Op"
//!             }
//!         },
//!         "value": {
//!             "type": "object",
//!             "properties": {
//!                 "prop": {
//!                     "$ref": "#/definitions/radicle::cob::external::External"
//!                 }
//!             }
//!         },
//!         "op": {
//!             "type": "object",
//!             "properties": {
//!                 "prop": {
//!                     "$ref": "#/definitions/radicle::cob::Op"
//!                 }
//!             }
//!         }
//!     },
//! }
//! ```
//!
//! [JSON Lines]: https://jsonlines.org/
//! [credential helpers]: https://git-scm.com/doc/credential-helpers
//! [remote helpers]: https://git-scm.com/docs/gitremote-helpers

use std::collections::HashMap;
use std::io::Error as IoError;
use std::process::{Command, Stdio};

use serde::{Deserialize, Serialize};
use thiserror::Error;

use serde_json::{Error as JsonError, Map, Value, from_slice, to_writer};

use crate::cob::object::collaboration::Evaluate;
use crate::cob::op::{Op as CobOp, OpEncodingError};
use crate::cob::store::{Cob, CobAction};
use crate::git::Oid;
use crate::storage::ReadRepository;

/// This prefix is used to generate the name of the command,
/// which is executed by the helper to apply operations.
static COB_EXTERNAL_COMMAND_PREFIX: &str = "rad-cob-";

#[derive(PartialEq, Debug, Serialize, Deserialize)]
pub struct External(Value);

impl Default for External {
    fn default() -> Self {
        Self(Value::Object(Map::default()))
    }
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("op decoding failed: {0}")]
    Op(#[from] OpEncodingError),
    #[error("serde_json: {0}")]
    Serde(#[from] JsonError),
    #[error("io: {0}")]
    Io(#[from] IoError),
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Action {
    #[serde(default)]
    parents: Vec<Oid>,

    #[serde(flatten)]
    map: HashMap<String, Value>,
}

impl CobAction for Action {
    fn parents(&self) -> Vec<Oid> {
        self.parents.clone()
    }
}

impl From<Action> for nonempty::NonEmpty<Action> {
    fn from(action: Action) -> Self {
        Self::new(action)
    }
}

pub type Op = CobOp<Action>;

impl External {
    fn handle<R: ReadRepository>(
        &mut self,
        op: Op,
        concurrent: Vec<Op>,
        _repo: &R,
    ) -> Result<(), Error> {
        let command_name = {
            let prefix = String::from(COB_EXTERNAL_COMMAND_PREFIX);
            let type_name = op.manifest.type_name.to_string();
            let suffix = type_name
                .rsplit_once('.')
                .map(|(_, suffix)| suffix)
                .unwrap_or(type_name.as_str());
            prefix + suffix
        };

        let mut child = Command::new(command_name)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .spawn()?;

        let stdin = child.stdin.take().expect("handle preset");

        #[derive(Serialize)]
        struct OpMessage {
            value: Value,
            op: Op,
            concurrent: Vec<Op>,
        }

        to_writer(
            stdin,
            &OpMessage {
                value: self.0.clone(),
                op,
                concurrent,
            },
        )?;

        self.0 = from_slice(&child.wait_with_output()?.stdout)?;
        Ok(())
    }
}

impl Cob for External {
    type Action = Action;
    type Error = Error;

    fn from_root<R: ReadRepository>(
        op: super::Op<Self::Action>,
        repo: &R,
    ) -> Result<Self, Self::Error> {
        let mut root = Self::default();
        root.handle(op, vec![], repo)?;
        Ok(root)
    }

    fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a super::Entry>>(
        &mut self,
        op: super::Op<Self::Action>,
        concurrent: I,
        repo: &R,
    ) -> Result<(), <Self as Cob>::Error> {
        let concurrent: Vec<Op> = concurrent
            .into_iter()
            .map(Op::try_from)
            .collect::<Result<Vec<Op>, _>>()?;
        self.handle(op, concurrent, repo)
    }
}

impl<R: ReadRepository> Evaluate<R> for External {
    type Error = Error;

    fn init(entry: &radicle_cob::Entry, store: &R) -> Result<Self, Self::Error> {
        Self::from_root(Op::try_from(entry)?, store)
    }

    fn apply<'a, I: Iterator<Item = (&'a crate::git::Oid, &'a radicle_cob::Entry)>>(
        &mut self,
        entry: &radicle_cob::Entry,
        concurrent: I,
        repo: &R,
    ) -> Result<(), Self::Error> {
        let concurrent: Vec<Op> = concurrent
            .map(|(_, e)| e)
            .map(Op::try_from)
            .collect::<Result<Vec<Op>, _>>()?;
        self.handle(Op::try_from(entry)?, concurrent, repo)
    }
}