microvms-app 1.0.0

The MicroVMs client's use cases, written against ports: the control plane, sandboxes, sessions, agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
// SPDX-License-Identifier: Apache-2.0
//! The contract recorder: a fake control plane that asserts on what was *emitted*.
//!
//! # The rule this fake follows
//!
//! **It never asserts on a value the client computed, and it never answers in a spelling
//! the client chose.**
//!
//! That rule is the fix for a specific bug. The Python client's stall probe read
//! `b.get("state")` from a build summary whose member is `buildState`, so it read `None`
//! from every real response — and its unit test passed, because the test's fake returned
//! `{"state": "PENDING"}`. The fake shared the client's own misreading, so the two agreed
//! with each other and with nothing else, and the only guard separating a wedged image
//! from a slow build was dead against live AWS for a review round.
//!
//! So this fake:
//!
//! * answers with **literal JSON strings** written in the service model's spelling, not
//!   with values serialized from this crate's own types;
//! * records every [`Call`] verbatim and lets a test assert on the method, the path, and
//!   the body's *JSON member names*;
//! * counts calls per operation, which is what makes the TRAP-11 assertion possible — "no
//!   `CreateMicrovmShellAuthToken` across a full lifecycle" is a count, not a refusal.
//!
//! # Why it is hand-rolled rather than wiremock
//!
//! Because the assertion is on the request's *shape against the model*, not on an HTTP
//! exchange. A wiremock matcher would sit behind the signing path and test reqwest; this
//! sits at the [`Transport`] seam and tests the thing the model constrains. It also runs
//! with no ports, no runtime setup, and no ordering flakiness.

use std::collections::HashMap;
use std::sync::Mutex;

use super::transport::{Call, Reply, Transport};
use crate::error::{Error, ErrorKind};

/// A queued answer: the status and the raw body bytes.
///
/// The body is a `String` because every one is written as a literal in a test — which is
/// the honest-fake rule made mechanical, since there is no way to *accidentally* pass a
/// serialized `ops::` type here.
#[derive(Clone, Debug)]
pub struct Answer {
    pub status: u16,
    pub body: String,
    /// What the `x-amzn-ErrorType` header would carry, already cut to the error name.
    pub error_type: Option<String>,
}

impl Answer {
    /// A 200 carrying `body`.
    pub fn ok(body: impl Into<String>) -> Self {
        Self {
            status: 200,
            body: body.into(),
            error_type: None,
        }
    }

    /// A 201 carrying `body`, which is what `CreateMicrovmImage` answers.
    pub fn created(body: impl Into<String>) -> Self {
        Self {
            status: 201,
            body: body.into(),
            error_type: None,
        }
    }

    /// A failure status carrying a modeled error body.
    pub fn failure(status: u16, message: &str) -> Self {
        Self {
            status,
            body: format!(r#"{{"message": {}}}"#, json_string(message)),
            error_type: None,
        }
    }

    /// The throttle as the service sent it to `ListMicrovms` on 2026-10-02: HTTP 400, the
    /// error named only by the header, and this body (docs/PLATFORM.md).
    pub fn throttled_as_400() -> Self {
        Self {
            status: 400,
            body: r#"{"message":null,"throttlingReasons":null}"#.to_string(),
            error_type: Some("ThrottlingException".to_string()),
        }
    }
}

/// Escapes `text` as a JSON string, so a test can put a quote in a message.
fn json_string(text: &str) -> String {
    serde_json::to_string(text).expect("a string always serializes")
}

/// The fake, plus its ledger.
#[derive(Debug, Default)]
pub struct FakeControlPlane {
    inner: Mutex<Inner>,
}

#[derive(Debug, Default)]
struct Inner {
    /// Queued answers per operation, consumed front-to-back. The last one repeats, so a
    /// polling test can queue two answers and let the second stand for every later poll.
    queued: HashMap<&'static str, std::collections::VecDeque<Answer>>,
    /// Every call, in order.
    calls: Vec<Call>,
    /// A transport-level failure to raise for an operation instead of answering.
    transport_errors: HashMap<&'static str, usize>,
    /// What resolving credentials fails with, or `None` for a chain that resolves.
    credentials_error: Option<String>,
}

impl FakeControlPlane {
    pub fn new() -> Self {
        Self::default()
    }

    /// Queues `answer` for the next call to `operation`.
    ///
    /// Answers are consumed in order; when the queue for an operation runs dry the **last**
    /// answer repeats. That is what lets `wait_for_running` be tested with two queued
    /// answers — PENDING then RUNNING — rather than with one per expected poll, which would
    /// make the test depend on the poll count it is not trying to assert.
    pub fn answer(&self, operation: &'static str, answer: Answer) -> &Self {
        self.inner
            .lock()
            .expect("no test panics while holding this")
            .queued
            .entry(operation)
            .or_default()
            .push_back(answer);
        self
    }

    /// Makes the next `count` calls to `operation` fail at the transport level — no status
    /// at all, which is what a connection reset looks like.
    pub fn fail_transport(&self, operation: &'static str, count: usize) -> &Self {
        self.inner
            .lock()
            .expect("not poisoned")
            .transport_errors
            .insert(operation, count);
        self
    }

    /// Makes resolving credentials fail with a `Credentials` error carrying `message`, the
    /// way a default chain that finds nothing does.
    ///
    /// Calls are still recorded and answered from the queue, so a test asserts that none
    /// followed the failure by reading [`Self::calls`], not by the fake refusing them.
    pub fn fail_credentials(&self, message: impl Into<String>) -> &Self {
        self.inner.lock().expect("not poisoned").credentials_error = Some(message.into());
        self
    }

    /// Every call made, in order.
    pub fn calls(&self) -> Vec<Call> {
        self.inner.lock().expect("not poisoned").calls.clone()
    }

    /// How many times `operation` was called.
    ///
    /// The TRAP-11 assertion is this function returning zero for
    /// `CreateMicrovmShellAuthToken` after a full lifecycle.
    pub fn call_count(&self, operation: &str) -> usize {
        self.inner
            .lock()
            .expect("not poisoned")
            .calls
            .iter()
            .filter(|call| call.operation == operation)
            .count()
    }

    /// The operations called, in order, deduplicated only where consecutive.
    pub fn operations(&self) -> Vec<&'static str> {
        self.inner
            .lock()
            .expect("not poisoned")
            .calls
            .iter()
            .map(|call| call.operation)
            .collect()
    }

    /// The body of the *n*th call to `operation`, parsed as generic JSON.
    ///
    /// Generic `Value` rather than an `ops::` type on purpose: a test that deserialized
    /// into this crate's own struct would only ever see the members that struct declares,
    /// so a wrong member name would be invisible — which is the whole failure mode this
    /// fake exists to prevent. Asserting on `value["additionalOsCapabilities"]` reads the
    /// wire member.
    pub fn body_of(&self, operation: &str, nth: usize) -> serde_json::Value {
        let inner = self.inner.lock().expect("not poisoned");
        let call = inner
            .calls
            .iter()
            .filter(|call| call.operation == operation)
            .nth(nth)
            .unwrap_or_else(|| panic!("no call {nth} to {operation}; calls: {:?}", inner.calls));
        let body = call
            .body
            .as_deref()
            .unwrap_or_else(|| panic!("{operation} sent no body"));
        serde_json::from_slice(body)
            .unwrap_or_else(|error| panic!("{operation} body is not JSON: {error}"))
    }

    /// The first body sent to `operation`.
    pub fn first_body(&self, operation: &str) -> serde_json::Value {
        self.body_of(operation, 0)
    }

    /// Every `clientToken` this fake saw, in call order.
    ///
    /// Read off the **wire member name**, which is what makes the TRAP-1 proof honest: it
    /// observes what was emitted rather than calling the minting function again.
    pub fn client_tokens(&self) -> Vec<String> {
        self.inner
            .lock()
            .expect("not poisoned")
            .calls
            .iter()
            .filter_map(|call| {
                let body = call.body.as_deref()?;
                let value: serde_json::Value = serde_json::from_slice(body).ok()?;
                Some(value.get("clientToken")?.as_str()?.to_string())
            })
            .collect()
    }

    /// Every path this fake was called on, which is where a `SHELL_INGRESS` or shell-auth
    /// route would show up even if the operation name were changed.
    pub fn paths(&self) -> Vec<String> {
        self.inner
            .lock()
            .expect("not poisoned")
            .calls
            .iter()
            .map(|call| call.path.clone())
            .collect()
    }

    /// Every request body, as raw text. Used for the TRAP-11 scan: a `SHELL_INGRESS`
    /// connector would appear here whatever named it.
    pub fn bodies_as_text(&self) -> Vec<String> {
        self.inner
            .lock()
            .expect("not poisoned")
            .calls
            .iter()
            .filter_map(|call| call.body.as_deref().map(String::from_utf8_lossy))
            .map(|text| text.to_string())
            .collect()
    }
}

impl Transport for FakeControlPlane {
    fn send(
        &self,
        call: Call,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>
    {
        let result = {
            let mut inner = self.inner.lock().expect("not poisoned");
            let operation = call.operation;
            inner.calls.push(call);

            if let Some(remaining) = inner.transport_errors.get_mut(operation)
                && *remaining > 0
            {
                *remaining -= 1;
                Err(Error::new(
                    ErrorKind::Retryable,
                    format!("the {operation} request did not complete: connection reset"),
                ))
            } else {
                let queue = inner
                    .queued
                    .get_mut(operation)
                    .unwrap_or_else(|| panic!("the fake has no answer queued for {operation}"));
                // The last answer repeats, so a polling test queues states rather than
                // one answer per poll.
                let answer = if queue.len() > 1 {
                    queue.pop_front().expect("non-empty")
                } else {
                    queue
                        .front()
                        .cloned()
                        .unwrap_or_else(|| panic!("the fake's queue for {operation} is empty"))
                };
                Ok(Reply {
                    status: answer.status,
                    body: answer.body.into_bytes(),
                    error_type: answer.error_type,
                })
            }
        };
        Box::pin(async move { result })
    }

    fn resolve_credentials(
        &self,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
        let result = match &self.inner.lock().expect("not poisoned").credentials_error {
            Some(message) => Err(Error::new(ErrorKind::Credentials, message.clone())),
            None => Ok(()),
        };
        Box::pin(async move { result })
    }
}

/// The crate's shared test clock, where the control-plane tests have always found it.
#[cfg(test)]
pub(crate) use crate::clock::testing::TestClock;

// ── literal response bodies, in the model's spelling ────────────────────────
//
// Every string below is written by hand from `service-2.json`. None is produced by
// serializing an `ops::` type, which is the point: a member misspelled in `ops.rs` cannot
// be misspelled identically here by accident.
//
// **Every image ARN below uses `microvm-image:<name>`, with a colon.** These fakes used to
// spell it `microvm-image/<name>` while `artifact.rs` built the colon form for the managed
// base, so the repo disagreed with itself about the shape of the one identifier every call
// takes — and the model's own `TaggableResource` pattern accepts only the colon.
//
// Settled by measurement rather than by reading, 2026-08-15, one read-only
// `ListMicrovmImages` plus one `GetMicrovmImage` in us-east-1:
//
//     arn:aws:lambda:us-east-1:<account>:microvm-image:coding-agents-on-bedrock
//
// The colon form is what the service returns and what it accepts. The slash form is not
// merely cosmetic: `GetMicrovmImage` on it answers **`AccessDeniedException`**, because IAM
// evaluates the malformed ARN as a resource the caller has no policy for. So a client that
// built a slash ARN would report a permissions problem for a resource that exists, which is
// the most expensive possible spelling of this mistake.

/// `CreateMicrovmImageResponse` for an image that has just entered `CREATING`.
pub fn create_image_response(name: &str) -> String {
    format!(
        r#"{{
            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
            "name": "{name}",
            "state": "CREATING",
            "createdAt": 1754524800,
            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
            "buildRoleArn": "arn:aws:iam::123456789012:role/build",
            "codeArtifact": {{"uri": "s3://bucket/{name}.zip"}},
            "imageVersion": "1"
        }}"#
    )
}

/// `GetMicrovmImageOutput` in `state`.
///
/// `tags` and `updatedAt` are here because a real response carries them — measured
/// 2026-08-15, a `GetMicrovmImage` on an untagged image answers `"tags": {}` and an
/// `updatedAt` — and a fake that omitted a member the client now reads would let a
/// misreading of it pass.
pub fn get_image_response(name: &str, state: &str) -> String {
    format!(
        r#"{{
            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
            "name": "{name}",
            "state": "{state}",
            "createdAt": 1754524800,
            "updatedAt": 1754528400,
            "tags": {{}}
        }}"#
    )
}

/// `GetMicrovmImageOutput` in `state`, naming a failed version and carrying tags.
///
/// Separate from [`get_image_response`] rather than another parameter on it, because the
/// failure-diagnosis path is the only caller that needs `latestFailedImageVersion` and
/// every other test would have to pass a `None` for it.
pub fn get_image_response_failed(name: &str, state: &str, failed_version: &str) -> String {
    format!(
        r#"{{
            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
            "name": "{name}",
            "state": "{state}",
            "latestFailedImageVersion": "{failed_version}",
            "createdAt": 1754524800,
            "updatedAt": 1754528400,
            "tags": {{"owner": "conformance"}}
        }}"#
    )
}

/// `ListMicrovmImageVersionsOutput` with one version.
pub fn list_versions_response(version: &str) -> String {
    list_versions_page(&[version], None)
}

/// `ListMicrovmImageVersionsOutput` with one version in `state`, carrying `state_reason`.
pub fn list_versions_response_failed(
    version: &str,
    state: &str,
    state_reason: Option<&str>,
) -> String {
    let reason = match state_reason {
        Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
        None => String::new(),
    };
    format!(
        r#"{{"items": [{}]}}"#,
        version_item(version, state, &reason)
    )
}

/// One page of `ListMicrovmImageVersionsOutput`, with an optional `nextToken`.
///
/// The `nextToken` is **absent** on the last page rather than null, which is what a real
/// final page looks like and what the pagination loop's exit reads.
pub fn list_versions_page(versions: &[&str], next_token: Option<&str>) -> String {
    let items: Vec<String> = versions
        .iter()
        .map(|version| version_item(version, "IN_PROGRESS", ""))
        .collect();
    let token = match next_token {
        Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
        None => String::new(),
    };
    format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}

/// One `MicrovmImageVersionSummary`, in the model's spelling. `state`, not `buildState` —
/// the asymmetry with the build summary is the whole trap.
fn version_item(version: &str, state: &str, extra: &str) -> String {
    version_item_with_status(version, state, "ACTIVE", extra)
}

/// One `MicrovmImageVersionSummary` in `state` with an explicit availability `status`.
///
/// The `status` parameter is what a retire test needs: `INACTIVE` is the value
/// `UpdateMicrovmImageVersion` sets and the value a readback has to be able to disagree
/// with, and a fake that always answered `ACTIVE` would make the readback assertion in
/// `set_image_version_status` unfalsifiable.
///
/// Every one of the model's eight required members is present, `status` included, because
/// [`super::ops::MicrovmImageVersionSummaryWire`] now requires all eight — a fake that
/// omitted one would fail to parse, which is the honest-fake rule doing its job.
fn version_item_with_status(version: &str, state: &str, status: &str, extra: &str) -> String {
    format!(
        r#"{{
            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
            "buildRoleArn": "arn:aws:iam::123456789012:role/build",
            "codeArtifact": {{"uri": "s3://bucket/img.zip"}},
            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
            "imageVersion": "{version}",
            "state": "{state}",
            "status": "{status}",
            "createdAt": 1754524800{extra}
        }}"#
    )
}

/// `GetMicrovmImageVersionOutput` / `UpdateMicrovmImageVersionResponse` for one version.
///
/// The two operations answer the same shape as the listing's item, which is why one literal
/// serves all three — the model declares them member-for-member identical. The response is
/// the item alone rather than wrapped in `items`, which is the one difference between a get
/// and a list and the one a wrong deserializer would get wrong.
pub fn get_image_version_response(version: &str, state: &str, status: &str) -> String {
    version_item_with_status(version, state, status, "")
}

/// The same, with a version-level `stateReason` — the member measured **null** on real
/// failures, kept parametric so a test can assert either way.
pub fn get_image_version_response_with_reason(
    version: &str,
    state: &str,
    status: &str,
    state_reason: &str,
) -> String {
    version_item_with_status(
        version,
        state,
        status,
        &format!(r#", "stateReason": {}"#, json_string(state_reason)),
    )
}

/// `GetMicrovmImageBuildOutput`: the summary's members plus `snapshotBuild`.
///
/// `snapshot_build` is the raw JSON fragment rather than three numbers, so a test can seed
/// the **partial** shape a real failed build answers with — measured 2026-08-16, a `FAILED`
/// build carried `codeInstallSizeInBytes` alone. A helper taking three `u64`s could not
/// express that, and the partial shape is the diagnosis.
pub fn get_image_build_response(
    build_id: &str,
    build_state: &str,
    generation: &str,
    state_reason: Option<&str>,
    snapshot_build: Option<&str>,
) -> String {
    let reason = match state_reason {
        Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
        None => String::new(),
    };
    let sizes = match snapshot_build {
        Some(fragment) => format!(r#", "snapshotBuild": {fragment}"#),
        None => String::new(),
    };
    format!(
        r#"{{
            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
            "imageVersion": "1",
            "buildId": "{build_id}",
            "buildState": "{build_state}",
            "architecture": "ARM_64",
            "chipset": "GRAVITON",
            "chipsetGeneration": "{generation}",
            "createdAt": 1754524800{reason}{sizes}
        }}"#
    )
}

/// `ListManagedMicrovmImageVersionsOutput`: `(imageVersion)` strings and an optional cursor.
///
/// The version strings are **bare integers** here because that is what the managed base
/// answers — `"0"` and `"1"`, measured 2026-08-16 — where a custom image's are `"1.0"`. A
/// fake that used the custom spelling would let a client that parsed one format look correct.
pub fn list_managed_versions_page(versions: &[&str], next_token: Option<&str>) -> String {
    let items: Vec<String> = versions
        .iter()
        .map(|version| {
            format!(
                r#"{{
                    "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
                    "imageVersion": "{version}",
                    "createdAt": 1750000000,
                    "updatedAt": 1753000000
                }}"#
            )
        })
        .collect();
    let token = match next_token {
        Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
        None => String::new(),
    };
    format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}

/// `ListManagedMicrovmImagesOutput`: an ARN and two timestamps per item, which is **all** the
/// model declares on `ManagedMicrovmImageSummary`.
///
/// No `name`, no `architecture`, no registry reference — the poverty of this shape is why a
/// discovered base cannot construct a `BaseImage`, and a fake that invented a `dockerRef`
/// would make the limitation look like an oversight in the client.
pub fn list_managed_images_page(names: &[&str], next_token: Option<&str>) -> String {
    let items: Vec<String> = names
        .iter()
        .map(|name| {
            format!(
                r#"{{
                    "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:{name}",
                    "createdAt": 1750000000,
                    "updatedAt": 1753000000
                }}"#
            )
        })
        .collect();
    let token = match next_token {
        Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
        None => String::new(),
    };
    format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}

/// `ListMicrovmImageBuildsOutput` whose builds are all in `build_state`.
///
/// **`buildState`**, spelled as the model spells it. This is the single most important
/// literal in this file: the Python fake wrote `state` here and that is what made the
/// stall guard unfalsifiable.
pub fn list_builds_response(build_state: &str) -> String {
    list_builds_page(&[("build-1", build_state), ("build-2", build_state)], None)
}

/// One page of `ListMicrovmImageBuildsOutput`: `(buildId, buildState)` pairs and an
/// optional `nextToken`.
///
/// The pairs are named rather than counted so a two-page test can assert *which* build the
/// verdict was made over — a page-one/page-two pair with different states is the only shape
/// that distinguishes a paginating probe from one that stops early.
pub fn list_builds_page(builds: &[(&str, &str)], next_token: Option<&str>) -> String {
    list_builds_page_with_reasons(
        &builds
            .iter()
            .map(|(id, state)| (*id, *state, None))
            .collect::<Vec<_>>(),
        next_token,
    )
}

/// One page of `ListMicrovmImageBuildsOutput` whose builds may carry a `stateReason`.
pub fn list_builds_page_with_reasons(
    builds: &[(&str, &str, Option<&str>)],
    next_token: Option<&str>,
) -> String {
    let items: Vec<String> = builds
        .iter()
        .map(|(build_id, build_state, state_reason)| {
            let reason = match state_reason {
                Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
                None => String::new(),
            };
            format!(
                r#"{{
                    "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
                    "imageVersion": "1",
                    "buildId": "{build_id}",
                    "buildState": "{build_state}",
                    "architecture": "ARM_64",
                    "chipset": "GRAVITON",
                    "chipsetGeneration": "1",
                    "createdAt": 1754524800{reason}
                }}"#
            )
        })
        .collect();
    let token = match next_token {
        Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
        None => String::new(),
    };
    format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}

/// `RunMicrovmResponse`/`GetMicrovmResponse` in `state`, with an optional `stateReason`.
///
/// `idlePolicy` is present with all three members, because a real `GetMicrovm` sends it
/// that way — measured 2026-08-15 against a RUNNING VM, which is what corrected
/// [`super::ops::IdlePolicy`]'s claim that `suspendedDurationSeconds` is request-only.
pub fn microvm_response(state: &str, state_reason: Option<&str>) -> String {
    let reason = match state_reason {
        Some(reason) => format!(r#", "stateReason": {}"#, json_string(reason)),
        None => String::new(),
    };
    format!(
        r#"{{
            "microvmId": "mvm-abc123",
            "state": "{state}",
            "endpoint": "https://mvm-abc123.microvm.us-east-1.amazonaws.com",
            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
            "imageVersion": "1",
            "idlePolicy": {{
                "maxIdleDurationSeconds": 1800,
                "suspendedDurationSeconds": 600,
                "autoResumeEnabled": false
            }},
            "maximumDurationInSeconds": 3600,
            "startedAt": 1754524800{reason}
        }}"#
    )
}

/// One page of `ListMicrovmsResponse`, with an optional `nextToken`.
///
/// `MicrovmItem` is narrower than `GetMicrovmResponse`: no `endpoint` and no `stateReason`,
/// which is the model's own asymmetry and not an omission here.
pub fn list_microvms_page(ids: &[&str], next_token: Option<&str>) -> String {
    let items: Vec<String> = ids
        .iter()
        .map(|id| {
            format!(
                r#"{{
                    "microvmId": "{id}",
                    "state": "RUNNING",
                    "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
                    "imageVersion": "1",
                    "startedAt": 1754524800
                }}"#
            )
        })
        .collect();
    let token = match next_token {
        Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
        None => String::new(),
    };
    format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}

/// `CreateMicrovmAuthTokenResponse` — a header **map**, per TRAP-7.
pub fn auth_token_response(token: &str) -> String {
    format!(
        r#"{{"authToken": {{"X-aws-proxy-auth": {}}}}}"#,
        json_string(token)
    )
}

/// `ListMicrovmImagesResponse` with one page of named images and an optional `nextToken`.
///
/// The member names are the model's: `imageArn`, `name`, `state`, `createdAt`, and a
/// top-level `nextToken` that is **absent** on the last page rather than null — which is
/// what a real final page looks like, and what the pagination loop's exit reads.
pub fn list_images_response(names: &[&str], next_token: Option<&str>) -> String {
    let items: Vec<String> = names
        .iter()
        .map(|name| {
            format!(
                r#"{{
                    "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:{name}",
                    "name": "{name}",
                    "state": "ACTIVE",
                    "latestActiveImageVersion": "1",
                    "createdAt": 1754524800
                }}"#
            )
        })
        .collect();
    let token = match next_token {
        Some(token) => format!(r#", "nextToken": {}"#, json_string(token)),
        None => String::new(),
    };
    format!(r#"{{"items": [{}]{token}}}"#, items.join(", "))
}

/// `DeleteMicrovmImageOutput` in `DELETING`, the ordinary answer.
pub fn delete_image_response() -> String {
    delete_image_response_in("DELETING")
}

/// `DeleteMicrovmImageOutput` in `state`.
///
/// The parameter exists so a test can seed the `DELETE_FAILED` readback — a 2xx whose state
/// says the work was refused, which is the case that makes reading this shape worth doing.
pub fn delete_image_response_in(state: &str) -> String {
    format!(
        r#"{{"imageIdentifier": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
        "state": "{state}"}}"#
    )
}

/// `SuspendMicrovmResponse`, `ResumeMicrovmResponse`, `TerminateMicrovmResponse` — all
/// three are empty structures in the model.
pub fn empty_response() -> String {
    "{}".to_string()
}