Skip to main content

trellis_testing/
audit.rs

1use trellis_core::{
2    Graph, NodeId, OutputFrameKind, OutputFrameKindTrace, OutputKey, ResourceCommandKind,
3    ResourceKey, Revision, ScopeId, TransactionId, TransactionResult,
4};
5
6/// Structural context for an audited resource command assertion.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct ResourceAuditContext {
9    /// Resource key for the audited command.
10    pub key: ResourceKey,
11    /// Scope associated with the command.
12    pub scope: ScopeId,
13    /// Transaction that emitted the command.
14    pub transaction_id: TransactionId,
15    /// Revision that emitted the command.
16    pub revision: Revision,
17    /// Command operation without application payload.
18    pub kind: ResourceCommandKind,
19}
20
21/// Structural context for an audited output frame assertion.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct OutputAuditContext {
24    /// Output key for the audited frame.
25    pub key: OutputKey,
26    /// Scope associated with the frame.
27    pub scope: ScopeId,
28    /// Transaction that emitted the frame.
29    pub transaction_id: TransactionId,
30    /// Revision carried by the frame.
31    pub revision: Revision,
32    /// Frame kind without materialized payload.
33    pub kind: OutputFrameKindTrace,
34}
35
36/// Failure from an explainability assertion over transaction audit data.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum AuditAssertionError {
39    /// A resource command had no graph-visible explanation.
40    MissingResourceCommand {
41        /// Command context.
42        context: ResourceAuditContext,
43    },
44    /// A resource command explanation did not match the emitted command.
45    ResourceCommandMismatch {
46        /// Command context.
47        context: ResourceAuditContext,
48        /// Missing or mismatched field name.
49        field: &'static str,
50    },
51    /// An output frame had no graph-visible explanation.
52    MissingOutputFrame {
53        /// Frame context.
54        context: OutputAuditContext,
55    },
56    /// An output frame explanation did not match the emitted frame.
57    OutputFrameMismatch {
58        /// Frame context.
59        context: OutputAuditContext,
60        /// Missing or mismatched field name.
61        field: &'static str,
62    },
63    /// A requested dependency path was missing.
64    MissingDependencyPath {
65        /// Upstream node.
66        from: NodeId,
67        /// Downstream node.
68        to: NodeId,
69    },
70}
71
72/// Asserts every resource command in a result has matching audit explanation.
73pub fn assert_no_unexplained_plan<C, O>(
74    graph: &Graph<C, O>,
75    result: &TransactionResult<C, O>,
76) -> Result<(), AuditAssertionError> {
77    for command in result.resource_plan.commands() {
78        let context = resource_audit_context(command, result);
79        let key = command.key();
80        let explanation = graph.why_resource_command(key).ok_or_else(|| {
81            AuditAssertionError::MissingResourceCommand {
82                context: context.clone(),
83            }
84        })?;
85        if explanation.scope != command.scope() {
86            return Err(AuditAssertionError::ResourceCommandMismatch {
87                context,
88                field: "scope",
89            });
90        }
91        if explanation.transaction_id != result.transaction_id {
92            return Err(AuditAssertionError::ResourceCommandMismatch {
93                context,
94                field: "transaction_id",
95            });
96        }
97        if explanation.revision != result.revision {
98            return Err(AuditAssertionError::ResourceCommandMismatch {
99                context,
100                field: "revision",
101            });
102        }
103        if explanation.kind != resource_command_kind(command) {
104            return Err(AuditAssertionError::ResourceCommandMismatch {
105                context,
106                field: "kind",
107            });
108        }
109        if !resource_cause_is_explainable(explanation, command, result) {
110            return Err(AuditAssertionError::ResourceCommandMismatch {
111                context,
112                field: "cause",
113            });
114        }
115    }
116    Ok(())
117}
118
119/// Asserts every resource command has a graph-visible cause.
120pub fn assert_every_resource_command_has_cause<C, O>(
121    graph: &Graph<C, O>,
122    result: &TransactionResult<C, O>,
123) -> Result<(), AuditAssertionError> {
124    assert_no_unexplained_plan(graph, result)
125}
126
127/// Asserts every output frame in a result has matching audit explanation.
128pub fn assert_no_unexplained_output_frame<C, O>(
129    graph: &Graph<C, O>,
130    result: &TransactionResult<C, O>,
131) -> Result<(), AuditAssertionError> {
132    for frame in &result.output_frames {
133        let context = output_audit_context(frame);
134        let explanation = graph.why_output_frame(frame.output_key).ok_or(
135            AuditAssertionError::MissingOutputFrame {
136                context: context.clone(),
137            },
138        )?;
139        if explanation.scope != frame.scope {
140            return Err(AuditAssertionError::OutputFrameMismatch {
141                context,
142                field: "scope",
143            });
144        }
145        if explanation.transaction_id != frame.transaction_id {
146            return Err(AuditAssertionError::OutputFrameMismatch {
147                context,
148                field: "transaction_id",
149            });
150        }
151        if explanation.revision != frame.revision {
152            return Err(AuditAssertionError::OutputFrameMismatch {
153                context,
154                field: "revision",
155            });
156        }
157        if explanation.kind != output_frame_kind(&frame.kind) {
158            return Err(AuditAssertionError::OutputFrameMismatch {
159                context,
160                field: "kind",
161            });
162        }
163        if !output_frame_is_explainable(explanation, result) {
164            return Err(AuditAssertionError::OutputFrameMismatch {
165                context,
166                field: "input_causes",
167            });
168        }
169    }
170    Ok(())
171}
172
173/// Asserts every output frame has a graph-visible revision explanation.
174pub fn assert_every_output_frame_has_revision<C, O>(
175    graph: &Graph<C, O>,
176    result: &TransactionResult<C, O>,
177) -> Result<(), AuditAssertionError> {
178    assert_no_unexplained_output_frame(graph, result)
179}
180
181/// Asserts every output frame has a graph-visible scope explanation.
182pub fn assert_every_output_frame_has_scope<C, O>(
183    graph: &Graph<C, O>,
184    result: &TransactionResult<C, O>,
185) -> Result<(), AuditAssertionError> {
186    assert_no_unexplained_output_frame(graph, result)
187}
188
189/// Asserts that a deterministic dependency path exists in the graph.
190pub fn assert_dependency_path_exists<C, O>(
191    graph: &Graph<C, O>,
192    from: NodeId,
193    to: NodeId,
194) -> Result<(), AuditAssertionError> {
195    graph
196        .dependency_path(from, to)
197        .map(|_| ())
198        .ok_or(AuditAssertionError::MissingDependencyPath { from, to })
199}
200
201fn resource_command_kind<C>(command: &trellis_core::ResourceCommand<C>) -> ResourceCommandKind {
202    match command {
203        trellis_core::ResourceCommand::Open { .. } => ResourceCommandKind::Open,
204        trellis_core::ResourceCommand::Close { .. } => ResourceCommandKind::Close,
205        trellis_core::ResourceCommand::Replace { .. } => ResourceCommandKind::Replace,
206        trellis_core::ResourceCommand::Refresh { .. } => ResourceCommandKind::Refresh,
207    }
208}
209
210fn output_frame_kind<O>(kind: &OutputFrameKind<O>) -> OutputFrameKindTrace {
211    match kind {
212        OutputFrameKind::Baseline(_) => OutputFrameKindTrace::Baseline,
213        OutputFrameKind::Delta(_) => OutputFrameKindTrace::Delta,
214        OutputFrameKind::Clear(reason) => OutputFrameKindTrace::Clear(*reason),
215        OutputFrameKind::Rebaseline(_, reason) => OutputFrameKindTrace::Rebaseline(*reason),
216    }
217}
218
219fn resource_audit_context<C, O>(
220    command: &trellis_core::ResourceCommand<C>,
221    result: &TransactionResult<C, O>,
222) -> ResourceAuditContext {
223    ResourceAuditContext {
224        key: command.key().clone(),
225        scope: command.scope(),
226        transaction_id: result.transaction_id,
227        revision: result.revision,
228        kind: resource_command_kind(command),
229    }
230}
231
232fn output_audit_context<O>(frame: &trellis_core::OutputFrame<O>) -> OutputAuditContext {
233    OutputAuditContext {
234        key: frame.output_key,
235        scope: frame.scope,
236        transaction_id: frame.transaction_id,
237        revision: frame.revision,
238        kind: output_frame_kind(&frame.kind),
239    }
240}
241
242fn resource_cause_is_explainable<C, O>(
243    explanation: &trellis_core::ResourceCommandExplanation,
244    command: &trellis_core::ResourceCommand<C>,
245    result: &TransactionResult<C, O>,
246) -> bool {
247    match explanation.cause {
248        trellis_core::ResourceCommandCause::Planner { collection } => {
249            explanation.collection_diffs.contains(&collection)
250                && (result.changed_inputs.is_empty()
251                    || (!explanation.input_causes.is_empty()
252                        && !explanation.dependency_paths.is_empty()))
253        }
254        trellis_core::ResourceCommandCause::ScopeClosed { scope } => scope == command.scope(),
255    }
256}
257
258fn output_frame_is_explainable<C, O>(
259    explanation: &trellis_core::OutputFrameExplanation,
260    result: &TransactionResult<C, O>,
261) -> bool {
262    result.changed_inputs.is_empty()
263        || explanation.changed_dependencies.is_empty()
264        || (!explanation.input_causes.is_empty() && !explanation.dependency_paths.is_empty())
265}