Tool to recursively find Cargo.toml files and update the rust-version value to the latest installed Rust.
1const std = @import("std");
2const Io = std.Io;
3const Dir = std.Io.Dir;
4const File = std.Io.File;
5const process = std.process;
6
7const Version = struct {
8 major: u32,
9 minor: u32,
10 patch: u32,
11
12 fn parse(str: []const u8) ?Version {
13 const trimmed = std.mem.trim(u8, str, " \t\n\r\"");
14 var parts = std.mem.splitScalar(u8, trimmed, '.');
15 const major = std.fmt.parseInt(u32, parts.next() orelse return null, 10) catch return null;
16 const minor = std.fmt.parseInt(u32, parts.next() orelse return null, 10) catch return null;
17 const patch = std.fmt.parseInt(u32, parts.next() orelse "0", 10) catch return null;
18 return Version{ .major = major, .minor = minor, .patch = patch };
19 }
20
21 fn isOlderThan(self: Version, other: Version) bool {
22 if (self.major != other.major) return self.major < other.major;
23 if (self.minor != other.minor) return self.minor < other.minor;
24 return self.patch < other.patch;
25 }
26
27 fn format(self: Version, allocator: std.mem.Allocator) ![]u8 {
28 return std.fmt.allocPrint(allocator, "{d}.{d}.{d}", .{ self.major, self.minor, self.patch });
29 }
30};
31
32const Stats = struct {
33 scanned: u32 = 0,
34 updated: u32 = 0,
35 errors: u32 = 0,
36 skipped: u32 = 0,
37};
38
39pub fn main(init: process.Init) !void {
40 const allocator = init.gpa;
41 const io = init.io;
42
43 // Get stdout/stderr writers
44 var stdout_buf: [4096]u8 = undefined;
45 var stderr_buf: [4096]u8 = undefined;
46 var stdout_writer = File.Writer.initStreaming(.stdout(), io, &stdout_buf);
47 var stderr_writer = File.Writer.initStreaming(.stderr(), io, &stderr_buf);
48 const stdout = &stdout_writer.interface;
49 const stderr = &stderr_writer.interface;
50
51 // Parse CLI arguments
52 var args_iter = try process.Args.Iterator.initAllocator(init.minimal.args, allocator);
53 defer args_iter.deinit();
54 _ = args_iter.skip(); // skip program name
55
56 var dry_run = false;
57 while (args_iter.next()) |arg| {
58 if (std.mem.eql(u8, arg, "--dry-run")) {
59 dry_run = true;
60 } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
61 try stdout.print("Usage: rust-version-updater [--dry-run]\n\n", .{});
62 try stdout.print("Recursively updates rust-version in Cargo.toml files to match current rustc.\n\n", .{});
63 try stdout.print("Options:\n", .{});
64 try stdout.print(" --dry-run Preview changes without modifying files\n", .{});
65 try stdout.print(" --help Show this help message\n", .{});
66 try stdout_writer.flush();
67 return;
68 }
69 }
70
71 // Check for rust-toolchain.toml
72 try checkRustToolchain(allocator, io, stdout, &stdout_writer, dry_run);
73
74 // Get current Rust version
75 const current_version = getCurrentRustVersion(allocator, io) catch |err| {
76 try stderr.print("Error: Failed to get Rust version: {}\n", .{err});
77 try stderr_writer.flush();
78 std.process.exit(1);
79 };
80 defer allocator.free(current_version.str);
81
82 try stdout.print("Current Rust version: {d}.{d}.{d}\n", .{ current_version.version.major, current_version.version.minor, current_version.version.patch });
83 if (dry_run) {
84 try stdout.print("Dry-run mode: no files will be modified\n", .{});
85 }
86 try stdout.print("\n", .{});
87
88 // Walk directory tree
89 var stats = Stats{};
90 const cwd = Dir.cwd();
91
92 // Open directory with iterate capability
93 const iterable_dir = Dir.openDir(cwd, io, ".", .{ .iterate = true }) catch |err| {
94 try stderr.print("Error: Failed to open current directory: {}\n", .{err});
95 try stderr_writer.flush();
96 std.process.exit(1);
97 };
98 defer iterable_dir.close(io);
99
100 var walker = Dir.walk(iterable_dir, allocator) catch |err| {
101 try stderr.print("Error: Failed to create directory walker: {}\n", .{err});
102 try stderr_writer.flush();
103 std.process.exit(1);
104 };
105 defer walker.deinit();
106
107 while (true) {
108 const entry = walker.next(io) catch |err| {
109 try stderr.print("Warning: Error during directory walk: {}\n", .{err});
110 stats.errors += 1;
111 continue;
112 };
113 if (entry == null) break;
114 const e = entry.?;
115
116 if (e.kind != .file) continue;
117 if (!std.mem.eql(u8, e.basename, "Cargo.toml")) continue;
118
119 stats.scanned += 1;
120 processCargoToml(allocator, io, e.path, current_version.version, dry_run, &stats, stdout, stderr) catch |err| {
121 try stderr.print("Error processing {s}: {}\n", .{ e.path, err });
122 stats.errors += 1;
123 };
124 }
125
126 // Print summary
127 try stdout.print("\n--- Summary ---\n", .{});
128 try stdout.print("Files scanned: {d}\n", .{stats.scanned});
129 try stdout.print("Files updated: {d}\n", .{stats.updated});
130 try stdout.print("Files skipped: {d}\n", .{stats.skipped});
131 try stdout.print("Errors: {d}\n", .{stats.errors});
132 try stdout_writer.flush();
133}
134
135fn checkRustToolchain(
136 allocator: std.mem.Allocator,
137 io: Io,
138 stdout: *Io.Writer,
139 stdout_writer: *File.Writer,
140 dry_run: bool,
141) !void {
142 const cwd = Dir.cwd();
143
144 // Try to read the file contents
145 const content = blk: {
146 const file = Dir.openFile(cwd, io, "rust-toolchain.toml", .{}) catch return;
147 defer file.close(io);
148
149 var read_buf: [4096]u8 = undefined;
150 var reader = File.Reader.init(file, io, &read_buf);
151 break :blk try reader.interface.allocRemaining(allocator, .limited(1024 * 1024));
152 };
153 defer allocator.free(content);
154
155 try stdout.print("Found rust-toolchain.toml:\n---\n{s}---\n\n", .{content});
156
157 if (dry_run) {
158 try stdout.print("Dry-run mode: skipping rust-toolchain.toml deletion prompt\n\n", .{});
159 try stdout_writer.flush();
160 return;
161 }
162
163 try stdout.print("Delete rust-toolchain.toml? [y/N] ", .{});
164 try stdout_writer.flush();
165
166 // Read response from stdin
167 var stdin_buf: [256]u8 = undefined;
168 var stdin_reader = File.Reader.init(.stdin(), io, &stdin_buf);
169 const byte = stdin_reader.interface.takeByte() catch return;
170
171 if (byte == 'y' or byte == 'Y') {
172 Dir.deleteFile(cwd, io, "rust-toolchain.toml") catch |err| {
173 try stdout.print("Warning: Failed to delete rust-toolchain.toml: {}\n", .{err});
174 try stdout_writer.flush();
175 return;
176 };
177 try stdout.print("Deleted rust-toolchain.toml\n\n", .{});
178 } else {
179 try stdout.print("Keeping rust-toolchain.toml, exiting.\n", .{});
180 try stdout_writer.flush();
181 std.process.exit(0);
182 }
183 try stdout_writer.flush();
184}
185
186const VersionResult = struct {
187 version: Version,
188 str: []u8,
189};
190
191fn getCurrentRustVersion(allocator: std.mem.Allocator, io: Io) !VersionResult {
192 const result = try process.run(allocator, io, .{
193 .argv = &.{ "rustc", "--version" },
194 });
195 defer allocator.free(result.stdout);
196 defer allocator.free(result.stderr);
197
198 if (result.term != .exited or result.term.exited != 0) {
199 return error.RustcFailed;
200 }
201
202 // Parse "rustc X.Y.Z (...)" format
203 const trimmed = std.mem.trim(u8, result.stdout, " \t\n\r");
204 if (!std.mem.startsWith(u8, trimmed, "rustc ")) {
205 return error.InvalidRustcOutput;
206 }
207
208 const after_rustc = trimmed[6..];
209 const space_idx = std.mem.indexOf(u8, after_rustc, " ") orelse after_rustc.len;
210 const version_str = after_rustc[0..space_idx];
211
212 const version = Version.parse(version_str) orelse return error.InvalidVersion;
213 const str = try allocator.dupe(u8, version_str);
214
215 return VersionResult{ .version = version, .str = str };
216}
217
218fn processCargoToml(
219 allocator: std.mem.Allocator,
220 io: Io,
221 path: []const u8,
222 current_version: Version,
223 dry_run: bool,
224 stats: *Stats,
225 stdout: *Io.Writer,
226 stderr: *Io.Writer,
227) !void {
228 const cwd = Dir.cwd();
229 const file = try Dir.openFile(cwd, io, path, .{});
230 defer file.close(io);
231
232 var read_buf: [4096]u8 = undefined;
233 var reader = File.Reader.init(file, io, &read_buf);
234 const content = try reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024));
235 defer allocator.free(content);
236
237 // Find and process rust-version line
238 const result = try processContent(allocator, content, current_version);
239 defer if (result.new_content) |nc| allocator.free(nc);
240
241 switch (result.status) {
242 .not_found => {
243 stats.skipped += 1;
244 },
245 .skipped_non_semver => {
246 try stdout.print("{s}: skipped (non-semver value)\n", .{path});
247 stats.skipped += 1;
248 },
249 .skipped_not_older => {
250 try stdout.print("{s}: skipped (version {s} is not older)\n", .{ path, result.old_version orelse "unknown" });
251 stats.skipped += 1;
252 },
253 .updated => {
254 const new_content = result.new_content orelse return error.NoNewContent;
255 if (dry_run) {
256 try stdout.print("{s}: would update {s} -> {d}.{d}.{d}\n", .{
257 path,
258 result.old_version orelse "unknown",
259 current_version.major,
260 current_version.minor,
261 current_version.patch,
262 });
263 } else {
264 const out_file = try Dir.createFile(cwd, io, path, .{});
265 defer out_file.close(io);
266 var write_buf: [4096]u8 = undefined;
267 var writer = File.Writer.initStreaming(out_file, io, &write_buf);
268 try writer.interface.writeAll(new_content);
269 try writer.flush();
270 try stdout.print("{s}: updated {s} -> {d}.{d}.{d}\n", .{
271 path,
272 result.old_version orelse "unknown",
273 current_version.major,
274 current_version.minor,
275 current_version.patch,
276 });
277 }
278 stats.updated += 1;
279 },
280 }
281 _ = stderr;
282}
283
284const ProcessStatus = enum {
285 not_found,
286 skipped_non_semver,
287 skipped_not_older,
288 updated,
289};
290
291const ProcessResult = struct {
292 status: ProcessStatus,
293 old_version: ?[]const u8,
294 new_content: ?[]u8,
295};
296
297fn processContent(allocator: std.mem.Allocator, content: []const u8, current_version: Version) !ProcessResult {
298 const rust_version_key = "rust-version";
299
300 // Find rust-version line
301 var line_start: usize = 0;
302 while (line_start < content.len) {
303 const line_end = std.mem.indexOfScalarPos(u8, content, line_start, '\n') orelse content.len;
304 const line = content[line_start..line_end];
305
306 // Check if line contains rust-version
307 if (std.mem.indexOf(u8, line, rust_version_key)) |key_pos| {
308 const after_key = line[key_pos + rust_version_key.len ..];
309 const trimmed = std.mem.trimStart(u8, after_key, " \t");
310
311 if (trimmed.len > 0 and trimmed[0] == '=') {
312 const after_eq = std.mem.trimStart(u8, trimmed[1..], " \t");
313
314 // Skip non-string values (like { workspace = true })
315 if (after_eq.len > 0 and after_eq[0] == '{') {
316 return ProcessResult{ .status = .skipped_non_semver, .old_version = null, .new_content = null };
317 }
318
319 // Find quoted version string
320 if (std.mem.indexOf(u8, after_eq, "\"")) |quote_start| {
321 const version_start = quote_start + 1;
322 if (std.mem.indexOfScalarPos(u8, after_eq, version_start, '"')) |quote_end| {
323 const old_version_str = after_eq[version_start..quote_end];
324
325 // Parse the version
326 const old_version = Version.parse(old_version_str) orelse {
327 return ProcessResult{ .status = .skipped_non_semver, .old_version = null, .new_content = null };
328 };
329
330 // Check if update needed
331 if (!old_version.isOlderThan(current_version)) {
332 return ProcessResult{ .status = .skipped_not_older, .old_version = old_version_str, .new_content = null };
333 }
334
335 // Build new content
336 const new_version_str = try current_version.format(allocator);
337 defer allocator.free(new_version_str);
338
339 // Calculate position in full content
340 const abs_version_start = line_start + key_pos + rust_version_key.len + (@intFromPtr(after_eq.ptr) - @intFromPtr(line.ptr) - key_pos - rust_version_key.len) + version_start;
341 const abs_version_end = abs_version_start + old_version_str.len;
342
343 const new_content = try allocator.alloc(u8, content.len - old_version_str.len + new_version_str.len);
344 @memcpy(new_content[0..abs_version_start], content[0..abs_version_start]);
345 @memcpy(new_content[abs_version_start..][0..new_version_str.len], new_version_str);
346 @memcpy(new_content[abs_version_start + new_version_str.len ..], content[abs_version_end..]);
347
348 return ProcessResult{ .status = .updated, .old_version = old_version_str, .new_content = new_content };
349 }
350 }
351 }
352 }
353
354 line_start = line_end + 1;
355 }
356
357 return ProcessResult{ .status = .not_found, .old_version = null, .new_content = null };
358}
359
360// Tests
361
362test "Version.parse valid versions" {
363 const v1 = Version.parse("1.70.0").?;
364 try std.testing.expectEqual(@as(u32, 1), v1.major);
365 try std.testing.expectEqual(@as(u32, 70), v1.minor);
366 try std.testing.expectEqual(@as(u32, 0), v1.patch);
367
368 const v2 = Version.parse("1.84.1").?;
369 try std.testing.expectEqual(@as(u32, 1), v2.major);
370 try std.testing.expectEqual(@as(u32, 84), v2.minor);
371 try std.testing.expectEqual(@as(u32, 1), v2.patch);
372
373 // Two-part version (patch defaults to 0)
374 const v3 = Version.parse("1.70").?;
375 try std.testing.expectEqual(@as(u32, 1), v3.major);
376 try std.testing.expectEqual(@as(u32, 70), v3.minor);
377 try std.testing.expectEqual(@as(u32, 0), v3.patch);
378}
379
380test "Version.parse with whitespace and quotes" {
381 const v1 = Version.parse(" 1.70.0 ").?;
382 try std.testing.expectEqual(@as(u32, 1), v1.major);
383 try std.testing.expectEqual(@as(u32, 70), v1.minor);
384
385 const v2 = Version.parse("\"1.84.0\"").?;
386 try std.testing.expectEqual(@as(u32, 1), v2.major);
387 try std.testing.expectEqual(@as(u32, 84), v2.minor);
388}
389
390test "Version.parse invalid versions" {
391 try std.testing.expectEqual(@as(?Version, null), Version.parse(""));
392 try std.testing.expectEqual(@as(?Version, null), Version.parse("abc"));
393 try std.testing.expectEqual(@as(?Version, null), Version.parse("1"));
394 try std.testing.expectEqual(@as(?Version, null), Version.parse("1.x.0"));
395}
396
397test "Version.isOlderThan" {
398 const v1_70 = Version{ .major = 1, .minor = 70, .patch = 0 };
399 const v1_84 = Version{ .major = 1, .minor = 84, .patch = 0 };
400 const v1_84_1 = Version{ .major = 1, .minor = 84, .patch = 1 };
401 const v2_0 = Version{ .major = 2, .minor = 0, .patch = 0 };
402
403 try std.testing.expect(v1_70.isOlderThan(v1_84));
404 try std.testing.expect(!v1_84.isOlderThan(v1_70));
405 try std.testing.expect(!v1_84.isOlderThan(v1_84)); // equal
406 try std.testing.expect(v1_84.isOlderThan(v1_84_1));
407 try std.testing.expect(v1_84_1.isOlderThan(v2_0));
408}
409
410test "processContent updates older version" {
411 const content = "[package]\nrust-version = \"1.70.0\"\n";
412 const current = Version{ .major = 1, .minor = 84, .patch = 0 };
413
414 const result = try processContent(std.testing.allocator, content, current);
415 defer if (result.new_content) |nc| std.testing.allocator.free(nc);
416
417 try std.testing.expectEqual(ProcessStatus.updated, result.status);
418 try std.testing.expectEqualStrings("1.70.0", result.old_version.?);
419 try std.testing.expectEqualStrings("[package]\nrust-version = \"1.84.0\"\n", result.new_content.?);
420}
421
422test "processContent skips newer version" {
423 const content = "[package]\nrust-version = \"1.84.0\"\n";
424 const current = Version{ .major = 1, .minor = 70, .patch = 0 };
425
426 const result = try processContent(std.testing.allocator, content, current);
427
428 try std.testing.expectEqual(ProcessStatus.skipped_not_older, result.status);
429 try std.testing.expectEqual(@as(?[]u8, null), result.new_content);
430}
431
432test "processContent skips equal version" {
433 const content = "[package]\nrust-version = \"1.84.0\"\n";
434 const current = Version{ .major = 1, .minor = 84, .patch = 0 };
435
436 const result = try processContent(std.testing.allocator, content, current);
437
438 try std.testing.expectEqual(ProcessStatus.skipped_not_older, result.status);
439}
440
441test "processContent skips workspace inheritance" {
442 const content = "[package]\nrust-version = { workspace = true }\n";
443 const current = Version{ .major = 1, .minor = 84, .patch = 0 };
444
445 const result = try processContent(std.testing.allocator, content, current);
446
447 try std.testing.expectEqual(ProcessStatus.skipped_non_semver, result.status);
448}
449
450test "processContent not found" {
451 const content = "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n";
452 const current = Version{ .major = 1, .minor = 84, .patch = 0 };
453
454 const result = try processContent(std.testing.allocator, content, current);
455
456 try std.testing.expectEqual(ProcessStatus.not_found, result.status);
457}
458
459test "processContent preserves surrounding content" {
460 const content =
461 \\[package]
462 \\name = "myapp"
463 \\version = "0.1.0"
464 \\rust-version = "1.70.0"
465 \\edition = "2021"
466 \\
467 \\[dependencies]
468 \\serde = "1.0"
469 \\
470 ;
471 const current = Version{ .major = 1, .minor = 84, .patch = 0 };
472
473 const result = try processContent(std.testing.allocator, content, current);
474 defer if (result.new_content) |nc| std.testing.allocator.free(nc);
475
476 try std.testing.expectEqual(ProcessStatus.updated, result.status);
477
478 const expected =
479 \\[package]
480 \\name = "myapp"
481 \\version = "0.1.0"
482 \\rust-version = "1.84.0"
483 \\edition = "2021"
484 \\
485 \\[dependencies]
486 \\serde = "1.0"
487 \\
488 ;
489 try std.testing.expectEqualStrings(expected, result.new_content.?);
490}