rvf 0.1.0

Rust implementation of the ValueFlows vocabulary for distributed economic networks
Documentation
//! Proposals - offers and requests for economic activity
//!
//! Proposals publish intents for others to see and respond to,
//! enabling marketplace functionality and discovery.

use crate::error::{Error, Result};
use chrono::{DateTime, Utc};

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

/// Status of a proposal
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub enum ProposalStatus {
    /// The proposal is being drafted
    Draft,
    /// The proposal is open for responses
    Open,
    /// The proposal has been accepted
    Accepted,
    /// The proposal was rejected
    Rejected,
    /// The proposal has expired
    Expired,
    /// The proposal was withdrawn
    Withdrawn,
}

impl Default for ProposalStatus {
    fn default() -> Self {
        ProposalStatus::Draft
    }
}

/// A proposed intent - linking a proposal to an intent
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct ProposedIntent {
    /// Unique identifier
    pub id: String,
    /// The intent being proposed
    pub publishes: String,
    /// The proposal this belongs to
    pub published_in: String,
    /// Is this the primary intent in the proposal?
    pub reciprocal: bool,
    /// When this was created
    pub created_at: DateTime<Utc>,
}

impl ProposedIntent {
    /// Create a new proposed intent
    pub fn new(
        id: impl Into<String>,
        publishes: impl Into<String>,
        published_in: impl Into<String>,
        reciprocal: bool,
    ) -> Self {
        ProposedIntent {
            id: id.into(),
            publishes: publishes.into(),
            published_in: published_in.into(),
            reciprocal,
            created_at: Utc::now(),
        }
    }
}

/// A proposed to relationship - who can see/respond to a proposal
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct ProposedTo {
    /// Unique identifier
    pub id: String,
    /// The agent who is being proposed to
    pub proposed_to: String,
    /// The proposal
    pub proposed: String,
    /// When this was created
    pub created_at: DateTime<Utc>,
}

impl ProposedTo {
    /// Create a new ProposedTo relationship
    pub fn new(
        id: impl Into<String>,
        proposed_to: impl Into<String>,
        proposed: impl Into<String>,
    ) -> Self {
        ProposedTo {
            id: id.into(),
            proposed_to: proposed_to.into(),
            proposed: proposed.into(),
            created_at: Utc::now(),
        }
    }
}

/// A proposal publishes one or more intents for others to respond to
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct Proposal {
    /// Unique identifier
    pub id: String,
    /// Display name
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub name: Option<String>,
    /// Longer description
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub description: Option<String>,
    /// Location where this proposal is available
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub eligible_location: Option<String>,
    /// Status of the proposal
    pub status: ProposalStatus,
    /// When the proposal was published
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub has_beginning: Option<DateTime<Utc>>,
    /// When the proposal expires
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub has_end: Option<DateTime<Utc>>,
    /// A count of how many units can respond
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub unit_based: Option<bool>,
    /// The unit to count responders
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub unit_of_measure: 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>,
    /// When this was last updated
    pub updated_at: DateTime<Utc>,
}

impl Proposal {
    /// Create a new proposal builder
    pub fn builder() -> ProposalBuilder {
        ProposalBuilder::default()
    }

    /// Publish the proposal (make it open)
    pub fn publish(&mut self) {
        if self.status == ProposalStatus::Draft {
            self.status = ProposalStatus::Open;
            self.has_beginning = Some(Utc::now());
            self.updated_at = Utc::now();
        }
    }

    /// Accept the proposal
    pub fn accept(&mut self) {
        if self.status == ProposalStatus::Open {
            self.status = ProposalStatus::Accepted;
            self.updated_at = Utc::now();
        }
    }

    /// Reject the proposal
    pub fn reject(&mut self) {
        if self.status == ProposalStatus::Open {
            self.status = ProposalStatus::Rejected;
            self.updated_at = Utc::now();
        }
    }

    /// Withdraw the proposal
    pub fn withdraw(&mut self) {
        if matches!(self.status, ProposalStatus::Draft | ProposalStatus::Open) {
            self.status = ProposalStatus::Withdrawn;
            self.updated_at = Utc::now();
        }
    }

    /// Expire the proposal
    pub fn expire(&mut self) {
        if self.status == ProposalStatus::Open {
            self.status = ProposalStatus::Expired;
            self.has_end = Some(Utc::now());
            self.updated_at = Utc::now();
        }
    }

    /// Check if the proposal is open
    pub fn is_open(&self) -> bool {
        matches!(self.status, ProposalStatus::Open)
    }

    /// Check if the proposal has expired based on has_end
    pub fn is_expired(&self) -> bool {
        if let Some(end) = self.has_end {
            end < Utc::now()
        } else {
            false
        }
    }
}

/// Builder for Proposal
#[derive(Debug, Default)]
pub struct ProposalBuilder {
    id: Option<String>,
    name: Option<String>,
    description: Option<String>,
    eligible_location: Option<String>,
    has_beginning: Option<DateTime<Utc>>,
    has_end: Option<DateTime<Utc>>,
    unit_based: Option<bool>,
    unit_of_measure: Option<String>,
    in_scope_of: Option<String>,
    note: Option<String>,
}

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

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

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

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

    /// Set the beginning time
    pub fn has_beginning(mut self, time: DateTime<Utc>) -> Self {
        self.has_beginning = Some(time);
        self
    }

    /// Set the end time
    pub fn has_end(mut self, time: DateTime<Utc>) -> Self {
        self.has_end = Some(time);
        self
    }

    /// Set unit-based tracking
    pub fn unit_based(mut self, unit_based: bool) -> Self {
        self.unit_based = Some(unit_based);
        self
    }

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

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

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

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

        Ok(Proposal {
            id,
            name: self.name,
            description: self.description,
            eligible_location: self.eligible_location,
            status: ProposalStatus::Draft,
            has_beginning: self.has_beginning,
            has_end: self.has_end,
            unit_based: self.unit_based,
            unit_of_measure: self.unit_of_measure,
            in_scope_of: self.in_scope_of,
            note: self.note,
            created_at: now,
            updated_at: now,
        })
    }
}

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

    #[test]
    fn test_proposal_builder() {
        let proposal = Proposal::builder()
            .id("proposal-001")
            .name("Wheat for Sale")
            .description("100 bushels of organic wheat")
            .build()
            .unwrap();

        assert_eq!(proposal.id, "proposal-001");
        assert_eq!(proposal.name, Some("Wheat for Sale".to_string()));
        assert_eq!(proposal.status, ProposalStatus::Draft);
    }

    #[test]
    fn test_proposal_lifecycle() {
        let mut proposal = Proposal::builder()
            .id("proposal-001")
            .name("Test Proposal")
            .build()
            .unwrap();

        assert_eq!(proposal.status, ProposalStatus::Draft);

        proposal.publish();
        assert!(proposal.is_open());
        assert!(proposal.has_beginning.is_some());

        proposal.accept();
        assert_eq!(proposal.status, ProposalStatus::Accepted);
    }

    #[test]
    fn test_proposal_withdraw() {
        let mut proposal = Proposal::builder()
            .id("proposal-001")
            .name("Test Proposal")
            .build()
            .unwrap();

        proposal.withdraw();
        assert_eq!(proposal.status, ProposalStatus::Withdrawn);
    }

    #[test]
    fn test_proposed_intent() {
        let proposed_intent = ProposedIntent::new(
            "pi-001",
            "intent-001",
            "proposal-001",
            false,
        );

        assert_eq!(proposed_intent.publishes, "intent-001");
        assert_eq!(proposed_intent.published_in, "proposal-001");
        assert!(!proposed_intent.reciprocal);
    }

    #[test]
    fn test_proposed_to() {
        let proposed_to = ProposedTo::new(
            "pt-001",
            "agent-001",
            "proposal-001",
        );

        assert_eq!(proposed_to.proposed_to, "agent-001");
        assert_eq!(proposed_to.proposed, "proposal-001");
    }

    #[cfg(feature = "serde")]
    #[test]
    fn test_proposal_serialization() {
        let proposal = Proposal::builder()
            .id("proposal-001")
            .name("Test Proposal")
            .build()
            .unwrap();

        let json = serde_json::to_string(&proposal).unwrap();
        let parsed: Proposal = serde_json::from_str(&json).unwrap();
        assert_eq!(proposal.id, parsed.id);
    }
}