const std = @import("std"); const Io = std.Io; const Dir = std.Io.Dir; const File = std.Io.File; const process = std.process; const Version = struct { major: u32, minor: u32, patch: u32, fn parse(str: []const u8) ?Version { const trimmed = std.mem.trim(u8, str, " \t\n\r\""); var parts = std.mem.splitScalar(u8, trimmed, '.'); const major = std.fmt.parseInt(u32, parts.next() orelse return null, 10) catch return null; const minor = std.fmt.parseInt(u32, parts.next() orelse return null, 10) catch return null; const patch = std.fmt.parseInt(u32, parts.next() orelse "0", 10) catch return null; return Version{ .major = major, .minor = minor, .patch = patch }; } fn isOlderThan(self: Version, other: Version) bool { if (self.major != other.major) return self.major < other.major; if (self.minor != other.minor) return self.minor < other.minor; return self.patch < other.patch; } fn format(self: Version, allocator: std.mem.Allocator) ![]u8 { return std.fmt.allocPrint(allocator, "{d}.{d}.{d}", .{ self.major, self.minor, self.patch }); } }; const Stats = struct { scanned: u32 = 0, updated: u32 = 0, errors: u32 = 0, skipped: u32 = 0, }; pub fn main(init: process.Init) !void { const allocator = init.gpa; const io = init.io; // Get stdout/stderr writers var stdout_buf: [4096]u8 = undefined; var stderr_buf: [4096]u8 = undefined; var stdout_writer = File.Writer.initStreaming(.stdout(), io, &stdout_buf); var stderr_writer = File.Writer.initStreaming(.stderr(), io, &stderr_buf); const stdout = &stdout_writer.interface; const stderr = &stderr_writer.interface; // Parse CLI arguments var args_iter = try process.Args.Iterator.initAllocator(init.minimal.args, allocator); defer args_iter.deinit(); _ = args_iter.skip(); // skip program name var dry_run = false; while (args_iter.next()) |arg| { if (std.mem.eql(u8, arg, "--dry-run")) { dry_run = true; } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { try stdout.print("Usage: rust-version-updater [--dry-run]\n\n", .{}); try stdout.print("Recursively updates rust-version in Cargo.toml files to match current rustc.\n\n", .{}); try stdout.print("Options:\n", .{}); try stdout.print(" --dry-run Preview changes without modifying files\n", .{}); try stdout.print(" --help Show this help message\n", .{}); try stdout_writer.flush(); return; } } // Check for rust-toolchain.toml try checkRustToolchain(allocator, io, stdout, &stdout_writer, dry_run); // Get current Rust version const current_version = getCurrentRustVersion(allocator, io) catch |err| { try stderr.print("Error: Failed to get Rust version: {}\n", .{err}); try stderr_writer.flush(); std.process.exit(1); }; defer allocator.free(current_version.str); try stdout.print("Current Rust version: {d}.{d}.{d}\n", .{ current_version.version.major, current_version.version.minor, current_version.version.patch }); if (dry_run) { try stdout.print("Dry-run mode: no files will be modified\n", .{}); } try stdout.print("\n", .{}); // Walk directory tree var stats = Stats{}; const cwd = Dir.cwd(); // Open directory with iterate capability const iterable_dir = Dir.openDir(cwd, io, ".", .{ .iterate = true }) catch |err| { try stderr.print("Error: Failed to open current directory: {}\n", .{err}); try stderr_writer.flush(); std.process.exit(1); }; defer iterable_dir.close(io); var walker = Dir.walk(iterable_dir, allocator) catch |err| { try stderr.print("Error: Failed to create directory walker: {}\n", .{err}); try stderr_writer.flush(); std.process.exit(1); }; defer walker.deinit(); while (true) { const entry = walker.next(io) catch |err| { try stderr.print("Warning: Error during directory walk: {}\n", .{err}); stats.errors += 1; continue; }; if (entry == null) break; const e = entry.?; if (e.kind != .file) continue; if (!std.mem.eql(u8, e.basename, "Cargo.toml")) continue; stats.scanned += 1; processCargoToml(allocator, io, e.path, current_version.version, dry_run, &stats, stdout, stderr) catch |err| { try stderr.print("Error processing {s}: {}\n", .{ e.path, err }); stats.errors += 1; }; } // Print summary try stdout.print("\n--- Summary ---\n", .{}); try stdout.print("Files scanned: {d}\n", .{stats.scanned}); try stdout.print("Files updated: {d}\n", .{stats.updated}); try stdout.print("Files skipped: {d}\n", .{stats.skipped}); try stdout.print("Errors: {d}\n", .{stats.errors}); try stdout_writer.flush(); } fn checkRustToolchain( allocator: std.mem.Allocator, io: Io, stdout: *Io.Writer, stdout_writer: *File.Writer, dry_run: bool, ) !void { const cwd = Dir.cwd(); // Try to read the file contents const content = blk: { const file = Dir.openFile(cwd, io, "rust-toolchain.toml", .{}) catch return; defer file.close(io); var read_buf: [4096]u8 = undefined; var reader = File.Reader.init(file, io, &read_buf); break :blk try reader.interface.allocRemaining(allocator, .limited(1024 * 1024)); }; defer allocator.free(content); try stdout.print("Found rust-toolchain.toml:\n---\n{s}---\n\n", .{content}); if (dry_run) { try stdout.print("Dry-run mode: skipping rust-toolchain.toml deletion prompt\n\n", .{}); try stdout_writer.flush(); return; } try stdout.print("Delete rust-toolchain.toml? [y/N] ", .{}); try stdout_writer.flush(); // Read response from stdin var stdin_buf: [256]u8 = undefined; var stdin_reader = File.Reader.init(.stdin(), io, &stdin_buf); const byte = stdin_reader.interface.takeByte() catch return; if (byte == 'y' or byte == 'Y') { Dir.deleteFile(cwd, io, "rust-toolchain.toml") catch |err| { try stdout.print("Warning: Failed to delete rust-toolchain.toml: {}\n", .{err}); try stdout_writer.flush(); return; }; try stdout.print("Deleted rust-toolchain.toml\n\n", .{}); } else { try stdout.print("Keeping rust-toolchain.toml, exiting.\n", .{}); try stdout_writer.flush(); std.process.exit(0); } try stdout_writer.flush(); } const VersionResult = struct { version: Version, str: []u8, }; fn getCurrentRustVersion(allocator: std.mem.Allocator, io: Io) !VersionResult { const result = try process.run(allocator, io, .{ .argv = &.{ "rustc", "--version" }, }); defer allocator.free(result.stdout); defer allocator.free(result.stderr); if (result.term != .exited or result.term.exited != 0) { return error.RustcFailed; } // Parse "rustc X.Y.Z (...)" format const trimmed = std.mem.trim(u8, result.stdout, " \t\n\r"); if (!std.mem.startsWith(u8, trimmed, "rustc ")) { return error.InvalidRustcOutput; } const after_rustc = trimmed[6..]; const space_idx = std.mem.indexOf(u8, after_rustc, " ") orelse after_rustc.len; const version_str = after_rustc[0..space_idx]; const version = Version.parse(version_str) orelse return error.InvalidVersion; const str = try allocator.dupe(u8, version_str); return VersionResult{ .version = version, .str = str }; } fn processCargoToml( allocator: std.mem.Allocator, io: Io, path: []const u8, current_version: Version, dry_run: bool, stats: *Stats, stdout: *Io.Writer, stderr: *Io.Writer, ) !void { const cwd = Dir.cwd(); const file = try Dir.openFile(cwd, io, path, .{}); defer file.close(io); var read_buf: [4096]u8 = undefined; var reader = File.Reader.init(file, io, &read_buf); const content = try reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)); defer allocator.free(content); // Find and process rust-version line const result = try processContent(allocator, content, current_version); defer if (result.new_content) |nc| allocator.free(nc); switch (result.status) { .not_found => { stats.skipped += 1; }, .skipped_non_semver => { try stdout.print("{s}: skipped (non-semver value)\n", .{path}); stats.skipped += 1; }, .skipped_not_older => { try stdout.print("{s}: skipped (version {s} is not older)\n", .{ path, result.old_version orelse "unknown" }); stats.skipped += 1; }, .updated => { const new_content = result.new_content orelse return error.NoNewContent; if (dry_run) { try stdout.print("{s}: would update {s} -> {d}.{d}.{d}\n", .{ path, result.old_version orelse "unknown", current_version.major, current_version.minor, current_version.patch, }); } else { const out_file = try Dir.createFile(cwd, io, path, .{}); defer out_file.close(io); var write_buf: [4096]u8 = undefined; var writer = File.Writer.initStreaming(out_file, io, &write_buf); try writer.interface.writeAll(new_content); try writer.flush(); try stdout.print("{s}: updated {s} -> {d}.{d}.{d}\n", .{ path, result.old_version orelse "unknown", current_version.major, current_version.minor, current_version.patch, }); } stats.updated += 1; }, } _ = stderr; } const ProcessStatus = enum { not_found, skipped_non_semver, skipped_not_older, updated, }; const ProcessResult = struct { status: ProcessStatus, old_version: ?[]const u8, new_content: ?[]u8, }; fn processContent(allocator: std.mem.Allocator, content: []const u8, current_version: Version) !ProcessResult { const rust_version_key = "rust-version"; // Find rust-version line var line_start: usize = 0; while (line_start < content.len) { const line_end = std.mem.indexOfScalarPos(u8, content, line_start, '\n') orelse content.len; const line = content[line_start..line_end]; // Check if line contains rust-version if (std.mem.indexOf(u8, line, rust_version_key)) |key_pos| { const after_key = line[key_pos + rust_version_key.len ..]; const trimmed = std.mem.trimStart(u8, after_key, " \t"); if (trimmed.len > 0 and trimmed[0] == '=') { const after_eq = std.mem.trimStart(u8, trimmed[1..], " \t"); // Skip non-string values (like { workspace = true }) if (after_eq.len > 0 and after_eq[0] == '{') { return ProcessResult{ .status = .skipped_non_semver, .old_version = null, .new_content = null }; } // Find quoted version string if (std.mem.indexOf(u8, after_eq, "\"")) |quote_start| { const version_start = quote_start + 1; if (std.mem.indexOfScalarPos(u8, after_eq, version_start, '"')) |quote_end| { const old_version_str = after_eq[version_start..quote_end]; // Parse the version const old_version = Version.parse(old_version_str) orelse { return ProcessResult{ .status = .skipped_non_semver, .old_version = null, .new_content = null }; }; // Check if update needed if (!old_version.isOlderThan(current_version)) { return ProcessResult{ .status = .skipped_not_older, .old_version = old_version_str, .new_content = null }; } // Build new content const new_version_str = try current_version.format(allocator); defer allocator.free(new_version_str); // Calculate position in full content 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; const abs_version_end = abs_version_start + old_version_str.len; const new_content = try allocator.alloc(u8, content.len - old_version_str.len + new_version_str.len); @memcpy(new_content[0..abs_version_start], content[0..abs_version_start]); @memcpy(new_content[abs_version_start..][0..new_version_str.len], new_version_str); @memcpy(new_content[abs_version_start + new_version_str.len ..], content[abs_version_end..]); return ProcessResult{ .status = .updated, .old_version = old_version_str, .new_content = new_content }; } } } } line_start = line_end + 1; } return ProcessResult{ .status = .not_found, .old_version = null, .new_content = null }; } // Tests test "Version.parse valid versions" { const v1 = Version.parse("1.70.0").?; try std.testing.expectEqual(@as(u32, 1), v1.major); try std.testing.expectEqual(@as(u32, 70), v1.minor); try std.testing.expectEqual(@as(u32, 0), v1.patch); const v2 = Version.parse("1.84.1").?; try std.testing.expectEqual(@as(u32, 1), v2.major); try std.testing.expectEqual(@as(u32, 84), v2.minor); try std.testing.expectEqual(@as(u32, 1), v2.patch); // Two-part version (patch defaults to 0) const v3 = Version.parse("1.70").?; try std.testing.expectEqual(@as(u32, 1), v3.major); try std.testing.expectEqual(@as(u32, 70), v3.minor); try std.testing.expectEqual(@as(u32, 0), v3.patch); } test "Version.parse with whitespace and quotes" { const v1 = Version.parse(" 1.70.0 ").?; try std.testing.expectEqual(@as(u32, 1), v1.major); try std.testing.expectEqual(@as(u32, 70), v1.minor); const v2 = Version.parse("\"1.84.0\"").?; try std.testing.expectEqual(@as(u32, 1), v2.major); try std.testing.expectEqual(@as(u32, 84), v2.minor); } test "Version.parse invalid versions" { try std.testing.expectEqual(@as(?Version, null), Version.parse("")); try std.testing.expectEqual(@as(?Version, null), Version.parse("abc")); try std.testing.expectEqual(@as(?Version, null), Version.parse("1")); try std.testing.expectEqual(@as(?Version, null), Version.parse("1.x.0")); } test "Version.isOlderThan" { const v1_70 = Version{ .major = 1, .minor = 70, .patch = 0 }; const v1_84 = Version{ .major = 1, .minor = 84, .patch = 0 }; const v1_84_1 = Version{ .major = 1, .minor = 84, .patch = 1 }; const v2_0 = Version{ .major = 2, .minor = 0, .patch = 0 }; try std.testing.expect(v1_70.isOlderThan(v1_84)); try std.testing.expect(!v1_84.isOlderThan(v1_70)); try std.testing.expect(!v1_84.isOlderThan(v1_84)); // equal try std.testing.expect(v1_84.isOlderThan(v1_84_1)); try std.testing.expect(v1_84_1.isOlderThan(v2_0)); } test "processContent updates older version" { const content = "[package]\nrust-version = \"1.70.0\"\n"; const current = Version{ .major = 1, .minor = 84, .patch = 0 }; const result = try processContent(std.testing.allocator, content, current); defer if (result.new_content) |nc| std.testing.allocator.free(nc); try std.testing.expectEqual(ProcessStatus.updated, result.status); try std.testing.expectEqualStrings("1.70.0", result.old_version.?); try std.testing.expectEqualStrings("[package]\nrust-version = \"1.84.0\"\n", result.new_content.?); } test "processContent skips newer version" { const content = "[package]\nrust-version = \"1.84.0\"\n"; const current = Version{ .major = 1, .minor = 70, .patch = 0 }; const result = try processContent(std.testing.allocator, content, current); try std.testing.expectEqual(ProcessStatus.skipped_not_older, result.status); try std.testing.expectEqual(@as(?[]u8, null), result.new_content); } test "processContent skips equal version" { const content = "[package]\nrust-version = \"1.84.0\"\n"; const current = Version{ .major = 1, .minor = 84, .patch = 0 }; const result = try processContent(std.testing.allocator, content, current); try std.testing.expectEqual(ProcessStatus.skipped_not_older, result.status); } test "processContent skips workspace inheritance" { const content = "[package]\nrust-version = { workspace = true }\n"; const current = Version{ .major = 1, .minor = 84, .patch = 0 }; const result = try processContent(std.testing.allocator, content, current); try std.testing.expectEqual(ProcessStatus.skipped_non_semver, result.status); } test "processContent not found" { const content = "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n"; const current = Version{ .major = 1, .minor = 84, .patch = 0 }; const result = try processContent(std.testing.allocator, content, current); try std.testing.expectEqual(ProcessStatus.not_found, result.status); } test "processContent preserves surrounding content" { const content = \\[package] \\name = "myapp" \\version = "0.1.0" \\rust-version = "1.70.0" \\edition = "2021" \\ \\[dependencies] \\serde = "1.0" \\ ; const current = Version{ .major = 1, .minor = 84, .patch = 0 }; const result = try processContent(std.testing.allocator, content, current); defer if (result.new_content) |nc| std.testing.allocator.free(nc); try std.testing.expectEqual(ProcessStatus.updated, result.status); const expected = \\[package] \\name = "myapp" \\version = "0.1.0" \\rust-version = "1.84.0" \\edition = "2021" \\ \\[dependencies] \\serde = "1.0" \\ ; try std.testing.expectEqualStrings(expected, result.new_content.?); }