rvf 0.1.0

Rust implementation of the ValueFlows vocabulary for distributed economic networks
Documentation
//! Recipes - knowledge layer for reusable economic patterns
//!
//! Recipes define templates for processes and flows that can be
//! instantiated into actual plans and commitments.

use crate::actions::ActionType;
use crate::error::{Error, Result};
use crate::measures::Measure;
use chrono::{DateTime, Utc};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

/// A recipe resource - template for a resource in a recipe
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct RecipeResource {
    /// Unique identifier
    pub id: String,
    /// Display name
    pub name: String,
    /// Description
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub description: Option<String>,
    /// The resource specification this conforms to
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub resource_conforms_to: Option<String>,
    /// Image URL
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub image: Option<String>,
    /// Unit of resource
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub unit_of_resource: Option<String>,
    /// If this is a substitutable resource
    pub substitutable: bool,
    /// Optional note
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub note: Option<String>,
    /// When this was created
    pub created_at: DateTime<Utc>,
}

impl RecipeResource {
    /// Create a new recipe resource
    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
        RecipeResource {
            id: id.into(),
            name: name.into(),
            description: None,
            resource_conforms_to: None,
            image: None,
            unit_of_resource: None,
            substitutable: true,
            note: None,
            created_at: Utc::now(),
        }
    }

    /// Set description
    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    /// Set resource specification
    pub fn with_resource_conforms_to(mut self, spec_id: impl Into<String>) -> Self {
        self.resource_conforms_to = Some(spec_id.into());
        self
    }

    /// Set substitutable
    pub fn with_substitutable(mut self, substitutable: bool) -> Self {
        self.substitutable = substitutable;
        self
    }
}

/// A recipe flow - template for an economic flow in a recipe
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct RecipeFlow {
    /// Unique identifier
    pub id: String,
    /// The action for this flow
    pub action: ActionType,
    /// The process this is an input to
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub recipe_input_of: Option<String>,
    /// The process this is an output of
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub recipe_output_of: Option<String>,
    /// The recipe resource
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub recipe_flow_resource: Option<String>,
    /// The resource specification this conforms to
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub resource_conforms_to: Option<String>,
    /// Quantity for this flow
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub resource_quantity: Option<Measure>,
    /// Effort quantity for this flow
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub effort_quantity: Option<Measure>,
    /// Stage of resource
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub stage: Option<String>,
    /// State of resource
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub state: Option<String>,
    /// Optional note
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub note: Option<String>,
    /// When this was created
    pub created_at: DateTime<Utc>,
}

impl RecipeFlow {
    /// Create a new recipe flow builder
    pub fn builder() -> RecipeFlowBuilder {
        RecipeFlowBuilder::default()
    }
}

/// Builder for RecipeFlow
#[derive(Debug, Default)]
pub struct RecipeFlowBuilder {
    id: Option<String>,
    action: Option<ActionType>,
    recipe_input_of: Option<String>,
    recipe_output_of: Option<String>,
    recipe_flow_resource: Option<String>,
    resource_conforms_to: Option<String>,
    resource_quantity: Option<Measure>,
    effort_quantity: Option<Measure>,
    stage: Option<String>,
    state: Option<String>,
    note: Option<String>,
}

impl RecipeFlowBuilder {
    /// Set the ID
    pub fn id(mut self, id: impl Into<String>) -> Self {
        self.id = Some(id.into());
        self
    }

    /// Set the action
    pub fn action(mut self, action: ActionType) -> Self {
        self.action = Some(action);
        self
    }

    /// Set as input to a recipe process
    pub fn input_of(mut self, process_id: impl Into<String>) -> Self {
        self.recipe_input_of = Some(process_id.into());
        self
    }

    /// Set as output of a recipe process
    pub fn output_of(mut self, process_id: impl Into<String>) -> Self {
        self.recipe_output_of = Some(process_id.into());
        self
    }

    /// Set the recipe resource
    pub fn recipe_flow_resource(mut self, resource_id: impl Into<String>) -> Self {
        self.recipe_flow_resource = Some(resource_id.into());
        self
    }

    /// Set the resource specification
    pub fn resource_conforms_to(mut self, spec_id: impl Into<String>) -> Self {
        self.resource_conforms_to = Some(spec_id.into());
        self
    }

    /// Set the resource quantity
    pub fn resource_quantity(mut self, quantity: Measure) -> Self {
        self.resource_quantity = Some(quantity);
        self
    }

    /// Set the effort quantity
    pub fn effort_quantity(mut self, quantity: Measure) -> Self {
        self.effort_quantity = Some(quantity);
        self
    }

    /// Set the stage
    pub fn stage(mut self, stage: impl Into<String>) -> Self {
        self.stage = Some(stage.into());
        self
    }

    /// Set the state
    pub fn state(mut self, state: impl Into<String>) -> Self {
        self.state = Some(state.into());
        self
    }

    /// Set a note
    pub fn note(mut self, note: impl Into<String>) -> Self {
        self.note = Some(note.into());
        self
    }

    /// Build the RecipeFlow
    pub fn build(self) -> Result<RecipeFlow> {
        let id = self.id.ok_or_else(|| Error::missing_field("id"))?;
        let action = self.action.ok_or_else(|| Error::missing_field("action"))?;

        Ok(RecipeFlow {
            id,
            action,
            recipe_input_of: self.recipe_input_of,
            recipe_output_of: self.recipe_output_of,
            recipe_flow_resource: self.recipe_flow_resource,
            resource_conforms_to: self.resource_conforms_to,
            resource_quantity: self.resource_quantity,
            effort_quantity: self.effort_quantity,
            stage: self.stage,
            state: self.state,
            note: self.note,
            created_at: Utc::now(),
        })
    }
}

/// A recipe process - template for a transformation process
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct RecipeProcess {
    /// Unique identifier
    pub id: String,
    /// Display name
    pub name: String,
    /// Description
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub description: Option<String>,
    /// Duration of the process (in some time unit)
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub has_duration: Option<i64>,
    /// Unit for duration
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub duration_unit: Option<String>,
    /// The process specification this is based on
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub process_conforms_to: Option<String>,
    /// Scope (organization context)
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub in_scope_of: Option<String>,
    /// Optional note
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub note: Option<String>,
    /// When this was created
    pub created_at: DateTime<Utc>,
}

impl RecipeProcess {
    /// Create a new recipe process
    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
        RecipeProcess {
            id: id.into(),
            name: name.into(),
            description: None,
            has_duration: None,
            duration_unit: None,
            process_conforms_to: None,
            in_scope_of: None,
            note: None,
            created_at: Utc::now(),
        }
    }

    /// Set description
    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    /// Set duration
    pub fn with_duration(mut self, duration: i64, unit: impl Into<String>) -> Self {
        self.has_duration = Some(duration);
        self.duration_unit = Some(unit.into());
        self
    }

    /// Set process specification
    pub fn with_process_conforms_to(mut self, spec_id: impl Into<String>) -> Self {
        self.process_conforms_to = Some(spec_id.into());
        self
    }
}

/// A recipe exchange - template for reciprocal transfers
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct RecipeExchange {
    /// Unique identifier
    pub id: String,
    /// Display name
    pub name: String,
    /// Description
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub description: Option<String>,
    /// Optional note
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub note: Option<String>,
    /// When this was created
    pub created_at: DateTime<Utc>,
}

impl RecipeExchange {
    /// Create a new recipe exchange
    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
        RecipeExchange {
            id: id.into(),
            name: name.into(),
            description: None,
            note: None,
            created_at: Utc::now(),
        }
    }

    /// Set description
    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }
}

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

    #[test]
    fn test_recipe_resource() {
        let resource = RecipeResource::new("rr-001", "Flour")
            .with_description("All-purpose flour")
            .with_substitutable(false);

        assert_eq!(resource.id, "rr-001");
        assert_eq!(resource.name, "Flour");
        assert!(!resource.substitutable);
    }

    #[test]
    fn test_recipe_flow_builder() {
        let flow = RecipeFlow::builder()
            .id("rf-001")
            .action(ActionType::Consume)
            .input_of("rp-001")
            .resource_conforms_to("flour-spec")
            .build()
            .unwrap();

        assert_eq!(flow.id, "rf-001");
        assert_eq!(flow.action, ActionType::Consume);
        assert_eq!(flow.recipe_input_of, Some("rp-001".to_string()));
    }

    #[test]
    fn test_recipe_process() {
        let process = RecipeProcess::new("rp-001", "Baking")
            .with_description("Bake bread in oven")
            .with_duration(45, "minutes");

        assert_eq!(process.id, "rp-001");
        assert_eq!(process.name, "Baking");
        assert_eq!(process.has_duration, Some(45));
        assert_eq!(process.duration_unit, Some("minutes".to_string()));
    }

    #[test]
    fn test_recipe_exchange() {
        let exchange = RecipeExchange::new("re-001", "Standard Sale")
            .with_description("Exchange goods for currency");

        assert_eq!(exchange.id, "re-001");
        assert_eq!(exchange.name, "Standard Sale");
    }

    #[cfg(feature = "serde")]
    #[test]
    fn test_recipe_flow_serialization() {
        let flow = RecipeFlow::builder()
            .id("rf-001")
            .action(ActionType::Produce)
            .output_of("rp-001")
            .build()
            .unwrap();

        let json = serde_json::to_string(&flow).unwrap();
        let parsed: RecipeFlow = serde_json::from_str(&json).unwrap();
        assert_eq!(flow.id, parsed.id);
        assert_eq!(flow.action, parsed.action);
    }
}