use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use indexmap::IndexMap;
use turnframe_core::case::CaseRef;
use turnframe_core::hash::derive_uuid;
use turnframe_core::ids::{
AccountId, CaseId, CaseRevision, OriginToken, TargetToken, TurnId, WorkflowKey,
};
use turnframe_core::operation::OperationSpec;
use turnframe_core::plan::TargetPolicy;
use turnframe_core::reduce::ActiveInteractionSummary;
use turnframe_core::target::{TargetCandidate, TargetResolution, TargetTokenMap};
use turnframe_core::understanding::{ActAction, ActId, ActTarget, UnderstoodAct};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizedCase {
pub case_ref: CaseRef,
pub label: String,
pub subject_only_when_named: bool,
}
impl AuthorizedCase {
#[must_use]
pub fn new(case_ref: CaseRef, label: impl Into<String>) -> Self {
Self {
case_ref,
label: label.into(),
subject_only_when_named: false,
}
}
#[must_use]
pub const fn reachable_only(mut self) -> Self {
self.subject_only_when_named = true;
self
}
}
pub trait CaseIdFactory: Send + Sync {
fn new_case_id(&self, workflow: &WorkflowKey, turn_id: &TurnId, act: ActId) -> CaseId;
}
const NEW_CASE_DOMAIN: &str = "turnframe.new_case.v2";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DerivedCaseIdFactory;
impl CaseIdFactory for DerivedCaseIdFactory {
fn new_case_id(&self, workflow: &WorkflowKey, turn_id: &TurnId, act: ActId) -> CaseId {
let uuid = derive_uuid(
NEW_CASE_DOMAIN,
&[&turn_id.to_string(), workflow.as_str(), &act.to_string()],
);
CaseId::from(format!("tf_{}", uuid.simple()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TargetOutcome {
Resolved {
resolution: TargetResolution,
new_case: bool,
},
SameTurn {
case_ref: CaseRef,
},
NoActiveInteraction,
PolicyMismatch {
policy: TargetPolicy,
},
SeveralOpenCases,
NoTarget,
}
impl TargetOutcome {
#[must_use]
pub const fn resolved(resolution: TargetResolution) -> Self {
Self::Resolved {
resolution,
new_case: false,
}
}
#[must_use]
pub fn exact(&self) -> Option<&CaseRef> {
match self {
Self::Resolved { resolution, .. } => resolution.exact(),
Self::SameTurn { case_ref } => Some(case_ref),
_ => None,
}
}
#[must_use]
pub fn resolution(&self) -> Option<TargetResolution> {
match self {
Self::Resolved { resolution, .. } => Some(resolution.clone()),
Self::SameTurn { case_ref } => Some(TargetResolution::Exact {
case_ref: case_ref.clone(),
}),
_ => None,
}
}
#[must_use]
pub const fn is_new_case(&self) -> bool {
matches!(self, Self::Resolved { new_case: true, .. })
}
#[must_use]
pub const fn is_unborn(&self) -> bool {
matches!(
self,
Self::Resolved { new_case: true, .. } | Self::SameTurn { .. }
)
}
}
#[derive(Clone)]
pub struct TargetResolverBuilder {
account_id: AccountId,
turn_id: TurnId,
candidates: Vec<AuthorizedCase>,
origins: IndexMap<OriginToken, CaseRef>,
active_interaction: Option<ActiveInteractionSummary>,
resuming: BTreeSet<WorkflowKey>,
case_ids: Arc<dyn CaseIdFactory>,
}
impl std::fmt::Debug for TargetResolverBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TargetResolverBuilder")
.field("account_id", &self.account_id)
.field("turn_id", &self.turn_id)
.field("candidates", &self.candidates.len())
.finish_non_exhaustive()
}
}
impl TargetResolverBuilder {
#[must_use]
pub fn candidate(mut self, candidate: AuthorizedCase) -> Self {
self.candidates.push(candidate);
self
}
#[must_use]
pub fn candidates(mut self, candidates: impl IntoIterator<Item = AuthorizedCase>) -> Self {
self.candidates.extend(candidates);
self
}
#[must_use]
pub fn origin(mut self, origin: OriginToken, candidate: AuthorizedCase) -> Self {
self.origins.insert(origin, candidate.case_ref.clone());
self.candidates.push(candidate);
self
}
#[must_use]
pub fn active_interaction(mut self, summary: ActiveInteractionSummary) -> Self {
self.active_interaction = Some(summary);
self
}
#[must_use]
pub fn resuming(mut self, workflow: WorkflowKey) -> Self {
self.resuming.insert(workflow);
self
}
#[must_use]
pub fn case_id_factory(mut self, factory: Arc<dyn CaseIdFactory>) -> Self {
self.case_ids = factory;
self
}
#[must_use]
pub fn build(self) -> TargetResolver {
let mut resolver = TargetResolver {
tokens: TargetTokenMap::new(self.account_id.clone(), self.turn_id),
account_id: self.account_id,
turn_id: self.turn_id,
candidates: IndexMap::new(),
origins: self.origins,
active_interaction: self.active_interaction,
resuming: self.resuming,
case_ids: self.case_ids,
};
for candidate in self.candidates {
resolver.admit(candidate);
}
resolver
}
}
#[derive(Clone)]
pub struct TargetResolver {
account_id: AccountId,
turn_id: TurnId,
tokens: TargetTokenMap,
candidates: IndexMap<TargetToken, AuthorizedCase>,
origins: IndexMap<OriginToken, CaseRef>,
active_interaction: Option<ActiveInteractionSummary>,
resuming: BTreeSet<WorkflowKey>,
case_ids: Arc<dyn CaseIdFactory>,
}
impl std::fmt::Debug for TargetResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TargetResolver")
.field("account_id", &self.account_id)
.field("turn_id", &self.turn_id)
.field("tokens", &self.tokens.len())
.finish_non_exhaustive()
}
}
impl TargetResolver {
#[must_use]
pub fn builder(account_id: AccountId, turn_id: TurnId) -> TargetResolverBuilder {
TargetResolverBuilder {
account_id,
turn_id,
candidates: Vec::new(),
origins: IndexMap::new(),
active_interaction: None,
resuming: BTreeSet::new(),
case_ids: Arc::new(DerivedCaseIdFactory),
}
}
pub fn admit(&mut self, candidate: AuthorizedCase) -> TargetToken {
let token = self
.tokens
.issue(candidate.case_ref.clone(), candidate.label.clone());
self.candidates.insert(token.clone(), candidate);
token
}
#[must_use]
pub const fn account_id(&self) -> &AccountId {
&self.account_id
}
#[must_use]
pub const fn turn_id(&self) -> &TurnId {
&self.turn_id
}
#[must_use]
pub const fn token_map(&self) -> &TargetTokenMap {
&self.tokens
}
#[must_use]
pub fn catalog(&self) -> Vec<TargetCandidate> {
self.tokens.candidates()
}
#[must_use]
pub fn catalog_for(&self, workflow: &WorkflowKey) -> Vec<TargetCandidate> {
self.tokens.candidates_for(workflow)
}
#[must_use]
pub fn candidate(&self, token: &TargetToken) -> Option<&AuthorizedCase> {
self.candidates.get(token)
}
pub fn in_view(&self) -> impl Iterator<Item = &AuthorizedCase> {
self.candidates.values()
}
#[must_use]
pub const fn active_interaction(&self) -> Option<&ActiveInteractionSummary> {
self.active_interaction.as_ref()
}
#[must_use]
pub fn resolve_origin(&self, origin: &OriginToken) -> TargetResolution {
match self.origins.get(origin) {
Some(case_ref) => TargetResolution::Exact {
case_ref: case_ref.clone(),
},
None => TargetResolution::Unauthorized,
}
}
#[must_use]
pub fn origin_token(&self, origin: &OriginToken) -> Option<&TargetToken> {
let case_ref = self.origins.get(origin)?;
self.tokens.token_for(&case_ref.key())
}
#[must_use]
pub fn resolve(
&self,
act: &UnderstoodAct,
spec: Option<&OperationSpec>,
same_turn: &BTreeMap<ActId, CaseRef>,
) -> TargetOutcome {
if let ActAction::Start { workflow } = &act.action {
return self.start(workflow, act.id);
}
let policy = spec.map_or(TargetPolicy::RequiresExistingCase, |spec| {
spec.target_policy
});
if !policy.permits(&act.target) {
return TargetOutcome::PolicyMismatch { policy };
}
match &act.target {
ActTarget::Record { token } => {
TargetOutcome::resolved(self.tokens.resolve(&self.account_id, token))
}
ActTarget::New { workflow } => self.mint_case(workflow, act.id),
ActTarget::SameTurn { act: earlier } => match same_turn.get(earlier) {
Some(case_ref) => TargetOutcome::SameTurn {
case_ref: case_ref.clone(),
},
None => TargetOutcome::resolved(TargetResolution::Missing),
},
ActTarget::Card => match &self.active_interaction {
Some(summary) => TargetOutcome::resolved(TargetResolution::Exact {
case_ref: summary.case_ref.clone(),
}),
None => TargetOutcome::NoActiveInteraction,
},
ActTarget::Ambiguous { candidates } => {
let candidates: Vec<TargetCandidate> = candidates
.iter()
.filter(|token| self.tokens.resolve(&self.account_id, token).is_exact())
.filter_map(|token| self.candidate_view(token))
.collect();
TargetOutcome::resolved(match <[TargetCandidate; 1]>::try_from(candidates) {
Ok([only]) => TargetResolution::Exact {
case_ref: only.case_ref,
},
Err(several) if several.is_empty() => TargetResolution::Missing,
Err(several) => TargetResolution::Ambiguous {
candidates: several,
},
})
}
ActTarget::NotListed { .. } => TargetOutcome::resolved(TargetResolution::Missing),
_ => TargetOutcome::NoTarget,
}
}
fn start(&self, workflow: &WorkflowKey, act: ActId) -> TargetOutcome {
if !self.resuming.contains(workflow) {
return self.mint_case(workflow, act);
}
let open: Vec<TargetResolution> = self
.candidates
.iter()
.filter(|(_, candidate)| &candidate.case_ref.workflow == workflow)
.map(|(token, _)| self.tokens.resolve(&self.account_id, token))
.filter(TargetResolution::is_exact)
.collect();
match <[TargetResolution; 1]>::try_from(open) {
Ok([only]) => TargetOutcome::resolved(only),
Err(several) if several.is_empty() => self.mint_case(workflow, act),
Err(_) => TargetOutcome::SeveralOpenCases,
}
}
fn mint_case(&self, workflow: &WorkflowKey, act: ActId) -> TargetOutcome {
let case_id = self.case_ids.new_case_id(workflow, &self.turn_id, act);
TargetOutcome::Resolved {
resolution: TargetResolution::Exact {
case_ref: CaseRef::new(workflow.clone(), case_id, CaseRevision::ZERO),
},
new_case: true,
}
}
fn candidate_view(&self, token: &TargetToken) -> Option<TargetCandidate> {
let entry = self.tokens.get(token)?;
Some(TargetCandidate {
token: token.clone(),
case_ref: entry.case_ref.clone(),
label: entry.label.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use turnframe_core::hash::Digest;
use turnframe_core::ids::{InteractionId, OptionId};
use turnframe_core::interaction::{InteractionKind, TextResolutionPolicy};
use turnframe_core::understanding::{ActStatus, UnitId, WordRange};
fn case(id: &str, revision: u64) -> CaseRef {
CaseRef::new("trip", id, CaseRevision(revision))
}
fn resolver() -> TargetResolver {
TargetResolver::builder(AccountId::from("acct"), TurnId::nil())
.candidate(AuthorizedCase::new(case("trip-1", 3), "Trip 1"))
.candidate(AuthorizedCase::new(case("trip-2", 5), "Trip 2"))
.build()
}
fn act(target: ActTarget) -> UnderstoodAct {
UnderstoodAct {
id: ActId::new(UnitId(1), 1),
action: ActAction::Apply {
operation: "trip.set_name".into(),
},
target,
arguments: BTreeMap::new(),
words: WordRange {
first: 0,
last: 0,
start: 0,
end: 1,
},
depends_on: Vec::new(),
status: ActStatus::Ready,
}
}
fn spec(policy: TargetPolicy) -> OperationSpec {
OperationSpec::new("trip.set_name")
.summary("s")
.target(policy)
}
fn token(resolver: &TargetResolver, id: &str, revision: u64) -> TargetToken {
resolver
.token_map()
.token_for(&case(id, revision).key())
.unwrap()
.clone()
}
#[test]
fn a_token_resolves_through_the_map_and_a_foreign_one_does_not() {
let resolver = resolver();
let known = act(ActTarget::Record {
token: token(&resolver, "trip-1", 3),
});
let spec = spec(TargetPolicy::RequiresExistingCase);
let none = BTreeMap::new();
assert_eq!(
resolver.resolve(&known, Some(&spec), &none).exact(),
Some(&case("trip-1", 3))
);
let forged = act(ActTarget::Record {
token: "t_forged".into(),
});
assert_eq!(
resolver.resolve(&forged, Some(&spec), &none).resolution(),
Some(TargetResolution::Unauthorized)
);
}
#[test]
fn a_new_case_needs_a_policy_that_allows_it_and_is_derived_from_the_act() {
let resolver = resolver();
let new = act(ActTarget::New {
workflow: "trip".into(),
});
let none = BTreeMap::new();
assert_eq!(
resolver.resolve(&new, Some(&spec(TargetPolicy::RequiresExistingCase)), &none),
TargetOutcome::PolicyMismatch {
policy: TargetPolicy::RequiresExistingCase
}
);
let first = resolver.resolve(&new, Some(&spec(TargetPolicy::NewCaseOnly)), &none);
assert!(first.is_new_case());
assert_eq!(
first,
resolver.resolve(&new, Some(&spec(TargetPolicy::NewCaseOnly)), &none),
"the same act mints the same identifier (I20)"
);
}
#[test]
fn a_same_turn_target_is_the_case_the_earlier_act_opens() {
let resolver = resolver();
let opener = ActId::new(UnitId(1), 1);
let dependent = act(ActTarget::SameTurn { act: opener });
let minted = BTreeMap::from([(opener, CaseRef::new("trip", "tf_new", CaseRevision::ZERO))]);
let outcome = resolver.resolve(
&dependent,
Some(&spec(TargetPolicy::RequiresExistingCase)),
&minted,
);
assert!(outcome.is_unborn() && !outcome.is_new_case());
assert_eq!(outcome.exact().map(|c| c.case_id.as_str()), Some("tf_new"));
let orphan = resolver.resolve(
&dependent,
Some(&spec(TargetPolicy::RequiresExistingCase)),
&BTreeMap::new(),
);
assert_eq!(orphan.resolution(), Some(TargetResolution::Missing));
}
#[test]
fn several_candidates_are_a_question_and_one_is_an_answer() {
let resolver = resolver();
let spec = spec(TargetPolicy::RequiresExistingCase);
let both = act(ActTarget::Ambiguous {
candidates: vec![token(&resolver, "trip-1", 3), token(&resolver, "trip-2", 5)],
});
assert!(matches!(
resolver.resolve(&both, Some(&spec), &BTreeMap::new()).resolution(),
Some(TargetResolution::Ambiguous { candidates }) if candidates.len() == 2
));
let one = act(ActTarget::Ambiguous {
candidates: vec![token(&resolver, "trip-2", 5)],
});
assert_eq!(
resolver
.resolve(&one, Some(&spec), &BTreeMap::new())
.exact(),
Some(&case("trip-2", 5))
);
}
#[test]
fn the_card_target_resolves_to_the_card_on_screen() {
let summary = ActiveInteractionSummary {
interaction_id: InteractionId::nil(),
case_ref: case("trip-2", 5),
kind: InteractionKind::SingleSelect,
blocking: true,
option_ids: vec![OptionId::from("a")],
text_resolution: TextResolutionPolicy::Never,
confirms_risk: turnframe_core::command::RiskClass::ReversibleLowRisk,
payload_hash: Digest::of_bytes(b"p"),
};
let spec = spec(TargetPolicy::ActiveInteractionOnly);
let card = act(ActTarget::Card);
assert_eq!(
resolver().resolve(&card, Some(&spec), &BTreeMap::new()),
TargetOutcome::NoActiveInteraction
);
let with_card = TargetResolver::builder(AccountId::from("acct"), TurnId::nil())
.active_interaction(summary)
.build();
assert_eq!(
with_card
.resolve(&card, Some(&spec), &BTreeMap::new())
.exact(),
Some(&case("trip-2", 5))
);
}
}