1use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11
12use crate::error::{EngineError, Result};
13use crate::event::{Event, EvidenceEvent};
14use crate::model::{Claim, RemediationEvent, RemediationMove, RequirementMeta};
15use crate::projection::{self, InterviewState};
16use crate::session::CoverageSchema;
17use crate::signal::{Signal, next_question};
18use crate::store::{StateStore, validate_session_id};
19
20type LockMap = Mutex<HashMap<String, Arc<Mutex<()>>>>;
29
30#[derive(Clone)]
33pub struct Engine {
34 store: Arc<dyn StateStore>,
35 session_locks: Arc<LockMap>,
38}
39
40impl Engine {
41 pub fn new(store: Arc<dyn StateStore>) -> Self {
43 Self {
44 store,
45 session_locks: Arc::new(Mutex::new(HashMap::new())),
46 }
47 }
48
49 fn session_lock(&self, session_id: &str) -> Arc<Mutex<()>> {
52 let mut map = self.session_locks.lock().unwrap_or_else(|p| p.into_inner());
53 Arc::clone(
54 map.entry(session_id.to_string())
55 .or_insert_with(|| Arc::new(Mutex::new(()))),
56 )
57 }
58
59 pub fn open_session(
64 &self,
65 session_id: &str,
66 coverage_schema: Option<CoverageSchema>,
67 ) -> Result<InterviewState> {
68 validate_session_id(session_id)?;
69 let lock = self.session_lock(session_id);
70 let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
71 if !self.store.exists(session_id)? {
72 let event = Event::SessionOpened {
73 session_id: session_id.to_string(),
74 coverage_schema: coverage_schema.unwrap_or_default(),
75 };
76 self.store.append(session_id, &event)?;
77 }
78 self.state(session_id)
79 }
80
81 pub fn state(&self, session_id: &str) -> Result<InterviewState> {
83 validate_session_id(session_id)?;
84 let events = self.store.replay(session_id)?;
85 Ok(projection::project(&events))
86 }
87
88 pub fn add_claim(
91 &self,
92 session_id: &str,
93 claim: Claim,
94 requirement: Option<RequirementMeta>,
95 ) -> Result<InterviewState> {
96 validate_session_id(session_id)?;
97 let lock = self.session_lock(session_id);
98 let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
99 let events = self.require_session(session_id)?;
100 validate_claim(&claim, session_id)?;
101 if events
102 .iter()
103 .any(|e| matches!(e, Event::ClaimAdded { claim: c, .. } if c.id == claim.id))
104 {
105 return Err(EngineError::DuplicateId(claim.id));
106 }
107 self.store.append(
108 session_id,
109 &Event::ClaimAdded {
110 claim: Box::new(claim),
111 requirement,
112 },
113 )?;
114 self.state(session_id)
115 }
116
117 pub fn add_evidence(
119 &self,
120 session_id: &str,
121 evidence: EvidenceEvent,
122 ) -> Result<InterviewState> {
123 validate_session_id(session_id)?;
124 let lock = self.session_lock(session_id);
125 let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
126 let events = self.require_session(session_id)?;
127 if evidence.id.is_empty() {
128 return Err(EngineError::InvalidEvidence("empty evidence id".into()));
129 }
130 if evidence.session_id != session_id {
131 return Err(EngineError::InvalidEvidence(format!(
132 "session_id mismatch: payload '{}' vs '{session_id}'",
133 evidence.session_id
134 )));
135 }
136 if !claim_exists(&events, &evidence.claim_id) {
137 return Err(EngineError::ClaimNotFound(evidence.claim_id));
138 }
139 if duplicate_event_id(&events, &evidence.id) {
140 return Err(EngineError::DuplicateId(evidence.id));
141 }
142 self.store
143 .append(session_id, &Event::EvidenceAdded { evidence })?;
144 self.state(session_id)
145 }
146
147 pub fn remediate(
150 &self,
151 session_id: &str,
152 remediation: RemediationEvent,
153 ) -> Result<InterviewState> {
154 validate_session_id(session_id)?;
155 let lock = self.session_lock(session_id);
156 let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
157 let events = self.require_session(session_id)?;
158 if remediation.id.is_empty() {
159 return Err(EngineError::InvalidRemediation(
160 "empty remediation id".into(),
161 ));
162 }
163 if remediation.session_id != session_id {
164 return Err(EngineError::InvalidRemediation(format!(
165 "session_id mismatch: payload '{}' vs '{session_id}'",
166 remediation.session_id
167 )));
168 }
169 if duplicate_event_id(&events, &remediation.id) {
170 return Err(EngineError::DuplicateId(remediation.id));
171 }
172 validate_remediation(&events, &remediation)?;
173 self.store
174 .append(session_id, &Event::Remediated { remediation })?;
175 self.state(session_id)
176 }
177
178 pub fn next_question(&self, session_id: &str) -> Result<Option<Signal>> {
180 let state = self.state(session_id)?;
181 Ok(next_question(&state.signals).cloned())
182 }
183
184 pub fn readiness(&self, session_id: &str) -> Result<crate::readiness::ReadinessReport> {
186 Ok(self.state(session_id)?.readiness)
187 }
188
189 pub fn export(&self, session_id: &str) -> Result<crate::export::SpecDocument> {
191 let state = self.state(session_id)?;
192 Ok(crate::export::build(&state))
193 }
194
195 fn require_session(&self, session_id: &str) -> Result<Vec<Event>> {
196 if !self.store.exists(session_id)? {
197 return Err(EngineError::SessionNotFound(session_id.to_string()));
198 }
199 self.store.replay(session_id)
200 }
201}
202
203fn validate_claim(claim: &Claim, session_id: &str) -> Result<()> {
206 if claim.id.is_empty() {
207 return Err(EngineError::InvalidClaim("empty claim id".into()));
208 }
209 if claim.session_id != session_id {
210 return Err(EngineError::InvalidClaim(format!(
211 "session_id mismatch: payload '{}' vs '{session_id}'",
212 claim.session_id
213 )));
214 }
215 if claim.subject.id.is_empty() {
216 return Err(EngineError::InvalidClaim("empty subject id".into()));
217 }
218 if claim.predicate.is_empty() {
219 return Err(EngineError::InvalidClaim("empty predicate token".into()));
220 }
221 Ok(())
222}
223
224fn claim_exists(events: &[Event], claim_id: &str) -> bool {
225 events
226 .iter()
227 .any(|e| matches!(e, Event::ClaimAdded { claim, .. } if claim.id == claim_id))
228}
229
230fn duplicate_event_id(events: &[Event], id: &str) -> bool {
231 events.iter().any(|e| match e {
232 Event::ClaimAdded { claim, .. } => claim.id == id,
233 Event::EvidenceAdded { evidence } => evidence.id == id,
234 Event::Remediated { remediation } => remediation.id == id,
235 Event::SessionOpened { .. } => false,
236 })
237}
238
239fn validate_remediation(events: &[Event], remediation: &RemediationEvent) -> Result<()> {
240 let claim_ok = |id: &str| claim_exists(events, id);
241 match &remediation.r#move {
242 RemediationMove::Supersede { old, new } => {
243 if old == new {
244 return Err(EngineError::InvalidRemediation(format!(
245 "supersede old and new must differ (both '{old}')"
246 )));
247 }
248 if !claim_ok(old) {
249 return Err(EngineError::ClaimNotFound(old.clone()));
250 }
251 if !claim_ok(new) {
252 return Err(EngineError::ClaimNotFound(new.clone()));
253 }
254 }
255 RemediationMove::Retract { claim }
256 | RemediationMove::Qualify { claim, .. }
257 | RemediationMove::Accept { target: claim, .. } => {
258 if !claim_ok(claim) {
259 return Err(EngineError::ClaimNotFound(claim.clone()));
260 }
261 }
262 RemediationMove::ReconcileByEvidence { contradiction } => {
263 let state = projection::project(events);
265 if !state.contradictions.iter().any(|c| &c.id == contradiction) {
266 return Err(EngineError::ContradictionNotFound(contradiction.clone()));
267 }
268 }
269 }
270 Ok(())
271}