Tool to recursively find Cargo.toml files and update the rust-version value to the latest installed Rust.
at main 490 lines 19 kB view raw
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}