wip-interface 0.1.0

Public Rust authoring API for WIP interfaces and operation value codecs
Documentation
use std::collections::BTreeMap;

use wip_interface::wip_protocol::{INTERFACE_FORMAT_V1, TypeExpr, Value};
use wip_interface::{
    DispatchError, DispatchPhase, EffectGuarantee, HostError, HostErrorKind, OperationResult,
    WipType, interface,
};

/// Publication request.
#[derive(Debug, Clone, PartialEq, WipType)]
pub struct PublishRequest {
    /// Item title.
    pub title: String,
}

/// Application outcome, including domain failure.
#[derive(Debug, Clone, PartialEq, WipType)]
pub enum PublishOutcome {
    /// The item was accepted.
    Published(bool),
    /// The application rejected the item.
    Rejected(String),
}

/// Catalog editing capability.
///
/// The shape is implementation-independent.
#[interface]
pub trait Catalog {
    /// Publishes one item.
    ///
    /// This operation can report a domain rejection in its application union.
    ///
    /// # Returns
    /// The application publication outcome.
    fn publish(
        &self,
        /// Item to publish.
        request: PublishRequest,
        /// Optional audit note.
        note: Option<String>,
    ) -> OperationResult<PublishOutcome>;

    /// Checks connectivity without returning application data.
    fn ping(&self) -> OperationResult<()>;

    /// Demonstrates a host/runtime failure.
    ///
    /// # Returns
    /// A value only when the host succeeds.
    fn host_value(&self) -> OperationResult<String>;
}

struct AcceptingCatalog;

impl Catalog for AcceptingCatalog {
    fn publish(
        &self,
        request: PublishRequest,
        note: Option<String>,
    ) -> OperationResult<PublishOutcome> {
        assert_eq!(request.title, "example");
        assert_eq!(note.as_deref(), Some("audit"));
        Ok(PublishOutcome::Published(true))
    }

    fn ping(&self) -> OperationResult<()> {
        Ok(())
    }

    fn host_value(&self) -> OperationResult<String> {
        Err(HostError::internal(
            "backend unavailable",
            EffectGuarantee::Unknown,
        ))
    }
}

struct RejectingCatalog;

impl Catalog for RejectingCatalog {
    fn publish(
        &self,
        _request: PublishRequest,
        _note: Option<String>,
    ) -> OperationResult<PublishOutcome> {
        Ok(PublishOutcome::Rejected("policy".into()))
    }

    fn ping(&self) -> OperationResult<()> {
        Ok(())
    }

    fn host_value(&self) -> OperationResult<String> {
        Ok("ready".into())
    }
}

struct PanickingCatalog;

impl Catalog for PanickingCatalog {
    fn publish(
        &self,
        _request: PublishRequest,
        _note: Option<String>,
    ) -> OperationResult<PublishOutcome> {
        panic!("private panic payload")
    }

    fn ping(&self) -> OperationResult<()> {
        panic!("private panic payload")
    }

    fn host_value(&self) -> OperationResult<String> {
        panic!("private panic payload")
    }
}

fn publish_arguments(with_note: bool) -> BTreeMap<String, Value> {
    let mut arguments = BTreeMap::from([(
        "request".into(),
        PublishRequest {
            title: "example".into(),
        }
        .encode()
        .unwrap(),
    )]);
    if with_note {
        arguments.insert("note".into(), Value::String("audit".into()));
    }
    arguments
}

#[test]
fn descriptor_is_canonical_and_generated_only_from_the_trait_declaration() {
    let first = CatalogDefinition::descriptor();
    let second = CatalogDefinition::descriptor();
    assert_eq!(first, second);
    first.validate().unwrap();

    assert_eq!(first.format, INTERFACE_FORMAT_V1);
    assert_eq!(
        first.documentation.as_ref().unwrap().summary,
        "Catalog editing capability."
    );
    assert_eq!(
        first.documentation.as_ref().unwrap().details.as_deref(),
        Some("The shape is implementation-independent.")
    );
    assert_eq!(first.operations.len(), 3);

    let publish = &first.operations[0];
    assert_eq!(publish.name, "publish");
    assert_eq!(publish.parameters[0].name, "request");
    assert_eq!(
        publish.parameters[0]
            .documentation
            .as_ref()
            .unwrap()
            .summary,
        "Item to publish."
    );
    assert!(publish.parameters[0].required);
    assert!(!publish.parameters[1].required);
    assert_eq!(
        publish.returns.documentation.as_ref().unwrap().summary,
        "The application publication outcome."
    );
    assert_eq!(
        publish.returns.r#type,
        TypeExpr::Named {
            name: "PublishOutcome".into(),
        }
    );
    assert_eq!(first.operations[1].returns.r#type, TypeExpr::Unit);
    assert!(
        first
            .types
            .iter()
            .any(|declaration| matches!(declaration.definition, TypeExpr::Record { .. }))
    );
    assert!(
        first
            .types
            .iter()
            .any(|declaration| matches!(declaration.definition, TypeExpr::Union { .. }))
    );

    // Both implementation types satisfy one immutable generated definition;
    // implementation selection cannot contribute fields to the descriptor.
    fn accepts_definition<T: Catalog>(_: &T) {}
    accepts_definition(&AcceptingCatalog);
    accepts_definition(&RejectingCatalog);
    assert_eq!(CatalogDefinition::descriptor(), first);
}

#[test]
fn generated_argument_decoder_and_result_encoder_use_raw_values() {
    let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap();
    assert_eq!(decoded.request.title, "example");
    assert_eq!(decoded.note, None);

    let encoded =
        CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())).unwrap();
    assert_eq!(
        PublishOutcome::decode(encoded).unwrap(),
        PublishOutcome::Rejected("domain".into())
    );
    assert_eq!(
        CatalogPingArguments::encode_result(()).unwrap(),
        Value::Unit
    );
}

#[test]
fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() {
    let value =
        CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)).unwrap();
    assert_eq!(
        PublishOutcome::decode(value).unwrap(),
        PublishOutcome::Published(true)
    );

    assert_eq!(
        CatalogDefinition::dispatch(&AcceptingCatalog, "ping", BTreeMap::new()).unwrap(),
        Value::Unit
    );

    let invalid =
        CatalogDefinition::dispatch(&AcceptingCatalog, "publish", BTreeMap::new()).unwrap_err();
    assert!(matches!(invalid, DispatchError::InvalidArguments(_)));
    assert_eq!(invalid.phase(), DispatchPhase::BeforeInvocation);
    assert_eq!(invalid.effect_guarantee(), None);

    let host =
        CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", BTreeMap::new()).unwrap_err();
    assert!(
        matches!(&host, DispatchError::Host(error) if error.message() == "backend unavailable")
    );
    assert_eq!(host.phase(), DispatchPhase::AfterInvocation);
    assert_eq!(host.effect_guarantee(), Some(EffectGuarantee::Unknown));

    let panic =
        CatalogDefinition::dispatch(&PanickingCatalog, "host_value", BTreeMap::new()).unwrap_err();
    assert!(matches!(panic, DispatchError::Panic));
    assert_eq!(panic.phase(), DispatchPhase::AfterInvocation);
    assert_eq!(panic.effect_guarantee(), Some(EffectGuarantee::Unknown));
    assert!(!panic.to_string().contains("private panic payload"));

    let unknown =
        CatalogDefinition::dispatch(&AcceptingCatalog, "missing", BTreeMap::new()).unwrap_err();
    assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing"));
}

#[test]
fn host_errors_require_a_typed_kind_and_effect_guarantee() {
    let internal = HostError::internal("x".repeat(4_096), EffectGuarantee::NotCommitted);
    assert_eq!(internal.kind(), HostErrorKind::Internal);
    assert_eq!(internal.effect_guarantee(), EffectGuarantee::NotCommitted);
    assert!(internal.message().len() <= 512);

    let resource = HostError::resource_limit("deadline", EffectGuarantee::Unknown);
    assert_eq!(resource.kind(), HostErrorKind::ResourceLimitExceeded);
    assert_eq!(resource.effect_guarantee(), EffectGuarantee::Unknown);
}

/// Operations whose distinct source names normalize to the same PascalCase.
#[interface]
pub trait DistinctOperations {
    /// Returns the first item marker.
    fn item(&self) -> OperationResult<String>;

    /// Returns the underscored item marker.
    fn item_(&self) -> OperationResult<String>;
}

struct DistinctImplementation;

impl DistinctOperations for DistinctImplementation {
    fn item(&self) -> OperationResult<String> {
        Ok("item".into())
    }

    fn item_(&self) -> OperationResult<String> {
        Ok("item_".into())
    }
}

#[test]
fn distinct_operation_names_always_generate_distinct_public_items() {
    let descriptor = DistinctOperationsDefinition::descriptor();
    assert_eq!(descriptor.operations[0].name, "item");
    assert_eq!(descriptor.operations[1].name, "item_");

    let first =
        DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", BTreeMap::new())
            .unwrap();
    let second =
        DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", BTreeMap::new())
            .unwrap();
    assert_eq!(first, Value::String("item".into()));
    assert_eq!(second, Value::String("item_".into()));
}