//! Explicit, on-demand synchronization transformations. //! //! A transformation records that a change was reviewed. It never chooses a //! semantic relationship, never advances a semantic revision, and never runs //! from a read-only gate. const std = @import("std"); const model_types = @import("model.zig"); const project = @import("project.zig"); const profile = @import("profile"); const digest_length = std.crypto.hash.sha2.Sha256.digest_length * 2; pub const Change = struct { registry: []const u8, path: []const u8, owner: []const u8, previous: [digest_length]u8, current: [digest_length]u8, }; /// Rewrite every reviewed fingerprint that no longer matches its file. /// /// `filters`, when non-empty, restricts the update to those tracked paths. pub fn fingerprints( allocator: std.mem.Allocator, io: std.Io, graph: *const model_types.Graph, filters: []const []const u8, writer: *std.Io.Writer, ) !u32 { var updated: u32 = 0; updated += try syncRegistry(allocator, io, profile.paths.test_specs, filters, writer, .{ .fixtures = graph.test_specs, }); updated += try syncRegistry(allocator, io, profile.paths.test_bindings, filters, writer, .{ .bindings = graph.test_bindings, }); updated += try syncRegistry(allocator, io, profile.paths.implementations, filters, writer, .{ .implementations = graph.implementations, }); if (updated == 0) { try writer.writeAll("every reviewed fingerprint already matches its file\n"); return 0; } try writer.print( "\n{d} reviewed fingerprint(s) updated.\n" ++ "A fingerprint records review only. Semantic changes still need a revision " ++ "increment\nand migration of every dependent link.\n" ++ "verify: {s}\n", .{ updated, profile.verify_command }, ); return updated; } const Entries = union(enum) { fixtures: []const model_types.TestSpec, bindings: []const model_types.TestBinding, implementations: []const model_types.ImplementationUnit, }; fn syncRegistry( allocator: std.mem.Allocator, io: std.Io, registry: []const u8, filters: []const []const u8, writer: *std.Io.Writer, entries: Entries, ) !u32 { var source = try std.Io.Dir.cwd().readFileAlloc( io, registry, allocator, .limited(model_types.file_size_max), ); defer allocator.free(source); var updated: u32 = 0; switch (entries) { .fixtures => |items| for (items) |item| { updated += try replaceDigest( allocator, io, &source, registry, item.fixture.path, item.fixture.sha256, item.fixture.id, filters, writer, ); }, .bindings => |items| for (items) |item| { updated += try replaceDigest( allocator, io, &source, registry, item.source, item.source_sha256, item.test_spec.id, filters, writer, ); }, .implementations => |items| for (items) |item| { for (item.sources) |reference| { updated += try replaceDigest( allocator, io, &source, registry, reference.path, reference.sha256, item.id, filters, writer, ); } }, } if (updated > 0) { var temporary_buffer: [256]u8 = undefined; const temporary = try std.fmt.bufPrint( &temporary_buffer, "{s}.spec-tool.tmp", .{registry}, ); try project.replaceFile(io, registry, temporary, source); } return updated; } fn replaceDigest( allocator: std.mem.Allocator, io: std.Io, source: *[]u8, registry: []const u8, tracked_path: []const u8, recorded: []const u8, owner: []const u8, filters: []const []const u8, writer: *std.Io.Writer, ) !u32 { if (filters.len > 0 and !pathSelected(filters, tracked_path)) return 0; const bytes = try std.Io.Dir.cwd().readFileAlloc( io, tracked_path, allocator, .limited(model_types.file_size_max), ); defer allocator.free(bytes); var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); var current: [digest_length]u8 = undefined; _ = std.fmt.bufPrint(¤t, "{x}", .{&digest}) catch unreachable; if (std.mem.eql(u8, recorded, ¤t)) return 0; if (recorded.len != digest_length) return error.InvalidRecordedFingerprint; const position = std.mem.indexOf(u8, source.*, recorded) orelse return error.RecordedFingerprintNotFound; if (std.mem.indexOfPos(u8, source.*, position + recorded.len, recorded) != null) { return error.AmbiguousRecordedFingerprint; } @memcpy(source.*[position..][0..digest_length], ¤t); try writer.print("{s}\n registry: {s}\n owner: {s}\n {s} -> {s}\n", .{ tracked_path, registry, owner, recorded, current, }); return 1; } fn pathSelected(filters: []const []const u8, tracked_path: []const u8) bool { for (filters) |filter| { if (std.mem.eql(u8, filter, tracked_path)) return true; } return false; } /// Render the implementation-unit stanza for a source that is not yet registered. pub fn newSource( allocator: std.mem.Allocator, io: std.Io, graph: *const model_types.Graph, source_path: []const u8, obligation_ids: []const []const u8, writer: *std.Io.Writer, ) !void { if (!std.mem.startsWith(u8, source_path, profile.paths.implementation_root)) { return error.SourceOutsideImplementationRoot; } if (!std.mem.endsWith(u8, source_path, ".zig")) return error.SourceIsNotZig; if (obligation_ids.len == 0) return error.MissingObligation; for (graph.implementations) |unit| { for (unit.sources) |reference| { if (std.mem.eql(u8, reference.path, source_path)) return error.SourceAlreadyRegistered; } } const bytes = try std.Io.Dir.cwd().readFileAlloc( io, source_path, allocator, .limited(model_types.file_size_max), ); defer allocator.free(bytes); var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); var current: [digest_length]u8 = undefined; _ = std.fmt.bufPrint(¤t, "{x}", .{&digest}) catch unreachable; var stem_buffer: [128]u8 = undefined; const stem = try unitName(&stem_buffer, sourceStem(source_path)); try writer.print("Append to {s}\n\n", .{profile.paths.implementations}); try writer.writeAll("----------------------------------------------------------------------\n"); try writer.print(" .{{\n .id = \"implementation-{s}\",\n", .{stem}); try writer.writeAll(" .revision = 1,\n .sources = .{\n .{\n"); try writer.print(" .path = \"{s}\",\n", .{source_path}); try writer.print(" .sha256 = \"{s}\",\n", .{current}); try writer.writeAll(" },\n },\n .realizes = .{\n"); for (obligation_ids) |id| { const obligation = model_types.findObligation(graph.verification, id) orelse return error.ObligationNotFound; try writer.print(" .{{ .id = \"{s}\", .revision = {d} }},\n", .{ obligation.id, obligation.revision, }); } try writer.writeAll(" },\n },\n"); try writer.writeAll("----------------------------------------------------------------------\n"); try writer.print( "\nOne source belongs to exactly one implementation unit. Merge the stanza into an\n" ++ "existing unit instead when that unit already realizes the same obligations.\n" ++ "verify: {s}\n", .{profile.verify_command}, ); } /// Implementation-unit identifiers use the hyphenated name grammar. fn unitName(buffer: []u8, stem: []const u8) ![]const u8 { if (stem.len > buffer.len) return error.SourceNameTooLong; for (stem, 0..) |byte, index| buffer[index] = if (byte == '_') '-' else byte; return buffer[0..stem.len]; } fn sourceStem(source_path: []const u8) []const u8 { const start = if (std.mem.lastIndexOfScalar(u8, source_path, '/')) |index| index + 1 else 0; const end = source_path.len - ".zig".len; return source_path[start..end]; } test "source stems drop directories, extensions, and underscores" { try std.testing.expectEqualStrings("main", sourceStem("src/main.zig")); try std.testing.expectEqualStrings("scan", sourceStem("src/filesystem/scan.zig")); var buffer: [64]u8 = undefined; try std.testing.expectEqualStrings( "probe-unit", try unitName(&buffer, sourceStem("src/probe_unit.zig")), ); } test "unregistered sources are rejected before a stanza is offered" { const graph: model_types.Graph = .{ .documents = &.{}, .concepts = &.{}, .groups = &.{}, .test_specs = &.{}, .test_bindings = &.{}, .verification = &.{}, .implementations = &.{}, }; var output = std.Io.Writer.Allocating.init(std.testing.allocator); defer output.deinit(); try std.testing.expectError(error.SourceIsNotZig, newSource( std.testing.allocator, undefined, &graph, profile.paths.implementation_root ++ "notes.md", &.{"obligation-example"}, &output.writer, )); try std.testing.expectError(error.MissingObligation, newSource( std.testing.allocator, undefined, &graph, profile.paths.implementation_root ++ "example.zig", &.{}, &output.writer, )); }