wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
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
//! An upgrade held to what it does to real files: an install directory with a
//! pair in it, built binaries that start or do not, a remote that moves.
//!
//! Nothing here builds wsb — that is minutes of cargo per test. What is built
//! is stood for by executables that behave as a build does at the one point
//! the upgrade asks anything of it: they start, or they do not, and they say
//! what they are.

use super::*;

#[cfg(unix)]
fn executable(path: &Path, body: &[u8]) {
    use std::os::unix::fs::PermissionsExt;
    std::fs::write(path, body).expect("an executable is written");
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).expect("it is made executable");
}

/// A binary that starts and reports `<name> <version>`, as wsb and wsb-daemon
/// do.
#[cfg(unix)]
fn reporting(path: &Path, name: &str, version: &str) {
    executable(path, format!("#!/bin/sh\necho '{name} {version}'\n").as_bytes());
}

/// A binary that cannot start: no loader, no interpreter, nothing that runs it.
#[cfg(unix)]
fn unstartable(path: &Path) {
    executable(path, &[0x00, 0x01, 0x02, 0x03, 0xde, 0xad, 0xbe, 0xef]);
}

/// An install directory holding a pair at 1.0.0.
#[cfg(unix)]
fn installed(root: &Path) -> PathBuf {
    let dir = root.join("bin");
    std::fs::create_dir_all(&dir).expect("the install directory");
    reporting(&dir.join("wsb"), "wsb", "1.0.0");
    reporting(&dir.join("wsb-daemon"), "wsb-daemon", "1.0.0");
    dir
}

fn built_in(root: &Path) -> Built {
    let dir = root.join("built");
    std::fs::create_dir_all(&dir).expect("the build directory");
    Built {
        cli: dir.join("wsb"),
        daemon: dir.join("wsb-daemon"),
    }
}

fn bytes(path: &Path) -> Vec<u8> {
    std::fs::read(path).expect("the file reads")
}

/// Every name in `dir`, so a staged binary left behind shows up.
fn names(dir: &Path) -> Vec<String> {
    let mut names: Vec<String> = std::fs::read_dir(dir)
        .expect("the directory reads")
        .map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned())
        .collect();
    names.sort();
    names
}

/// TEST20063: an upgrade replaces an installation and does not create one.
///
/// A directory missing either half is refused, naming what is missing and the
/// command that installs the pair.
#[test]
fn test20063_a_missing_half_is_refused_naming_the_install() {
    let dir = tempfile::tempdir().expect("a directory");
    let refused = installed_pair(dir.path()).expect_err("an empty directory holds no installation");
    assert!(refused.contains("(wsb, wsb-daemon missing)"), "{refused}");
    assert!(refused.contains("qrust install wsb wsb-daemon"), "the refusal does not say how to install: {refused}");

    std::fs::write(dir.path().join(Half::Cli.file_name()), b"").expect("one half");
    let refused = installed_pair(dir.path()).expect_err("half a pair is not an installation");
    assert!(refused.contains("(wsb-daemon missing)"), "{refused}");

    std::fs::write(dir.path().join(Half::Daemon.file_name()), b"").expect("the other half");
    installed_pair(dir.path()).expect("both halves are an installation");
}

/// TEST20064: an install directory that cannot be written to is refused before
/// anything is fetched or built.
///
/// Twenty minutes of compiling followed by "permission denied" is the same
/// failure, reported at the least useful moment.
#[cfg(unix)]
#[test]
fn test20064_an_unwritable_install_directory_is_refused_before_any_build() {
    use std::os::unix::fs::PermissionsExt;
    // SAFETY: `geteuid` takes no arguments and cannot fail.
    if unsafe { libc::geteuid() } == 0 {
        crate::errln!("TEST20064 proves nothing as root, whose writes no permission stops; run it as a user");
        return;
    }
    let root = tempfile::tempdir().expect("a directory");
    let install_dir = installed(root.path());
    std::fs::set_permissions(&install_dir, std::fs::Permissions::from_mode(0o555)).expect("read-only");
    let checkout = root.path().join("checkout");

    let refused = run_in(&install_dir, &checkout, &root.path().join("no-remote").display().to_string(), "main", false);
    std::fs::set_permissions(&install_dir, std::fs::Permissions::from_mode(0o755)).expect("writable again");

    let refused = refused.expect_err("an unwritable install directory is refused");
    assert!(refused.contains("cannot be written to"), "refused for another reason: {refused}");
    assert!(!checkout.exists(), "the checkout was started before the refusal");
}

/// TEST20065: a built binary that will not start is not installed, and the
/// installed pair is left byte for byte as it was.
///
/// Either half, and a pair whose halves say they are different builds.
#[cfg(unix)]
#[test]
fn test20065_a_built_binary_that_will_not_start_is_not_installed() {
    let cases: [(&str, fn(&Built)); 3] = [
        ("the CLI cannot start", |built| {
            unstartable(&built.cli);
            reporting(&built.daemon, "wsb-daemon", "2.0.0");
        }),
        ("the daemon cannot start", |built| {
            reporting(&built.cli, "wsb", "2.0.0");
            unstartable(&built.daemon);
        }),
        ("the halves are two builds", |built| {
            reporting(&built.cli, "wsb", "2.0.0");
            reporting(&built.daemon, "wsb-daemon", "2.0.1");
        }),
    ];
    for (case, build) in cases {
        let root = tempfile::tempdir().expect("a directory");
        let install_dir = installed(root.path());
        let before = (bytes(&install_dir.join("wsb")), bytes(&install_dir.join("wsb-daemon")));
        let built = built_in(root.path());
        build(&built);

        let refused = replace(&built, &install_dir, false).expect_err(case);
        assert!(refused.contains("Nothing was replaced"), "{case}: {refused}");
        assert_eq!(
            (bytes(&install_dir.join("wsb")), bytes(&install_dir.join("wsb-daemon"))),
            before,
            "{case}: the installed pair changed"
        );
        assert_eq!(names(&install_dir), ["wsb", "wsb-daemon"], "{case}: something was left in the install directory");
    }
}

/// TEST20066: both halves are replaced when both start, and nothing is left
/// behind.
#[cfg(unix)]
#[test]
fn test20066_both_halves_are_replaced_when_both_start() {
    let root = tempfile::tempdir().expect("a directory");
    let install_dir = installed(root.path());
    let built = built_in(root.path());
    reporting(&built.cli, "wsb", "2.0.0");
    reporting(&built.daemon, "wsb-daemon", "2.0.0");

    let (version, replaced) = replace(&built, &install_dir, false).expect("a pair that starts is installed");

    assert_eq!(version, "2.0.0");
    assert_eq!(replaced, [install_dir.join("wsb"), install_dir.join("wsb-daemon")]);
    assert_eq!(bytes(&install_dir.join("wsb")), bytes(&built.cli), "the CLI was not replaced");
    assert_eq!(bytes(&install_dir.join("wsb-daemon")), bytes(&built.daemon), "the daemon was not replaced");
    assert_eq!(
        started_version(&install_dir.join("wsb"), Half::Cli).expect("the installed CLI starts"),
        "2.0.0",
        "what was installed does not start as the build did"
    );
    assert_eq!(names(&install_dir), ["wsb", "wsb-daemon"], "a staged binary was left behind");
}

/// TEST20067: a dry run starts both and replaces nothing, and says what it
/// would replace.
#[cfg(unix)]
#[test]
fn test20067_a_dry_run_replaces_nothing() {
    let root = tempfile::tempdir().expect("a directory");
    let install_dir = installed(root.path());
    let before = (bytes(&install_dir.join("wsb")), bytes(&install_dir.join("wsb-daemon")));
    let built = built_in(root.path());
    reporting(&built.cli, "wsb", "2.0.0");
    reporting(&built.daemon, "wsb-daemon", "2.0.0");

    let (version, would) = replace(&built, &install_dir, true).expect("a dry run of a pair that starts");

    assert_eq!(version, "2.0.0");
    assert_eq!(would, [install_dir.join("wsb"), install_dir.join("wsb-daemon")]);
    assert_eq!(
        (bytes(&install_dir.join("wsb")), bytes(&install_dir.join("wsb-daemon"))),
        before,
        "a dry run replaced the installed pair"
    );
    assert_eq!(names(&install_dir), ["wsb", "wsb-daemon"]);

    // And it still refuses what a real run would refuse.
    unstartable(&built.daemon);
    replace(&built, &install_dir, true).expect_err("a dry run passed a daemon that cannot start");
}

/// TEST20068: the pair an upgrade replaces is the one QuickRust installed:
/// `$QRUST_HOME/bin`, else `~/.qrust/bin`.
#[test]
fn test20068_the_install_root_follows_qrust_home() {
    let home = PathBuf::from("/home/someone");
    let qrust_home = PathBuf::from("/opt/qrust");
    assert_eq!(
        install_root_bin(Some(qrust_home.clone()), Some(home.clone())).expect("QRUST_HOME"),
        qrust_home.join("bin")
    );
    assert_eq!(
        install_root_bin(Some(PathBuf::new()), Some(home.clone())).expect("an empty QRUST_HOME"),
        home.join(".qrust").join("bin"),
        "an empty QRUST_HOME is unset"
    );
    assert_eq!(install_root_bin(None, Some(home.clone())).expect("home"), home.join(".qrust").join("bin"));
    install_root_bin(None, None).expect_err("with no home there is no install root to name");
}

fn git_in(dir: &Path, args: &[&str]) -> String {
    let output = crate::git_backend::git_command()
        .current_dir(dir)
        .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
        .args(args)
        .output()
        .expect("git runs");
    assert!(output.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&output.stderr));
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

/// A bare remote with one commit on main, and the clone that pushes to it.
fn remote(root: &Path) -> (PathBuf, PathBuf) {
    let bare = root.join("remote.git");
    let work = root.join("work");
    std::fs::create_dir_all(&bare).expect("the remote");
    std::fs::create_dir_all(&work).expect("the work clone");
    git_in(&bare, &["init", "--quiet", "--bare"]);
    git_in(&work, &["init", "--quiet"]);
    std::fs::write(work.join("source.rs"), "fn one() {}\n").expect("a file");
    git_in(&work, &["add", "-A"]);
    git_in(&work, &["commit", "--quiet", "-m", "one"]);
    git_in(&work, &["push", "--quiet", &bare.display().to_string(), "main"]);
    (bare, work)
}

/// TEST20069: the managed checkout is exactly the ref every time — after a
/// local edit, an untracked file and a commit made in it.
///
/// Nobody works there. A build of "the ref plus whatever was lying around"
/// would install something no commit names.
#[test]
fn test20069_the_managed_checkout_is_reset_to_the_ref() {
    let root = tempfile::tempdir().expect("a directory");
    let (bare, work) = remote(root.path());
    let repository = bare.display().to_string();
    let checkout = root.path().join("machine").join("upgrade").join("wsb");

    let first = fetch(&checkout, &repository, "main").expect("a first fetch clones");
    assert_eq!(first, git_in(&work, &["rev-parse", "HEAD"]));
    assert_eq!(std::fs::read_to_string(checkout.join("source.rs")).expect("checked out"), "fn one() {}\n");

    // Somebody edits it, leaves a file, and commits there; the remote moves on.
    std::fs::write(checkout.join("source.rs"), "fn edited() {}\n").expect("an edit");
    git_in(&checkout, &["commit", "--quiet", "-am", "local"]);
    std::fs::write(checkout.join("source.rs"), "fn edited_again() {}\n").expect("an edit");
    std::fs::write(checkout.join("stray.rs"), "fn stray() {}\n").expect("an untracked file");
    std::fs::write(work.join("source.rs"), "fn two() {}\n").expect("the remote moves");
    git_in(&work, &["commit", "--quiet", "-am", "two"]);
    git_in(&work, &["push", "--quiet", &repository, "main"]);
    git_in(&work, &["tag", "v1", "HEAD~1"]);
    git_in(&work, &["push", "--quiet", &repository, "v1"]);

    let second = fetch(&checkout, &repository, "main").expect("a second fetch");
    assert_eq!(second, git_in(&work, &["rev-parse", "HEAD"]), "the checkout is not at the remote's main");
    assert_eq!(git_in(&checkout, &["rev-parse", "HEAD"]), second);
    assert_eq!(std::fs::read_to_string(checkout.join("source.rs")).expect("reset"), "fn two() {}\n");
    assert!(!checkout.join("stray.rs").exists(), "an untracked file survived");
    assert_eq!(git_in(&checkout, &["status", "--porcelain"]), "", "the checkout is not clean");

    // A tag is a ref too, and a name that is neither is refused by name.
    assert_eq!(fetch(&checkout, &repository, "v1").expect("a tag"), first);
    let refused = fetch(&checkout, &repository, "no-such-ref").expect_err("an unknown ref");
    assert!(refused.contains("no branch or tag named 'no-such-ref'"), "{refused}");
}

/// TEST20070: when the second rename fails after the first succeeded, the
/// refusal says the pair is now mismatched, and which half was replaced.
#[cfg(unix)]
#[test]
fn test20070_a_half_finished_swap_says_the_pair_is_mismatched() {
    let root = tempfile::tempdir().expect("a directory");
    let install_dir = installed(root.path());
    // Something no file can be renamed over: a directory with something in it.
    std::fs::remove_file(install_dir.join("wsb-daemon")).expect("the daemon goes");
    std::fs::create_dir_all(install_dir.join("wsb-daemon").join("held")).expect("a directory in its place");
    let built = built_in(root.path());
    reporting(&built.cli, "wsb", "2.0.0");
    reporting(&built.daemon, "wsb-daemon", "2.0.0");

    let refused = replace(&built, &install_dir, false).expect_err("the daemon cannot be put in place");

    assert!(refused.contains("ALREADY replaced"), "{refused}");
    assert!(refused.contains(&install_dir.join("wsb").display().to_string()), "it does not name the half replaced: {refused}");
    assert!(refused.contains("mismatched"), "{refused}");
    assert_eq!(bytes(&install_dir.join("wsb")), bytes(&built.cli), "the first half was not the one replaced");
    assert_eq!(names(&install_dir), ["wsb", "wsb-daemon"], "a staged binary was left behind");
}

/// TEST20071: the service is reinstalled on the ports it was installed with.
///
/// Read from the record's own argument vector — the one the daemon is started
/// with — so a reinstall does not probe for a port and move the workbench.
#[test]
fn test20071_the_service_keeps_the_ports_it_was_installed_with() {
    let spawn = crate::daemon::Spawn {
        listen: crate::daemon::LOOPBACK,
        http_port: crate::daemon::Port::Exact(3001),
        parent_pid: None,
        debug: false,
        peer_port: 7420,
    };
    let arguments = crate::daemon::daemon_arguments(Path::new("/home/op/ws"), &spawn).expect("exact ports");
    assert_eq!(recorded(&arguments, "--port").expect("the port"), "3001");
    assert_eq!(recorded(&arguments, "--peer-port").expect("the peer port"), "7420");
    let without: Vec<String> = arguments.iter().filter(|a| *a != "--peer-port").cloned().collect();
    recorded(&without, "--peer-port").expect_err("a record without the flag is refused, not guessed");
}

/// An installed `wsb` that records where and with what it was run, and
/// fails when asked to `fail_on`.
#[cfg(unix)]
fn recording_wsb(dir: &Path, log: &Path, fail_on: &str) {
    std::fs::create_dir_all(dir).expect("the install directory");
    executable(
        &dir.join("wsb"),
        format!(
            "#!/bin/sh\necho \"$(pwd -P)|$*\" >> '{}'\n[ \"$1\" = '{fail_on}' ] && exit 1\nexit 0\n",
            log.display()
        )
        .as_bytes(),
    );
}

/// TEST20133: the new pair brings the machine over in the one order that
/// works — the old daemon stopped, the machine migrated where the upgrade was
/// run, and only then the service rendered again and restarted, in the
/// worktree and on the ports it was installed with; with no service, the
/// daemon is stopped and the machine migrated; and a step that fails stops
/// everything after it.
///
/// The migration refuses to run beside a daemon, and a service started
/// before it would open the old layout. Run in another order, an upgrade
/// either stops at the migration or leaves the new daemon on state it
/// cannot read.
#[cfg(unix)]
#[test]
fn test20133_the_machine_is_migrated_between_the_old_daemon_and_the_new() {
    let temp = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(temp.path()).expect("resolves");
    let (machine, here, service_root) = (root.join("machine"), root.join("here"), root.join("served"));
    for dir in [&machine, &here, &service_root] {
        std::fs::create_dir_all(dir).expect("a folder");
    }
    let _env = crate::test_environment::Environment::set(&[(
        crate::daemon::MACHINE_DIR_ENV,
        &machine.to_string_lossy(),
    )]);
    let log = root.join("calls.log");
    let calls = || std::fs::read_to_string(&log).unwrap_or_default();

    recording_wsb(&root.join("bin"), &log, "never");
    assert_eq!(bring_the_machine_over(&root.join("bin"), &here), Ok(MachineStep::Stopped));
    let here_shown = here.display();
    assert_eq!(calls(), format!("{here_shown}|daemon stop\n{here_shown}|migrate\n"));

    std::fs::remove_file(&log).expect("the log is cleared");
    let record = crate::daemon::service::Record {
        executable: root.join("bin/wsb-daemon"),
        arguments: ["--worktree", &service_root.to_string_lossy(), "--port", "3123", "--peer-port", "7499"]
            .map(String::from)
            .to_vec(),
        worktree_root: service_root.clone(),
        log: machine.join("daemon.log"),
        path: "/usr/bin".to_string(),
        machine_dir: machine.clone(),
        prj: None,
        autostart: false,
    };
    std::fs::write(
        crate::daemon::service::record_path(&machine),
        serde_json::to_string_pretty(&record).expect("a record"),
    )
    .expect("the service record");
    assert_eq!(
        bring_the_machine_over(&root.join("bin"), &here),
        Ok(MachineStep::Restarted { worktree: service_root.clone() })
    );
    let served = service_root.display();
    assert_eq!(
        calls(),
        format!(
            "{here_shown}|daemon stop\n{here_shown}|migrate\n\
             {served}|daemon install --port 3123 --peer-port 7499\n{served}|daemon restart\n"
        )
    );

    std::fs::remove_file(&log).expect("the log is cleared");
    recording_wsb(&root.join("bin"), &log, "migrate");
    let refused = bring_the_machine_over(&root.join("bin"), &here).expect_err("a migration that fails");
    assert!(refused.contains("`wsb migrate` failed"), "{refused}");
    assert_eq!(calls(), format!("{here_shown}|daemon stop\n{here_shown}|migrate\n"), "steps ran after a failure");
}