Skip to main content

made_core/entities/
proposal.rs

1//! [`Proposal`] entity.
2//!
3//! A proposal is a concrete solution artifact authored by an agent
4//! during a deliberation. It has identity (`proposal_id`) and can be
5//! revised in place: a revision replaces its content while keeping the
6//! same identity — mirrors the Python reference implementation.
7
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10
11use crate::error::DomainError;
12use crate::value_objects::{AgentId, Attributes, ProposalContent, ProposalId, Specialty};
13
14/// Authored proposal inside a deliberation.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Proposal {
17    id: ProposalId,
18    author: AgentId,
19    specialty: Specialty,
20    content: ProposalContent,
21    attributes: Attributes,
22    #[serde(with = "time::serde::rfc3339")]
23    created_at: OffsetDateTime,
24    #[serde(with = "time::serde::rfc3339")]
25    updated_at: OffsetDateTime,
26    revision_count: u32,
27}
28
29impl Proposal {
30    /// Create a new proposal.
31    ///
32    /// The content must not be empty.
33    pub fn new(
34        id: ProposalId,
35        author: AgentId,
36        specialty: Specialty,
37        content: impl Into<ProposalContent>,
38        attributes: Attributes,
39        now: OffsetDateTime,
40    ) -> Result<Self, DomainError> {
41        let content = content.into();
42        if content.as_str().trim().is_empty() {
43            return Err(DomainError::EmptyField {
44                field: "proposal.content",
45            });
46        }
47        Ok(Self {
48            id,
49            author,
50            specialty,
51            content,
52            attributes,
53            created_at: now,
54            updated_at: now,
55            revision_count: 0,
56        })
57    }
58
59    /// Replace the content (e.g. after peer-review revision).
60    /// Increments the revision counter and refreshes `updated_at`.
61    pub fn revise(
62        &mut self,
63        new_content: impl Into<ProposalContent>,
64        now: OffsetDateTime,
65    ) -> Result<(), DomainError> {
66        let content = new_content.into();
67        if content.as_str().trim().is_empty() {
68            return Err(DomainError::EmptyField {
69                field: "proposal.content",
70            });
71        }
72        self.content = content;
73        self.updated_at = now;
74        self.revision_count = self.revision_count.saturating_add(1);
75        Ok(())
76    }
77
78    #[must_use]
79    pub fn id(&self) -> &ProposalId {
80        &self.id
81    }
82    #[must_use]
83    pub fn author(&self) -> &AgentId {
84        &self.author
85    }
86    #[must_use]
87    pub fn specialty(&self) -> &Specialty {
88        &self.specialty
89    }
90    #[must_use]
91    pub const fn content(&self) -> &ProposalContent {
92        &self.content
93    }
94    #[must_use]
95    pub fn attributes(&self) -> &Attributes {
96        &self.attributes
97    }
98    #[must_use]
99    pub fn created_at(&self) -> OffsetDateTime {
100        self.created_at
101    }
102    #[must_use]
103    pub fn updated_at(&self) -> OffsetDateTime {
104        self.updated_at
105    }
106    #[must_use]
107    pub fn revision_count(&self) -> u32 {
108        self.revision_count
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use time::macros::datetime;
116
117    fn at() -> OffsetDateTime {
118        datetime!(2026-04-15 12:00:00 UTC)
119    }
120
121    fn make(content: &str) -> Result<Proposal, DomainError> {
122        Proposal::new(
123            ProposalId::new("p1").unwrap(),
124            AgentId::new("a1").unwrap(),
125            Specialty::new("triage").unwrap(),
126            content,
127            Attributes::empty(),
128            at(),
129        )
130    }
131
132    #[test]
133    fn new_requires_non_empty_content() {
134        assert!(matches!(
135            make("").unwrap_err(),
136            DomainError::EmptyField {
137                field: "proposal.content"
138            }
139        ));
140        assert!(matches!(
141            make("   \n").unwrap_err(),
142            DomainError::EmptyField { .. }
143        ));
144    }
145
146    #[test]
147    fn new_seeds_timestamps_and_zero_revisions() {
148        let p = make("plan A").unwrap();
149        assert_eq!(p.created_at(), at());
150        assert_eq!(p.updated_at(), at());
151        assert_eq!(p.revision_count(), 0);
152        assert_eq!(p.content(), "plan A");
153    }
154
155    #[test]
156    fn revise_updates_content_and_bumps_revision() {
157        let mut p = make("plan A").unwrap();
158        let later = datetime!(2026-04-15 12:00:05 UTC);
159        p.revise("plan A prime", later).unwrap();
160        assert_eq!(p.content(), "plan A prime");
161        assert_eq!(p.revision_count(), 1);
162        assert_eq!(p.updated_at(), later);
163        assert_eq!(p.created_at(), at()); // created_at unchanged
164    }
165
166    #[test]
167    fn revise_preserves_identity() {
168        let mut p = make("x").unwrap();
169        let id_before = p.id().clone();
170        p.revise("y", at()).unwrap();
171        assert_eq!(p.id(), &id_before);
172    }
173
174    #[test]
175    fn revise_rejects_empty_content() {
176        let mut p = make("x").unwrap();
177        assert!(p.revise("   ", at()).is_err());
178        assert_eq!(p.content(), "x");
179        assert_eq!(p.revision_count(), 0);
180    }
181
182    #[test]
183    fn revision_count_saturates() {
184        let mut p = make("x").unwrap();
185        for _ in 0..3 {
186            p.revise("y", at()).unwrap();
187        }
188        assert_eq!(p.revision_count(), 3);
189    }
190}