1use std::collections::BTreeMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::error::Result;
10use crate::model::{ActorRef, KeepsakeId, RelationId, RelationSpec, SubjectRef};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct CommandContext {
15 pub actor: ActorRef,
17 pub idempotency_key: Option<String>,
19 pub metadata: BTreeMap<String, String>,
21}
22
23impl CommandContext {
24 #[must_use]
26 pub const fn new(actor: ActorRef) -> Self {
27 Self {
28 actor,
29 idempotency_key: None,
30 metadata: BTreeMap::new(),
31 }
32 }
33
34 #[must_use]
36 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
37 self.idempotency_key = Some(key.into());
38 self
39 }
40
41 #[must_use]
43 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
44 self.metadata.insert(key.into(), value.into());
45 self
46 }
47
48 pub fn validate(&self) -> Result<()> {
50 self.actor.validate()
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ApplyKeepsake {
57 pub id: KeepsakeId,
59 pub subject: SubjectRef,
61 pub relation_id: RelationId,
63 pub at: DateTime<Utc>,
65 pub metadata: BTreeMap<String, String>,
67 pub context: CommandContext,
69}
70
71impl ApplyKeepsake {
72 #[must_use]
74 pub fn new(
75 subject: SubjectRef,
76 relation_id: RelationId,
77 at: DateTime<Utc>,
78 context: CommandContext,
79 ) -> Self {
80 Self {
81 id: Uuid::now_v7(),
82 subject,
83 relation_id,
84 at,
85 metadata: BTreeMap::new(),
86 context,
87 }
88 }
89
90 #[must_use]
92 pub fn for_spec<Spec>(subject: SubjectRef, at: DateTime<Utc>, context: CommandContext) -> Self
93 where
94 Spec: RelationSpec,
95 {
96 Self::new(subject, Spec::ID, at, context)
97 }
98
99 #[must_use]
101 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
102 self.metadata.insert(key.into(), value.into());
103 self
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct RevokeKeepsake {
110 pub keepsake_id: KeepsakeId,
112 pub at: DateTime<Utc>,
114 pub context: CommandContext,
116}
117
118impl RevokeKeepsake {
119 #[must_use]
121 pub const fn new(keepsake_id: KeepsakeId, at: DateTime<Utc>, context: CommandContext) -> Self {
122 Self {
123 keepsake_id,
124 at,
125 context,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct RevokeBySubject {
137 pub subject: SubjectRef,
139 pub relation_id: RelationId,
141 pub at: DateTime<Utc>,
143 pub context: CommandContext,
145}
146
147impl RevokeBySubject {
148 #[must_use]
150 pub const fn new(
151 subject: SubjectRef,
152 relation_id: RelationId,
153 at: DateTime<Utc>,
154 context: CommandContext,
155 ) -> Self {
156 Self {
157 subject,
158 relation_id,
159 at,
160 context,
161 }
162 }
163
164 #[must_use]
166 pub const fn for_spec<Spec>(
167 subject: SubjectRef,
168 at: DateTime<Utc>,
169 context: CommandContext,
170 ) -> Self
171 where
172 Spec: RelationSpec,
173 {
174 Self::new(subject, Spec::ID, at, context)
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use chrono::Utc;
181 use uuid::Uuid;
182
183 use super::*;
184 use crate::model::{ActorRef, StaticRelationKey, SubjectRef};
185 use crate::{ExpiryPolicy, RelationSpec};
186
187 struct TrustedTag;
188
189 impl RelationSpec for TrustedTag {
190 const ID: Uuid = Uuid::from_u128(1);
191 const KEY: StaticRelationKey = StaticRelationKey::new("tag", "trusted");
192
193 fn expiry(_at: chrono::DateTime<chrono::Utc>) -> ExpiryPolicy {
194 ExpiryPolicy::ManualOnly
195 }
196 }
197
198 #[test]
199 fn command_context_builder_sets_idempotency_and_metadata() -> crate::Result<()> {
200 let context = CommandContext::new(ActorRef::new("user", "admin")?)
201 .with_idempotency_key("request-1")
202 .with_metadata("request_id", "req_123");
203
204 assert_eq!(context.actor, ActorRef::new("user", "admin")?);
205 assert_eq!(context.idempotency_key.as_deref(), Some("request-1"));
206 assert_eq!(
207 context.metadata.get("request_id").map(String::as_str),
208 Some("req_123")
209 );
210 Ok(())
211 }
212
213 #[test]
214 fn apply_builder_attaches_metadata() -> crate::Result<()> {
215 let command = ApplyKeepsake::new(
216 SubjectRef::new("account", "acct_123")?,
217 Uuid::nil(),
218 Utc::now(),
219 CommandContext::new(ActorRef::new("system", "worker")?),
220 )
221 .with_metadata("source", "support");
222
223 assert_eq!(
224 command.metadata.get("source").map(String::as_str),
225 Some("support")
226 );
227 Ok(())
228 }
229
230 #[test]
231 fn typed_apply_and_revoke_constructors_set_command_fields() -> crate::Result<()> {
232 let at = Utc::now();
233 let context = CommandContext::new(ActorRef::new("system", "worker")?);
234 let apply = ApplyKeepsake::for_spec::<TrustedTag>(
235 SubjectRef::new("account", "acct_123")?,
236 at,
237 context.clone(),
238 );
239
240 assert_eq!(apply.relation_id, TrustedTag::ID);
241 assert_eq!(apply.at, at);
242 assert_eq!(apply.context, context);
243
244 let revoke = RevokeKeepsake::new(apply.id, at, apply.context.clone());
245 assert_eq!(revoke.keepsake_id, apply.id);
246 assert_eq!(revoke.at, at);
247 assert_eq!(revoke.context, apply.context);
248 Ok(())
249 }
250
251 #[test]
252 fn revoke_by_subject_constructors_set_command_fields() -> crate::Result<()> {
253 let at = Utc::now();
254 let subject = SubjectRef::new("account", "acct_123")?;
255 let context = CommandContext::new(ActorRef::new("user", "moderator")?);
256
257 let by_id = RevokeBySubject::new(subject.clone(), Uuid::nil(), at, context.clone());
258 assert_eq!(by_id.subject, subject);
259 assert_eq!(by_id.relation_id, Uuid::nil());
260 assert_eq!(by_id.at, at);
261 assert_eq!(by_id.context, context);
262
263 let by_spec = RevokeBySubject::for_spec::<TrustedTag>(subject, at, context);
264 assert_eq!(by_spec.relation_id, TrustedTag::ID);
265 Ok(())
266 }
267}