tw-cli 0.1.0

One command for a Markdoc corpus
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
//! `tw foo` runs `tw-foo`, as git runs `git-foo`.
//!
//! ## This is not a convenience
//!
//! It is the seam that makes `tw` publishable at all. Some of what a corpus
//! needs cannot go upstream under any design: a command that names one
//! translation service's project and its rules, another that names one
//! storage account and container. Without a seam those would be compiled
//! into a tool meant for anyone's corpus, and `tw` could not be contributed.
//! With it, a corpus ships small binaries of its own and the CLI stays
//! general.
//!
//! Not to be confused with `external.rs`, which is the other half of the
//! same idea pointed elsewhere: **this adds commands, `[[check.external]]`
//! adds findings.** Neither is an alternative to the other.
//!
//! ## Built-ins win
//!
//! `clap` matches its own subcommands before it reaches the catch-all, so an
//! extension cannot shadow `check` by being first on `PATH`. That ordering
//! is the whole of the security argument for the naming: a corpus can add
//! commands and cannot redefine the ones a hook depends on.
//!
//! ## Discovery is only paid for when something asks
//!
//! Listing `PATH` means a `read_dir` per entry, and `tw check --staged` is
//! in pre-commit. A hook that stats every directory on `PATH` before it
//! lints anything is a hook that gets `--no-verify`. So [`discover`] runs
//! on `tw help` and nowhere else; an ordinary run resolves one name with
//! [`find`], and a run of a built-in resolves nothing at all.
//!
//! ## Where it looks, and the one directory beyond `PATH`
//!
//! `PATH` first, so a writer building an extension of their own wins over
//! whatever is pinned — the same way a sibling `target/release` build beats
//! a downloaded `markdoc-ls`.
//!
//! Then the pinned cache. `tw setup` fetches pinned archives into
//! `.tooling/bin/`, which is on nobody's `PATH`, so until this a corpus
//! could pin its commands and a writer still could not run them. The
//! archives looked in are only the ones `tw.toml` names as carrying
//! extensions, and only after `tw` itself downloaded them from the URL the
//! pin gives and checked them against the `.sha256` their release
//! publishes -- which is a different thing from *"any directory the corpus
//! names"*, the attack surface this file otherwise refuses.
//!
//! The working directory is still never searched, and `tw-` is still exact.
//!
//! ## Context arrives in the environment, not in the arguments
//!
//! An extension's arguments are its own -- `tw` passes them through
//! untouched, so `tw xlate push --dry-run` is `tw-xlate push --dry-run` and
//! the extension's `--help` is its own. What `tw` knows and the extension
//! would otherwise have to rediscover goes in the environment instead.
//!
//! ## The writer's `.env`, and how an extension tells it from CI
//!
//! Inside a corpus, `tw` reads the corpus root's `.env` (see `dotenv.rs`)
//! and gives the extension each of its values that the real environment
//! does not set, as an ordinary environment variable -- so an extension that
//! only reads a token needs nothing more. One that also *writes* the file,
//! say a sign-in it renews, must not mistake those values for ones CI set,
//! and must never rewrite a value CI set. So `tw` also passes:
//!
//! * `TW_DOTENV` -- the absolute path of the `.env` it read, which is
//!   `$TW_ROOT/.env` (set only when it read one; outside a corpus it reads
//!   none). A `.env` that is a symbolic link is named as the link, not as
//!   the file it points to, so an extension judges and writes it the way
//!   tw judged it;
//! * `TW_DOTENV_KEYS` -- the names it set from that file, comma-separated.
//!
//! When tw read no `.env`, both are removed from the extension's
//! environment, even if tw itself was started with them: they name the file
//! tw read, never one an outer tool or a shell left behind.
//!
//! A name listed in `TW_DOTENV_KEYS` is the file's: the extension may renew
//! it and write it back to `TW_DOTENV`. Any other name that is set came from
//! the real environment and is used as it is. When `TW_DOTENV` is set, that
//! file is the `.env`, for reading and writing, and the extension does not
//! look for another. Without `tw` neither variable is set, and an extension
//! that reads `.env` itself does so by the same convention `dotenv.rs`
//! describes, at the root `tools::corpus_root` finds.

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

/// What a command has to be called to be found. Exact, and the reason `tw`
/// never searches the working directory: `PATH` is a modest attack surface
/// that git has too, and `./tw-foo` is not.
pub const PREFIX: &str = "tw-";

/// The contract version. An extension that does not understand it should
/// say so and exit rather than guess: the variables below are the host's
/// promises, and a newer host may make different ones.
pub const PROTOCOL: &str = "1";

/// One extension found on `PATH`.
pub struct Extension {
    /// `xlate`, not `tw-xlate` -- what a writer types.
    pub name: String,
    pub path: PathBuf,
}

/// Every `tw-*` on `PATH` and then in `extra`, first of each name winning.
///
/// Called by `tw help` and by nothing else.
pub fn discover(extra: &[PathBuf]) -> Vec<Extension> {
    let mut found: Vec<Extension> = Vec::new();
    let path = std::env::var_os("PATH").unwrap_or_default();
    let dirs = std::env::split_paths(&path).chain(extra.iter().cloned());

    for dir in dirs {
        // An empty PATH entry means the working directory on POSIX, which
        // is the one place this deliberately does not look.
        if dir.as_os_str().is_empty() {
            continue;
        }
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let file = entry.file_name().to_string_lossy().to_string();
            let Some(name) = command_name(&file) else {
                continue;
            };
            if found.iter().any(|e| e.name == name) {
                continue;
            }
            let path = entry.path();
            if is_executable(&path) {
                found.push(Extension { name, path });
            }
        }
    }

    found.sort_by(|a, b| a.name.cmp(&b.name));
    found
}

/// Something `PATH` can actually run.
///
/// Without this, every `tw-assets.d` cargo leaves beside a binary is a
/// command, and `tw help` lists `assets.d`. On Unix that is the executable
/// bit; on Windows it is `PATHEXT`, which is the same question the system
/// asks.
fn is_executable(path: &Path) -> bool {
    let Ok(meta) = std::fs::metadata(path) else {
        return false;
    };
    if !meta.is_file() {
        return false;
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        meta.permissions().mode() & 0o111 != 0
    }

    #[cfg(not(unix))]
    {
        path.extension()
            .map(|e| is_path_ext(&e.to_string_lossy()))
            .unwrap_or(false)
    }
}

/// Is this suffix one `PATHEXT` says makes a file runnable?
fn is_path_ext(ext: &str) -> bool {
    std::env::var("PATHEXT")
        .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
        .split(';')
        .any(|known| known.trim_start_matches('.').eq_ignore_ascii_case(ext))
}

/// `tw-xlate` -> `xlate`; on Windows `tw-xlate.exe` -> `xlate`. `None` for
/// anything that is not an extension, including the bare prefix.
fn command_name(file_name: &str) -> Option<String> {
    let rest = file_name.strip_prefix(PREFIX)?;
    // On Windows the executable suffix is how the system decides a file is
    // runnable, so it is not part of the command's name. Only a suffix
    // PATHEXT names is dropped: `tw-foo.bak` is not `foo`.
    let rest = match rest.rsplit_once('.') {
        Some((stem, ext)) if cfg!(windows) && is_path_ext(ext) => stem,
        _ => rest,
    };
    if rest.is_empty() {
        return None;
    }
    Some(rest.to_string())
}

/// Resolve one extension by the name a writer typed: `PATH`, then the
/// pinned archives `tw.toml` named.
///
/// `PATHEXT` on Windows comes from [`crate::tools::which`], which every
/// launched tool already goes through, so `tw-foo.cmd` and `tw-foo.exe`
/// both count and only in one place.
pub fn find(name: &str, extra: &[PathBuf]) -> Option<PathBuf> {
    if let Some(path) = crate::tools::which(&format!("{PREFIX}{name}")) {
        return Some(path);
    }
    // A pinned archive's `bin/` holds the release build: `.exe` on Windows.
    let file = crate::tools::exe(&format!("{PREFIX}{name}"));
    extra
        .iter()
        .map(|dir| dir.join(&file))
        .find(|p| is_executable(p))
}

/// The `bin/` of every pinned archive this corpus says carries commands.
///
/// Silent about a pin that has not been fetched: `tw setup` is what
/// fetches, and `tw help` asking the network whether a command exists
/// would be a surprising thing for it to do.
pub fn pinned_dirs(root: &Path, config: &crate::external::Config) -> Vec<PathBuf> {
    if config.extensions.is_empty() {
        return Vec::new();
    }
    let Ok(tooling) = crate::tools::Tooling::find(root) else {
        return Vec::new();
    };
    config
        .extensions
        .iter()
        .filter_map(|name| tooling.cached(name))
        .map(|dir| dir.join("bin"))
        .filter(|dir| dir.is_dir())
        .collect()
}

/// Run `tw-<name>`, handing it the terminal and its arguments.
///
/// `args[0]` is the subcommand name as typed; the rest are the
/// extension's. The child's exit code is this process's, because a writer
/// in a hook cannot tell the difference and should not have to.
pub fn run(
    args: &[String],
    root: &Path,
    config: &crate::external::Config,
) -> Result<ExitCode, String> {
    let Some((name, rest)) = args.split_first() else {
        return Err("no command".to_string());
    };

    let extra = pinned_dirs(root, config);
    let Some(path) = find(name, &extra) else {
        // Say what was looked for, not just that it was not found: the
        // naming is the whole interface and a writer who has never read
        // this file cannot guess it.
        let mut message = format!(
            "`{name}` is not a tw command, and no `{PREFIX}{name}` is on PATH \
             or in a pinned archive."
        );
        if config.extensions.is_empty() {
            message.push_str(
                "\n\nThis corpus names no extension archives. `tw.toml`'s \
                 `extensions` lists the .tooling/tooling-versions.json pins \
                 that carry commands, and `tw setup` fetches them.",
            );
        }
        let others = discover(&extra);
        if !others.is_empty() {
            message.push_str("\n\nExtensions that are:\n");
            for e in &others {
                message.push_str(&format!("    {:<14} {}\n", e.name, e.path.display()));
            }
        }
        return Err(message);
    };

    let mut command = std::process::Command::new(&path);
    command.args(rest);
    // The corpus's `.env` first, for the names the real environment does
    // not set: a writer's tokens reach the extension as if exported.
    crate::dotenv::apply(&mut command);
    for (key, value) in context(root) {
        command.env(key, value);
    }

    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        // Replaces this process, so the extension owns the terminal
        // outright -- the same choice `tw preview` and `tw lsp` make, and
        // for the same reason: a relay in the middle is somewhere for a
        // prompt, a progress bar or Ctrl-C to go wrong.
        let error = command.exec();
        Err(format!("{}: {error}", path.display()))
    }

    #[cfg(not(unix))]
    {
        let status = command
            .status()
            .map_err(|e| format!("{}: {e}", path.display()))?;
        Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
    }
}

/// What `tw` knows that an extension would otherwise have to rediscover.
///
/// * `TW_PROTOCOL` — this contract's version, so an extension can refuse a
///   host it does not understand instead of misreading it.
/// * `TW_ROOT` — the corpus root, by tw's one rule (`tools::corpus_root`)
///   rather than assumed to be the working directory, so `tw xlate push`
///   works from inside `products/`: the nearest directory at or above the
///   working directory with `.tooling/`, when any has one; else the nearest
///   with `tw.toml` or `markdoc-lint.toml`; else the working directory. A
///   `markdoc-lint.toml` in a subdirectory does not move it below a
///   `.tooling/`, so the `.env` tw read is always `$TW_ROOT/.env`.
/// * `TW_CONFIG` — the corpus's `tw.toml`, when it has one. Absent rather
///   than empty when it does not, so a test for it is a test for the file.
/// * `TW_FORMAT` — `text`. Advisory, and it exists so that a `tw`-wide
///   format switch has somewhere to land later; an extension still owns
///   its own `--json` flag, because its arguments are its own.
/// * `TW_DOTENV`, `TW_DOTENV_KEYS` — set by `dotenv::apply` with the
///   `.env`'s values: which file they came from, and which names are its
///   (see "The writer's `.env`" above).
///
/// An extension's own configuration stays in the corpus where it already
/// lives — a translation tool's file where that tool reads it — which is the
/// same split `markdoc-lint` and `markdoc-lint.toml` have.
fn context(root: &Path) -> Vec<(String, String)> {
    let mut out = vec![
        ("TW_PROTOCOL".to_string(), PROTOCOL.to_string()),
        ("TW_ROOT".to_string(), root.display().to_string()),
        ("TW_FORMAT".to_string(), "text".to_string()),
    ];
    let config = root.join(crate::external::CONFIG_FILENAME);
    if config.is_file() {
        out.push(("TW_CONFIG".to_string(), config.display().to_string()));
    }
    out
}

/// The section `tw help` adds when there are extensions to add.
///
/// A `tw-check` on `PATH` is named and marked rather than hidden. It can
/// never run -- built-ins win -- and both halves of that are worth saying:
/// listing it plainly would promise a command that does nothing, and
/// leaving it out would leave somebody wondering why the extension they
/// installed is ignored.
pub fn help_section(found: &[Extension], builtins: &[String]) -> String {
    let width = found.iter().map(|e| e.name.len()).max().unwrap_or(0);
    let mut out = String::from("Commands from PATH and pinned archives:\n");
    for e in found {
        let shadowed = builtins.contains(&e.name);
        out.push_str(&format!(
            "  {:<width$}  {}{}\n",
            e.name,
            e.path.display(),
            if shadowed {
                "   (never runs: tw has a command of that name)"
            } else {
                ""
            },
            width = width
        ));
    }
    out.push_str(&format!(
        "\nAnything not listed above is looked for as `{PREFIX}<command>`: on \
         PATH first, then in the archives tw.toml names under `extensions`."
    ));
    out
}

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

    #[test]
    fn a_name_is_what_a_writer_types() {
        assert_eq!(command_name("tw-xlate").as_deref(), Some("xlate"));
        assert_eq!(command_name("tw-assets").as_deref(), Some("assets"));
        // Not an extension.
        assert_eq!(command_name("tw"), None);
        assert_eq!(command_name("twiddle"), None);
        assert_eq!(command_name("markdoc-ls"), None);
        // The bare prefix is not a command.
        assert_eq!(command_name("tw-"), None);
    }

    #[cfg(windows)]
    #[test]
    fn windows_drops_an_executable_suffix_and_only_that() {
        assert_eq!(command_name("tw-xlate.exe").as_deref(), Some("xlate"));
        assert_eq!(command_name("tw-xlate.cmd").as_deref(), Some("xlate"));
        // Not runnable, so not a command called `xlate`.
        assert_eq!(command_name("tw-xlate.bak").as_deref(), Some("xlate.bak"));
    }

    #[cfg(unix)]
    #[test]
    fn a_file_that_cannot_be_run_is_not_a_command() {
        // cargo leaves `tw-assets.d` beside `tw-assets`, and without this
        // `tw help` offers a command called `assets.d`.
        use std::os::unix::fs::PermissionsExt;
        let dir = std::env::temp_dir().join(format!("tw-ext-exe-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();

        let runnable = dir.join("tw-real");
        std::fs::write(&runnable, "#!/bin/sh\n").unwrap();
        std::fs::set_permissions(&runnable, std::fs::Permissions::from_mode(0o755)).unwrap();

        let plain = dir.join("tw-real.d");
        std::fs::write(&plain, "deps").unwrap();

        assert!(is_executable(&runnable));
        assert!(!is_executable(&plain));
        assert!(!is_executable(&dir));

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[cfg(not(windows))]
    #[test]
    fn elsewhere_a_dot_is_part_of_the_name() {
        // `tw-foo.sh` is a command called `foo.sh` on Unix, where the
        // suffix is part of the filename rather than part of how the
        // system decides what is executable.
        assert_eq!(command_name("tw-foo.sh").as_deref(), Some("foo.sh"));
    }

    #[test]
    fn the_root_is_the_nearest_corpus_above_us() {
        use crate::tools::corpus;
        let dir = made(std::env::temp_dir().join(format!("tw-ext-root-{}", std::process::id())));
        let deep = dir.join("products").join("robot").join("manual");
        std::fs::create_dir_all(&deep).unwrap();
        std::fs::write(dir.join("markdoc-lint.toml"), "").unwrap();

        assert_eq!(corpus(&deep), dir);
        assert_eq!(corpus(&dir), dir);

        // No corpus anywhere above: stand where you are rather than walk
        // to `/` and hand an extension somebody else's directory.
        let orphan = std::env::temp_dir().join(format!("tw-ext-none-{}", std::process::id()));
        std::fs::create_dir_all(&orphan).unwrap();
        assert_eq!(
            corpus(&orphan).canonicalize().unwrap(),
            orphan.canonicalize().unwrap()
        );

        let _ = std::fs::remove_dir_all(&dir);
        let _ = std::fs::remove_dir_all(&orphan);
    }

    #[test]
    fn a_tooling_dir_above_a_nested_config_is_the_root_tw_root_and_the_env_share() {
        use crate::tools::corpus;
        // The corpus keeps .tooling/ and .env at its root; one manual has a
        // markdoc-lint.toml of its own. TW_ROOT, the .env and tw.toml are
        // all the root's, from anywhere inside.
        let dir = made(std::env::temp_dir().join(format!("tw-ext-nested-{}", std::process::id())));
        let manual = dir.join("products").join("x");
        std::fs::create_dir_all(&manual).unwrap();
        std::fs::create_dir_all(dir.join(".tooling")).unwrap();
        std::fs::write(manual.join("markdoc-lint.toml"), "").unwrap();
        std::fs::write(dir.join("tw.toml"), "").unwrap();

        assert_eq!(corpus(&manual), dir);
        assert_eq!(corpus(&manual.join("..")), dir);
        let context = context(&corpus(&manual));
        let get = |k: &str| context.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
        assert_eq!(get("TW_ROOT"), Some(dir.display().to_string()));
        assert_eq!(
            get("TW_CONFIG"),
            Some(dir.join("tw.toml").display().to_string())
        );
        // And where tw reads the .env: the same directory.
        assert_eq!(crate::tools::tooling_root(&manual), Some(dir.clone()));

        // Without a .tooling/ anywhere, the nearest config file is the root.
        std::fs::remove_dir(dir.join(".tooling")).unwrap();
        assert_eq!(corpus(&manual), manual);
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A new directory, canonical: the temporary directory may be a link
    /// (macOS: /var -> /private/var), and the root found is canonical.
    fn made(dir: PathBuf) -> PathBuf {
        std::fs::create_dir_all(&dir).unwrap();
        dir.canonicalize().unwrap_or(dir)
    }

    // Unix only: the stand-in extensions are sh scripts made executable.
    #[cfg(unix)]
    #[test]
    fn a_pinned_archive_is_looked_in_after_path_and_not_before() {
        use std::os::unix::fs::PermissionsExt;
        let dir = std::env::temp_dir().join(format!("tw-ext-pin-{}", std::process::id()));
        let pinned = dir.join("pinned");
        let on_path = dir.join("path");
        std::fs::create_dir_all(&pinned).unwrap();
        std::fs::create_dir_all(&on_path).unwrap();

        let make = |at: &Path, body: &str| {
            let p = at.join("tw-demo");
            std::fs::write(&p, body).unwrap();
            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
            p
        };
        let in_pin = make(&pinned, "#!/bin/sh\necho pinned\n");

        // Nothing on PATH: the pinned archive answers.
        let found = find("demo", std::slice::from_ref(&pinned));
        assert_eq!(found.as_deref(), Some(in_pin.as_path()));

        // Something on PATH: it wins, the way a sibling build beats a
        // downloaded markdoc-ls.
        let in_path = make(&on_path, "#!/bin/sh\necho path\n");
        let saved = std::env::var_os("PATH");
        // SAFETY: cargo runs tests on several threads, so this is only
        // sound because every other test in this binary that reads PATH
        // (dotenv.rs's, which run git) holds PATH_LOCK too.
        let _path = crate::PATH_LOCK
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        // `on_path` goes in front of the PATH there was, not in its place:
        // the tests that fetch an archive run `tar` without PATH_LOCK, and
        // while PATH held `on_path` alone one of them could not start it.
        let in_front = std::env::join_paths(
            std::iter::once(on_path.clone()).chain(saved.iter().flat_map(std::env::split_paths)),
        )
        .unwrap();
        // SAFETY: restored immediately below, before any assertion.
        unsafe { std::env::set_var("PATH", &in_front) };
        let found = find("demo", std::slice::from_ref(&pinned));
        unsafe {
            match saved {
                Some(v) => std::env::set_var("PATH", v),
                None => std::env::remove_var("PATH"),
            }
        }
        assert_eq!(found.as_deref(), Some(in_path.as_path()));

        // A name in neither is in neither.
        assert!(find("nosuch", std::slice::from_ref(&pinned)).is_none());

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_corpus_that_names_no_archives_looks_in_none() {
        let config = crate::external::Config::default();
        assert!(pinned_dirs(Path::new("/nonexistent"), &config).is_empty());
    }

    #[test]
    fn the_help_section_lists_what_was_found() {
        let found = vec![
            Extension {
                name: "assets".to_string(),
                path: PathBuf::from("/usr/local/bin/tw-assets"),
            },
            Extension {
                name: "xlate".to_string(),
                path: PathBuf::from("/usr/local/bin/tw-xlate"),
            },
        ];
        let text = help_section(&found, &[]);
        assert!(text.contains("assets  /usr/local/bin/tw-assets"), "{text}");
        assert!(text.contains("pinned archives"), "{text}");
        assert!(text.contains("xlate   /usr/local/bin/tw-xlate"), "{text}");
    }

    #[test]
    fn an_extension_a_builtin_shadows_is_named_and_marked() {
        let found = vec![Extension {
            name: "check".to_string(),
            path: PathBuf::from("/usr/local/bin/tw-check"),
        }];
        let text = help_section(&found, &["check".to_string()]);
        // Named, because somebody installed it and deserves to know why
        // nothing happens.
        assert!(text.contains("tw-check"), "{text}");
        // And marked, because listing it plainly would promise a command.
        assert!(text.contains("never runs"), "{text}");
    }
}