@git.zone/tsrust 1.4.1 → 1.6.0

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.
@@ -0,0 +1,304 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+
4
+ export const toolCacheMarkerFile = '.gitzone-tool-cache.json';
5
+ export const tsrustCacheOwner = '@git.zone/tsrust';
6
+ export const targetCacheKind = 'rust-target-cache';
7
+
8
+ export interface IToolCacheMarker {
9
+ owner: string;
10
+ kind: string;
11
+ safeToPrune: boolean;
12
+ createdAt: string;
13
+ schemaVersion: 1;
14
+ }
15
+
16
+ export interface ITsrustPruneCandidate {
17
+ path: string;
18
+ workspace: string;
19
+ bytes: number;
20
+ marked: boolean;
21
+ shouldPrune: boolean;
22
+ reason: string;
23
+ truncated?: boolean;
24
+ }
25
+
26
+ export interface ITsrustPruneOptions {
27
+ workspace: string;
28
+ targetDir?: string;
29
+ days?: number;
30
+ maxSizeBytes?: number;
31
+ }
32
+
33
+ export function createTargetCacheMarker(): IToolCacheMarker {
34
+ return {
35
+ owner: tsrustCacheOwner,
36
+ kind: targetCacheKind,
37
+ safeToPrune: true,
38
+ createdAt: new Date().toISOString(),
39
+ schemaVersion: 1,
40
+ };
41
+ }
42
+
43
+ export function isValidTargetCacheMarker(markerArg: unknown): markerArg is IToolCacheMarker {
44
+ const marker = markerArg as Partial<IToolCacheMarker>;
45
+ return marker.owner === tsrustCacheOwner
46
+ && marker.kind === targetCacheKind
47
+ && marker.safeToPrune === true
48
+ && marker.schemaVersion === 1;
49
+ }
50
+
51
+ export function getDefaultManagedTargetDir(workspaceArg: string): string {
52
+ return path.resolve(workspaceArg, '.nogit', 'tsrust-target');
53
+ }
54
+
55
+ export function isSafeManagedTargetDir(workspaceArg: string, targetDirArg: string): boolean {
56
+ const workspace = path.resolve(workspaceArg);
57
+ const targetDir = path.resolve(targetDirArg);
58
+ const relative = path.relative(workspace, targetDir);
59
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
60
+ return false;
61
+ }
62
+ return relative === path.join('.nogit', 'tsrust-target')
63
+ || relative.startsWith(`${path.join('.nogit', 'tsrust-')}`)
64
+ || relative.startsWith(`${path.join('.nogit', 'tsrust')}${path.sep}`);
65
+ }
66
+
67
+ export function resolveManagedTargetDir(workspaceArg: string, configTargetDirArg?: string): string {
68
+ const configured = process.env.TSRUST_TARGET_DIR || configTargetDirArg;
69
+ const targetDir = configured
70
+ ? path.isAbsolute(configured) ? configured : path.resolve(workspaceArg, configured)
71
+ : getDefaultManagedTargetDir(workspaceArg);
72
+ if (!isSafeManagedTargetDir(workspaceArg, targetDir)) {
73
+ throw new Error(`Refusing unsafe tsrust target dir outside workspace .nogit/: ${targetDir}`);
74
+ }
75
+ return targetDir;
76
+ }
77
+
78
+ export function getCargoArtifactDir(targetDirArg: string, profileArg: string, targetTripleArg?: string): string {
79
+ return targetTripleArg
80
+ ? path.join(targetDirArg, targetTripleArg, profileArg)
81
+ : path.join(targetDirArg, profileArg);
82
+ }
83
+
84
+ export function parseSizeToBytes(valueArg: string | undefined): number | undefined {
85
+ if (!valueArg) {
86
+ return undefined;
87
+ }
88
+ const match = valueArg.trim().match(/^(\d+(?:\.\d+)?)(b|kb|kib|mb|mib|gb|gib)?$/i);
89
+ if (!match) {
90
+ return undefined;
91
+ }
92
+ const value = Number(match[1]);
93
+ const unit = (match[2] || 'b').toLowerCase();
94
+ const multiplier = unit === 'gb' || unit === 'gib'
95
+ ? 1024 ** 3
96
+ : unit === 'mb' || unit === 'mib'
97
+ ? 1024 ** 2
98
+ : unit === 'kb' || unit === 'kib'
99
+ ? 1024
100
+ : 1;
101
+ return Math.round(value * multiplier);
102
+ }
103
+
104
+ export async function writeTargetCacheMarker(targetDirArg: string): Promise<void> {
105
+ await fs.mkdir(targetDirArg, { recursive: true });
106
+ const existingMarker = await readTargetCacheMarker(targetDirArg);
107
+ if (existingMarker) {
108
+ return;
109
+ }
110
+ const entries = await fs.readdir(targetDirArg);
111
+ if (entries.length > 0) {
112
+ throw new Error(`Refusing to mark pre-existing non-empty tsrust target cache: ${targetDirArg}`);
113
+ }
114
+ await fs.writeFile(
115
+ path.join(targetDirArg, toolCacheMarkerFile),
116
+ `${JSON.stringify(createTargetCacheMarker(), null, 2)}\n`,
117
+ );
118
+ }
119
+
120
+ export async function readTargetCacheMarker(targetDirArg: string): Promise<IToolCacheMarker | undefined> {
121
+ try {
122
+ const markerRaw = await fs.readFile(path.join(targetDirArg, toolCacheMarkerFile), 'utf8');
123
+ const marker = JSON.parse(markerRaw);
124
+ return isValidTargetCacheMarker(marker) ? marker : undefined;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ async function pathExists(pathArg: string): Promise<boolean> {
131
+ try {
132
+ await fs.stat(pathArg);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ export async function getDirectorySize(pathArg: string): Promise<number> {
140
+ let total = 0;
141
+ let entries;
142
+ try {
143
+ entries = await fs.readdir(pathArg, { withFileTypes: true });
144
+ } catch {
145
+ return 0;
146
+ }
147
+
148
+ for (const entry of entries) {
149
+ const entryPath = path.join(pathArg, entry.name);
150
+ if (entry.isDirectory()) {
151
+ total += await getDirectorySize(entryPath);
152
+ } else if (entry.isFile()) {
153
+ try {
154
+ total += (await fs.stat(entryPath)).size;
155
+ } catch {
156
+ // Ignore files that disappear while planning a prune.
157
+ }
158
+ }
159
+ }
160
+
161
+ return total;
162
+ }
163
+
164
+ async function getDirectoryUsage(pathArg: string, maxEntriesArg = 100_000): Promise<{ bytes: number; newestMtimeMs: number; truncated: boolean }> {
165
+ let bytes = 0;
166
+ let newestMtimeMs = 0;
167
+ let visitedEntries = 0;
168
+ let truncated = false;
169
+
170
+ const visit = async (entryPathArg: string): Promise<void> => {
171
+ if (visitedEntries >= maxEntriesArg) {
172
+ truncated = true;
173
+ return;
174
+ }
175
+ visitedEntries++;
176
+
177
+ let stat;
178
+ try {
179
+ stat = await fs.stat(entryPathArg);
180
+ } catch {
181
+ return;
182
+ }
183
+ newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
184
+ if (stat.isFile()) {
185
+ bytes += stat.size;
186
+ return;
187
+ }
188
+ if (!stat.isDirectory()) {
189
+ return;
190
+ }
191
+
192
+ let entries;
193
+ try {
194
+ entries = await fs.readdir(entryPathArg, { withFileTypes: true });
195
+ } catch {
196
+ return;
197
+ }
198
+ for (const entry of entries) {
199
+ if (visitedEntries >= maxEntriesArg) {
200
+ truncated = true;
201
+ return;
202
+ }
203
+ await visit(path.join(entryPathArg, entry.name));
204
+ }
205
+ };
206
+
207
+ await visit(pathArg);
208
+ return { bytes, newestMtimeMs, truncated };
209
+ }
210
+
211
+ async function getNewestMtimeMs(pathArg: string): Promise<number> {
212
+ let newest = 0;
213
+ try {
214
+ const stat = await fs.stat(pathArg);
215
+ newest = stat.mtimeMs;
216
+ } catch {
217
+ return 0;
218
+ }
219
+
220
+ let entries;
221
+ try {
222
+ entries = await fs.readdir(pathArg, { withFileTypes: true });
223
+ } catch {
224
+ return newest;
225
+ }
226
+
227
+ for (const entry of entries) {
228
+ const entryPath = path.join(pathArg, entry.name);
229
+ if (entry.isDirectory()) {
230
+ newest = Math.max(newest, await getNewestMtimeMs(entryPath));
231
+ } else if (entry.isFile()) {
232
+ try {
233
+ newest = Math.max(newest, (await fs.stat(entryPath)).mtimeMs);
234
+ } catch {
235
+ // Ignore files that disappear while planning a prune.
236
+ }
237
+ }
238
+ }
239
+
240
+ return newest;
241
+ }
242
+
243
+ export async function createTsrustPrunePlan(optionsArg: ITsrustPruneOptions): Promise<ITsrustPruneCandidate[]> {
244
+ const workspace = path.resolve(optionsArg.workspace);
245
+ const managedTargetDir = optionsArg.targetDir
246
+ ? resolveManagedTargetDir(workspace, optionsArg.targetDir)
247
+ : resolveManagedTargetDir(workspace);
248
+ const candidates = [
249
+ managedTargetDir,
250
+ path.join(workspace, 'rust', 'target'),
251
+ path.join(workspace, 'ts_rust', 'target'),
252
+ ];
253
+ const uniqueCandidates = [...new Set(candidates.map((candidate) => path.resolve(candidate)))];
254
+ const pruneCandidates: ITsrustPruneCandidate[] = [];
255
+ const maxAgeMs = typeof optionsArg.days === 'number' ? optionsArg.days * 24 * 60 * 60 * 1000 : undefined;
256
+
257
+ for (const candidate of uniqueCandidates) {
258
+ if (!(await pathExists(candidate))) {
259
+ continue;
260
+ }
261
+ const marker = await readTargetCacheMarker(candidate);
262
+ const usage = await getDirectoryUsage(candidate);
263
+ const isManagedTarget = isSafeManagedTargetDir(workspace, candidate);
264
+ const ageMatches = typeof maxAgeMs === 'number' ? Date.now() - usage.newestMtimeMs >= maxAgeMs : false;
265
+ const bytes = usage.bytes;
266
+ const sizeMatches = typeof optionsArg.maxSizeBytes === 'number' ? bytes >= optionsArg.maxSizeBytes : false;
267
+ const shouldPrune = !!marker && isManagedTarget && (ageMatches || sizeMatches);
268
+ const reason = marker
269
+ ? !isManagedTarget
270
+ ? 'marked Rust target outside managed tsrust target allowlist; report-only'
271
+ : shouldPrune
272
+ ? [ageMatches ? `older than ${optionsArg.days} day(s)` : '', sizeMatches ? `at least ${optionsArg.maxSizeBytes} bytes` : ''].filter(Boolean).join(', ')
273
+ : 'marked tsrust target cache; below prune thresholds'
274
+ : 'unmarked conventional Rust target; report-only';
275
+
276
+ pruneCandidates.push({
277
+ path: candidate,
278
+ workspace,
279
+ bytes,
280
+ marked: !!marker,
281
+ shouldPrune,
282
+ reason,
283
+ truncated: usage.truncated,
284
+ });
285
+ }
286
+
287
+ return pruneCandidates;
288
+ }
289
+
290
+ export async function applyTsrustPrunePlan(planArg: ITsrustPruneCandidate[]): Promise<void> {
291
+ for (const candidate of planArg) {
292
+ if (!candidate.shouldPrune) {
293
+ continue;
294
+ }
295
+ if (!isSafeManagedTargetDir(candidate.workspace, candidate.path)) {
296
+ throw new Error(`Refusing unsafe Rust target cache path: ${candidate.path}`);
297
+ }
298
+ const marker = await readTargetCacheMarker(candidate.path);
299
+ if (!marker) {
300
+ throw new Error(`Refusing to remove unmarked Rust target cache: ${candidate.path}`);
301
+ }
302
+ await fs.rm(candidate.path, { recursive: true, force: true });
303
+ }
304
+ }
@@ -1,2 +1,3 @@
1
1
  export { CargoConfig } from './classes.cargoconfig.js';
2
2
  export { CargoRunner } from './classes.cargorunner.js';
3
+ export * from './classes.targetcache.js';
@@ -1,7 +1,16 @@
1
1
  import * as path from 'path';
2
+ import * as os from 'os';
2
3
  import * as plugins from '../plugins.js';
3
4
  import { CargoConfig } from '../mod_cargo/index.js';
4
5
  import { CargoRunner } from '../mod_cargo/index.js';
6
+ import {
7
+ applyTsrustPrunePlan,
8
+ createTsrustPrunePlan,
9
+ getCargoArtifactDir,
10
+ parseSizeToBytes,
11
+ resolveManagedTargetDir,
12
+ writeTargetCacheMarker,
13
+ } from '../mod_cargo/index.js';
5
14
  import { ElfInspector } from '../mod_elf/index.js';
6
15
  import { FsHelpers } from '../mod_fs/index.js';
7
16
  import { ToolchainManager } from '../mod_toolchain/index.js';
@@ -45,6 +54,10 @@ function friendlyName(triple: string): string {
45
54
  interface ITsrustConfig {
46
55
  targets?: string[];
47
56
  static?: boolean;
57
+ rustflags?: string[];
58
+ remapLocalPaths?: boolean;
59
+ targetDir?: string;
60
+ pruneAfterBuild?: boolean;
48
61
  }
49
62
 
50
63
  /** crt-static injection only applies to glibc targets; musl is static by default. */
@@ -72,6 +85,7 @@ export class TsRustCli {
72
85
  private registerCommands(): void {
73
86
  this.registerStandardCommand();
74
87
  this.registerCleanCommand();
88
+ this.registerPruneCommand();
75
89
  }
76
90
 
77
91
  /**
@@ -132,6 +146,8 @@ export class TsRustCli {
132
146
  const shouldClean = !!(argvArg as any).clean;
133
147
  const distDir = path.join(this.cwd, 'dist_rust');
134
148
  const profile = isDebug ? 'debug' : 'release';
149
+ const managedTargetDir = resolveManagedTargetDir(this.cwd, this.config.targetDir);
150
+ await writeTargetCacheMarker(managedTargetDir);
135
151
 
136
152
  // Parse --target flag (can appear multiple times), fall back to smartconfig.json config
137
153
  const cliTargets = (argvArg as any).target;
@@ -144,6 +160,11 @@ export class TsRustCli {
144
160
  console.log('Static linking enabled (crt-static for linux-gnu targets)');
145
161
  }
146
162
 
163
+ const rustflags = this.buildRustflags(rustDir);
164
+ if (rustflags.length > 0) {
165
+ console.log(`Using additional Rust flags: ${rustflags.join(' ')}`);
166
+ }
167
+
147
168
  if (targets.length > 0) {
148
169
  // Cross-compilation mode
149
170
  const resolvedTargets = targets.map((t: string) => ({
@@ -166,6 +187,8 @@ export class TsRustCli {
166
187
  clean: shouldClean,
167
188
  target: triple,
168
189
  crtStatic: useStatic && wantsCrtStatic(triple),
190
+ rustflags,
191
+ targetDir: managedTargetDir,
169
192
  });
170
193
 
171
194
  if (!buildResult.success) {
@@ -173,8 +196,7 @@ export class TsRustCli {
173
196
  process.exit(1);
174
197
  }
175
198
 
176
- // Cross-compiled binaries go to target/<triple>/<profile>/
177
- const targetDir = path.join(rustDir, 'target', triple, profile);
199
+ const targetDir = getCargoArtifactDir(managedTargetDir, profile, triple);
178
200
 
179
201
  for (const binName of workspaceInfo.binTargets) {
180
202
  const srcBinary = path.join(targetDir, binName);
@@ -224,6 +246,8 @@ export class TsRustCli {
224
246
  clean: shouldClean,
225
247
  target: nativeTriple,
226
248
  crtStatic: !!nativeTriple && wantsCrtStatic(nativeTriple),
249
+ rustflags,
250
+ targetDir: managedTargetDir,
227
251
  });
228
252
 
229
253
  if (!buildResult.success) {
@@ -231,9 +255,7 @@ export class TsRustCli {
231
255
  process.exit(1);
232
256
  }
233
257
 
234
- const targetDir = nativeTriple
235
- ? path.join(rustDir, 'target', nativeTriple, profile)
236
- : path.join(rustDir, 'target', profile);
258
+ const targetDir = getCargoArtifactDir(managedTargetDir, profile, nativeTriple);
237
259
 
238
260
  await FsHelpers.ensureEmptyDir(distDir);
239
261
 
@@ -262,11 +284,44 @@ export class TsRustCli {
262
284
  }
263
285
  }
264
286
 
287
+ if (this.shouldPruneAfterBuild()) {
288
+ const plan = await createTsrustPrunePlan({
289
+ workspace: this.cwd,
290
+ targetDir: managedTargetDir,
291
+ days: 0,
292
+ });
293
+ await applyTsrustPrunePlan(plan);
294
+ console.log('Pruned marked tsrust target cache after successful build.');
295
+ }
296
+
265
297
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
266
298
  console.log(`\nDone in ${elapsed}s`);
267
299
  });
268
300
  }
269
301
 
302
+ private shouldPruneAfterBuild(): boolean {
303
+ const normalized = process.env.TSRUST_PRUNE_AFTER_BUILD?.toLowerCase();
304
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'y' || this.config.pruneAfterBuild === true;
305
+ }
306
+
307
+ private buildRustflags(rustDir: string): string[] {
308
+ const rustflags = [...(this.config.rustflags || [])];
309
+ if (this.config.remapLocalPaths) {
310
+ const remaps = new Map<string, string>();
311
+ remaps.set(this.cwd, '/workspace');
312
+ remaps.set(rustDir, '/workspace/rust');
313
+ remaps.set(process.env.CARGO_HOME || path.join(os.homedir(), '.cargo'), '/cargo');
314
+ remaps.set(ToolchainManager.CARGO_HOME, '/cargo');
315
+ remaps.set(process.env.RUSTUP_HOME || path.join(os.homedir(), '.rustup'), '/rustup');
316
+ remaps.set(ToolchainManager.RUSTUP_HOME, '/rustup');
317
+
318
+ for (const [from, to] of remaps.entries()) {
319
+ rustflags.push(`--remap-path-prefix=${from}=${to}`);
320
+ }
321
+ }
322
+ return rustflags;
323
+ }
324
+
270
325
  private registerCleanCommand(): void {
271
326
  this.cli.addCommand('clean').subscribe(async (_argvArg) => {
272
327
  // Clean cargo build
@@ -275,7 +330,7 @@ export class TsRustCli {
275
330
  const envPrefix = await this.resolveToolchain();
276
331
  console.log('Running cargo clean...');
277
332
  const runner = new CargoRunner(rustDir, envPrefix);
278
- await runner.clean();
333
+ await runner.clean({ targetDir: resolveManagedTargetDir(this.cwd, this.config.targetDir) });
279
334
  console.log('Cargo clean complete.');
280
335
  }
281
336
 
@@ -290,6 +345,36 @@ export class TsRustCli {
290
345
  });
291
346
  }
292
347
 
348
+ private registerPruneCommand(): void {
349
+ this.cli.addCommand('prune').subscribe(async (argvArg) => {
350
+ const workspace = ((argvArg as any).workspace as string | undefined) || this.cwd;
351
+ const daysArg = (argvArg as any).days;
352
+ const days = daysArg === undefined ? 14 : Number(daysArg);
353
+ const maxSizeBytes = parseSizeToBytes((argvArg as any).maxSize as string | undefined);
354
+ const apply = !!(argvArg as any).apply;
355
+ const plan = await createTsrustPrunePlan({
356
+ workspace,
357
+ targetDir: resolveManagedTargetDir(workspace, this.config.targetDir),
358
+ days: Number.isFinite(days) ? days : 14,
359
+ maxSizeBytes,
360
+ });
361
+
362
+ console.log(apply ? 'tsrust prune apply plan:' : 'tsrust prune dry-run plan:');
363
+ if (plan.length === 0) {
364
+ console.log('No Rust target caches found.');
365
+ }
366
+ for (const candidate of plan) {
367
+ const action = candidate.shouldPrune ? (apply ? 'remove' : 'would remove') : 'report';
368
+ console.log(`${action}: ${candidate.path} (${FsHelpers.formatFileSize(candidate.bytes)}) - ${candidate.reason}`);
369
+ }
370
+ if (apply) {
371
+ await applyTsrustPrunePlan(plan);
372
+ } else {
373
+ console.log('Dry-run only. Re-run with --apply to remove marked tsrust target caches.');
374
+ }
375
+ });
376
+ }
377
+
293
378
  private async detectRustDir(): Promise<string | null> {
294
379
  // Check rust/ first
295
380
  const rustDir = path.join(this.cwd, 'rust');