whisker-dev-server 0.1.0

Host-side dev server for `whisker run`. File watch + cargo build + WebSocket push of subsecond patches. Pulled in by whisker-cli; no presence in release builds.
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
//! Thin-rebuild driver — produce a patch dylib from a single
//! captured rustc invocation by editing as few of its args as
//! possible.
//!
//! ## Design principle: "minimal edit, verbatim everything else"
//!
//! Whisker does **not** want to re-derive linker / sysroot / SDK args
//! itself — those are the parts most likely to break across OS
//! versions, NDK upgrades, Xcode releases, glibc CSU layout
//! changes, and so on. Instead we capture cargo+rustc's full
//! invocation in I4g-4 and replay it here, **changing only the
//! handful of args that have to differ for a hot-patch dylib**:
//!
//!   - `--crate-type` is forced to `rlib` so rustc emits an object
//!     file containing every `pub fn`'s mangled symbol (cdylib
//!     would strip them — see I4g-6 pivot).
//!   - `--emit` is forced to `obj` so we get a single `.o` we can
//!     hand to the linker ourselves.
//!   - `--out-dir` is redirected to a session-local cache so the
//!     patch artifact doesn't clobber the original `target/`
//!     output.
//!
//! Everything else — target triple, sysroot, link-args, optimisation
//! level, `cfg` flags, `-L` search paths, `-l` link directives — is
//! preserved verbatim. That is the whole point: rustc + cargo
//! already know how to make the linker happy on this OS / SDK
//! combo, and we lean on that.
//!
//! After rustc emits the `.o`, [`build_link_plan`] (X2b) takes the
//! captured **linker** invocation, drops its object inputs (we have
//! a fresh one), substitutes our `.o` and `-o`, and adds
//! `-undefined dynamic_lookup` (macOS) /
//! `--unresolved-symbols=ignore-all` (Linux) so unresolved symbols
//! are deferred to the host process at `dlopen` time. The result is
//! a `.so` / `.dylib` that re-exports back into the original binary
//! for everything except the patched function bodies — exactly what
//! `subsecond::apply_patch` expects.

use std::path::{Path, PathBuf};

use super::wrapper::CapturedRustcInvocation;

/// What [`build_obj_plan`] returns — captured rustc args, edited so
/// that running `rustc` with them produces a single `.o` containing
/// every `pub fn`'s mangled symbol.
///
/// `output_dir` is the directory rustc will write the object into;
/// the actual filename rustc emits is `<crate_name>.o` (with the
/// usual hyphen → underscore translation). `expected_object` is the
/// absolute path the runner should expect to see after the call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjBuildPlan {
    pub args: Vec<String>,
    pub output_dir: PathBuf,
    pub expected_object: PathBuf,
}

/// Object filename rustc emits for `--emit=obj --crate-type=rlib`:
/// `<crate>.o` with hyphens converted to underscores. (Notably no
/// `lib` prefix and no extension other than `.o` — cdylib's
/// `lib<crate>.dylib` rules don't apply here.)
pub fn object_filename(crate_name: &str) -> String {
    let stem = crate_name.replace('-', "_");
    format!("{stem}.o")
}

/// Edit a captured rustc invocation so that running it produces an
/// object file containing every `pub fn`'s mangled symbol — the
/// input to the linker step in [`build_link_plan`].
///
/// Three changes only:
///
///   - **`--crate-type`** is forced to `rlib`. Object files emitted
///     for an `rlib` crate-type retain mangled `pub fn` symbols
///     (cdylib's symbol-visibility filter wouldn't have run yet,
///     because we stop before linking). `lib` would also work but
///     `rlib` is what cargo itself uses for normal dependency
///     compilation, so we stay closer to the rustc call shape that
///     gets the most testing.
///   - **`--emit`** is forced to a single `obj=<output_dir>/<crate>.o`
///     directive. This skips the link step (no `lib<crate>.rlib`
///     metadata bundle, no `.rmeta`, no codegen-units fan-out into
///     deps) and writes one consolidated object file we can hand
///     directly to the linker.
///   - **`--out-dir`** is redirected so the host's `target/` isn't
///     touched (it's still the rustc-default location for any
///     auxiliary file rustc decides to emit).
///
/// Everything else is preserved verbatim — same target triple,
/// sysroot, sysroot suffix, `-C` flags, `-L`/`-l` directives, cfg
/// gates. This is the same "minimal edit, verbatim everything else"
/// principle as the captured-args replay does for the linker side
/// in [`super::link_plan::build_link_plan`]; the only difference is
/// where we stop in rustc's pipeline (`obj` vs `link`).
pub fn build_obj_plan(captured: &CapturedRustcInvocation, output_dir: &Path) -> ObjBuildPlan {
    let mut args = captured.args.clone();
    set_crate_type(&mut args, "rlib");
    set_out_dir(&mut args, output_dir);
    let object_path = output_dir.join(object_filename(&captured.crate_name));
    set_emit_obj(&mut args, &object_path);
    // cargo's captured args include `--json=artifacts,…` +
    // `--error-format=json` so its build pipeline can parse rustc's
    // structured output. We just spawn rustc and inspect exit status;
    // leaving the JSON flags in produces a noisy
    // `{"$message_type":"artifact",…}` line on stdout for every
    // patch, which clutters the dev terminal. Strip them so rustc
    // falls back to human-readable output (errors still surface on
    // stderr in plain text).
    strip_json_flags(&mut args);
    // Reuse rustc's incremental cache across thin rebuilds. rustc
    // fingerprints source files + query results into this dir; the
    // next invocation skips re-typechecking + re-codegenning anything
    // the fingerprints prove unchanged. For a single-function edit
    // in a small user crate this can cut Stage 1 (~200 ms cold) to
    // 50–100 ms warm. The cache lives under our patch dir so it's
    // wiped together with the thin object on a fresh `whisker run`.
    set_incremental(&mut args, &output_dir.join("incremental"));
    // The fat (release) build captured `-Copt-level=3`. For Tier 1
    // hot patches we strip the LLVM optimization pipeline entirely —
    // `time-passes` confirms LLVM passes account for ~88% of rustc's
    // wall time on opt-level=3 user crates. The patched code only
    // has to *run*, not run fast: hot reload is a dev affordance, so
    // a 3-4x runtime slowdown of just the patched function bodies is
    // an excellent trade for a 3× compile-time win. opt-level=0 also
    // disables intra-crate inlining, which keeps the patch dylib's
    // call graph well-aligned with the host's exported symbols (no
    // mystery UND references from functions the host inlined away).
    override_opt_level(&mut args, "0");
    ObjBuildPlan {
        args,
        output_dir: output_dir.to_path_buf(),
        expected_object: object_path,
    }
}

/// Force `-Copt-level=<level>`. Strips any existing opt-level
/// directive (single-arg `-Copt-level=N`, split form `-C opt-level=N`,
/// and the shorthand `-O` = opt-level=2).
fn override_opt_level(args: &mut Vec<String>, level: &str) {
    let mut i = 0;
    while i < args.len() {
        if (args[i] == "-C" || args[i] == "--codegen")
            && i + 1 < args.len()
            && args[i + 1].starts_with("opt-level=")
        {
            args.drain(i..i + 2);
            continue;
        }
        if args[i].starts_with("-Copt-level=") || args[i].starts_with("--codegen=opt-level=") {
            args.remove(i);
            continue;
        }
        if args[i] == "-O" {
            args.remove(i);
            continue;
        }
        i += 1;
    }
    args.push("-C".into());
    args.push(format!("opt-level={level}"));
}

/// Force `-C incremental=<dir>` to point at our patch cache dir.
/// Strips any existing `-C incremental=...` rustc dropped in (cargo
/// usually doesn't pass one when building a `dylib`/`cdylib`, but
/// we strip defensively in case the captured args ever carry one).
fn set_incremental(args: &mut Vec<String>, incremental_dir: &Path) {
    let mut i = 0;
    while i < args.len() {
        if (args[i] == "-C" || args[i] == "--codegen")
            && i + 1 < args.len()
            && args[i + 1].starts_with("incremental=")
        {
            args.drain(i..=i + 1);
            continue;
        }
        // Combined form: `-Cincremental=...`
        if args[i].starts_with("-Cincremental=") || args[i].starts_with("--codegen=incremental=") {
            args.remove(i);
            continue;
        }
        i += 1;
    }
    args.push("-C".into());
    args.push(format!("incremental={}", incremental_dir.display()));
}

/// Remove `--json=…` and `--error-format=json` from the captured
/// rustc args so the thin rebuild emits plain human text instead of
/// the structured JSON channel cargo would have parsed. Both forms
/// (separated and `=`) are stripped; we don't restore a default
/// because rustc's default is already human output.
fn strip_json_flags(args: &mut Vec<String>) {
    let mut i = 0;
    while i < args.len() {
        let arg = &args[i];
        // `--json <list>` or `--error-format <fmt>` (separated forms)
        if (arg == "--json" || arg == "--error-format") && i + 1 < args.len() {
            args.drain(i..=i + 1);
            continue;
        }
        // `--json=...` or `--error-format=...` (equals forms)
        if arg.starts_with("--json=") || arg.starts_with("--error-format=") {
            args.remove(i);
            continue;
        }
        i += 1;
    }
}

/// Force `--emit` to exactly one directive: `obj=<path>`. Strips
/// every existing `--emit` (separated, `=`, comma-separated mix)
/// and appends one fresh pair. Same fold-and-add semantics as
/// [`set_crate_type`].
///
/// rustc accepts `--emit obj=<path>` as a single output kind with
/// an explicit destination, which avoids ambiguity when other
/// `--emit` directives would otherwise have asked for `link` or
/// `dep-info` etc. (cargo always passes a comma-separated set:
/// `dep-info,metadata,link`. We collapse the lot to just `obj`.)
pub fn set_emit_obj(args: &mut Vec<String>, object_path: &Path) {
    let mut i = 0;
    while i < args.len() {
        let arg = &args[i];
        if arg == "--emit" && i + 1 < args.len() {
            args.drain(i..=i + 1);
            continue;
        }
        if arg.starts_with("--emit=") {
            args.remove(i);
            continue;
        }
        i += 1;
    }
    args.push("--emit".into());
    args.push(format!("obj={}", object_path.to_string_lossy()));
}

/// Force every `--crate-type` arg to a single value (`new_kind`).
/// rustc allows the flag to repeat (one binary can be multiple
/// crate-types in one invocation); for a hot-patch we always want
/// exactly one — `cdylib`. The fold-and-add behaviour is:
///
///   - every existing `--crate-type X` (separate or `=` form) is
///     stripped;
///   - one fresh `--crate-type <new_kind>` pair is appended at the
///     end.
///
/// This is more idempotent than "rewrite in place" — the result
/// is always a single contiguous pair regardless of how many
/// the input had.
pub fn set_crate_type(args: &mut Vec<String>, new_kind: &str) {
    let mut i = 0;
    while i < args.len() {
        let arg = &args[i];
        if arg == "--crate-type" && i + 1 < args.len() {
            args.drain(i..=i + 1);
            continue;
        }
        if arg.starts_with("--crate-type=") {
            args.remove(i);
            continue;
        }
        i += 1;
    }
    args.push("--crate-type".into());
    args.push(new_kind.into());
}

/// Platform-specific cdylib filename for the **host** OS. Matches
/// what rustc itself emits for `--crate-type cdylib`:
///   macOS    → `lib<crate>.dylib`
///   Linux    → `lib<crate>.so`     (Android uses the same convention)
///   Windows  → `<crate>.dll`
///
/// Hyphens in the crate name become underscores (rustc convention).
/// Use [`library_filename_for_os`] when the patch target's OS differs
/// from the host (e.g. cross-compiling for Android from macOS).
pub fn library_filename(crate_name: &str) -> String {
    let stem = crate_name.replace('-', "_");
    if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
        format!("lib{stem}.dylib")
    } else if cfg!(target_os = "windows") {
        format!("{stem}.dll")
    } else {
        format!("lib{stem}.so")
    }
}

/// Cross-platform variant: produce the cdylib filename for the
/// **patch target** OS (which may differ from the host). The hot-
/// patch dylib has to match the on-device shared-library naming
/// convention, not the host's — Android wants `lib<crate>.so` even
/// when the dev session is on macOS.
pub fn library_filename_for_os(crate_name: &str, os: super::link_plan::LinkerOs) -> String {
    use super::link_plan::LinkerOs;
    let stem = crate_name.replace('-', "_");
    match os {
        LinkerOs::Macos => format!("lib{stem}.dylib"),
        LinkerOs::Linux => format!("lib{stem}.so"),
        LinkerOs::Other => format!("{stem}.dll"),
    }
}

/// Redirect rustc's output directory. Same fold-and-add semantics
/// as [`set_crate_type`]: strip every existing form, append one
/// fresh pair. Handles `--out-dir <DIR>`, `--out-dir=<DIR>`, and
/// the `-o <PATH>` short form (rare in cargo invocations but
/// possible — we drop it because `--out-dir` wins for `--crate-type
/// cdylib`).
pub fn set_out_dir(args: &mut Vec<String>, dir: &Path) {
    let dir_str = dir.to_string_lossy().to_string();
    let mut i = 0;
    while i < args.len() {
        let arg = &args[i];
        if (arg == "--out-dir" || arg == "-o") && i + 1 < args.len() {
            args.drain(i..=i + 1);
            continue;
        }
        if arg.starts_with("--out-dir=") {
            args.remove(i);
            continue;
        }
        i += 1;
    }
    args.push("--out-dir".into());
    args.push(dir_str);
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    fn s(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    fn captured_with(args: Vec<String>) -> CapturedRustcInvocation {
        CapturedRustcInvocation {
            crate_name: "demo".into(),
            args,
            timestamp_micros: 0,
        }
    }

    // ----- set_crate_type ----------------------------------------------

    #[test]
    fn set_crate_type_replaces_a_single_existing_separated_pair() {
        let mut args = s(&["--edition=2021", "--crate-type", "rlib", "src/lib.rs"]);
        set_crate_type(&mut args, "cdylib");
        assert_eq!(
            args,
            s(&["--edition=2021", "src/lib.rs", "--crate-type", "cdylib"]),
        );
    }

    #[test]
    fn set_crate_type_replaces_the_equals_form() {
        let mut args = s(&["--crate-type=rlib", "--edition=2021"]);
        set_crate_type(&mut args, "cdylib");
        assert_eq!(args, s(&["--edition=2021", "--crate-type", "cdylib"]));
    }

    #[test]
    fn set_crate_type_collapses_multiple_existing_into_one() {
        // rustc allows `--crate-type rlib --crate-type cdylib` to
        // produce both at once. For a hot-patch we want exactly
        // one, regardless of how many came in.
        let mut args = s(&[
            "--crate-type",
            "rlib",
            "--crate-type",
            "dylib",
            "--crate-type=staticlib",
            "src/lib.rs",
        ]);
        set_crate_type(&mut args, "cdylib");
        assert_eq!(args, s(&["src/lib.rs", "--crate-type", "cdylib"]));
    }

    #[test]
    fn set_crate_type_appends_when_no_existing() {
        let mut args = s(&["--edition=2021", "src/lib.rs"]);
        set_crate_type(&mut args, "cdylib");
        assert_eq!(
            args,
            s(&["--edition=2021", "src/lib.rs", "--crate-type", "cdylib"]),
        );
    }

    // ----- set_out_dir -------------------------------------------------

    #[test]
    fn set_out_dir_replaces_separated_form() {
        let mut args = s(&["--out-dir", "/old/path", "src/lib.rs"]);
        set_out_dir(&mut args, Path::new("/new/path"));
        assert_eq!(args, s(&["src/lib.rs", "--out-dir", "/new/path"]));
    }

    #[test]
    fn set_out_dir_replaces_equals_form() {
        let mut args = s(&["--out-dir=/old/path", "src/lib.rs"]);
        set_out_dir(&mut args, Path::new("/new/path"));
        assert_eq!(args, s(&["src/lib.rs", "--out-dir", "/new/path"]));
    }

    #[test]
    fn set_out_dir_replaces_the_short_o_form() {
        let mut args = s(&["-o", "/old/file.rlib", "src/lib.rs"]);
        set_out_dir(&mut args, Path::new("/new/path"));
        assert_eq!(args, s(&["src/lib.rs", "--out-dir", "/new/path"]));
    }

    #[test]
    fn set_out_dir_appends_when_no_existing() {
        let mut args = s(&["src/lib.rs"]);
        set_out_dir(&mut args, Path::new("/new/path"));
        assert_eq!(args, s(&["src/lib.rs", "--out-dir", "/new/path"]));
    }

    // ----- set_emit_obj ------------------------------------------------

    #[test]
    fn set_emit_obj_replaces_separated_form() {
        let mut args = s(&["--emit", "link", "src/lib.rs"]);
        set_emit_obj(&mut args, Path::new("/p/demo.o"));
        assert_eq!(args, s(&["src/lib.rs", "--emit", "obj=/p/demo.o"]));
    }

    #[test]
    fn set_emit_obj_replaces_equals_form_including_comma_lists() {
        // cargo always passes `--emit=dep-info,metadata,link`; the
        // whole comma-separated lot collapses to a single `obj=…`.
        let mut args = s(&["--emit=dep-info,metadata,link", "src/lib.rs"]);
        set_emit_obj(&mut args, Path::new("/p/demo.o"));
        assert_eq!(args, s(&["src/lib.rs", "--emit", "obj=/p/demo.o"]));
    }

    #[test]
    fn set_emit_obj_collapses_multiple_existing_into_one() {
        let mut args = s(&[
            "--emit",
            "link",
            "--emit=dep-info,metadata",
            "--emit",
            "metadata",
            "src/lib.rs",
        ]);
        set_emit_obj(&mut args, Path::new("/p/demo.o"));
        assert_eq!(args, s(&["src/lib.rs", "--emit", "obj=/p/demo.o"]));
    }

    #[test]
    fn set_emit_obj_appends_when_no_existing() {
        let mut args = s(&["src/lib.rs"]);
        set_emit_obj(&mut args, Path::new("/p/demo.o"));
        assert_eq!(args, s(&["src/lib.rs", "--emit", "obj=/p/demo.o"]));
    }

    // ----- object_filename ---------------------------------------------

    #[test]
    fn object_filename_is_crate_dot_o_with_underscores() {
        assert_eq!(object_filename("demo"), "demo.o");
        assert_eq!(object_filename("hello-world"), "hello_world.o");
        assert_eq!(object_filename("a-b-c"), "a_b_c.o");
    }

    // ----- build_obj_plan ----------------------------------------------

    #[test]
    fn obj_plan_forces_rlib_and_obj_emit_and_redirects_out_dir() {
        let captured = captured_with(s(&[
            "--edition=2021",
            "--crate-name",
            "demo",
            "--crate-type",
            "lib",
            "--emit=dep-info,metadata,link",
            "--out-dir",
            "/cargo/target/debug/deps",
            "-C",
            "opt-level=3",
            "src/lib.rs",
        ]));
        let plan = build_obj_plan(&captured, Path::new("/whisker/objs/x"));
        assert_eq!(
            plan.args,
            s(&[
                "--edition=2021",
                "--crate-name",
                "demo",
                "src/lib.rs",
                "--crate-type",
                "rlib",
                "--out-dir",
                "/whisker/objs/x",
                "--emit",
                "obj=/whisker/objs/x/demo.o",
                "-C",
                "incremental=/whisker/objs/x/incremental",
                "-C",
                "opt-level=0",
            ]),
        );
        assert_eq!(plan.output_dir, Path::new("/whisker/objs/x"));
        assert_eq!(plan.expected_object, Path::new("/whisker/objs/x/demo.o"));
    }

    #[test]
    fn obj_plan_picks_object_filename_from_captured_crate_name() {
        // crate_name comes from CapturedRustcInvocation.crate_name,
        // *not* from the --crate-name arg — they're typically equal,
        // but the captured field is what we use, so test that.
        let captured = CapturedRustcInvocation {
            crate_name: "thin-build-fixture".into(),
            args: s(&["src/lib.rs"]),
            timestamp_micros: 0,
        };
        let plan = build_obj_plan(&captured, Path::new("/o"));
        assert_eq!(plan.expected_object, Path::new("/o/thin_build_fixture.o"));
        assert!(
            plan.args.contains(&"obj=/o/thin_build_fixture.o".into()),
            "args: {:?}",
            plan.args,
        );
    }

    #[test]
    fn obj_plan_is_idempotent_on_re_run() {
        let captured = captured_with(s(&["src/lib.rs"]));
        let plan1 = build_obj_plan(&captured, Path::new("/o"));
        let plan2 = build_obj_plan(
            &CapturedRustcInvocation {
                crate_name: captured.crate_name.clone(),
                args: plan1.args.clone(),
                timestamp_micros: 0,
            },
            Path::new("/o"),
        );
        assert_eq!(plan1.args, plan2.args);
    }

    #[test]
    fn obj_plan_preserves_target_triple_and_sysroot_args() {
        // The whole point of "minimal edit" is that target-triple,
        // sysroot, link-args, etc. survive untouched. Regression
        // guard: these specific flags must come through verbatim.
        let captured = captured_with(s(&[
            "--target",
            "aarch64-linux-android",
            "--sysroot",
            "/some/ndk/sysroot",
            "-Clinker=lld",
            "-Clink-arg=-fuse-ld=lld",
            "-L",
            "native=/some/lib",
            "-l",
            "log",
            "src/lib.rs",
        ]));
        let plan = build_obj_plan(&captured, Path::new("/o"));
        for needle in [
            "--target",
            "aarch64-linux-android",
            "--sysroot",
            "/some/ndk/sysroot",
            "-Clinker=lld",
            "-Clink-arg=-fuse-ld=lld",
            "-L",
            "native=/some/lib",
            "-l",
            "log",
        ] {
            assert!(
                plan.args.iter().any(|a| a == needle),
                "missing {needle:?} from {:?}",
                plan.args,
            );
        }
    }
}