1use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11
12use crate::case::{CaseKey, CaseRef};
13use crate::command::CommandOrigin;
14use crate::hash::{Digest, digest_hex};
15use crate::ids::{AccountId, CaseRevision, OperationKey, TargetToken, TurnId, WorkflowKey};
16use crate::understanding::ActId;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TargetCandidate {
21 pub token: TargetToken,
23 pub case_ref: CaseRef,
25 pub label: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35#[non_exhaustive]
36pub enum TargetResolution {
37 Exact {
39 case_ref: CaseRef,
41 },
42 Ambiguous {
44 candidates: Vec<TargetCandidate>,
46 },
47 Missing,
49 Unauthorized,
51 Stale {
53 case_ref: CaseRef,
55 current_revision: CaseRevision,
57 },
58}
59
60impl TargetResolution {
61 #[must_use]
63 pub fn exact(&self) -> Option<&CaseRef> {
64 match self {
65 Self::Exact { case_ref } => Some(case_ref),
66 _ => None,
67 }
68 }
69
70 #[must_use]
72 pub fn is_exact(&self) -> bool {
73 matches!(self, Self::Exact { .. })
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "kind", rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum ResolvedActKind {
85 ApplyOperation {
87 operation: OperationKey,
89 },
90 StartWorkflow,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ResolvedAct {
98 pub act: ActId,
100 pub kind: ResolvedActKind,
102 pub case_ref: CaseRef,
104 pub arguments: serde_json::Value,
106 pub evidence_digest: Digest,
108}
109
110impl ResolvedAct {
111 #[must_use]
113 pub fn operation(&self) -> Option<&OperationKey> {
114 match &self.kind {
115 ResolvedActKind::ApplyOperation { operation } => Some(operation),
116 ResolvedActKind::StartWorkflow => None,
117 }
118 }
119
120 #[must_use]
122 pub fn direct_origin(&self) -> CommandOrigin {
123 CommandOrigin::DirectSafeUserAct {
124 evidence_digest: self.evidence_digest.clone(),
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(tag = "kind", rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum TokenStatus {
136 Authorized,
138 Missing,
140 Stale {
142 current_revision: CaseRevision,
144 },
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct TargetEntry {
150 pub case_ref: CaseRef,
152 pub label: String,
154 pub status: TokenStatus,
156}
157
158const TOKEN_DOMAIN: &str = "turnframe.target.v1";
159const TOKEN_PREFIX: &str = "t_";
160const TOKEN_MIN_HEX: usize = 12;
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct TargetTokenMap {
165 account_id: AccountId,
166 turn_id: TurnId,
167 entries: IndexMap<TargetToken, TargetEntry>,
168}
169
170impl TargetTokenMap {
171 #[must_use]
173 pub fn new(account_id: AccountId, turn_id: TurnId) -> Self {
174 Self {
175 account_id,
176 turn_id,
177 entries: IndexMap::new(),
178 }
179 }
180
181 #[must_use]
183 pub fn account_id(&self) -> &AccountId {
184 &self.account_id
185 }
186
187 #[must_use]
189 pub fn turn_id(&self) -> &TurnId {
190 &self.turn_id
191 }
192
193 pub fn issue(&mut self, case_ref: CaseRef, label: impl Into<String>) -> TargetToken {
200 let key = case_ref.key();
201 if let Some((token, entry)) = self
202 .entries
203 .iter_mut()
204 .find(|(_, e)| e.case_ref.key() == key)
205 {
206 entry.case_ref = case_ref;
207 entry.label = label.into();
208 entry.status = TokenStatus::Authorized;
209 return token.clone();
210 }
211 let token = self.fresh_token(&key);
212 self.entries.insert(
213 token.clone(),
214 TargetEntry {
215 case_ref,
216 label: label.into(),
217 status: TokenStatus::Authorized,
218 },
219 );
220 token
221 }
222
223 fn fresh_token(&self, key: &CaseKey) -> TargetToken {
224 let material = format!(
225 "{TOKEN_DOMAIN}\u{0}{}\u{0}{}\u{0}{}\u{0}{}",
226 self.account_id, self.turn_id, key.workflow, key.case_id
227 );
228 let hex = digest_hex(material.as_bytes());
229 let mut len = TOKEN_MIN_HEX;
230 loop {
231 let candidate = TargetToken::from(format!("{TOKEN_PREFIX}{}", &hex[..len]));
232 if !self.entries.contains_key(&candidate) || len >= hex.len() {
233 return candidate;
234 }
235 len = (len + 4).min(hex.len());
236 }
237 }
238
239 #[must_use]
245 pub fn resolve(&self, account: &AccountId, token: &TargetToken) -> TargetResolution {
246 if account != &self.account_id {
247 return TargetResolution::Unauthorized;
248 }
249 match self.entries.get(token) {
250 None => TargetResolution::Unauthorized,
251 Some(entry) => match &entry.status {
252 TokenStatus::Authorized => TargetResolution::Exact {
253 case_ref: entry.case_ref.clone(),
254 },
255 TokenStatus::Missing => TargetResolution::Missing,
256 TokenStatus::Stale { current_revision } => TargetResolution::Stale {
257 case_ref: entry.case_ref.clone(),
258 current_revision: *current_revision,
259 },
260 },
261 }
262 }
263
264 pub fn mark_missing(&mut self, token: &TargetToken) -> bool {
266 match self.entries.get_mut(token) {
267 Some(entry) => {
268 entry.status = TokenStatus::Missing;
269 true
270 }
271 None => false,
272 }
273 }
274
275 pub fn mark_stale(&mut self, token: &TargetToken, current_revision: CaseRevision) -> bool {
278 match self.entries.get_mut(token) {
279 Some(entry) => {
280 entry.status = TokenStatus::Stale { current_revision };
281 true
282 }
283 None => false,
284 }
285 }
286
287 #[must_use]
289 pub fn token_for(&self, key: &CaseKey) -> Option<&TargetToken> {
290 self.entries
291 .iter()
292 .find(|(_, e)| e.case_ref.key() == *key)
293 .map(|(token, _)| token)
294 }
295
296 #[must_use]
299 pub fn get(&self, token: &TargetToken) -> Option<&TargetEntry> {
300 self.entries.get(token)
301 }
302
303 #[must_use]
305 pub fn candidates(&self) -> Vec<TargetCandidate> {
306 self.entries
307 .iter()
308 .filter(|(_, e)| e.status == TokenStatus::Authorized)
309 .map(|(token, e)| TargetCandidate {
310 token: token.clone(),
311 case_ref: e.case_ref.clone(),
312 label: e.label.clone(),
313 })
314 .collect()
315 }
316
317 #[must_use]
319 pub fn candidates_for(&self, workflow: &WorkflowKey) -> Vec<TargetCandidate> {
320 self.candidates()
321 .into_iter()
322 .filter(|c| &c.case_ref.workflow == workflow)
323 .collect()
324 }
325
326 pub fn iter(&self) -> impl Iterator<Item = (&TargetToken, &TargetEntry)> {
328 self.entries.iter()
329 }
330
331 #[must_use]
333 pub fn len(&self) -> usize {
334 self.entries.len()
335 }
336
337 #[must_use]
339 pub fn is_empty(&self) -> bool {
340 self.entries.is_empty()
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn map() -> TargetTokenMap {
349 TargetTokenMap::new(AccountId::from("acct"), TurnId::nil())
350 }
351
352 #[test]
353 fn issue_is_deterministic_and_opaque() {
354 let mut a = map();
355 let mut b = map();
356 let case = CaseRef::new("trip", "trip-42", CaseRevision(3));
357 let t1 = a.issue(case.clone(), "Trip 42");
358 let t2 = b.issue(case.clone(), "Trip 42");
359 assert_eq!(t1, t2);
360 assert!(t1.as_str().starts_with("t_"));
361 assert!(!t1.as_str().contains("trip-42"));
362 assert_eq!(a.issue(case.with_revision(CaseRevision(4)), "Trip 42"), t1);
363 assert_eq!(a.len(), 1);
364 assert_eq!(
365 a.get(&t1).unwrap().case_ref.expected_revision,
366 CaseRevision(4)
367 );
368 }
369
370 #[test]
371 fn tokens_are_bound_to_their_account_and_turn() {
372 let case = CaseRef::new("trip", "trip-42", CaseRevision(3));
373 let mut mine = map();
374 let token = mine.issue(case.clone(), "Trip 42");
375 let mut other_account = TargetTokenMap::new(AccountId::from("other"), TurnId::nil());
376 let mut other_turn = TargetTokenMap::new(AccountId::from("acct"), TurnId::new());
377 assert_ne!(
378 token,
379 other_account.issue(case.clone(), "Trip 42"),
380 "another tenant never gets the same token for the same case"
381 );
382 assert_ne!(
383 token,
384 other_turn.issue(case, "Trip 42"),
385 "a token issued in another turn is a different token"
386 );
387 assert_eq!(
389 other_turn.resolve(&AccountId::from("acct"), &token),
390 TargetResolution::Unauthorized
391 );
392 }
393
394 #[test]
395 fn unknown_and_foreign_tokens_are_indistinguishable() {
396 let mut m = map();
397 let token = m.issue(CaseRef::new("trip", "i1", CaseRevision(1)), "l");
398 assert_eq!(
399 m.resolve(&AccountId::from("other"), &token),
400 TargetResolution::Unauthorized
401 );
402 assert_eq!(
403 m.resolve(
404 &AccountId::from("acct"),
405 &TargetToken::from("t_deadbeef0000")
406 ),
407 TargetResolution::Unauthorized
408 );
409 assert!(m.resolve(&AccountId::from("acct"), &token).is_exact());
410 }
411
412 #[test]
413 fn missing_and_stale_are_kept_for_issued_tokens() {
414 let mut m = map();
415 let token = m.issue(CaseRef::new("trip", "i1", CaseRevision(1)), "l");
416 assert!(m.mark_stale(&token, CaseRevision(2)));
417 assert_eq!(
418 m.resolve(&AccountId::from("acct"), &token),
419 TargetResolution::Stale {
420 case_ref: CaseRef::new("trip", "i1", CaseRevision(1)),
421 current_revision: CaseRevision(2)
422 }
423 );
424 assert!(m.mark_missing(&token));
425 assert_eq!(
426 m.resolve(&AccountId::from("acct"), &token),
427 TargetResolution::Missing
428 );
429 assert!(!m.mark_missing(&TargetToken::from("nope")));
430 assert!(m.candidates().is_empty());
431 }
432
433 #[test]
434 fn candidates_filter_by_workflow() {
435 let mut m = map();
436 m.issue(CaseRef::new("trip", "i1", CaseRevision(1)), "a");
437 m.issue(CaseRef::new("traveler", "c1", CaseRevision(1)), "b");
438 assert_eq!(m.candidates().len(), 2);
439 assert_eq!(m.candidates_for(&WorkflowKey::from("trip")).len(), 1);
440 assert!(m.token_for(&CaseKey::new("traveler", "c1")).is_some());
441 }
442}