1use std::collections::{BTreeMap, BTreeSet};
19use std::sync::Arc;
20
21use indexmap::IndexMap;
22use turnframe_core::case::CaseRef;
23use turnframe_core::hash::derive_uuid;
24use turnframe_core::ids::{
25 AccountId, CaseId, CaseRevision, OriginToken, TargetToken, TurnId, WorkflowKey,
26};
27use turnframe_core::operation::OperationSpec;
28use turnframe_core::plan::TargetPolicy;
29use turnframe_core::reduce::ActiveInteractionSummary;
30use turnframe_core::target::{TargetCandidate, TargetResolution, TargetTokenMap};
31use turnframe_core::understanding::{ActAction, ActId, ActTarget, UnderstoodAct};
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AuthorizedCase {
36 pub case_ref: CaseRef,
38 pub label: String,
40 pub subject_only_when_named: bool,
43}
44
45impl AuthorizedCase {
46 #[must_use]
48 pub fn new(case_ref: CaseRef, label: impl Into<String>) -> Self {
49 Self {
50 case_ref,
51 label: label.into(),
52 subject_only_when_named: false,
53 }
54 }
55
56 #[must_use]
58 pub const fn reachable_only(mut self) -> Self {
59 self.subject_only_when_named = true;
60 self
61 }
62}
63
64pub trait CaseIdFactory: Send + Sync {
69 fn new_case_id(&self, workflow: &WorkflowKey, turn_id: &TurnId, act: ActId) -> CaseId;
71}
72
73const NEW_CASE_DOMAIN: &str = "turnframe.new_case.v2";
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub struct DerivedCaseIdFactory;
79
80impl CaseIdFactory for DerivedCaseIdFactory {
81 fn new_case_id(&self, workflow: &WorkflowKey, turn_id: &TurnId, act: ActId) -> CaseId {
82 let uuid = derive_uuid(
83 NEW_CASE_DOMAIN,
84 &[&turn_id.to_string(), workflow.as_str(), &act.to_string()],
85 );
86 CaseId::from(format!("tf_{}", uuid.simple()))
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum TargetOutcome {
94 Resolved {
96 resolution: TargetResolution,
98 new_case: bool,
100 },
101 SameTurn {
103 case_ref: CaseRef,
105 },
106 NoActiveInteraction,
108 PolicyMismatch {
110 policy: TargetPolicy,
112 },
113 SeveralOpenCases,
115 NoTarget,
117}
118
119impl TargetOutcome {
120 #[must_use]
122 pub const fn resolved(resolution: TargetResolution) -> Self {
123 Self::Resolved {
124 resolution,
125 new_case: false,
126 }
127 }
128
129 #[must_use]
131 pub fn exact(&self) -> Option<&CaseRef> {
132 match self {
133 Self::Resolved { resolution, .. } => resolution.exact(),
134 Self::SameTurn { case_ref } => Some(case_ref),
135 _ => None,
136 }
137 }
138
139 #[must_use]
141 pub fn resolution(&self) -> Option<TargetResolution> {
142 match self {
143 Self::Resolved { resolution, .. } => Some(resolution.clone()),
144 Self::SameTurn { case_ref } => Some(TargetResolution::Exact {
145 case_ref: case_ref.clone(),
146 }),
147 _ => None,
148 }
149 }
150
151 #[must_use]
153 pub const fn is_new_case(&self) -> bool {
154 matches!(self, Self::Resolved { new_case: true, .. })
155 }
156
157 #[must_use]
160 pub const fn is_unborn(&self) -> bool {
161 matches!(
162 self,
163 Self::Resolved { new_case: true, .. } | Self::SameTurn { .. }
164 )
165 }
166}
167
168#[derive(Clone)]
170pub struct TargetResolverBuilder {
171 account_id: AccountId,
172 turn_id: TurnId,
173 candidates: Vec<AuthorizedCase>,
174 origins: IndexMap<OriginToken, CaseRef>,
175 active_interaction: Option<ActiveInteractionSummary>,
176 resuming: BTreeSet<WorkflowKey>,
177 case_ids: Arc<dyn CaseIdFactory>,
178}
179
180impl std::fmt::Debug for TargetResolverBuilder {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("TargetResolverBuilder")
183 .field("account_id", &self.account_id)
184 .field("turn_id", &self.turn_id)
185 .field("candidates", &self.candidates.len())
186 .finish_non_exhaustive()
187 }
188}
189
190impl TargetResolverBuilder {
191 #[must_use]
193 pub fn candidate(mut self, candidate: AuthorizedCase) -> Self {
194 self.candidates.push(candidate);
195 self
196 }
197
198 #[must_use]
200 pub fn candidates(mut self, candidates: impl IntoIterator<Item = AuthorizedCase>) -> Self {
201 self.candidates.extend(candidates);
202 self
203 }
204
205 #[must_use]
207 pub fn origin(mut self, origin: OriginToken, candidate: AuthorizedCase) -> Self {
208 self.origins.insert(origin, candidate.case_ref.clone());
209 self.candidates.push(candidate);
210 self
211 }
212
213 #[must_use]
215 pub fn active_interaction(mut self, summary: ActiveInteractionSummary) -> Self {
216 self.active_interaction = Some(summary);
217 self
218 }
219
220 #[must_use]
222 pub fn resuming(mut self, workflow: WorkflowKey) -> Self {
223 self.resuming.insert(workflow);
224 self
225 }
226
227 #[must_use]
229 pub fn case_id_factory(mut self, factory: Arc<dyn CaseIdFactory>) -> Self {
230 self.case_ids = factory;
231 self
232 }
233
234 #[must_use]
236 pub fn build(self) -> TargetResolver {
237 let mut resolver = TargetResolver {
238 tokens: TargetTokenMap::new(self.account_id.clone(), self.turn_id),
239 account_id: self.account_id,
240 turn_id: self.turn_id,
241 candidates: IndexMap::new(),
242 origins: self.origins,
243 active_interaction: self.active_interaction,
244 resuming: self.resuming,
245 case_ids: self.case_ids,
246 };
247 for candidate in self.candidates {
248 resolver.admit(candidate);
249 }
250 resolver
251 }
252}
253
254#[derive(Clone)]
256pub struct TargetResolver {
257 account_id: AccountId,
258 turn_id: TurnId,
259 tokens: TargetTokenMap,
260 candidates: IndexMap<TargetToken, AuthorizedCase>,
261 origins: IndexMap<OriginToken, CaseRef>,
262 active_interaction: Option<ActiveInteractionSummary>,
263 resuming: BTreeSet<WorkflowKey>,
264 case_ids: Arc<dyn CaseIdFactory>,
265}
266
267impl std::fmt::Debug for TargetResolver {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 f.debug_struct("TargetResolver")
270 .field("account_id", &self.account_id)
271 .field("turn_id", &self.turn_id)
272 .field("tokens", &self.tokens.len())
273 .finish_non_exhaustive()
274 }
275}
276
277impl TargetResolver {
278 #[must_use]
280 pub fn builder(account_id: AccountId, turn_id: TurnId) -> TargetResolverBuilder {
281 TargetResolverBuilder {
282 account_id,
283 turn_id,
284 candidates: Vec::new(),
285 origins: IndexMap::new(),
286 active_interaction: None,
287 resuming: BTreeSet::new(),
288 case_ids: Arc::new(DerivedCaseIdFactory),
289 }
290 }
291
292 pub fn admit(&mut self, candidate: AuthorizedCase) -> TargetToken {
295 let token = self
296 .tokens
297 .issue(candidate.case_ref.clone(), candidate.label.clone());
298 self.candidates.insert(token.clone(), candidate);
299 token
300 }
301
302 #[must_use]
304 pub const fn account_id(&self) -> &AccountId {
305 &self.account_id
306 }
307
308 #[must_use]
310 pub const fn turn_id(&self) -> &TurnId {
311 &self.turn_id
312 }
313
314 #[must_use]
316 pub const fn token_map(&self) -> &TargetTokenMap {
317 &self.tokens
318 }
319
320 #[must_use]
322 pub fn catalog(&self) -> Vec<TargetCandidate> {
323 self.tokens.candidates()
324 }
325
326 #[must_use]
328 pub fn catalog_for(&self, workflow: &WorkflowKey) -> Vec<TargetCandidate> {
329 self.tokens.candidates_for(workflow)
330 }
331
332 #[must_use]
334 pub fn candidate(&self, token: &TargetToken) -> Option<&AuthorizedCase> {
335 self.candidates.get(token)
336 }
337
338 pub fn in_view(&self) -> impl Iterator<Item = &AuthorizedCase> {
340 self.candidates.values()
341 }
342
343 #[must_use]
345 pub const fn active_interaction(&self) -> Option<&ActiveInteractionSummary> {
346 self.active_interaction.as_ref()
347 }
348
349 #[must_use]
352 pub fn resolve_origin(&self, origin: &OriginToken) -> TargetResolution {
353 match self.origins.get(origin) {
354 Some(case_ref) => TargetResolution::Exact {
355 case_ref: case_ref.clone(),
356 },
357 None => TargetResolution::Unauthorized,
358 }
359 }
360
361 #[must_use]
363 pub fn origin_token(&self, origin: &OriginToken) -> Option<&TargetToken> {
364 let case_ref = self.origins.get(origin)?;
365 self.tokens.token_for(&case_ref.key())
366 }
367
368 #[must_use]
371 pub fn resolve(
372 &self,
373 act: &UnderstoodAct,
374 spec: Option<&OperationSpec>,
375 same_turn: &BTreeMap<ActId, CaseRef>,
376 ) -> TargetOutcome {
377 if let ActAction::Start { workflow } = &act.action {
378 return self.start(workflow, act.id);
379 }
380 let policy = spec.map_or(TargetPolicy::RequiresExistingCase, |spec| {
381 spec.target_policy
382 });
383 if !policy.permits(&act.target) {
384 return TargetOutcome::PolicyMismatch { policy };
385 }
386 match &act.target {
387 ActTarget::Record { token } => {
388 TargetOutcome::resolved(self.tokens.resolve(&self.account_id, token))
389 }
390 ActTarget::New { workflow } => self.mint_case(workflow, act.id),
391 ActTarget::SameTurn { act: earlier } => match same_turn.get(earlier) {
392 Some(case_ref) => TargetOutcome::SameTurn {
393 case_ref: case_ref.clone(),
394 },
395 None => TargetOutcome::resolved(TargetResolution::Missing),
396 },
397 ActTarget::Card => match &self.active_interaction {
398 Some(summary) => TargetOutcome::resolved(TargetResolution::Exact {
399 case_ref: summary.case_ref.clone(),
400 }),
401 None => TargetOutcome::NoActiveInteraction,
402 },
403 ActTarget::Ambiguous { candidates } => {
404 let candidates: Vec<TargetCandidate> = candidates
405 .iter()
406 .filter(|token| self.tokens.resolve(&self.account_id, token).is_exact())
407 .filter_map(|token| self.candidate_view(token))
408 .collect();
409 TargetOutcome::resolved(match <[TargetCandidate; 1]>::try_from(candidates) {
410 Ok([only]) => TargetResolution::Exact {
411 case_ref: only.case_ref,
412 },
413 Err(several) if several.is_empty() => TargetResolution::Missing,
414 Err(several) => TargetResolution::Ambiguous {
415 candidates: several,
416 },
417 })
418 }
419 ActTarget::NotListed { .. } => TargetOutcome::resolved(TargetResolution::Missing),
420 _ => TargetOutcome::NoTarget,
421 }
422 }
423
424 fn start(&self, workflow: &WorkflowKey, act: ActId) -> TargetOutcome {
427 if !self.resuming.contains(workflow) {
428 return self.mint_case(workflow, act);
429 }
430 let open: Vec<TargetResolution> = self
431 .candidates
432 .iter()
433 .filter(|(_, candidate)| &candidate.case_ref.workflow == workflow)
434 .map(|(token, _)| self.tokens.resolve(&self.account_id, token))
435 .filter(TargetResolution::is_exact)
436 .collect();
437 match <[TargetResolution; 1]>::try_from(open) {
438 Ok([only]) => TargetOutcome::resolved(only),
439 Err(several) if several.is_empty() => self.mint_case(workflow, act),
440 Err(_) => TargetOutcome::SeveralOpenCases,
441 }
442 }
443
444 fn mint_case(&self, workflow: &WorkflowKey, act: ActId) -> TargetOutcome {
445 let case_id = self.case_ids.new_case_id(workflow, &self.turn_id, act);
446 TargetOutcome::Resolved {
447 resolution: TargetResolution::Exact {
448 case_ref: CaseRef::new(workflow.clone(), case_id, CaseRevision::ZERO),
449 },
450 new_case: true,
451 }
452 }
453
454 fn candidate_view(&self, token: &TargetToken) -> Option<TargetCandidate> {
455 let entry = self.tokens.get(token)?;
456 Some(TargetCandidate {
457 token: token.clone(),
458 case_ref: entry.case_ref.clone(),
459 label: entry.label.clone(),
460 })
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use turnframe_core::hash::Digest;
468 use turnframe_core::ids::{InteractionId, OptionId};
469 use turnframe_core::interaction::{InteractionKind, TextResolutionPolicy};
470 use turnframe_core::understanding::{ActStatus, UnitId, WordRange};
471
472 fn case(id: &str, revision: u64) -> CaseRef {
473 CaseRef::new("trip", id, CaseRevision(revision))
474 }
475
476 fn resolver() -> TargetResolver {
477 TargetResolver::builder(AccountId::from("acct"), TurnId::nil())
478 .candidate(AuthorizedCase::new(case("trip-1", 3), "Trip 1"))
479 .candidate(AuthorizedCase::new(case("trip-2", 5), "Trip 2"))
480 .build()
481 }
482
483 fn act(target: ActTarget) -> UnderstoodAct {
484 UnderstoodAct {
485 id: ActId::new(UnitId(1), 1),
486 action: ActAction::Apply {
487 operation: "trip.set_name".into(),
488 },
489 target,
490 arguments: BTreeMap::new(),
491 words: WordRange {
492 first: 0,
493 last: 0,
494 start: 0,
495 end: 1,
496 },
497 depends_on: Vec::new(),
498 status: ActStatus::Ready,
499 }
500 }
501
502 fn spec(policy: TargetPolicy) -> OperationSpec {
503 OperationSpec::new("trip.set_name")
504 .summary("s")
505 .target(policy)
506 }
507
508 fn token(resolver: &TargetResolver, id: &str, revision: u64) -> TargetToken {
509 resolver
510 .token_map()
511 .token_for(&case(id, revision).key())
512 .unwrap()
513 .clone()
514 }
515
516 #[test]
517 fn a_token_resolves_through_the_map_and_a_foreign_one_does_not() {
518 let resolver = resolver();
519 let known = act(ActTarget::Record {
520 token: token(&resolver, "trip-1", 3),
521 });
522 let spec = spec(TargetPolicy::RequiresExistingCase);
523 let none = BTreeMap::new();
524 assert_eq!(
525 resolver.resolve(&known, Some(&spec), &none).exact(),
526 Some(&case("trip-1", 3))
527 );
528 let forged = act(ActTarget::Record {
529 token: "t_forged".into(),
530 });
531 assert_eq!(
532 resolver.resolve(&forged, Some(&spec), &none).resolution(),
533 Some(TargetResolution::Unauthorized)
534 );
535 }
536
537 #[test]
538 fn a_new_case_needs_a_policy_that_allows_it_and_is_derived_from_the_act() {
539 let resolver = resolver();
540 let new = act(ActTarget::New {
541 workflow: "trip".into(),
542 });
543 let none = BTreeMap::new();
544 assert_eq!(
545 resolver.resolve(&new, Some(&spec(TargetPolicy::RequiresExistingCase)), &none),
546 TargetOutcome::PolicyMismatch {
547 policy: TargetPolicy::RequiresExistingCase
548 }
549 );
550 let first = resolver.resolve(&new, Some(&spec(TargetPolicy::NewCaseOnly)), &none);
551 assert!(first.is_new_case());
552 assert_eq!(
553 first,
554 resolver.resolve(&new, Some(&spec(TargetPolicy::NewCaseOnly)), &none),
555 "the same act mints the same identifier (I20)"
556 );
557 }
558
559 #[test]
560 fn a_same_turn_target_is_the_case_the_earlier_act_opens() {
561 let resolver = resolver();
562 let opener = ActId::new(UnitId(1), 1);
563 let dependent = act(ActTarget::SameTurn { act: opener });
564 let minted = BTreeMap::from([(opener, CaseRef::new("trip", "tf_new", CaseRevision::ZERO))]);
565 let outcome = resolver.resolve(
566 &dependent,
567 Some(&spec(TargetPolicy::RequiresExistingCase)),
568 &minted,
569 );
570 assert!(outcome.is_unborn() && !outcome.is_new_case());
571 assert_eq!(outcome.exact().map(|c| c.case_id.as_str()), Some("tf_new"));
572 let orphan = resolver.resolve(
573 &dependent,
574 Some(&spec(TargetPolicy::RequiresExistingCase)),
575 &BTreeMap::new(),
576 );
577 assert_eq!(orphan.resolution(), Some(TargetResolution::Missing));
578 }
579
580 #[test]
581 fn several_candidates_are_a_question_and_one_is_an_answer() {
582 let resolver = resolver();
583 let spec = spec(TargetPolicy::RequiresExistingCase);
584 let both = act(ActTarget::Ambiguous {
585 candidates: vec![token(&resolver, "trip-1", 3), token(&resolver, "trip-2", 5)],
586 });
587 assert!(matches!(
588 resolver.resolve(&both, Some(&spec), &BTreeMap::new()).resolution(),
589 Some(TargetResolution::Ambiguous { candidates }) if candidates.len() == 2
590 ));
591 let one = act(ActTarget::Ambiguous {
592 candidates: vec![token(&resolver, "trip-2", 5)],
593 });
594 assert_eq!(
595 resolver
596 .resolve(&one, Some(&spec), &BTreeMap::new())
597 .exact(),
598 Some(&case("trip-2", 5))
599 );
600 }
601
602 #[test]
603 fn the_card_target_resolves_to_the_card_on_screen() {
604 let summary = ActiveInteractionSummary {
605 interaction_id: InteractionId::nil(),
606 case_ref: case("trip-2", 5),
607 kind: InteractionKind::SingleSelect,
608 blocking: true,
609 option_ids: vec![OptionId::from("a")],
610 text_resolution: TextResolutionPolicy::Never,
611 confirms_risk: turnframe_core::command::RiskClass::ReversibleLowRisk,
612 payload_hash: Digest::of_bytes(b"p"),
613 };
614 let spec = spec(TargetPolicy::ActiveInteractionOnly);
615 let card = act(ActTarget::Card);
616 assert_eq!(
617 resolver().resolve(&card, Some(&spec), &BTreeMap::new()),
618 TargetOutcome::NoActiveInteraction
619 );
620 let with_card = TargetResolver::builder(AccountId::from("acct"), TurnId::nil())
621 .active_interaction(summary)
622 .build();
623 assert_eq!(
624 with_card
625 .resolve(&card, Some(&spec), &BTreeMap::new())
626 .exact(),
627 Some(&case("trip-2", 5))
628 );
629 }
630}