1use std::collections::{BTreeMap, BTreeSet};
4
5use sha2::{Digest, Sha256};
6
7use crate::{
8 coverage_report::{CoverageManifest, CoveragePhase},
9 rust_probe_transport::{
10 RustPhaseContext, RustTransportError, RustTransportRead, validate_rust_phase_contexts,
11 },
12 rust_runtime::RustProbeObservation,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct RustPhaseProjection {
17 pub phases: Vec<CoveragePhase>,
18 pub phase_id_by_context: BTreeMap<u64, String>,
19 pub thread_parent_by_context: BTreeMap<u64, u64>,
23}
24
25impl RustPhaseProjection {
26 pub fn phase_id_for_context<'a>(
27 &'a self,
28 base_context_id: u64,
29 base_phase_id: &'a str,
30 context_id: u64,
31 ) -> Result<Option<&'a str>, RustTransportError> {
32 if context_id == 0 {
33 return Ok(None);
34 }
35 let mut context_id = context_id;
36 let mut hops = 0_usize;
37 while let Some(parent) = self.thread_parent_by_context.get(&context_id) {
38 hops += 1;
39 if hops > self.thread_parent_by_context.len() {
40 return Err(RustTransportError::InvalidAssertionContext(format!(
41 "thread phase context cycle at {context_id:016x}"
42 )));
43 }
44 context_id = *parent;
45 }
46 if context_id == base_context_id {
47 return Ok(Some(base_phase_id));
48 }
49 self.phase_id_by_context
50 .get(&context_id)
51 .map(String::as_str)
52 .map(Some)
53 .ok_or_else(|| {
54 RustTransportError::InvalidAssertionContext(format!(
55 "context {context_id:016x} has no evidence-v3 phase"
56 ))
57 })
58 }
59}
60
61fn phase_id(base_phase_id: &str, context_id: u64, invocation_nonce: u64) -> String {
62 let mut digest = Sha256::new();
63 digest.update((base_phase_id.len() as u64).to_be_bytes());
64 digest.update(base_phase_id.as_bytes());
65 digest.update(context_id.to_be_bytes());
66 digest.update(invocation_nonce.to_be_bytes());
67 let hex = format!("{:x}", digest.finalize());
68 format!("rust-phase:{}", &hex[..40])
69}
70
71fn assertion_status(
72 phase: &RustPhaseContext,
73 read: &RustTransportRead,
74) -> Result<Option<String>, RustTransportError> {
75 let outcomes = read
76 .observations
77 .iter()
78 .filter_map(|record| match &record.observation {
79 RustProbeObservation::Decision { id, outcome, .. }
80 if record.context_id == phase.child_context_id && id == &phase.decision_id =>
81 {
82 Some(*outcome)
83 }
84 _ => None,
85 })
86 .collect::<BTreeSet<_>>();
87 match outcomes.len() {
88 0 => Ok(None),
89 1 if outcomes.contains(&true) => Ok(Some("passed".into())),
90 1 => Ok(Some("failed".into())),
91 _ => Err(RustTransportError::InvalidAssertionContext(format!(
92 "assertion phase {:016x} committed contradictory outcomes",
93 phase.child_context_id
94 ))),
95 }
96}
97
98pub fn project_rust_assertion_phases(
99 base_context_id: u64,
100 base_phase: &CoveragePhase,
101 read: &RustTransportRead,
102 manifest: &CoverageManifest,
103) -> Result<RustPhaseProjection, RustTransportError> {
104 validate_rust_phase_contexts(base_context_id, read)?;
105 let decisions = manifest
106 .decisions
107 .iter()
108 .map(|decision| (decision.id.as_str(), decision))
109 .collect::<BTreeMap<_, _>>();
110 let mut definitions = BTreeMap::<u64, &RustPhaseContext>::new();
111 for phase in &read.phases {
112 definitions.entry(phase.child_context_id).or_insert(phase);
113 }
114 let thread_parent_by_context = read
115 .thread_phases
116 .iter()
117 .map(|phase| (phase.child_context_id, phase.parent_context_id))
118 .collect::<BTreeMap<_, _>>();
119 let collapse_thread_parents = |mut context: u64| -> Result<u64, RustTransportError> {
123 let mut hops = 0_usize;
124 while let Some(parent) = thread_parent_by_context.get(&context) {
125 hops += 1;
126 if hops > thread_parent_by_context.len() {
127 return Err(RustTransportError::InvalidAssertionContext(format!(
128 "thread phase context cycle at {context:016x}"
129 )));
130 }
131 context = *parent;
132 }
133 Ok(context)
134 };
135 let phase_id_by_context = definitions
136 .values()
137 .map(|phase| {
138 (
139 phase.child_context_id,
140 phase_id(
141 &base_phase.id,
142 phase.child_context_id,
143 phase.invocation_nonce,
144 ),
145 )
146 })
147 .collect::<BTreeMap<_, _>>();
148
149 let mut ordered = definitions.values().copied().collect::<Vec<_>>();
150 ordered.sort_by_key(|phase| phase.invocation_nonce);
151 let mut phases = Vec::with_capacity(ordered.len());
152 for phase in ordered {
153 let decision = decisions.get(phase.decision_id.as_str()).ok_or_else(|| {
154 RustTransportError::InvalidAssertionContext(format!(
155 "phase {:016x} references unknown decision {}",
156 phase.child_context_id, phase.decision_id
157 ))
158 })?;
159 if decision.kind != "assertion" {
160 return Err(RustTransportError::InvalidAssertionContext(format!(
161 "phase {:016x} references non-assertion decision {}",
162 phase.child_context_id, phase.decision_id
163 )));
164 }
165 let parent_context_id = collapse_thread_parents(phase.parent_context_id)?;
166 let caused_by_phase_id = if parent_context_id == base_context_id {
167 Some(base_phase.id.clone())
168 } else {
169 Some(
170 phase_id_by_context
171 .get(&parent_context_id)
172 .cloned()
173 .ok_or_else(|| {
174 RustTransportError::InvalidAssertionContext(format!(
175 "phase {:016x} has unresolved parent {:016x}",
176 phase.child_context_id, parent_context_id
177 ))
178 })?,
179 )
180 };
181 let status = assertion_status(phase, read)?;
182 phases.push(CoveragePhase {
183 id: phase_id_by_context[&phase.child_context_id].clone(),
184 kind: "assertion".into(),
185 operation: format!(
186 "Rust assertion at {}:{}:{}",
187 decision.file, decision.line, decision.column
188 ),
189 source: Some(decision.source.clone()),
190 caused_by_phase_id,
191 started_at_ms: base_phase.started_at_ms,
192 ended_at_ms: status.as_ref().and(base_phase.ended_at_ms),
193 status,
194 error: None,
195 });
196 }
197 Ok(RustPhaseProjection {
198 phases,
199 phase_id_by_context,
200 thread_parent_by_context,
201 })
202}
203
204#[cfg(test)]
205mod tests {
206 use crate::{
207 coverage_report::{CoverageManifest, CoveragePhase, DecisionMeta},
208 rust_probe_transport::{
209 RustPhaseContext, RustThreadPhase, RustTransportObservation, RustTransportRead,
210 rust_assertion_context_id, rust_thread_context_id,
211 },
212 rust_runtime::RustProbeObservation,
213 };
214
215 use super::*;
216
217 const BASE: u64 = 42;
218 const ASSERTION: &str = "rs:decision:0123456789abcdef01234567";
219
220 fn base_phase() -> CoveragePhase {
221 CoveragePhase {
222 id: "test-phase".into(),
223 kind: "test".into(),
224 operation: "libtest test".into(),
225 source: Some("src/lib.rs".into()),
226 caused_by_phase_id: None,
227 started_at_ms: 10,
228 ended_at_ms: Some(20),
229 status: Some("passed".into()),
230 error: None,
231 }
232 }
233
234 fn manifest() -> CoverageManifest {
235 CoverageManifest {
236 unmeasured: Vec::new(),
237 decisions: vec![DecisionMeta {
238 id: ASSERTION.into(),
239 file: "src/lib.rs".into(),
240 line: 7,
241 column: 5,
242 source: "assert!(value)".into(),
243 conditions: vec!["value".into()],
244 kind: "assertion".into(),
245 }],
246 points: Vec::new(),
247 branches: Vec::new(),
248 limitations: Vec::new(),
249 scope: None,
250 }
251 }
252
253 #[test]
254 fn repeated_and_nested_assertions_become_distinct_causal_evidence_phases() {
255 let first = rust_assertion_context_id(BASE, ASSERTION, 0).unwrap();
256 let repeated = rust_assertion_context_id(BASE, ASSERTION, 1).unwrap();
257 let nested = rust_assertion_context_id(first, ASSERTION, 2).unwrap();
258 let phases = vec![
259 RustPhaseContext {
260 process_id: 1,
261 child_context_id: first,
262 parent_context_id: BASE,
263 invocation_nonce: 0,
264 decision_id: ASSERTION.into(),
265 },
266 RustPhaseContext {
267 process_id: 2,
268 child_context_id: repeated,
269 parent_context_id: BASE,
270 invocation_nonce: 1,
271 decision_id: ASSERTION.into(),
272 },
273 RustPhaseContext {
274 process_id: 1,
275 child_context_id: nested,
276 parent_context_id: first,
277 invocation_nonce: 2,
278 decision_id: ASSERTION.into(),
279 },
280 ];
281 let observations = [(first, true), (repeated, false)]
282 .into_iter()
283 .map(|(context_id, outcome)| RustTransportObservation {
284 process_id: 1,
285 context_id,
286 observation: RustProbeObservation::Decision {
287 id: ASSERTION.into(),
288 values: vec![Some(outcome)],
289 outcome,
290 },
291 })
292 .collect();
293 let thread_under_first = rust_thread_context_id(first, 9);
294 let thread_under_base = rust_thread_context_id(BASE, 10);
295 let read = RustTransportRead {
296 observations,
297 ordinal_hits: Vec::new(),
298 phases,
299 thread_phases: vec![
300 RustThreadPhase {
301 process_id: 1,
302 child_context_id: thread_under_first,
303 parent_context_id: first,
304 invocation_nonce: 9,
305 commit_index: 5,
306 },
307 RustThreadPhase {
308 process_id: 1,
309 child_context_id: thread_under_base,
310 parent_context_id: BASE,
311 invocation_nonce: 10,
312 commit_index: 6,
313 },
314 ],
315 committed: 7,
316 attachments: 2,
317 ..RustTransportRead::empty()
318 };
319 let projection =
320 project_rust_assertion_phases(BASE, &base_phase(), &read, &manifest()).unwrap();
321 assert_eq!(
322 projection
323 .phase_id_for_context(BASE, "test-phase", thread_under_first)
324 .unwrap(),
325 Some(projection.phases[0].id.as_str()),
326 "thread work belongs to the nearest enclosing assertion phase"
327 );
328 assert_eq!(
329 projection
330 .phase_id_for_context(BASE, "test-phase", thread_under_base)
331 .unwrap(),
332 Some("test-phase"),
333 "thread work directly under the test belongs to the test phase"
334 );
335 assert_eq!(projection.phases.len(), 3);
336 assert_eq!(
337 projection
338 .phases
339 .iter()
340 .map(|phase| phase.id.as_str())
341 .collect::<BTreeSet<_>>()
342 .len(),
343 3
344 );
345 assert_eq!(projection.phases[0].status.as_deref(), Some("passed"));
346 assert_eq!(projection.phases[1].status.as_deref(), Some("failed"));
347 assert_eq!(projection.phases[2].status, None);
348 assert_eq!(
349 projection.phases[2].caused_by_phase_id.as_deref(),
350 Some(projection.phases[0].id.as_str())
351 );
352 assert_eq!(
353 projection
354 .phase_id_for_context(BASE, "test-phase", repeated)
355 .unwrap(),
356 Some(projection.phases[1].id.as_str())
357 );
358 assert_eq!(
359 projection
360 .phase_id_for_context(BASE, "test-phase", 0)
361 .unwrap(),
362 None
363 );
364 }
365}