1use std::collections::BTreeSet;
24
25#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct DeclaringVersion {
29 pub content_hash: String,
33 pub workflow_types: Vec<String>,
37 pub route_active: bool,
40 pub body: usize,
43}
44
45#[must_use]
52pub fn ambiguous_body_refusal(
53 action: &str,
54 task_queue: &str,
55 declaring: &[DeclaringVersion],
56) -> String {
57 let distinct: BTreeSet<usize> = declaring.iter().map(|version| version.body).collect();
58 let live: BTreeSet<usize> = declaring
59 .iter()
60 .filter(|version| version.route_active)
61 .map(|version| version.body)
62 .collect();
63
64 let remedy = remedy_for(declaring, &live);
65
66 format!(
67 "terminal:action `{action}` on task queue `{task_queue}` declares {bodies} different \
68 bodies across {versions} retained package versions; refusing to guess which deploy this \
69 run meant. {remedy}",
70 bodies = distinct.len(),
71 versions = declaring.len(),
72 )
73}
74
75fn remedy_for(declaring: &[DeclaringVersion], live: &BTreeSet<usize>) -> String {
79 if live.len() > 1 {
80 return format!(
84 "Every disagreeing version is route-active, so there is no superseded version to \
85 retire — {live_versions} are all reachable by new starts and declare different \
86 commands for the same action name. Give the action a distinct name in each document, \
87 or declare the same body in both.",
88 live_versions = route_active_names(declaring),
89 );
90 }
91
92 let superseded = unload_commands(declaring);
93 if superseded.is_empty() {
94 return "No superseded version is retained, so there is nothing to unload; the \
98 disagreement is inside the route-active set and needs the documents themselves \
99 reconciled."
100 .to_owned();
101 }
102
103 if live.is_empty() {
104 return format!(
108 "No retained version is route-active, so deploy the document you mean first — that \
109 makes its version the one new starts reach — then retire the rest and start the run \
110 again: {superseded}.",
111 superseded = superseded.join(", "),
112 );
113 }
114
115 format!(
116 "The route-active version already carries the body new starts use; retiring the \
117 superseded ones is what leaves a single body. Redeploying adds a version rather than \
118 removing one, so unload instead, then start the run again: {superseded}. Kept, because \
119 new starts route to it: {kept}.",
120 superseded = superseded.join(", "),
121 kept = route_active_names(declaring),
122 )
123}
124
125fn unload_commands(declaring: &[DeclaringVersion]) -> Vec<String> {
131 declaring
132 .iter()
133 .filter(|version| !version.route_active)
134 .flat_map(|version| {
135 version.workflow_types.iter().map(move |workflow_type| {
136 format!(
137 "`aion unload {workflow_type} {hash}`",
138 hash = version.content_hash
139 )
140 })
141 })
142 .collect()
143}
144
145fn route_active_names(declaring: &[DeclaringVersion]) -> String {
148 let names: Vec<String> = declaring
149 .iter()
150 .filter(|version| version.route_active)
151 .flat_map(|version| {
152 version.workflow_types.iter().map(move |workflow_type| {
153 format!("{workflow_type}@{hash}", hash = version.content_hash)
154 })
155 })
156 .collect();
157 if names.is_empty() {
158 return "no version".to_owned();
162 }
163 names.join(", ")
164}
165
166#[cfg(test)]
167mod tests {
168 use super::{DeclaringVersion, ambiguous_body_refusal};
169
170 const OLD: &str = "1111111111111111111111111111111111111111111111111111111111111111";
171 const NEW: &str = "2222222222222222222222222222222222222222222222222222222222222222";
172
173 fn version(hash: &str, types: &[&str], route_active: bool, body: usize) -> DeclaringVersion {
174 DeclaringVersion {
175 content_hash: hash.to_owned(),
176 workflow_types: types.iter().map(|name| (*name).to_owned()).collect(),
177 route_active,
178 body,
179 }
180 }
181
182 #[test]
183 fn the_remedy_names_the_superseded_version_as_a_runnable_command() {
184 let refusal = ambiguous_body_refusal(
185 "find_repositories",
186 "local",
187 &[
188 version(OLD, &["git_status_sweep"], false, 0),
189 version(NEW, &["git_status_sweep"], true, 1),
190 ],
191 );
192 assert!(refusal.starts_with("terminal:"), "{refusal}");
193 assert!(
194 refusal.contains(&format!("`aion unload git_status_sweep {OLD}`")),
195 "the superseded version must be named as a command: {refusal}"
196 );
197 assert!(
198 !refusal.contains(&format!("`aion unload git_status_sweep {NEW}`")),
199 "the route-active version cannot be unloaded and must not be told to: {refusal}"
200 );
201 assert!(
202 refusal.contains(&format!("git_status_sweep@{NEW}")),
203 "the kept version must be identified: {refusal}"
204 );
205 }
206
207 #[test]
208 fn the_remedy_never_tells_the_operator_to_redeploy_into_the_problem() {
209 let refusal = ambiguous_body_refusal(
212 "find_repositories",
213 "local",
214 &[
215 version(OLD, &["sweep"], false, 0),
216 version(NEW, &["sweep"], true, 1),
217 ],
218 );
219 assert!(
220 !refusal.contains("redeploy so one body remains"),
221 "{refusal}"
222 );
223 assert!(
224 refusal.contains("Redeploying adds a version rather than removing one"),
225 "the message must say why the obvious move is wrong: {refusal}"
226 );
227 }
228
229 #[test]
230 fn a_version_with_several_entry_types_earns_one_command_per_type() {
231 let refusal = ambiguous_body_refusal(
232 "build",
233 "local",
234 &[
235 version(OLD, &["alpha", "beta"], false, 0),
236 version(NEW, &["alpha"], true, 1),
237 ],
238 );
239 assert!(
240 refusal.contains(&format!("`aion unload alpha {OLD}`")),
241 "{refusal}"
242 );
243 assert!(
244 refusal.contains(&format!("`aion unload beta {OLD}`")),
245 "{refusal}"
246 );
247 }
248
249 #[test]
250 fn two_route_active_packages_are_told_to_reconcile_not_to_unload() {
251 let refusal = ambiguous_body_refusal(
252 "build",
253 "local",
254 &[
255 version(OLD, &["alpha"], true, 0),
256 version(NEW, &["beta"], true, 1),
257 ],
258 );
259 assert!(
260 !refusal.contains("aion unload"),
261 "unloading a route-active version is refused, so it must not be prescribed: {refusal}"
262 );
263 assert!(
264 refusal.contains("distinct name"),
265 "the remedy for a live collision is an authoring change: {refusal}"
266 );
267 assert!(refusal.contains(&format!("alpha@{OLD}")), "{refusal}");
268 assert!(refusal.contains(&format!("beta@{NEW}")), "{refusal}");
269 }
270
271 #[test]
272 fn with_nothing_route_active_the_deploy_comes_before_the_unload() -> Result<(), String> {
273 let refusal = ambiguous_body_refusal(
274 "build",
275 "local",
276 &[
277 version(OLD, &["alpha"], false, 0),
278 version(NEW, &["alpha"], false, 1),
279 ],
280 );
281 let deploy = refusal
282 .find("deploy the document you mean first")
283 .ok_or_else(|| format!("the deploy step must be named: {refusal}"))?;
284 let unload = refusal
285 .find("aion unload")
286 .ok_or_else(|| format!("the unload step must be named: {refusal}"))?;
287 assert!(
288 deploy < unload,
289 "deploy must be prescribed before unload: {refusal}"
290 );
291 Ok(())
292 }
293
294 #[test]
295 fn the_counts_report_bodies_and_versions_separately() {
296 let refusal = ambiguous_body_refusal(
299 "build",
300 "local",
301 &[
302 version(OLD, &["alpha"], false, 0),
303 version(NEW, &["alpha"], false, 0),
304 version(
305 "3333333333333333333333333333333333333333333333333333333333333333",
306 &["alpha"],
307 true,
308 1,
309 ),
310 ],
311 );
312 assert!(
313 refusal.contains("declares 2 different bodies across 3 retained package versions"),
314 "{refusal}"
315 );
316 }
317}