@smoothbricks/cli 0.11.11 → 0.11.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it, spyOn } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { dirname, join } from 'node:path';
5
- import { validateCargoCachePolicy } from './cargo-policy.js';
6
+ import { applyCargoFeatureUnification, type CargoHakariShell, validateCargoCachePolicy } from './cargo-policy.js';
6
7
 
7
8
  async function withFixture<T>(files: Record<string, string>, callback: (root: string) => T): Promise<T> {
8
9
  const root = await mkdtemp(join(tmpdir(), 'smoo-cargo-policy-'));
@@ -18,17 +19,46 @@ async function withFixture<T>(files: Record<string, string>, callback: (root: st
18
19
  }
19
20
  }
20
21
 
21
- async function check(files: Record<string, string>): Promise<{ failures: number; messages: string[] }> {
22
+ async function check(
23
+ files: Record<string, string>,
24
+ shell?: CargoHakariShell,
25
+ ): Promise<{ failures: number; messages: string[] }> {
22
26
  return withFixture(files, (root) => {
23
27
  const captured = captureErrors();
24
28
  try {
25
- return { failures: validateCargoCachePolicy(root), messages: captured.messages };
29
+ return { failures: validateCargoCachePolicy(root, { shell }), messages: captured.messages };
26
30
  } finally {
27
31
  captured.restore();
28
32
  }
29
33
  });
30
34
  }
31
35
 
36
+ /** Records what update would run, and answers `verify` however the test needs. */
37
+ function recordingHakari(
38
+ verify: { code: number; output?: string; missing?: boolean } = { code: 0 },
39
+ ): CargoHakariShell & {
40
+ calls: string[][];
41
+ } {
42
+ const calls: string[][] = [];
43
+ return {
44
+ calls,
45
+ run(_directory, args) {
46
+ calls.push([...args]);
47
+ return args[0] === 'verify'
48
+ ? { code: verify.code, output: verify.output ?? '', missing: verify.missing ?? false }
49
+ : { code: 0, output: '', missing: false };
50
+ },
51
+ };
52
+ }
53
+
54
+ const NIGHTLY_DEVENV = 'languages.rust = {\n channel = "nightly";\n};\n';
55
+ const UNIFIED_CONFIG = '[unstable]\nfeature-unification = true\n\n[resolver]\nfeature-unification = "workspace"\n';
56
+ const TWO_CRATE_WORKSPACE = {
57
+ 'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
58
+ 'crates/alpha/Cargo.toml': '[package]\nname = "alpha"\n',
59
+ 'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
60
+ };
61
+
32
62
  function captureErrors(): { messages: string[]; restore: () => void } {
33
63
  const messages: string[] = [];
34
64
  const error = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
@@ -186,3 +216,176 @@ describe('Cargo cache policy', () => {
186
216
  expect(result.messages).toEqual([]);
187
217
  });
188
218
  });
219
+
220
+ describe('Cargo workspace feature unification', () => {
221
+ it('leaves a single-crate workspace alone', async () => {
222
+ // Nothing to unify: `feature-unification = "workspace"` and a workspace-hack
223
+ // both exist to stop ONE dependency being built twice with different
224
+ // features for two members, which needs two members.
225
+ const result = await check({
226
+ 'Cargo.toml': '[workspace]\nmembers = ["crates/only"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
227
+ 'crates/only/Cargo.toml': '[package]\nname = "only"\n',
228
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
229
+ });
230
+ expect(result.failures).toBe(0);
231
+ });
232
+
233
+ it('requires a mechanism once a workspace holds more than one crate', async () => {
234
+ const result = await check({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV });
235
+ expect(result.failures).toBe(1);
236
+ expect(result.messages[0]).toContain('feature-unification');
237
+ expect(result.messages[0]).toContain('smoo monorepo update');
238
+ });
239
+
240
+ it('accepts the nightly resolver configuration and rejects it without the unstable flag', async () => {
241
+ const configured = await check({
242
+ ...TWO_CRATE_WORKSPACE,
243
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
244
+ '.cargo/config.toml': UNIFIED_CONFIG,
245
+ });
246
+ expect(configured.failures).toBe(0);
247
+
248
+ // Cargo silently ignores [resolver] feature-unification without the
249
+ // unstable opt-in, so the half-configured repository believes it is unified
250
+ // while every crate still re-resolves features.
251
+ const inert = await check({
252
+ ...TWO_CRATE_WORKSPACE,
253
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
254
+ '.cargo/config.toml': '[resolver]\nfeature-unification = "workspace"\n',
255
+ });
256
+ expect(inert.failures).toBe(1);
257
+ expect(inert.messages[0]).toContain('[unstable] feature-unification = true');
258
+ });
259
+
260
+ it('reads the channel from the managed devenv module, not from a decorative rust-toolchain file', async () => {
261
+ // devenv resolves the toolchain through rust-overlay and ignores
262
+ // rust-toolchain.toml unless languages.rust.toolchainFile names it, so the
263
+ // module's channel is the compiler that actually runs these commands.
264
+ const result = await check({
265
+ ...TWO_CRATE_WORKSPACE,
266
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
267
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
268
+ '.cargo/config.toml': UNIFIED_CONFIG,
269
+ });
270
+ expect(result.failures).toBe(0);
271
+ });
272
+
273
+ it('refuses the nightly-only resolver key on a stable toolchain', async () => {
274
+ const result = await check({
275
+ ...TWO_CRATE_WORKSPACE,
276
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
277
+ '.cargo/config.toml': UNIFIED_CONFIG,
278
+ });
279
+ expect(result.failures).toBe(1);
280
+ expect(result.messages[0]).toContain('cargo-hakari');
281
+ expect(result.messages[0]).toContain('stable');
282
+ });
283
+
284
+ it('accepts a hakari-managed workspace-hack on a stable toolchain', async () => {
285
+ const hakari = recordingHakari();
286
+ const result = await check(
287
+ {
288
+ 'Cargo.toml':
289
+ '[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
290
+ 'crates/alpha/Cargo.toml':
291
+ '[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
292
+ 'crates/beta/Cargo.toml':
293
+ '[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
294
+ 'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
295
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\nresolver = "2"\n',
296
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
297
+ },
298
+ hakari,
299
+ );
300
+ expect(result.failures).toBe(0);
301
+ expect(hakari.calls).toEqual([['verify']]);
302
+ });
303
+
304
+ it('flags a crate that does not depend on the workspace-hack', async () => {
305
+ const result = await check(
306
+ {
307
+ 'Cargo.toml':
308
+ '[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
309
+ 'crates/alpha/Cargo.toml':
310
+ '[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
311
+ 'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
312
+ 'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
313
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
314
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
315
+ },
316
+ recordingHakari(),
317
+ );
318
+ expect(result.failures).toBe(1);
319
+ expect(result.messages[0]).toContain('beta');
320
+ expect(result.messages[0]).toContain('smoo monorepo update');
321
+ });
322
+
323
+ it('surfaces a stale workspace-hack that cargo hakari verify rejects', async () => {
324
+ const result = await check(
325
+ {
326
+ 'Cargo.toml':
327
+ '[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
328
+ 'crates/alpha/Cargo.toml':
329
+ '[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
330
+ 'crates/beta/Cargo.toml':
331
+ '[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
332
+ 'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
333
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
334
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
335
+ },
336
+ recordingHakari({ code: 1, output: 'workspace-hack is not up-to-date' }),
337
+ );
338
+ expect(result.failures).toBe(1);
339
+ expect(result.messages[0]).toContain('cargo hakari verify');
340
+ expect(result.messages[0]).toContain('workspace-hack is not up-to-date');
341
+ });
342
+ });
343
+
344
+ describe('Cargo feature unification update', () => {
345
+ it('writes the nightly resolver configuration once', async () => {
346
+ await withFixture({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV }, (root) => {
347
+ const configPath = join(root, '.cargo/config.toml');
348
+ const hakari = recordingHakari();
349
+ applyCargoFeatureUnification(root, { shell: hakari });
350
+ const written = readFileSync(configPath, 'utf8');
351
+ expect(written).toContain('[unstable]\nfeature-unification = true');
352
+ expect(written).toContain('[resolver]\nfeature-unification = "workspace"');
353
+ expect(hakari.calls).toEqual([]);
354
+
355
+ applyCargoFeatureUnification(root, { shell: hakari });
356
+ expect(readFileSync(configPath, 'utf8')).toBe(written);
357
+ const captured = captureErrors();
358
+ try {
359
+ expect(validateCargoCachePolicy(root, { shell: hakari })).toBe(0);
360
+ } finally {
361
+ captured.restore();
362
+ }
363
+ });
364
+ });
365
+
366
+ it('generates and wires a workspace-hack on a stable toolchain', async () => {
367
+ await withFixture(
368
+ { ...TWO_CRATE_WORKSPACE, 'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n' },
369
+ (root) => {
370
+ const hakari = recordingHakari();
371
+ applyCargoFeatureUnification(root, { shell: hakari });
372
+ expect(hakari.calls).toEqual([['init', 'workspace-hack'], ['generate'], ['manage-deps', '--yes']]);
373
+ },
374
+ );
375
+ });
376
+
377
+ it('skips hakari init when the workspace already declares one', async () => {
378
+ await withFixture(
379
+ {
380
+ ...TWO_CRATE_WORKSPACE,
381
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n',
382
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
383
+ },
384
+ (root) => {
385
+ const hakari = recordingHakari();
386
+ applyCargoFeatureUnification(root, { shell: hakari });
387
+ expect(hakari.calls).toEqual([['generate'], ['manage-deps', '--yes']]);
388
+ },
389
+ );
390
+ });
391
+ });
@@ -1,4 +1,5 @@
1
- import { type Dirent, existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { spawnSync } from 'node:child_process';
2
+ import { type Dirent, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
4
  import typia from 'typia';
4
5
  import { parsePackageJsonText } from '../lib/json.js';
@@ -21,6 +22,11 @@ interface CargoManifest {
21
22
  workspace?: string | boolean;
22
23
  };
23
24
  profile?: Record<string, CargoProfile>;
25
+ // Only the KEYS matter here: a workspace-hack is wired in by name, and every
26
+ // dependency spelling — string, table, inherited — carries the same key.
27
+ dependencies?: Record<string, unknown>;
28
+ 'dev-dependencies'?: Record<string, unknown>;
29
+ 'build-dependencies'?: Record<string, unknown>;
24
30
  }
25
31
 
26
32
  interface CargoConfigTarget {
@@ -42,6 +48,22 @@ interface CargoConfig {
42
48
  };
43
49
  target?: Record<string, CargoConfigTarget>;
44
50
  env?: Record<string, string | CargoConfigEnvObject>;
51
+ resolver?: {
52
+ 'feature-unification'?: string;
53
+ };
54
+ unstable?: {
55
+ 'feature-unification'?: boolean;
56
+ };
57
+ }
58
+
59
+ interface RustToolchainFile {
60
+ toolchain?: {
61
+ channel?: string;
62
+ };
63
+ }
64
+
65
+ interface HakariConfig {
66
+ 'hakari-package'?: string;
45
67
  }
46
68
 
47
69
  interface LoadedManifest {
@@ -62,6 +84,45 @@ interface EffectiveProfile {
62
84
 
63
85
  const validateCargoManifest = typia.createValidate<CargoManifest>();
64
86
  const validateCargoConfig = typia.createValidate<CargoConfig>();
87
+ const validateRustToolchainFile = typia.createValidate<RustToolchainFile>();
88
+ const validateHakariConfig = typia.createValidate<HakariConfig>();
89
+
90
+ /**
91
+ * The one command this policy shells out to, behind a seam. `cargo hakari
92
+ * verify` is the only authority on whether a generated workspace-hack still
93
+ * unifies what the workspace resolves today; nothing in these files can answer
94
+ * that. Tests supply their own so the policy stays runnable without the binary.
95
+ */
96
+ export interface CargoHakariShell {
97
+ run(directory: string, args: readonly string[]): { code: number; output: string; missing: boolean };
98
+ }
99
+
100
+ const defaultHakariShell: CargoHakariShell = {
101
+ run(directory, args) {
102
+ const result = spawnSync('cargo', ['hakari', ...args], { cwd: directory, encoding: 'utf8' });
103
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
104
+ // cargo itself exists; a missing subcommand is cargo's own error, not ENOENT.
105
+ const missing =
106
+ (result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' || output.includes('no such command');
107
+ return { code: result.status ?? 1, output, missing };
108
+ },
109
+ };
110
+
111
+ /** Cargo's nightly-only workspace feature unification, and the config that turns it on. */
112
+ const RESOLVER_UNIFICATION_BLOCK = [
113
+ '# One feature resolution for the whole workspace. Without it every per-crate',
114
+ '# cargo invocation resolves features for its own selection, so one shared',
115
+ '# dependency is compiled once per selection and no warm target directory is',
116
+ '# reused across crates. Requires the nightly channel the managed devenv',
117
+ '# module pins; on a stable toolchain a cargo-hakari workspace-hack does the',
118
+ '# same job. Both keys are needed: cargo ignores [resolver] without the',
119
+ '# [unstable] opt-in.',
120
+ '[unstable]',
121
+ 'feature-unification = true',
122
+ '',
123
+ '[resolver]',
124
+ 'feature-unification = "workspace"',
125
+ ].join('\n');
65
126
 
66
127
  const SKIPPED_DIRECTORY_NAMES = new Set([
67
128
  'node_modules',
@@ -256,6 +317,188 @@ function hasAncestorWorkspace(manifest: LoadedManifest, workspaceRoots: LoadedMa
256
317
  );
257
318
  }
258
319
 
320
+ /**
321
+ * The channel that will actually compile this repository.
322
+ *
323
+ * The managed devenv module wins when it exists: devenv resolves the toolchain
324
+ * through rust-overlay and ignores `rust-toolchain.toml` unless
325
+ * `languages.rust.toolchainFile` names it, so a rust-toolchain file beside that
326
+ * module is decoration. Without the module, rustup's file is the answer. With
327
+ * neither, stable is the safe verdict: it is the channel on which the nightly
328
+ * resolver keys silently do nothing.
329
+ */
330
+ function isNightlyToolchain(repositoryRoot: string, workspaceDirectory: string): boolean {
331
+ const devenvModule = join(repositoryRoot, 'tooling/direnv/devenv.smoo.nix');
332
+ if (existsSync(devenvModule)) {
333
+ const text = readFileSync(devenvModule, 'utf8');
334
+ const rustIndex = text.indexOf('languages.rust');
335
+ const channel = rustIndex === -1 ? null : /channel\s*=\s*"([^"]+)"/.exec(text.slice(rustIndex))?.[1];
336
+ return channel?.startsWith('nightly') === true;
337
+ }
338
+ for (const directory of [workspaceDirectory, repositoryRoot]) {
339
+ const path = join(directory, 'rust-toolchain.toml');
340
+ if (!existsSync(path)) {
341
+ continue;
342
+ }
343
+ try {
344
+ const validation = validateRustToolchainFile(Bun.TOML.parse(readFileSync(path, 'utf8')));
345
+ return validation.success && validation.data.toolchain?.channel?.startsWith('nightly') === true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }
350
+ return false;
351
+ }
352
+
353
+ /**
354
+ * Cargo merges `.cargo/config.toml` from the invocation directory upward, with
355
+ * the deepest file winning. Only the configs at or above the workspace root can
356
+ * govern a command run there, so the effective value is the deepest of those.
357
+ */
358
+ function effectiveConfigValue<T>(
359
+ configs: LoadedConfig[],
360
+ workspaceDirectory: string,
361
+ read: (config: CargoConfig) => T | undefined,
362
+ ): T | undefined {
363
+ let deepest: { directory: string; value: T } | null = null;
364
+ for (const loaded of configs) {
365
+ const directory = dirname(dirname(loaded.path));
366
+ if (!pathWithin(directory, workspaceDirectory)) {
367
+ continue;
368
+ }
369
+ const value = read(loaded.config);
370
+ if (value === undefined) {
371
+ continue;
372
+ }
373
+ if (deepest === null || directory.length > deepest.directory.length) {
374
+ deepest = { directory, value };
375
+ }
376
+ }
377
+ return deepest?.value;
378
+ }
379
+
380
+ function workspaceCrates(root: LoadedManifest, manifests: LoadedManifest[]): LoadedManifest[] {
381
+ const members = manifests.filter((manifest) => workspaceContains(root, manifest));
382
+ return root.manifest.package === undefined ? members : [root, ...members];
383
+ }
384
+
385
+ const FEATURE_UNIFICATION_FIX = 'Run: smoo monorepo update';
386
+
387
+ /**
388
+ * A workspace of two or more crates must resolve features ONCE for all of them.
389
+ *
390
+ * Without that, every per-crate cargo invocation resolves features for its own
391
+ * selection, and one shared dependency is compiled once per selection: the same
392
+ * `nx run cargo-test-<crate>` that should reuse a warm target directory rebuilds
393
+ * its half of the graph instead. Cargo's own mechanism is nightly-only, so a
394
+ * stable toolchain reaches the identical result through a cargo-hakari
395
+ * workspace-hack — a generated crate that depends on the union of features, which
396
+ * every member then depends on.
397
+ *
398
+ * One crate needs neither: there is only one selection to unify.
399
+ */
400
+ function featureUnificationPolicy(
401
+ repositoryRoot: string,
402
+ workspaceRoots: LoadedManifest[],
403
+ manifests: LoadedManifest[],
404
+ configs: LoadedConfig[],
405
+ shell: CargoHakariShell,
406
+ ): number {
407
+ let failures = 0;
408
+ for (const root of workspaceRoots) {
409
+ const crates = workspaceCrates(root, manifests);
410
+ if (crates.length < 2) {
411
+ continue;
412
+ }
413
+ const nightly = isNightlyToolchain(repositoryRoot, root.directory);
414
+ const resolver = effectiveConfigValue(
415
+ configs,
416
+ root.directory,
417
+ (config) => config.resolver?.['feature-unification'],
418
+ );
419
+ const unstable = effectiveConfigValue(
420
+ configs,
421
+ root.directory,
422
+ (config) => config.unstable?.['feature-unification'],
423
+ );
424
+ if (nightly && resolver === 'workspace') {
425
+ if (unstable !== true) {
426
+ failures += report(
427
+ join(root.directory, '.cargo/config.toml'),
428
+ 'sets [resolver] feature-unification = "workspace" without [unstable] feature-unification = true, ' +
429
+ `so cargo ignores it and every crate still resolves features on its own. ${FEATURE_UNIFICATION_FIX}`,
430
+ );
431
+ }
432
+ continue;
433
+ }
434
+ if (existsSync(join(root.directory, '.config/hakari.toml'))) {
435
+ failures += hakariWorkspaceHackPolicy(root, crates, shell);
436
+ continue;
437
+ }
438
+ failures += report(
439
+ root.path,
440
+ `workspace of ${crates.length} crates has no workspace-wide feature unification: ` +
441
+ (nightly
442
+ ? 'add [unstable] feature-unification = true and [resolver] feature-unification = "workspace" to .cargo/config.toml'
443
+ : 'the [resolver] feature-unification key needs the nightly channel, so this stable toolchain needs a ' +
444
+ 'cargo-hakari workspace-hack (.config/hakari.toml)') +
445
+ `. Every per-crate cargo invocation otherwise recompiles the shared graph for its own feature selection. ${FEATURE_UNIFICATION_FIX}`,
446
+ );
447
+ }
448
+ return failures;
449
+ }
450
+
451
+ function hakariWorkspaceHackPolicy(root: LoadedManifest, crates: LoadedManifest[], shell: CargoHakariShell): number {
452
+ const configPath = join(root.directory, '.config/hakari.toml');
453
+ let hackName: string | undefined;
454
+ try {
455
+ const validation = validateHakariConfig(Bun.TOML.parse(readFileSync(configPath, 'utf8')));
456
+ hackName = validation.success ? validation.data['hakari-package'] : undefined;
457
+ } catch {
458
+ hackName = undefined;
459
+ }
460
+ if (hackName === undefined || hackName.length === 0) {
461
+ return report(configPath, `must declare hakari-package = "<crate>". ${FEATURE_UNIFICATION_FIX}`);
462
+ }
463
+ const hack = crates.find((crate) => crate.manifest.package?.name === hackName);
464
+ if (hack === undefined) {
465
+ return report(
466
+ configPath,
467
+ `names hakari-package "${hackName}", which is not a member of this workspace. ${FEATURE_UNIFICATION_FIX}`,
468
+ );
469
+ }
470
+ const unwired = crates
471
+ .filter((crate) => crate !== hack)
472
+ .filter(
473
+ (crate) =>
474
+ ![crate.manifest.dependencies, crate.manifest['dev-dependencies'], crate.manifest['build-dependencies']].some(
475
+ (table) => table !== undefined && hackName in table,
476
+ ),
477
+ )
478
+ .map((crate) => crate.manifest.package?.name ?? crate.path);
479
+ if (unwired.length > 0) {
480
+ return report(
481
+ root.path,
482
+ `these crates do not depend on "${hackName}", so cargo-hakari cannot unify their features: ` +
483
+ `${unwired.join(', ')}. ${FEATURE_UNIFICATION_FIX}`,
484
+ );
485
+ }
486
+ const verify = shell.run(root.directory, ['verify']);
487
+ if (verify.missing) {
488
+ return report(
489
+ configPath,
490
+ 'needs cargo-hakari, which is not installed; the managed devenv module provides it — reload the shell (direnv reload).',
491
+ );
492
+ }
493
+ if (verify.code !== 0) {
494
+ return report(
495
+ root.path,
496
+ `cargo hakari verify rejected the workspace-hack: ${verify.output || 'no output'}. ${FEATURE_UNIFICATION_FIX}`,
497
+ );
498
+ }
499
+ return 0;
500
+ }
501
+
259
502
  function isWorkspaceMember(manifest: LoadedManifest, workspaceRoots: LoadedManifest[]): boolean {
260
503
  if (manifest.manifest.package?.workspace !== undefined) {
261
504
  return true;
@@ -563,7 +806,12 @@ function reportManifestDirectoryAdvisories(repositoryRoot: string, ignoredDirect
563
806
  }
564
807
  }
565
808
 
566
- export function validateCargoCachePolicy(root: string): number {
809
+ export interface CargoPolicyOptions {
810
+ /** Injected in tests; production shells out to the real `cargo hakari`. */
811
+ shell?: CargoHakariShell;
812
+ }
813
+
814
+ export function validateCargoCachePolicy(root: string, options: CargoPolicyOptions = {}): number {
567
815
  const repositoryRoot = resolve(root);
568
816
  const discoveredManifestPaths = discoverFiles(repositoryRoot, (name) => name === 'Cargo.toml');
569
817
  const ignoredDirectories = discoverIgnoredSubtrees(discoveredManifestPaths);
@@ -606,6 +854,13 @@ export function validateCargoCachePolicy(root: string): number {
606
854
 
607
855
  const workspaceRoots = manifests.filter((loaded) => loaded.manifest.workspace !== undefined);
608
856
  failures += reportManifestPolicy(manifests, workspaceRoots);
857
+ failures += featureUnificationPolicy(
858
+ repositoryRoot,
859
+ workspaceRoots,
860
+ manifests,
861
+ configs,
862
+ options.shell ?? defaultHakariShell,
863
+ );
609
864
  failures += cargoIncrementalPolicy(repositoryRoot, configs, packageJsonPaths, justfilePaths, ignoredDirectories);
610
865
  for (const config of configs) {
611
866
  failures += configPolicy(config, repositoryRoot);
@@ -613,3 +868,87 @@ export function validateCargoCachePolicy(root: string): number {
613
868
  reportManifestDirectoryAdvisories(repositoryRoot, ignoredDirectories);
614
869
  return failures;
615
870
  }
871
+
872
+ /**
873
+ * The fix `smoo monorepo update` applies for the policy above: write the
874
+ * nightly resolver configuration, or generate and wire the workspace-hack a
875
+ * stable toolchain needs. Both are idempotent — the second run of either does
876
+ * nothing — because update runs on every repository, not only broken ones.
877
+ */
878
+ export function applyCargoFeatureUnification(root: string, options: CargoPolicyOptions = {}): void {
879
+ const repositoryRoot = resolve(root);
880
+ const shell = options.shell ?? defaultHakariShell;
881
+ const discoveredManifestPaths = discoverFiles(repositoryRoot, (name) => name === 'Cargo.toml');
882
+ const manifestPaths = filterIgnoredPaths(discoveredManifestPaths, discoverIgnoredSubtrees(discoveredManifestPaths));
883
+ const manifests: LoadedManifest[] = [];
884
+ for (const path of manifestPaths) {
885
+ const manifest = loadManifest(path);
886
+ if (manifest !== null) {
887
+ manifests.push({ path, directory: dirname(path), manifest });
888
+ }
889
+ }
890
+ for (const root of manifests.filter((loaded) => loaded.manifest.workspace !== undefined)) {
891
+ if (workspaceCrates(root, manifests).length < 2) {
892
+ continue;
893
+ }
894
+ if (isNightlyToolchain(repositoryRoot, root.directory)) {
895
+ writeResolverUnification(root.directory);
896
+ continue;
897
+ }
898
+ const hakariConfig = join(root.directory, '.config/hakari.toml');
899
+ const relativeRoot = relative(repositoryRoot, root.directory) || '.';
900
+ if (!existsSync(hakariConfig)) {
901
+ console.log(`generating cargo workspace-hack in ${relativeRoot} (cargo hakari init workspace-hack)`);
902
+ runHakari(shell, root.directory, ['init', 'workspace-hack']);
903
+ }
904
+ console.log(`unifying cargo features in ${relativeRoot} (cargo hakari generate, manage-deps)`);
905
+ runHakari(shell, root.directory, ['generate']);
906
+ runHakari(shell, root.directory, ['manage-deps', '--yes']);
907
+ }
908
+ }
909
+
910
+ function runHakari(shell: CargoHakariShell, directory: string, args: readonly string[]): void {
911
+ const result = shell.run(directory, args);
912
+ if (result.missing) {
913
+ console.error(
914
+ `cargo hakari is not installed, so the workspace-hack in ${directory} was not updated; the managed devenv module provides it — reload the shell (direnv reload).`,
915
+ );
916
+ return;
917
+ }
918
+ if (result.code !== 0) {
919
+ console.error(`cargo hakari ${args.join(' ')} failed in ${directory}: ${result.output || 'no output'}`);
920
+ }
921
+ }
922
+
923
+ /**
924
+ * Append the two keys, never rewrite the file. A `.cargo/config.toml` carries
925
+ * linkers, env and target settings whose ORDER and comments are load-bearing;
926
+ * re-emitting parsed TOML would lose both. If either table already exists with
927
+ * some other content, appending a second one is invalid TOML — so that case is
928
+ * reported for a human instead of being guessed at.
929
+ */
930
+ function writeResolverUnification(workspaceDirectory: string): void {
931
+ const path = join(workspaceDirectory, '.cargo/config.toml');
932
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
933
+ if (existing === null) {
934
+ mkdirSync(dirname(path), { recursive: true });
935
+ writeFileSync(path, `${RESOLVER_UNIFICATION_BLOCK}\n`);
936
+ console.log(`writing ${path}`);
937
+ return;
938
+ }
939
+ const parsed = loadConfig(path);
940
+ if (parsed === null) {
941
+ return;
942
+ }
943
+ if (parsed.resolver?.['feature-unification'] === 'workspace' && parsed.unstable?.['feature-unification'] === true) {
944
+ return;
945
+ }
946
+ if (parsed.resolver !== undefined || parsed.unstable !== undefined) {
947
+ console.error(
948
+ `${path}: already declares [resolver] or [unstable]; add feature-unification to the existing tables by hand:\n${RESOLVER_UNIFICATION_BLOCK}`,
949
+ );
950
+ return;
951
+ }
952
+ writeFileSync(path, `${existing.replace(/\n*$/, '\n')}\n${RESOLVER_UNIFICATION_BLOCK}\n`);
953
+ console.log(`updating ${path}`);
954
+ }
@@ -2,7 +2,7 @@ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { printCommandOutput, run, runResult } from '../lib/run.js';
3
3
  import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePackages } from '../lib/workspace.js';
4
4
  import { readProjectTargets } from '../nx/index.js';
5
- import { validateCargoCachePolicy } from './cargo-policy.js';
5
+ import { applyCargoFeatureUnification, validateCargoCachePolicy } from './cargo-policy.js';
6
6
  import {
7
7
  formatCommitMessage,
8
8
  stagedDeletedPublicPackages,
@@ -112,6 +112,10 @@ export async function updateManagedFiles(root: string): Promise<void> {
112
112
  // Tool dependency policy (typescript API 6, @typescript/native for ttsc, nx, …)
113
113
  // lives next to managed templates — update must install them, not only rewrite files.
114
114
  await applyToolConfigDefaults(root);
115
+ // Rust's half of the same job: a multi-crate Cargo workspace must resolve
116
+ // features once for every member, or each per-crate cargo invocation
117
+ // recompiles the shared graph for its own selection.
118
+ applyCargoFeatureUnification(root);
115
119
  syncBunLockfileVersions(root, { mode: 'install' });
116
120
  console.log('installing workspace dependencies (bun install)');
117
121
  await run('bun', ['install', '--no-summary'], root);
@@ -221,6 +221,38 @@ describe('monorepo validation pack phases', () => {
221
221
  }
222
222
  });
223
223
 
224
+ it('validates and fixes cargo workspace feature unification through its own pack', async () => {
225
+ const root = await mkdtemp(join(tmpdir(), 'smoo-validate-cargo-'));
226
+ try {
227
+ await mkdir(join(root, 'crates/alpha'), { recursive: true });
228
+ await mkdir(join(root, 'crates/beta'), { recursive: true });
229
+ await mkdir(join(root, 'tooling/direnv'), { recursive: true });
230
+ await writeFile(
231
+ join(root, 'Cargo.toml'),
232
+ '[workspace]\nmembers = ["crates/*"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
233
+ );
234
+ await writeFile(join(root, 'crates/alpha/Cargo.toml'), '[package]\nname = "alpha"\n');
235
+ await writeFile(join(root, 'crates/beta/Cargo.toml'), '[package]\nname = "beta"\n');
236
+ await writeFile(join(root, 'tooling/direnv/devenv.smoo.nix'), 'languages.rust = {\n channel = "nightly";\n};\n');
237
+ const cargoPack = packsForTest.find((pack) => pack.name === 'cargo');
238
+ if (!cargoPack) {
239
+ throw new Error('cargo validation pack not found');
240
+ }
241
+ const runBuild = () => 0;
242
+
243
+ // A policy nothing calls is not a policy: validate must reach it.
244
+ expect(
245
+ await runValidatePacks({ root, syncRuntime: false }, { failFast: true }, { packs: [cargoPack], runBuild }),
246
+ ).toEqual({ failures: 1, failedChecks: 1 });
247
+
248
+ expect(
249
+ await runValidatePacks({ root, syncRuntime: false }, { fix: true }, { packs: [cargoPack], runBuild }),
250
+ ).toEqual({ failures: 0, failedChecks: 0 });
251
+ } finally {
252
+ await rm(root, { recursive: true, force: true });
253
+ }
254
+ });
255
+
224
256
  it('propagates parsed target dependencies through the production adapter', () => {
225
257
  const targetDependencies = new Map([
226
258
  ['build', ['compile-linux']],