smix-cli 13.1.0

smix — AI-native iOS Simulator automation CLI.
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
//! `smix lease list | status | reconcile` — the ledger, said out loud.
//!
//! The ledger exists so that a session killed without a graceful path
//! gets one at the next startup. That only helps if a person can see what
//! it thinks: a mechanism that silently decides which processes to signal
//! is one nobody can trust or debug. These three verbs are that window.

use crate::LeaseAction;
use smix_lease::store::{CheckoutLedgers, LeaseDir, LedgerDivergence};
use smix_lease::{Admission, StaleReason, store};
use std::path::{Path, PathBuf};

/// Render the verdict for one device.
///
/// Pure so the wording is testable — the phrasing is the product here,
/// not a detail of it.
pub fn describe(device_id: &str, admission: &Admission) -> String {
    match admission {
        Admission::Granted => format!("{device_id}: free"),
        Admission::Denied(c) if c.holder_alive => format!(
            "{device_id}: held by pid {} ({}) since {}",
            c.holder.pid, c.holder.cmd, c.acquired_at
        ),
        Admission::Denied(c) => format!(
            "{device_id}: in use — the command that took it (pid {}) has exited, \
             but what it started is still running",
            c.holder.pid
        ),
        Admission::Adoptable => format!(
            "{device_id}: a finished session left its runner serving — the next \
             command takes the lease over as-is"
        ),
        Admission::Reclaimable { cleanup, reason } => {
            let why = match reason {
                StaleReason::HolderExited => "holder exited",
                StaleReason::PidRecycled => "holder gone, its pid was reused",
                StaleReason::HeartbeatExpired => "holder stopped responding",
            };
            format!(
                "{device_id}: abandoned ({why}) — {} close(s) owed",
                cleanup.len()
            )
        }
    }
}

/// Who holds a device, for a script: `null` when a new claim would be
/// granted, otherwise the pid and command that hold it and whether that
/// process is still alive.
///
/// The human line says the same thing in words. A script that parsed
/// those words, or read the stored lease and judged the holder's
/// liveness itself, would keep a second copy of `assess` (2026-09-26:
/// e2e scripts deciding whether to yield a device).
pub fn held_by_json(admission: &Admission) -> serde_json::Value {
    match admission {
        Admission::Denied(c) => serde_json::json!({
            "pid": c.holder.pid,
            "cmd": c.holder.cmd,
            "alive": c.holder_alive,
        }),
        Admission::Granted | Admission::Adoptable | Admission::Reclaimable { .. } => {
            serde_json::Value::Null
        }
    }
}

/// The note for one device the tree's old book records differently.
///
/// Pure so the wording is testable. It says when the tree's copy was
/// last written, and never that something is writing it: a book frozen
/// since the ledgers moved disagrees with the machine's on every device
/// it names, forever, and "Something still writes there" turned that
/// into a claim about a live writer with nothing behind it.
pub fn divergence_note(
    d: &LedgerDivergence,
    checkout: &str,
    tree: &Path,
    leases: &str,
    last_written: Option<&str>,
) -> String {
    match d {
        LedgerDivergence::OnlyInCheckout { device_id } => format!(
            "note: {device_id} has a ledger in {checkout} and none here — no other \
             checkout can see it. `smix lease migrate --from {}` brings it over.",
            tree.display()
        ),
        LedgerDivergence::Disagrees { device_id, detail } => {
            let when = match last_written {
                Some(t) => format!("That copy was last written {t}"),
                None => "When it was last written could not be read".to_string(),
            };
            format!(
                "note: {device_id} is recorded differently in {checkout} — {detail}. \
                 {when}; this smix only reads it. This command answers from \
                 {leases} alone."
            )
        }
    }
}

/// Run the subcommand.
///
/// `leases` is this machine's ledger directory — the ledgers stopped
/// being a property of the tree you are standing in.
pub async fn run(leases: &LeaseDir, action: LeaseAction) -> Result<u8, crate::CliError> {
    // What the tree underfoot still holds, if it holds anything.
    //
    // Read for one purpose: to say when it disagrees. Never merged into
    // the machine's answer — a tree's book is written by whatever smix
    // that tree last ran, and on this machine that was still 3.0.0
    // ninety-one minutes after the ledgers moved.
    let checkout = std::env::current_dir()
        .ok()
        .and_then(|cwd| CheckoutLedgers::discover(&cwd));
    let divergences: Vec<LedgerDivergence> = match &checkout {
        Some(c) => store::survey(leases, c),
        None => Vec::new(),
    };
    let say_divergences = |only: Option<&str>| {
        let Some(c) = &checkout else { return };
        let rows: Vec<&LedgerDivergence> = divergences
            .iter()
            .filter(|d| only.is_none_or(|id| d.device_id() == id))
            .collect();
        if rows.is_empty() {
            return;
        }
        let tree = c
            .path()
            .parent()
            .and_then(std::path::Path::parent)
            .unwrap_or(c.path());
        eprintln!();
        for d in rows {
            let written = c
                .ledger_path(d.device_id())
                .ok()
                .and_then(|p| std::fs::metadata(p).ok())
                .and_then(|m| m.modified().ok())
                .map(|t| {
                    chrono::DateTime::<chrono::Local>::from(t)
                        .format("%Y-%m-%d %H:%M:%S %:z")
                        .to_string()
                });
            eprintln!(
                "{}",
                divergence_note(
                    d,
                    &c.to_string(),
                    tree,
                    &leases.to_string(),
                    written.as_deref()
                )
            );
        }
    };
    match action {
        LeaseAction::List => {
            let ids = leases.device_ids();
            if ids.is_empty() {
                println!("no device ledgers under {leases}");
                // Not a return. An empty machine and a tree that still
                // holds ledgers is the state somebody most needs told
                // about — it is what a fresh checkout of a machine mid-
                // migration looks like, and returning here answered
                // "nothing to see" while a tree held the only record of
                // a device.
                say_divergences(None);
                return Ok(0);
            }
            for id in ids {
                let facts = store::collect_facts(leases, &id).map_err(to_cli_error)?;
                println!("{}", describe(&id, &smix_lease::assess(&facts)));
            }
            say_divergences(None);
        }
        LeaseAction::Status { device, json: true } => {
            let udid = crate::resolve_device(&device)?;
            let lease = store::read(leases, &udid).map_err(to_cli_error)?;
            let path = store::lease_path(leases, &udid).map_err(to_cli_error)?;
            let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
            let held_by = held_by_json(&smix_lease::assess(&facts));
            println!(
                "{}",
                serde_json::json!({ "device": udid, "path": path, "lease": lease, "heldBy": held_by })
            );
        }
        LeaseAction::Status {
            device,
            json: false,
        } => {
            let udid = crate::resolve_device(&device)?;
            let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
            let admission = smix_lease::assess(&facts);
            println!("{}", describe(&udid, &admission));
            if let Some(held) = &facts.existing {
                for r in &held.lease.resources {
                    println!("  open: {r:?}");
                }
            }
            if let Admission::Reclaimable { cleanup, .. } = &admission {
                for a in cleanup {
                    println!("  owed: {a:?}");
                }
                println!("run `smix lease reconcile {device}` to close them");
            }
            say_divergences(Some(&udid));
        }
        LeaseAction::Reconcile { device } => {
            let udid = crate::resolve_device(&device)?;
            // A device the two books disagree about is not settled here.
            //
            // Reconcile acts: it stops runners and shuts simulators
            // down. The machine ledger is the only thing it reads, and
            // while a tree holds a different record for the same device
            // the machine's may be the older of the two. That is not a
            // guess about this machine — for ninety-one minutes on
            // 2026-08-11 the machine ledger read "abandoned, 2 close(s)
            // owed" for a simulator whose runner, recorded only in
            // `qualcomm/insight`, was alive and serving on port 22087.
            // Acting then would have killed it.
            //
            // Refusing costs a wait. `lease migrate` is the way out, and
            // it refuses in its own turn when both books name a live
            // holder rather than choosing between two people's work.
            if let Some(d) = divergences.iter().find(|d| d.device_id() == udid) {
                let c = checkout
                    .as_ref()
                    .expect("a divergence implies a checkout book");
                let tree = c
                    .path()
                    .parent()
                    .and_then(std::path::Path::parent)
                    .unwrap_or(c.path());
                eprintln!("{udid}: not settling — {c} has a different record for it.");
                if let LedgerDivergence::Disagrees { detail, .. } = d {
                    eprintln!("  {detail}");
                }
                eprintln!(
                    "  Settling from one book while the other says otherwise is how a \
                     live session gets torn down. Reconcile them first:"
                );
                eprintln!("    smix lease migrate --from {}", tree.display());
                return Ok(1);
            }
            let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
            match smix_lease::assess(&facts) {
                Admission::Granted => println!("{udid}: nothing to settle"),
                // Nothing to settle: the next command that wants the
                // device will adopt this lease in passing. Reconcile
                // closes abandoned things, and a serving runner is not
                // abandoned — it is waiting.
                a @ Admission::Adoptable => {
                    println!("{}", describe(&udid, &a));
                }
                // A live session is not ours to end, and a command that
                // quietly ended one would make every other command in
                // this tool unsafe to run next to somebody's work.
                a @ Admission::Denied(_) => {
                    println!("{}", describe(&udid, &a));
                    println!("not touching it — a live session is not this command's to end");
                }
                Admission::Reclaimable { cleanup, reason } => {
                    println!(
                        "{}",
                        describe(
                            &udid,
                            &Admission::Reclaimable {
                                cleanup: cleanup.clone(),
                                reason,
                            }
                        )
                    );
                    let outcomes = smix_capsule::reconcile::execute(&cleanup);
                    let mut all_clean = true;
                    for o in &outcomes {
                        println!("  {}", o.line());
                        all_clean &= o.is_clean();
                    }
                    if all_clean {
                        store::remove(leases, &udid).map_err(to_cli_error)?;
                        println!("{udid}: settled, ledger cleared");
                    } else {
                        // Keeping the ledger is the point: the next
                        // command must still see what did not close.
                        println!("{udid}: some closes failed — ledger kept so they stay visible");
                    }
                }
            }
        }
        LeaseAction::Claim { device } => return claim(leases, &device),
        LeaseAction::Release { device } => release(leases, &device)?,
        LeaseAction::Owner { device } => return owner(leases, &device),
        LeaseAction::Migrate { from, dry_run } => migrate(leases, &from, dry_run)?,
        LeaseAction::Prune { dry_run, device } => prune(leases, dry_run, device.as_deref()).await?,
        LeaseAction::History { json } => crate::departures::print_history(leases, json)?,
    }
    Ok(0)
}

/// Who booted this device, in one line and an exit code.
///
/// The device ref is resolved before the ledger is read, so a name this
/// machine does not know is a `1` — "the question could not be asked" —
/// rather than a `3` that would read as "nobody booted it".
fn owner(leases: &LeaseDir, device: &str) -> Result<u8, crate::CliError> {
    let udid = crate::resolve_device(device)?;
    let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
    let Some(held) = &facts.existing else {
        println!("{udid}: no ledger — nothing here booted it");
        return Ok(3);
    };
    let booted_by_us = held
        .lease
        .known_resources()
        .any(|r| matches!(r, smix_lease::Resource::Booted { by_us: true }));
    let claimed_at = held.lease.known_resources().find_map(|r| match r {
        smix_lease::Resource::Claimed { at } => Some(at.clone()),
        _ => None,
    });
    if !booted_by_us && claimed_at.is_none() {
        println!(
            "{udid}: a ledger exists (holder pid {}) but no row says smix booted \
             or claimed it",
            held.lease.holder.pid
        );
        return Ok(3);
    }
    let alive = held.holder.pid_exists && held.holder.identity_matches;
    // Both answer 0 and the sentence says which. A caller acting on the
    // code gets the entitlement it asked about — may I drive this — and
    // one that needs the stricter question reads the words, because a
    // claim is deliberately not a licence to switch the device off.
    if booted_by_us {
        println!(
            "{udid}: booted by smix — holder pid {} ({}), {}",
            held.lease.holder.pid,
            held.lease.holder.cmd,
            if alive { "still running" } else { "exited" }
        );
    } else {
        println!(
            "{udid}: claimed at {} — holder pid {} ({}), {}. Nothing here booted \
             it, so it is this machine's to drive and not to shut down.",
            claimed_at.unwrap_or_default(),
            held.lease.holder.pid,
            held.lease.holder.cmd,
            if alive { "still running" } else { "exited" }
        );
    }
    Ok(0)
}

/// Answer for a device this machine did not boot.
///
/// Admission first, and not as politeness: a claim is a statement about
/// a device nobody is using, and making one over somebody's live session
/// would be the escape hatch this exists to replace, wearing a better
/// name.
fn claim(leases: &LeaseDir, device: &str) -> Result<u8, crate::CliError> {
    let udid = crate::resolve_device(device)?;
    let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
    if let Admission::Denied(c) = smix_lease::assess(&facts) {
        println!(
            "{udid}: held by pid {} ({}) since {} — not a device to claim",
            c.holder.pid, c.holder.cmd, c.acquired_at
        );
        return Ok(3);
    }
    if facts.existing.as_ref().is_some_and(|h| {
        h.lease
            .known_resources()
            .any(|r| matches!(r, smix_lease::Resource::Booted { by_us: true }))
    }) {
        println!("{udid}: smix booted this one — it is already answered for");
        return Ok(0);
    }
    store::record_claim(leases, &udid).map_err(to_cli_error)?;
    println!(
        "{udid}: claimed. Yours to drive; not yours to shut down, because \
         nothing here booted it. `smix lease release {udid}` ends it, and so \
         does the device going off."
    );
    Ok(0)
}

/// Give up a claim, leaving every other row alone.
fn release(leases: &LeaseDir, device: &str) -> Result<(), crate::CliError> {
    let udid = crate::resolve_device(device)?;
    let had = store::read(leases, &udid)
        .map_err(to_cli_error)?
        .is_some_and(|l| {
            l.known_resources()
                .any(|r| matches!(r, smix_lease::Resource::Claimed { .. }))
        });
    if !had {
        println!("{udid}: no claim here to release");
        return Ok(());
    }
    store::drop_resource_kind(
        leases,
        &udid,
        &smix_lease::Resource::Claimed { at: String::new() },
    )
    .map_err(to_cli_error)?;
    println!("{udid}: claim released");
    Ok(())
}

/// Fold per-checkout ledgers into this machine's.
fn migrate(leases: &LeaseDir, from: &[PathBuf], dry_run: bool) -> Result<(), crate::CliError> {
    let sources: Vec<PathBuf> = if from.is_empty() {
        std::env::current_dir()
            .ok()
            .and_then(|cwd| checkout_lease_dir(&cwd))
            .into_iter()
            .collect()
    } else {
        from.iter()
            .map(|p| {
                if p.ends_with("leases") {
                    p.clone()
                } else {
                    p.join(".smix").join("leases")
                }
            })
            .collect()
    };
    if sources.is_empty() {
        println!("nothing to migrate — no checkout ledgers found. Name them with --from <dir>.");
        return Ok(());
    }
    let mut moved = 0usize;
    let mut refused = 0usize;
    for src in &sources {
        let src_dir = CheckoutLedgers::at(src.clone());
        let ids = src_dir.device_ids();
        if ids.is_empty() {
            println!("  {} held no ledgers", src.display());
            continue;
        }
        for id in ids {
            let Ok(Some(incoming)) = src_dir.read(&id) else {
                eprintln!("  {}/{id}: unreadable, left where it is", src.display());
                continue;
            };
            match store::read(leases, &id) {
                Ok(None) => {
                    // The only branch that differs between a rehearsal
                    // and the real thing. Everything above — which
                    // sources, which ids, which of the three verdicts —
                    // is one path, so what the rehearsal reports is what
                    // the run would do rather than a second opinion
                    // about it.
                    if !dry_run {
                        store::write(leases, &incoming).map_err(to_cli_error)?;
                    }
                    println!("  + {id} from {}", src.display());
                    moved += 1;
                }
                Ok(Some(existing)) if existing.holder.pid == incoming.holder.pid => {
                    println!("  = {id} already here");
                }
                // Two books, two holders, one device. Nothing here can
                // tell which session is the real one, and picking would
                // hand somebody's device to somebody else. Named, and
                // left for a person.
                Ok(Some(existing)) => {
                    eprintln!(
                        "  ! {id}: this machine already has a ledger held by pid {}, \
                         and {} has one held by pid {}. Settle one of them \
                         (`smix lease status {id}`) and run this again.",
                        existing.holder.pid,
                        src.display(),
                        incoming.holder.pid
                    );
                    refused += 1;
                }
                Err(e) => return Err(to_cli_error(e)),
            }
        }
    }
    if dry_run {
        println!("{moved} ledger(s) would move into {leases}; nothing was written");
    } else {
        println!("{moved} ledger(s) now in {leases}");
    }
    if refused > 0 {
        return Err(crate::CliError::Other(format!(
            "{refused} device(s) had a ledger in two places and were left alone"
        )));
    }
    if !dry_run {
        // The sources stay. Somebody unsure whether this worked has to
        // be able to run it again, and a migration that deletes what it
        // just copied cannot be run twice.
        println!("the source ledgers are untouched");
    }
    Ok(())
}

/// The checkout's own ledger directory, if it has one.
fn checkout_lease_dir(start: &Path) -> Option<PathBuf> {
    let mut dir = Some(start);
    while let Some(d) = dir {
        let candidate = d.join(".smix").join("leases");
        if candidate.is_dir() {
            return Some(candidate);
        }
        dir = d.parent();
    }
    None
}

/// Delete ledgers that no longer describe anything.
async fn prune(
    leases: &LeaseDir,
    dry_run: bool,
    only: Option<&str>,
) -> Result<(), crate::CliError> {
    let ids: Vec<String> = match only {
        Some(device) => {
            let id = crate::resolve_device(device)?;
            if !leases.device_ids().contains(&id) {
                println!("{id}: no ledger — nothing to prune");
                return Ok(());
            }
            vec![id]
        }
        None => leases.device_ids(),
    };
    if ids.is_empty() {
        println!("no device ledgers under {leases}");
        return Ok(());
    }
    // The same question `lease history` is kept by, asked the same way:
    // one judgement of whether a device is here. The old one asked
    // simctl and nothing else, so an emulator — which simctl has never
    // heard of — was "cannot tell whether it is still on" for ever,
    // although adb could say.
    let live = crate::departures::ask_what_is_here(&ids).await;
    let mut gone = 0usize;
    for id in ids {
        let facts = store::collect_facts(leases, &id).map_err(to_cli_error)?;
        let Some(held) = &facts.existing else {
            println!("  {id}: empty ledger — removing");
            if !dry_run {
                store::remove(leases, &id).map_err(to_cli_error)?;
            }
            gone += 1;
            continue;
        };
        // One place decides, and it is pure. Judging and doing I/O in
        // the same breath is what made the old rule untestable and let
        // `--help` describe a check the code never performed.
        let on = is_on(&smix_lease::vanish::presence(&held.lease, &live));
        if let smix_lease::PruneVerdict::Keep(why) = smix_lease::prune_verdict(held, on) {
            println!("  {id}: {why} — kept");
            continue;
        }
        println!(
            "  {id}: holder pid {} is gone and nothing it opened is still running — removing",
            held.lease.holder.pid
        );
        if !dry_run {
            store::remove(leases, &id).map_err(to_cli_error)?;
        }
        gone += 1;
    }
    if dry_run {
        println!("{gone} ledger(s) would be removed");
    } else {
        println!("{gone} ledger(s) removed");
    }
    Ok(())
}

/// What prune needs from a presence: on, off, or unknown.
fn is_on(p: &smix_lease::vanish::Presence) -> Option<bool> {
    use smix_lease::vanish::Presence;
    match p {
        Presence::Present => Some(true),
        Presence::Gone { .. } => Some(false),
        Presence::CannotTell => None,
    }
}

fn to_cli_error(e: store::LeaseError) -> crate::CliError {
    crate::CliError::Other(e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use smix_lease::{CleanupAction, Contention, ProcIdentity};

    fn ident(pid: u32) -> ProcIdentity {
        ProcIdentity {
            pid,
            started_at: "Thu Aug  6 10:00:00 2026".into(),
            cmd: "smix run hello.yaml".into(),
        }
    }

    fn disagreement() -> LedgerDivergence {
        LedgerDivergence::Disagrees {
            device_id: "5D087114".into(),
            detail: "holder: machine says pid 70842, the tree says pid 47495".into(),
        }
    }

    #[test]
    fn a_disagreement_says_when_the_tree_was_written_and_not_that_it_still_is() {
        // A frozen book always disagrees with a live one. The note said
        // "Something still writes there" about a file last touched on
        // 2026-08-11, and a release review took it at its word.
        let msg = divergence_note(
            &disagreement(),
            "/home/u/repo/.smix/leases",
            Path::new("/home/u/repo/.smix/leases")
                .ancestors()
                .nth(2)
                .expect("a book two levels under its tree"),
            "/home/u/.local/share/smix/leases",
            Some("2026-08-11 22:21:54 +09:00"),
        );
        assert!(!msg.contains("still writes"), "{msg}");
        assert!(
            msg.contains("last written 2026-08-11 22:21:54 +09:00"),
            "{msg}"
        );
        assert!(
            msg.contains("answers from /home/u/.local/share/smix/leases alone"),
            "{msg}"
        );
    }

    #[test]
    fn a_disagreement_whose_file_has_no_time_says_so() {
        let msg = divergence_note(
            &disagreement(),
            "/home/u/repo/.smix/leases",
            Path::new("/home/u/repo/.smix/leases")
                .ancestors()
                .nth(2)
                .expect("a book two levels under its tree"),
            "/home/u/.local/share/smix/leases",
            None,
        );
        assert!(
            msg.contains("When it was last written could not be read"),
            "{msg}"
        );
    }

    #[test]
    fn a_live_holder_is_held_by_for_a_script() {
        let v = held_by_json(&Admission::Denied(Contention {
            holder: ident(4242),
            acquired_at: "2026-08-06T10:00:00Z".into(),
            holder_alive: true,
        }));
        assert_eq!(v["pid"], 4242);
        assert_eq!(v["alive"], true);
        assert!(
            v["cmd"]
                .as_str()
                .is_some_and(|c| c.contains("smix run hello.yaml"))
        );
    }

    /// Its launcher exited and what it started still runs: a new claim is
    /// refused, so a script must see the device as held.
    #[test]
    fn a_device_whose_launcher_exited_is_still_held_for_a_script() {
        let v = held_by_json(&Admission::Denied(Contention {
            holder: ident(4242),
            acquired_at: "2026-08-06T10:00:00Z".into(),
            holder_alive: false,
        }));
        assert_eq!(v["pid"], 4242);
        assert_eq!(v["alive"], false);
    }

    #[test]
    fn a_free_device_is_held_by_nobody() {
        assert!(held_by_json(&Admission::Granted).is_null());
        assert!(held_by_json(&Admission::Adoptable).is_null());
    }

    #[test]
    fn a_live_holder_is_named_not_just_reported_busy() {
        let msg = describe(
            "UDID-1",
            &Admission::Denied(Contention {
                holder: ident(4242),
                acquired_at: "2026-08-06T10:00:00Z".into(),
                holder_alive: true,
            }),
        );
        assert!(msg.contains("4242"), "the pid is what makes it actionable");
        assert!(msg.contains("smix run hello.yaml"));
    }

    #[test]
    fn an_exited_launcher_says_what_is_still_running() {
        // Reporting this as "held by pid N" would send someone looking
        // for a process that is not there.
        let msg = describe(
            "UDID-1",
            &Admission::Denied(Contention {
                holder: ident(4242),
                acquired_at: "2026-08-06T10:00:00Z".into(),
                holder_alive: false,
            }),
        );
        assert!(msg.contains("has exited"));
        assert!(msg.contains("still running"));
    }

    #[test]
    fn an_abandoned_device_says_why_and_how_much_is_owed() {
        let msg = describe(
            "UDID-1",
            &Admission::Reclaimable {
                cleanup: vec![CleanupAction::ShutdownSim {
                    udid: "UDID-1".into(),
                }],
                reason: StaleReason::PidRecycled,
            },
        );
        assert!(msg.contains("pid was reused"));
        assert!(msg.contains("1 close"));
    }

    #[test]
    fn a_free_device_says_so_plainly() {
        assert_eq!(describe("UDID-1", &Admission::Granted), "UDID-1: free");
    }
}