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
//! Guards the facts that must agree before a version is published.
//!
//! kasl ships to three places - GitHub, crates.io and npm - each of which
//! renders its own copy of the metadata. They drift silently: nothing fails
//! when `npm/package.json` still says 1.0.0, or when the npm page describes
//! the product differently from the crate. The drift is only visible after
//! publishing, when it is too late to take back.
//!
//! These checks run in CI, so a mismatch fails the build instead of shipping.
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn read(path: impl AsRef<Path>) -> String {
let path = repo_root().join(path);
fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
/// Extracts a top-level `key = "value"` from Cargo.toml.
///
/// Deliberately naive: it reads only the `[package]` block, which is all
/// these checks need, and avoids adding a TOML parser as a dev-dependency.
fn cargo_field(key: &str) -> String {
let manifest = read("Cargo.toml");
for line in manifest.lines() {
let line = line.trim();
// Stop at the next section: `version` also appears under [lib],
// [[bin]] and in every dependency.
if line.starts_with('[') && line != "[package]" {
break;
}
let Some((name, value)) = line.split_once('=') else { continue };
// Exact match, so `rust-version` cannot answer a lookup for `version`.
if name.trim() != key {
continue;
}
return value.trim().trim_matches('"').to_string();
}
panic!("`{key}` not found in the [package] block of Cargo.toml");
}
/// Extracts a `"key": "value"` from a JSON file, without a JSON dependency.
fn json_field(file: &str, key: &str) -> String {
let text = read(file);
let needle = format!("\"{key}\"");
let start = text.find(&needle).unwrap_or_else(|| panic!("`{key}` not found in {file}"));
let after = &text[start + needle.len()..];
let after = after.trim_start().trim_start_matches(':').trim_start();
let after = after.strip_prefix('"').unwrap_or_else(|| panic!("`{key}` in {file} is not a string"));
after[..after.find('"').expect("unterminated string")].to_string()
}
#[test]
fn npm_package_version_matches_the_crate() {
let crate_version = cargo_field("version");
let npm_version = json_field("npm/package.json", "version");
assert_eq!(
npm_version, crate_version,
"npm/package.json version ({npm_version}) differs from Cargo.toml ({crate_version}); \
the npm page would advertise a version that was never released"
);
}
#[test]
fn npm_wrapper_downloads_the_matching_binary() {
let crate_version = cargo_field("version");
let binary_tag = json_field("npm/package.json", "binary");
assert_eq!(
binary_tag,
format!("v{crate_version}"),
"the npm wrapper points at release {binary_tag} while this is {crate_version}; \
installing from npm would fetch the wrong binary"
);
}
#[test]
fn the_product_is_described_the_same_way_everywhere() {
let crate_description = cargo_field("description");
let npm_description = json_field("npm/package.json", "description");
assert_eq!(
npm_description, crate_description,
"crates.io and npm describe the product differently; \
Cargo.toml `description` is the single source"
);
}
#[test]
fn readme_is_shared_rather_than_duplicated() {
// A second copy under npm/ is what let the two pages drift apart. The
// npm package takes the root README at publish time instead.
let duplicate = repo_root().join("npm/README.md");
assert!(
!duplicate.exists(),
"npm/README.md exists again; it will drift from the root README. \
The publish workflow copies the root one into npm/ instead."
);
}
#[test]
fn readme_links_resolve_off_github() {
// The same file is rendered on crates.io and npm, where a relative
// path has no repository to resolve against: the banner turns into a
// broken image and the links 404.
let readme = read("README.md");
for (line_no, line) in readme.lines().enumerate() {
for (marker, kind) in [("src=\"", "image"), ("](", "link")] {
let mut rest = line;
while let Some(at) = rest.find(marker) {
let target = &rest[at + marker.len()..];
let end = if marker == "](" { ')' } else { '"' };
let target = &target[..target.find(end).unwrap_or(target.len())];
let relative = !target.starts_with("http") && !target.starts_with('#') && !target.is_empty();
assert!(
!relative,
"README line {}: relative {kind} `{target}` breaks on crates.io and npm; use an absolute URL",
line_no + 1
);
rest = &rest[at + marker.len()..];
}
}
}
}
#[test]
fn every_declared_binary_is_packaged_into_the_release() {
// Field report, 14.08: `ka` was declared in Cargo.toml, built by CI
// and promised by the README, but the packaging step copied only
// `kasl` - so `where ka` came up empty on every machine installed
// from a release archive.
let manifest = read("Cargo.toml");
let binaries: Vec<String> = manifest
.lines()
.map(str::trim)
.scan(false, |in_bin, line| {
if line == "[[bin]]" {
*in_bin = true;
return Some(None);
}
if line.starts_with('[') {
*in_bin = false;
return Some(None);
}
if *in_bin && let Some(value) = line.strip_prefix("name") {
return Some(Some(value.trim_start_matches([' ', '=']).trim().trim_matches('"').to_string()));
}
Some(None)
})
.flatten()
.collect();
// One binary, deliberately: the `ka` alias is a link the installers
// create, not a second executable. Shipping it as its own `[[bin]]`
// put two identical 15 MB files in every archive and download.
assert_eq!(binaries, vec!["kasl".to_string()], "expected exactly the `kasl` binary in Cargo.toml");
let workflow = read(".github/workflows/release.yml");
for binary in &binaries {
assert!(
workflow.contains(&format!("release/{binary}.exe")),
"release.yml does not package `{binary}.exe`; the Windows archive would ship without it"
);
assert!(
workflow.contains(&format!("release/{binary}\"")),
"release.yml does not package `{binary}`; the Unix archives would ship without it"
);
}
}
#[test]
fn the_npm_package_exposes_every_declared_binary() {
// npm installs its own shims from `bin`, so a binary missing there is
// missing for everyone who installed through npm, however well the
// release archive is packed.
let npm = read("npm/package.json");
for name in ["kasl", "ka"] {
assert!(
npm.contains(&format!("\"{name}\": \"run.js\"")),
"npm/package.json does not expose `{name}`; the README promises both names"
);
}
}
#[test]
fn the_npm_readme_is_produced_by_the_package_itself() {
// Copying the README in the publish workflow meant a hand-run
// `npm publish` shipped a page with no README at all. `prepack` runs
// for every pack, CI or manual.
let npm = read("npm/package.json");
assert!(npm.contains("\"prepack\""), "npm/package.json has no prepack script to bring the README in");
assert!(npm.contains("README.md"), "README.md is not listed in the npm package files");
assert!(
repo_root().join("npm/prepack.js").exists(),
"npm/prepack.js is missing; the packed tarball would have no README"
);
let workflow = read(".github/workflows/publish.yml");
assert!(
!workflow.contains("cp README.md npm/README.md"),
"the publish workflow still copies the README; that step belongs to the package, \
otherwise a manual publish skips it"
);
}
#[test]
fn the_unix_installer_redirects_windows_shells() {
// Field report, 19.08: run in Git Bash on Windows, the script matched
// no case arm and answered "No prebuilt binary for MINGW64_NT-…",
// which reads as "unsupported platform" although a Windows release
// exists - it is just installed by the other script.
let installer = read("tools/install.sh");
for shell in ["MINGW*", "MSYS*", "CYGWIN*"] {
assert!(
installer.contains(shell),
"install.sh does not recognise {shell}; Windows shells fall through to the \
generic 'no prebuilt binary' message"
);
}
assert!(
installer.contains("install.ps1"),
"install.sh does not name the PowerShell installer, leaving Windows users at a dead end"
);
}
/// The Windows installer edits the user PATH in the registry and must keep
/// its type. `[Environment]::SetEnvironmentVariable` rewrites REG_EXPAND_SZ
/// as REG_SZ, which turns every `%VAR%` entry into literal text - found on
/// a sibling's installer (rigger v0.1.0), and this one carried the same code.
#[test]
fn the_windows_installer_keeps_the_path_expandable() {
let installer = read("tools/install.ps1");
assert!(
!installer.contains("SetEnvironmentVariable"),
"install.ps1 uses [Environment]::SetEnvironmentVariable, which downgrades PATH to a plain string"
);
assert!(
installer.contains("-Type ExpandString") && installer.contains("DoNotExpandEnvironmentNames"),
"install.ps1 must read the raw PATH and write it back as an expandable string"
);
}
#[test]
fn installers_name_the_crate_that_actually_exists() {
// The crate is published as `kasl-cli` (`kasl` on crates.io belongs to
// an unrelated project), so `cargo install kasl` fails - a fallback
// suggestion that does not work is worse than none.
let crate_name = cargo_field("name");
for file in ["tools/install.sh", "tools/install.ps1"] {
let text = read(file);
for (line_no, line) in text.lines().enumerate() {
let Some(at) = line.find("cargo install ") else { continue };
// Trim shell quoting around the suggestion, e.g. `... kasl-cli" >&2`.
let named = line[at + "cargo install ".len()..]
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_');
assert_eq!(
named,
crate_name,
"{file} line {} suggests `cargo install {named}`, but the crate is `{crate_name}`",
line_no + 1
);
}
}
}
#[test]
fn readme_only_shows_commands_that_exist() {
// The old README documented `kasl adjust` for months after the command
// was removed. Every `$ kasl <word>` in a console block must name a
// real subcommand.
let readme = read("README.md");
let help = String::from_utf8(
std::process::Command::new(env!("CARGO_BIN_EXE_kasl"))
.arg("--help")
.output()
.expect("cannot run kasl --help")
.stdout,
)
.expect("help output is not utf-8");
// Subcommand names are the indented first words in the Commands block.
let known: Vec<String> = help
.lines()
.skip_while(|l| !l.starts_with("Commands:"))
.skip(1)
.take_while(|l| l.starts_with(" ") && !l.trim().is_empty())
.filter_map(|l| l.split_whitespace().next())
.map(str::to_string)
.collect();
assert!(!known.is_empty(), "could not parse subcommands out of --help");
for line in readme.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("$ kasl ") else { continue };
let Some(word) = rest.split_whitespace().next() else { continue };
if word.starts_with('-') {
continue; // a flag on the bare binary, e.g. `kasl --version`
}
assert!(
known.contains(&word.to_string()),
"README shows `kasl {word}`, which is not a command; known: {known:?}"
);
}
}
/// No Windows test may reconfigure the machine it runs on.
///
/// The Unix autostart implementation is sandboxed by redirecting HOME and
/// XDG_CONFIG_HOME, so calling it in a test is safe - and the Unix tests
/// do. The Windows one writes to the Task Scheduler and to the registry,
/// and neither honours an environment variable: there is nothing to
/// redirect, so a call lands on the developer's own machine.
///
/// It did. A Windows test asserting only "this does not panic" - which a
/// function returning `Result` was never going to do - registered the test
/// binary for startup and then deleted the entry, taking the user's real
/// kasl autostart with it.
#[test]
fn no_windows_test_reconfigures_autostart() {
let source = read("tests/autostart.rs");
// Everything from a `#[cfg(windows)]` up to the next `#[cfg(` is the
// Windows-only region; the calls are only unsafe there.
let mut region_is_windows = false;
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if let Some(target) = trimmed.strip_prefix("#[cfg(") {
region_is_windows = target.starts_with("windows");
continue;
}
if region_is_windows && (trimmed.contains("autostart::enable(") || trimmed.contains("autostart::disable(")) {
panic!("a Windows-only test calls `{trimmed}`, which reconfigures the machine running it - there is no sandbox for that path");
}
}
}
/// A doctest that touches the machine must never actually run.
///
/// `cargo test` executes every example that is not marked `no_run` or
/// `ignore` - including, once, the one on `autostart::enable`. It really
/// registered the doctest's own temporary binary for startup, overwriting
/// the user's kasl entry with a path that is deleted minutes later. Found
/// in the field: an owner's Run key held
/// `...\rustdoctestkS63gI\rust_out.exe watch`.
///
/// So the examples on this handful of functions are checked for the
/// marker. The list is deliberately explicit rather than a heuristic over
/// every example: these are the calls that write to the registry, the
/// scheduler or the filesystem outside the project, and naming them is
/// what makes the check say something true.
#[test]
fn examples_that_touch_the_machine_are_marked_no_run() {
// (file, the call that makes an example unsafe to execute)
const SIDE_EFFECTING: &[(&str, &str)] = &[
("src/libs/autostart.rs", "autostart::enable()"),
("src/libs/autostart.rs", "autostart::disable()"),
];
for (file, call) in SIDE_EFFECTING {
let source = read(file);
let mut fence: Option<String> = None;
for line in source.lines() {
let trimmed = line.trim().trim_start_matches("///").trim_start_matches("//!").trim();
if let Some(attributes) = trimmed.strip_prefix("```") {
fence = if fence.is_some() { None } else { Some(attributes.to_string()) };
continue;
}
let Some(attributes) = &fence else { continue };
if !trimmed.contains(call) {
continue;
}
assert!(
attributes.contains("no_run") || attributes.contains("ignore"),
"{file}: an example calling {call} runs during `cargo test` and would change this machine's startup configuration - mark the block ```rust,no_run"
);
}
}
}
}