Luigit
repositories / termux-janitor

termux-janitor

Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.

owned by admin

tools/spec-engine/src/sync.zig

Raw
//! 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(&current, "{x}", .{&digest}) catch unreachable;
    if (std.mem.eql(u8, recorded, &current)) 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], &current);
    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(&current, "{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,
    ));
}