1use trellis_core::{
2 Graph, NodeId, OutputFrameKind, OutputFrameKindTrace, OutputKey, ResourceCommandKind,
3 ResourceKey, Revision, ScopeId, TransactionId, TransactionResult,
4};
5
6#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct ResourceAuditContext {
9 pub key: ResourceKey,
11 pub scope: ScopeId,
13 pub transaction_id: TransactionId,
15 pub revision: Revision,
17 pub kind: ResourceCommandKind,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct OutputAuditContext {
24 pub key: OutputKey,
26 pub scope: ScopeId,
28 pub transaction_id: TransactionId,
30 pub revision: Revision,
32 pub kind: OutputFrameKindTrace,
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum AuditAssertionError {
39 MissingResourceCommand {
41 context: ResourceAuditContext,
43 },
44 ResourceCommandMismatch {
46 context: ResourceAuditContext,
48 field: &'static str,
50 },
51 MissingOutputFrame {
53 context: OutputAuditContext,
55 },
56 OutputFrameMismatch {
58 context: OutputAuditContext,
60 field: &'static str,
62 },
63 MissingDependencyPath {
65 from: NodeId,
67 to: NodeId,
69 },
70}
71
72pub fn assert_no_unexplained_plan<C>(
74 graph: &Graph<C>,
75 result: &TransactionResult<C>,
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
119pub fn assert_no_unexplained_output_frame<C>(
121 graph: &Graph<C>,
122 result: &TransactionResult<C>,
123) -> Result<(), AuditAssertionError> {
124 for frame in &result.output_frames {
125 let context = output_audit_context(frame);
126 let explanation = graph.why_output_frame(frame.output_key).ok_or(
127 AuditAssertionError::MissingOutputFrame {
128 context: context.clone(),
129 },
130 )?;
131 if explanation.scope != frame.scope {
132 return Err(AuditAssertionError::OutputFrameMismatch {
133 context,
134 field: "scope",
135 });
136 }
137 if explanation.transaction_id != frame.transaction_id {
138 return Err(AuditAssertionError::OutputFrameMismatch {
139 context,
140 field: "transaction_id",
141 });
142 }
143 if explanation.revision != frame.revision {
144 return Err(AuditAssertionError::OutputFrameMismatch {
145 context,
146 field: "revision",
147 });
148 }
149 if explanation.kind != output_frame_kind(&frame.kind) {
150 return Err(AuditAssertionError::OutputFrameMismatch {
151 context,
152 field: "kind",
153 });
154 }
155 if !output_frame_is_explainable(explanation, result) {
156 return Err(AuditAssertionError::OutputFrameMismatch {
157 context,
158 field: "input_causes",
159 });
160 }
161 }
162 Ok(())
163}
164
165pub fn assert_dependency_path_exists<C>(
167 graph: &Graph<C>,
168 from: NodeId,
169 to: NodeId,
170) -> Result<(), AuditAssertionError> {
171 graph
172 .dependency_path(from, to)
173 .map(|_| ())
174 .ok_or(AuditAssertionError::MissingDependencyPath { from, to })
175}
176
177fn resource_command_kind<C>(command: &trellis_core::ResourceCommand<C>) -> ResourceCommandKind {
178 match command {
179 trellis_core::ResourceCommand::Open { .. } => ResourceCommandKind::Open,
180 trellis_core::ResourceCommand::Close { .. } => ResourceCommandKind::Close,
181 trellis_core::ResourceCommand::Replace { .. } => ResourceCommandKind::Replace,
182 trellis_core::ResourceCommand::Refresh { .. } => ResourceCommandKind::Refresh,
183 }
184}
185
186fn output_frame_kind(kind: &OutputFrameKind) -> OutputFrameKindTrace {
187 match kind {
188 OutputFrameKind::Baseline(_) => OutputFrameKindTrace::Baseline,
189 OutputFrameKind::Delta(_) => OutputFrameKindTrace::Delta,
190 OutputFrameKind::Clear(reason) => OutputFrameKindTrace::Clear(*reason),
191 OutputFrameKind::Rebaseline(_, reason) => OutputFrameKindTrace::Rebaseline(*reason),
192 }
193}
194
195fn resource_audit_context<C>(
196 command: &trellis_core::ResourceCommand<C>,
197 result: &TransactionResult<C>,
198) -> ResourceAuditContext {
199 ResourceAuditContext {
200 key: command.key().clone(),
201 scope: command.scope(),
202 transaction_id: result.transaction_id,
203 revision: result.revision,
204 kind: resource_command_kind(command),
205 }
206}
207
208fn output_audit_context(frame: &trellis_core::OutputFrame) -> OutputAuditContext {
209 OutputAuditContext {
210 key: frame.output_key,
211 scope: frame.scope,
212 transaction_id: frame.transaction_id,
213 revision: frame.revision,
214 kind: output_frame_kind(&frame.kind),
215 }
216}
217
218fn resource_cause_is_explainable<C>(
219 explanation: &trellis_core::ResourceCommandExplanation,
220 command: &trellis_core::ResourceCommand<C>,
221 result: &TransactionResult<C>,
222) -> bool {
223 match explanation.cause {
224 trellis_core::ResourceCommandCause::Planner { collection } => {
225 explanation.collection_diffs.contains(&collection)
226 && (result.changed_inputs.is_empty()
227 || (!explanation.input_causes.is_empty()
228 && !explanation.dependency_paths.is_empty()))
229 }
230 trellis_core::ResourceCommandCause::ScopeClosed { scope } => scope == command.scope(),
231 }
232}
233
234fn output_frame_is_explainable<C>(
235 explanation: &trellis_core::OutputFrameExplanation,
236 result: &TransactionResult<C>,
237) -> bool {
238 if result.changed_inputs.is_empty() {
239 return true;
240 }
241 if matches!(
242 explanation.kind,
243 OutputFrameKindTrace::Baseline
244 | OutputFrameKindTrace::Clear(_)
245 | OutputFrameKindTrace::Rebaseline(_)
246 ) && explanation.changed_dependencies.is_empty()
247 {
248 return true;
249 }
250 !explanation.changed_dependencies.is_empty()
251 && !explanation.input_causes.is_empty()
252 && !explanation.dependency_paths.is_empty()
253}