rightkit-qa 0.7.0

Rust QA harness for Right Suite apps: engine black-box scenarios, hidden native UI driving (the in-app control server is the dev-only `control` feature), Rust-test scenario wrapper, dynamic paid-provider mocks, loudness/WAV/PNG/frame validators, run lock, orphan sweep, hashed evidence.
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
//! Evidence retention for finished QA runs.
//!
//! Every run writes into its own directory `<managed root>/runs/<app>/<run-id>/` and carries a
//! marker, `.rightkit-run.json` ([`crate::storage::RunMarker`]: run id, app, kind, start and end
//! time, pass or fail). [`prune_runs`] decides from that marker alone:
//!
//! * a directory without a valid marker is never touched, and neither is one whose marker names
//!   another app or run id than its path;
//! * a run that has not finished (no recorded outcome) or is still active is kept;
//! * every failed run is kept;
//! * of the passing runs, the newest per app and kind is kept and the others are deleted;
//! * a run in which nothing executed (outcome `skipped`) holds no evidence and is deleted.
//!
//! The pass runs right after each job ends ([`prune_runs_after_job`], called by the suite and by
//! the Rust harness) and is also the CLI `rightkit-qa prune-runs`, for a schedule.
//!
//! Containment: nothing outside `<managed root>/runs` is read for deletion or removed. A `runs`
//! directory, an app directory or a run directory that is a link (symlink, or junction on Windows)
//! is never entered; a link inside a run is removed as itself and what it points at stays.
use crate::storage::{
    directory_bytes, is_inside, marker_active, read_marker, remove_app_root, remove_children,
    safe_dir, safe_key, try_lock, RunKind, RunMarker, RunOutcome, RUN_MARKER,
};
use crate::util::{err, now_ms, Result};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// How long a pass waits for another pass over the same app before it skips that app.
const LOCK_WAIT: Duration = Duration::from_secs(10);

/// What one retention pass did. With `dry_run` the `removed` entries are what would go.
#[derive(Debug, Default, Clone)]
pub struct RunsRetentionReport {
    /// (run directory, bytes it held, why).
    pub removed: Vec<(PathBuf, u64, String)>,
    /// (path, why): runs and directories the pass looked at and left alone.
    pub kept: Vec<(PathBuf, String)>,
    pub removed_bytes: u64,
    pub dry_run: bool,
}

impl RunsRetentionReport {
    /// One line for a log or the CLI.
    pub fn summary(&self) -> String {
        format!(
            "{} {} run dir(s) ({} bytes), kept {}",
            if self.dry_run {
                "would remove"
            } else {
                "removed"
            },
            self.removed.len(),
            self.removed_bytes,
            self.kept.len()
        )
    }
}

/// The managed root a run directory sits under (`<root>/runs/<app>/<run-id>`), if the path has
/// that shape.
pub fn managed_root_of_run(run_root: &Path) -> Option<PathBuf> {
    let app_dir = run_root.parent()?;
    let runs = app_dir.parent()?;
    if runs.file_name()? != "runs" {
        return None;
    }
    Some(runs.parent()?.to_path_buf())
}

/// Prune finished runs under `<managed_root>/runs`, for one `app` or, with `None`, for every app.
/// See the module documentation for the rule. `dry_run` reports and removes nothing.
///
/// A missing managed root or `runs` directory is not an error (nothing to prune). A `runs`
/// directory that is a link, or that does not resolve to `<managed root>/runs`, is refused.
pub fn prune_runs(
    managed_root: &Path,
    app: Option<&str>,
    dry_run: bool,
) -> Result<RunsRetentionReport> {
    let mut report = RunsRetentionReport {
        dry_run,
        ..Default::default()
    };
    let app = app.map(|a| safe_key(a, "app")).transpose()?;
    let Ok(root) = fs::canonicalize(managed_root) else {
        return Ok(report);
    };
    let runs = root.join("runs");
    match fs::symlink_metadata(&runs) {
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(report),
        Err(e) => return Err(e.into()),
        Ok(m) if m.file_type().is_symlink() || !m.is_dir() => {
            return err(format!(
                "refusing QA runs root that is not a plain directory: {}",
                runs.display()
            ));
        }
        Ok(_) => {}
    }
    if fs::canonicalize(&runs)? != runs {
        return err(format!(
            "refusing QA runs root that resolves outside the managed root: {}",
            runs.display()
        ));
    }
    let apps: Vec<String> = match app {
        Some(a) => vec![a],
        None => {
            let mut found = vec![];
            for entry in fs::read_dir(&runs)?.flatten() {
                let name = entry.file_name().to_string_lossy().into_owned();
                let Ok(ft) = entry.file_type() else { continue };
                if name.starts_with('.') || (!ft.is_dir() && !ft.is_symlink()) {
                    continue;
                }
                if safe_key(&name, "app").is_err() {
                    report
                        .kept
                        .push((entry.path(), "not a valid app directory name".into()));
                    continue;
                }
                found.push(name);
            }
            found.sort();
            found
        }
    };
    for name in apps {
        prune_app(&runs, &name, dry_run, &mut report)?;
    }
    Ok(report)
}

/// The pass that runs right after a job ends. It never fails the job: an error is returned inside
/// the report as a kept entry, and the scheduled `prune-runs` catches up later.
pub fn prune_runs_after_job(managed_root: &Path, app: &str) -> RunsRetentionReport {
    match prune_runs(managed_root, Some(app), false) {
        Ok(r) => r,
        Err(e) => RunsRetentionReport {
            kept: vec![(
                managed_root.join("runs").join(app),
                format!("retention pass failed: {e}"),
            )],
            ..Default::default()
        },
    }
}

struct Candidate {
    dir: PathBuf,
    marker: RunMarker,
}

fn prune_app(
    runs: &Path,
    app: &str,
    dry_run: bool,
    report: &mut RunsRetentionReport,
) -> Result<()> {
    let app_dir = runs.join(app);
    match fs::symlink_metadata(&app_dir) {
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(e) => return Err(e.into()),
        Ok(m) if m.file_type().is_symlink() || !m.is_dir() => {
            report
                .kept
                .push((app_dir, "app directory is a link or not a directory".into()));
            return Ok(());
        }
        Ok(_) => {}
    }
    if fs::canonicalize(&app_dir).ok().as_deref() != Some(app_dir.as_path()) {
        report
            .kept
            .push((app_dir, "app directory does not resolve to itself".into()));
        return Ok(());
    }
    let _guard = if dry_run {
        None
    } else {
        match try_lock(&app_dir, LOCK_WAIT)? {
            Some(g) => Some(g),
            None => {
                report.kept.push((
                    app_dir,
                    "another retention pass holds the lock; skipped".into(),
                ));
                return Ok(());
            }
        }
    };
    let now = now_ms();
    let mut passing: BTreeMap<RunKind, Vec<Candidate>> = BTreeMap::new();
    let mut doomed: Vec<(Candidate, String)> = vec![];
    for entry in fs::read_dir(&app_dir)?.flatten() {
        let dir = entry.path();
        let name = entry.file_name().to_string_lossy().into_owned();
        let Ok(ft) = entry.file_type() else { continue };
        if ft.is_symlink() {
            report
                .kept
                .push((dir, "link skipped, never followed".into()));
            continue;
        }
        if !ft.is_dir() || name.starts_with('.') {
            continue;
        }
        let marker_path = dir.join(RUN_MARKER);
        let marker_is_file = fs::symlink_metadata(&marker_path)
            .map(|m| m.is_file())
            .unwrap_or(false);
        let Some(marker) = marker_is_file.then(|| read_marker(&marker_path)).flatten() else {
            report
                .kept
                .push((dir, format!("no valid {RUN_MARKER}; not ours to remove")));
            continue;
        };
        if marker.app != app || marker.run_id != name {
            report
                .kept
                .push((dir, "run marker names another app or run id".into()));
            continue;
        }
        if marker_active(&marker, now) {
            report.kept.push((dir, "active run".into()));
            continue;
        }
        let outcome = marker.outcome;
        match outcome {
            None => report
                .kept
                .push((dir, "no recorded outcome (running or older marker)".into())),
            Some(RunOutcome::Failed) => report.kept.push((dir, "failed run".into())),
            Some(RunOutcome::Skipped) => {
                doomed.push((
                    Candidate { dir, marker },
                    "run executed nothing (all scenarios skipped)".into(),
                ));
            }
            Some(RunOutcome::Passed) => passing
                .entry(marker.kind.unwrap_or(RunKind::Functional))
                .or_default()
                .push(Candidate { dir, marker }),
        }
    }
    for (kind, mut list) in passing {
        // Newest first: end time, else start time, then run id so ties are deterministic.
        list.sort_by(|a, b| {
            let key = |c: &Candidate| c.marker.completed_at.unwrap_or(c.marker.created_at);
            key(b)
                .cmp(&key(a))
                .then_with(|| b.marker.run_id.cmp(&a.marker.run_id))
        });
        let mut it = list.into_iter();
        if let Some(newest) = it.next() {
            report
                .kept
                .push((newest.dir, format!("newest passing {kind} run")));
        }
        for old in it {
            doomed.push((old, format!("older passing {kind} run")));
        }
    }
    doomed.sort_by(|a, b| a.0.dir.cmp(&b.0.dir));
    for (c, why) in doomed {
        let bytes = directory_bytes(&c.dir).unwrap_or(0);
        if dry_run {
            report.removed_bytes = report.removed_bytes.saturating_add(bytes);
            report.removed.push((c.dir, bytes, why));
            continue;
        }
        match remove_run_dir(runs, &app_dir, &c) {
            Ok(()) => {
                report.removed_bytes = report.removed_bytes.saturating_add(bytes);
                report.removed.push((c.dir, bytes, why));
            }
            Err(e) => report.kept.push((c.dir, format!("not removed: {e}"))),
        }
    }
    Ok(())
}

/// Remove one run directory after re-checking, at the moment of removal, that it is a plain
/// directory directly inside `<runs>/<app>` that still carries its marker.
fn remove_run_dir(runs: &Path, app_dir: &Path, c: &Candidate) -> std::result::Result<(), String> {
    let dir = &c.dir;
    let real = fs::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
    if !safe_dir(dir)
        || real != *dir
        || real.parent() != Some(app_dir)
        || app_dir.parent() != Some(runs)
        || !is_inside(runs, &real)
    {
        return Err(format!(
            "{} is not a plain run directory inside the runs root",
            dir.display()
        ));
    }
    let marker_path = real.join(RUN_MARKER);
    let marker_ok = fs::symlink_metadata(&marker_path)
        .map(|m| m.is_file())
        .unwrap_or(false)
        && read_marker(&marker_path)
            .is_some_and(|m| m.app == c.marker.app && m.run_id == c.marker.run_id);
    if !marker_ok {
        return Err("run marker changed or vanished".into());
    }
    // The app home outside the run (bound by the marker) goes first, as the count prune does.
    remove_app_root(&real, &c.marker).map_err(|e| e.0)?;
    // The marker goes last: a removal that stops part way is still ours and is finished later.
    remove_children(&real, Some(std::ffi::OsStr::new(RUN_MARKER)))?;
    remove_children(&real, None)?;
    fs::remove_dir(&real).map_err(|e| format!("{}: {e}", real.display()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage;
    use crate::util::new_id;

    fn temp_root(label: &str) -> PathBuf {
        let p = std::env::temp_dir().join(format!("rkqa-retention-{label}-{}", new_id()));
        fs::create_dir_all(&p).unwrap();
        fs::canonicalize(p).unwrap()
    }

    /// A finished run under `root` with the given recorded facts. `done_at` is both its start
    /// and its end time, so ordering is the number given.
    fn run(
        root: &Path,
        app: &str,
        id: &str,
        kind: Option<RunKind>,
        outcome: Option<RunOutcome>,
        done_at: u128,
    ) -> PathBuf {
        // Written by hand, not through `storage::create`, whose own count-based prune would
        // remove these fixtures before the test runs.
        let dir = root.join("runs").join(app).join(id);
        fs::create_dir_all(dir.join("evidence")).unwrap();
        fs::write(dir.join("evidence").join("proof.txt"), id).unwrap();
        let marker = RunMarker {
            schema_version: 1,
            app: app.into(),
            run_id: id.into(),
            created_at: done_at,
            owner_pid: 0,
            lease_until: 0,
            completed_at: Some(done_at),
            app_root: None,
            kind,
            outcome,
        };
        storage::write_marker(&dir, &marker).unwrap();
        dir
    }

    fn exists(p: &Path) -> bool {
        fs::symlink_metadata(p).is_ok()
    }

    #[test]
    fn keeps_failed_and_the_newest_pass_per_kind_and_removes_the_rest() {
        let root = temp_root("rule");
        let f = RunKind::Functional;
        let v = RunKind::Visual;
        let pass = Some(RunOutcome::Passed);
        let fail_old = run(
            &root,
            "app",
            "fail-old",
            Some(f),
            Some(RunOutcome::Failed),
            1,
        );
        let f1 = run(&root, "app", "f1", Some(f), pass, 10);
        let f2 = run(&root, "app", "f2", Some(f), pass, 20);
        let f3 = run(&root, "app", "f3", Some(f), pass, 30);
        let v1 = run(&root, "app", "v1", Some(v), pass, 5);
        let v2 = run(&root, "app", "v2", Some(v), pass, 6);
        let fail_visual = run(
            &root,
            "app",
            "fail-visual",
            Some(v),
            Some(RunOutcome::Failed),
            2,
        );
        let running = run(&root, "app", "running", Some(f), None, 40);
        let nothing = run(
            &root,
            "app",
            "nothing",
            Some(f),
            Some(RunOutcome::Skipped),
            50,
        );
        let legacy = run(&root, "app", "legacy", None, pass, 3);
        let other = run(&root, "other", "o1", Some(f), pass, 1);
        let other2 = run(&root, "other", "o2", Some(f), pass, 2);

        let r = prune_runs(&root, Some("app"), false).unwrap();
        // Legacy marker without a kind counts as functional: older than f3, so it goes too.
        for gone in [&f1, &f2, &v1, &nothing, &legacy] {
            assert!(!exists(gone), "should be removed: {}", gone.display());
        }
        for kept in [&fail_old, &f3, &v2, &fail_visual, &running] {
            assert!(exists(kept), "should be kept: {}", kept.display());
        }
        assert_eq!(r.removed.len(), 5);
        assert!(r.removed_bytes > 0);
        // Another app is untouched when pruning one app.
        assert!(exists(&other) && exists(&other2));
        // `None` prunes every app.
        let r = prune_runs(&root, None, false).unwrap();
        assert!(!exists(&other) && exists(&other2));
        assert_eq!(r.removed.len(), 1);
        // A second pass has nothing left to do.
        assert!(prune_runs(&root, None, false).unwrap().removed.is_empty());
    }

    #[test]
    fn dry_run_reports_and_removes_nothing() {
        let root = temp_root("dry");
        let pass = Some(RunOutcome::Passed);
        let old = run(&root, "app", "old", Some(RunKind::Functional), pass, 1);
        let new = run(&root, "app", "new", Some(RunKind::Functional), pass, 2);
        let r = prune_runs(&root, Some("app"), true).unwrap();
        assert!(r.dry_run);
        assert_eq!(r.removed.len(), 1);
        assert!(exists(&old) && exists(&new));
    }

    #[test]
    fn only_directories_with_a_matching_marker_are_pruned() {
        let root = temp_root("marker");
        let pass = Some(RunOutcome::Passed);
        let f = Some(RunKind::Functional);
        let app_dir = root.join("runs").join("app");
        let newest = run(&root, "app", "newest", f, pass, 100);
        // No marker: never removed, whatever it holds.
        let bare = app_dir.join("not-a-run");
        fs::create_dir_all(&bare).unwrap();
        fs::write(bare.join("keep.txt"), "x").unwrap();
        // Malformed marker.
        let broken = app_dir.join("broken");
        fs::create_dir_all(&broken).unwrap();
        fs::write(broken.join(RUN_MARKER), "{ not json").unwrap();
        // A valid marker that belongs to a different run id than its directory name.
        let moved = run(&root, "app", "moved", f, pass, 1);
        let renamed = app_dir.join("renamed");
        fs::rename(&moved, &renamed).unwrap();
        // A valid marker for another app, copied into this app's tree.
        let alien = run(&root, "alien", "a1", f, pass, 1);
        let planted = app_dir.join("a1");
        fs::create_dir_all(&planted).unwrap();
        fs::copy(alien.join(RUN_MARKER), planted.join(RUN_MARKER)).unwrap();
        // A loose file and a hidden directory are ignored.
        fs::write(app_dir.join("latest.json"), "{}").unwrap();
        let old = run(&root, "app", "old", f, pass, 2);

        let r = prune_runs(&root, Some("app"), false).unwrap();
        assert!(!exists(&old));
        for kept in [&newest, &bare, &broken, &renamed, &planted, &alien] {
            assert!(exists(kept), "must stay: {}", kept.display());
        }
        assert!(exists(&bare.join("keep.txt")));
        assert!(exists(&app_dir.join("latest.json")));
        assert_eq!(r.removed.len(), 1);
    }

    #[test]
    fn an_active_run_is_kept_even_with_a_recorded_pass() {
        let root = temp_root("active");
        let pass = Some(RunOutcome::Passed);
        let f = Some(RunKind::Functional);
        let live = run(&root, "app", "live", f, pass, 1);
        storage::update_marker(&live, |m| m.lease_until = now_ms() + 3_600_000).unwrap();
        let newer = run(&root, "app", "newer", f, pass, 2);
        let r = prune_runs(&root, Some("app"), false).unwrap();
        assert!(r.removed.is_empty());
        assert!(exists(&live) && exists(&newer));
    }

    #[test]
    fn unsafe_names_and_missing_roots_never_reach_the_disk() {
        let root = temp_root("names");
        assert!(prune_runs(&root, Some("../escape"), false).is_err());
        assert!(prune_runs(&root, Some(""), false).is_err());
        assert!(prune_runs(&root.join("absent"), None, false)
            .unwrap()
            .removed
            .is_empty());
        // No `runs` directory yet: nothing to do.
        assert!(prune_runs(&root, None, false).unwrap().removed.is_empty());
    }

    #[test]
    fn a_link_never_leads_a_pass_outside_the_runs_root() {
        let root = temp_root("links");
        let outside = temp_root("outside");
        let pass = Some(RunOutcome::Passed);
        let f = Some(RunKind::Functional);

        // The outside tree looks exactly like a prunable app: if it were followed, it would lose a run.
        let victim_old = run(&outside, "app", "old", f, pass, 1);
        let victim_new = run(&outside, "app", "new", f, pass, 2);

        // 1. `runs` itself is a link to outside.
        if !try_symlink_dir(&outside.join("runs"), &root.join("runs")) {
            return; // this account may not create symlinks (Windows without the privilege)
        }
        let refused = prune_runs(&root, Some("app"), false).unwrap_err();
        assert!(refused.0.contains("runs root"), "{refused}");
        assert!(prune_runs(&root, None, false).is_err());
        assert!(exists(&victim_old) && exists(&victim_new));

        // 2. An app directory is a link to outside.
        let root = temp_root("links2");
        fs::create_dir_all(root.join("runs")).unwrap();
        assert!(try_symlink_dir(
            &outside.join("runs").join("app"),
            &root.join("runs").join("app")
        ));
        let r = prune_runs(&root, Some("app"), false).unwrap();
        assert!(r.removed.is_empty());
        assert!(r.kept.iter().any(|(_, why)| why.contains("link")));
        assert!(prune_runs(&root, None, false).unwrap().removed.is_empty());
        assert!(exists(&victim_old) && exists(&victim_new));

        // 3. A run directory is a link to a prunable run elsewhere; a link inside a run is
        //    removed as itself and what it points at stays.
        let root = temp_root("links3");
        let newest = run(&root, "app", "newest", f, pass, 100);
        assert!(try_symlink_dir(
            &victim_old,
            &root.join("runs").join("app").join("linked")
        ));
        let doomed = run(&root, "app", "doomed", f, pass, 1);
        let precious = temp_root("precious");
        fs::write(precious.join("keep.txt"), "x").unwrap();
        assert!(try_symlink_dir(
            &precious,
            &doomed.join("evidence").join("escape")
        ));
        let r = prune_runs(&root, Some("app"), false).unwrap();
        assert!(exists(&newest));
        assert!(!exists(&doomed));
        assert!(
            exists(&precious.join("keep.txt")),
            "link target was followed"
        );
        assert!(exists(&victim_old) && exists(&victim_new));
        assert!(r
            .kept
            .iter()
            .any(|(p, why)| p.ends_with("linked") && why.contains("link")));
    }

    #[test]
    fn after_job_pass_prunes_and_reports_without_failing() {
        let root = temp_root("after");
        let pass = Some(RunOutcome::Passed);
        let f = Some(RunKind::Functional);
        let old = run(&root, "app", "old", f, pass, 1);
        let new = run(&root, "app", "new", f, pass, 2);
        let r = prune_runs_after_job(&root, "app");
        assert!(!exists(&old) && exists(&new));
        assert_eq!(r.removed.len(), 1);
        assert!(r.summary().starts_with("removed 1"));
        // A bad app name is reported, not raised.
        let bad = prune_runs_after_job(&root, "../x");
        assert!(bad.removed.is_empty() && !bad.kept.is_empty());
    }

    #[test]
    fn managed_root_of_run_needs_the_runs_shape() {
        let p = Path::new("/m/runs/app/id");
        assert_eq!(managed_root_of_run(p), Some(PathBuf::from("/m")));
        assert_eq!(managed_root_of_run(Path::new("/m/other/app/id")), None);
        assert_eq!(managed_root_of_run(Path::new("id")), None);
    }

    fn try_symlink_dir(target: &Path, link: &Path) -> bool {
        #[cfg(unix)]
        let r = std::os::unix::fs::symlink(target, link);
        #[cfg(windows)]
        let r = std::os::windows::fs::symlink_dir(target, link);
        r.is_ok()
    }
}