1use std::collections::BTreeSet;
2
3use trellis_core::{Graph, ResourceCommandTrace, ResourceKey, Revision, ScopeId};
4
5use crate::{
6 HostStatusClass, HostStatusRecord, ResourceLedger, ResourceLedgerError, ResourceSnapshot,
7 ResourceStatusContext,
8};
9
10impl<C> ResourceLedger<C> {
11 pub fn assert_command_order(
13 &self,
14 expected: &[ResourceCommandTrace],
15 ) -> Result<(), ResourceLedgerError> {
16 if self.command_trace == expected {
17 Ok(())
18 } else {
19 Err(ResourceLedgerError::CommandOrderMismatch {
20 expected: expected.to_vec(),
21 actual: self.command_trace.clone(),
22 })
23 }
24 }
25
26 pub fn assert_all_resources_have_owner(&self) -> Result<(), ResourceLedgerError> {
28 for (key, snapshot) in &self.resources {
29 if snapshot.owners.is_empty() {
30 return Err(ResourceLedgerError::Orphan {
31 key: key.clone(),
32 context: Some(snapshot.command_context()),
33 });
34 }
35 }
36 Ok(())
37 }
38
39 pub fn assert_no_orphan_resources(&self) -> Result<(), ResourceLedgerError> {
41 self.assert_all_resources_have_owner()
42 }
43
44 pub fn assert_graph_has_no_orphan_resources(
46 &self,
47 graph: &Graph<C>,
48 ) -> Result<(), ResourceLedgerError> {
49 if let Some(key) = graph.orphan_resources().into_iter().next() {
50 Err(ResourceLedgerError::Orphan {
51 context: self.context_for_key(&key),
52 key,
53 })
54 } else {
55 Ok(())
56 }
57 }
58
59 pub fn assert_no_duplicate_close(&self) -> Result<(), ResourceLedgerError> {
61 if let Some(context) = self.duplicate_closes.first() {
62 Err(ResourceLedgerError::DuplicateClose {
63 key: context.key.clone(),
64 context: context.clone(),
65 })
66 } else {
67 Ok(())
68 }
69 }
70
71 pub fn assert_no_forbidden_opened(&self) -> Result<(), ResourceLedgerError> {
73 if let Some(context) = self.forbidden_opened.first() {
74 Err(ResourceLedgerError::ForbiddenOpen {
75 key: context.key.clone(),
76 context: Some(context.clone()),
77 })
78 } else {
79 Ok(())
80 }
81 }
82
83 pub fn assert_no_wildcard_resource_opened(&self) -> Result<(), ResourceLedgerError> {
85 self.assert_no_forbidden_opened()
86 }
87
88 pub fn assert_resource_not_open(&self, key: &ResourceKey) -> Result<(), ResourceLedgerError> {
90 if self.resources.contains_key(key) {
91 Err(ResourceLedgerError::StillOpen {
92 key: key.clone(),
93 context: self.context_for_key(key),
94 })
95 } else {
96 Ok(())
97 }
98 }
99
100 pub fn assert_closed_scope_owns_no_resources(
102 &self,
103 scope: ScopeId,
104 ) -> Result<(), ResourceLedgerError> {
105 let resources = self
106 .resources
107 .iter()
108 .filter(|(_, snapshot)| snapshot.owners.contains(&scope))
109 .map(|(key, _)| key.clone())
110 .collect::<Vec<_>>();
111 if resources.is_empty() {
112 Ok(())
113 } else {
114 let contexts = resources
115 .iter()
116 .filter_map(|key| self.context_for_key(key))
117 .collect();
118 Err(ResourceLedgerError::ClosedScopeOwnsResources {
119 scope,
120 resources,
121 contexts,
122 })
123 }
124 }
125
126 pub fn assert_resource_opened_once(
128 &self,
129 key: &ResourceKey,
130 ) -> Result<(), ResourceLedgerError> {
131 self.assert_count(key, "open_count", 1, |snapshot| snapshot.open_count)
132 }
133
134 pub fn assert_resource_closed_once(
136 &self,
137 key: &ResourceKey,
138 ) -> Result<(), ResourceLedgerError> {
139 self.assert_count(key, "close_count", 1, |snapshot| snapshot.close_count)
140 }
141
142 pub fn assert_resource_generation(
144 &self,
145 key: &ResourceKey,
146 expected: u64,
147 ) -> Result<(), ResourceLedgerError> {
148 let actual = self
149 .history
150 .get(key)
151 .map_or(0, |snapshot| snapshot.generation);
152 if actual == expected {
153 Ok(())
154 } else {
155 Err(ResourceLedgerError::GenerationMismatch {
156 key: key.clone(),
157 expected,
158 actual,
159 context: self.context_for_key(key),
160 })
161 }
162 }
163
164 pub fn assert_resource_shared_by(
166 &self,
167 key: &ResourceKey,
168 expected: BTreeSet<ScopeId>,
169 ) -> Result<(), ResourceLedgerError> {
170 let actual = self
171 .resources
172 .get(key)
173 .map(|snapshot| snapshot.owners.clone())
174 .unwrap_or_default();
175 if actual == expected {
176 Ok(())
177 } else {
178 Err(ResourceLedgerError::OwnerMismatch {
179 key: key.clone(),
180 expected,
181 actual,
182 context: self.context_for_key(key),
183 })
184 }
185 }
186
187 pub fn assert_status_is_stale(
189 &self,
190 key: &ResourceKey,
191 command_revision: Revision,
192 ) -> Result<(), ResourceLedgerError> {
193 let Some(record) = self.status_records.iter().find(|record| {
194 record.status.resource_key == *key && record.status.command_revision == command_revision
195 }) else {
196 return Err(ResourceLedgerError::MissingStatus {
197 key: key.clone(),
198 command_revision,
199 });
200 };
201 if record.class == HostStatusClass::Stale {
202 Ok(())
203 } else {
204 Err(ResourceLedgerError::StatusClassMismatch {
205 context: status_context(record),
206 expected: HostStatusClass::Stale,
207 })
208 }
209 }
210
211 pub fn assert_status_did_not_resurrect_closed_scope(
213 &self,
214 scope: ScopeId,
215 ) -> Result<(), ResourceLedgerError> {
216 self.assert_closed_scope_owns_no_resources(scope)?;
217 self.assert_no_status_mutated_closed_scope()
218 }
219
220 pub fn assert_no_status_mutated_closed_scope(&self) -> Result<(), ResourceLedgerError> {
222 for record in &self.status_records {
223 if record.class == HostStatusClass::Late
224 && self.scope_owns_resource(record.status.scope, &record.status.resource_key)
225 {
226 return Err(ResourceLedgerError::StatusMutatedClosedScope {
227 scope: record.status.scope,
228 context: status_context(record),
229 });
230 }
231 }
232 Ok(())
233 }
234
235 fn assert_count(
236 &self,
237 key: &ResourceKey,
238 field: &'static str,
239 expected: usize,
240 count: impl FnOnce(&ResourceSnapshot<C>) -> usize,
241 ) -> Result<(), ResourceLedgerError> {
242 let actual = self.history.get(key).map_or(0, count);
243 if actual == expected {
244 Ok(())
245 } else {
246 Err(ResourceLedgerError::CountMismatch {
247 key: key.clone(),
248 field,
249 expected,
250 actual,
251 context: self.context_for_key(key),
252 })
253 }
254 }
255
256 fn scope_owns_resource(&self, scope: ScopeId, key: &ResourceKey) -> bool {
257 self.resources
258 .get(key)
259 .is_some_and(|snapshot| snapshot.owners.contains(&scope))
260 }
261}
262
263fn status_context(record: &HostStatusRecord) -> ResourceStatusContext {
264 ResourceStatusContext {
265 status: record.status.clone(),
266 class: record.class,
267 last_transaction_id: record.last_transaction_id,
268 last_command_revision: record.last_command_revision,
269 }
270}