a73x

src/client/layoutfile.zig

Ref:   Size: 7.8 KiB   History

//! The layout file edited from OUTSIDE a wall: a read-modify-write over
//! `hosts.saveBytes`' atomic rename, for the fronts that change what the
//! wall will hold without one on the screen.
//!
//! The wall's own saves are `wall_layout.persist`, which serializes the tree
//! it is painting; the hub's own add is `webhub.appendLeaf`. What lives here
//! is the edit neither of those can make, because it is made by a command
//! that never opens a wall — `mux hosts rm`, which takes a daemon off the
//! device and must take that daemon's panes with it. A leaf naming a host
//! the hosts file does not list refuses the WHOLE layout at the next start
//! (`wall_layout.seedLayout`), so a `rm` that edited one file and not the
//! other cost the user every other pane they had authored.
//!
//! A `client`-row child rather than part of `hosts.zig`: this knows the
//! layout grammar, and `hosts.zig` deliberately does not — it owns the
//! hosts file and the path beside it, and says so. Reachable from
//! `src/cli/mux_main.zig` and from `webhub.zig`, which are the two fronts
//! that edit the file without a wall.
const std = @import("std");
const hosts = @import("hosts.zig");
const layout = @import("layout.zig");
const TmpDir = @import("testtmp").TmpDir;

/// What a removal did to the file.
pub const Outcome = union(enum) {
    /// No file, or no leaf named any of those hosts: nothing was written.
    /// A save that changes nothing is still a write another writer can lose
    /// an update to, which is `hosts.forgetMany`'s rule as well.
    unchanged,
    /// How many leaves went. The file was rewritten, or DELETED when the
    /// removal took the last one: an empty tree serializes to a header with
    /// no root, and `layout.parseReporting` refuses that file — so writing
    /// one would leave behind exactly the refusal this whole module exists
    /// to prevent. No panes is no layout.
    rewrote: usize,
    /// The file is not a layout. Nothing is written and the line it gave up
    /// on has been printed: a file mux cannot read is a file mux must not
    /// rewrite, and the user's own editor is the tool for it.
    refused,
};

/// Every leaf whose HOST part is one of `spellings`, gone from the tree.
/// The match is byte for byte against the hosts-file spelling, the same key
/// `seedLayout` matches a leaf by, so a `rm` removes exactly the leaves the
/// next start would have refused the file for.
///
/// `who` is the command saying so, for the one line this prints.
pub fn forgetHosts(
    alloc: std.mem.Allocator,
    path: []const u8,
    spellings: []const []const u8,
    who: []const u8,
) !Outcome {
    const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
        // A device that has never opened a wall has no layout, and removing
        // a host from its hosts file is not the moment to invent one.
        error.FileNotFound => return .unchanged,
        else => return e,
    };
    defer alloc.free(bytes);
    var bad_line: []const u8 = "";
    var parsed = layout.parseReporting(alloc, bytes, &bad_line) orelse {
        std.debug.print("{s}: layout ignored ({s}): {s}\n", .{ who, path, bad_line });
        return .refused;
    };
    defer parsed.deinit(alloc);

    var removed: usize = 0;
    for (parsed.spellings.items, 0..) |sp, id| {
        const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse continue;
        for (spellings) |want| {
            if (!std.mem.eql(u8, sp[0..cut], want)) continue;
            // The leaf id IS its index here: `parseReporting` hands ids out
            // in encounter order. The spelling stays in the array — the
            // tree no longer names it, and `serialize` walks the tree.
            parsed.tree.remove(@intCast(id));
            if (parsed.focus) |f| {
                if (f == id) parsed.focus = null;
            }
            removed += 1;
            break;
        }
    }
    if (removed == 0) return .unchanged;
    if (parsed.tree.root == null) {
        std.fs.cwd().deleteFile(path) catch |e| switch (e) {
            error.FileNotFound => {},
            else => return e,
        };
        return .{ .rewrote = removed };
    }
    var buf: std.ArrayListUnmanaged(u8) = .{};
    defer buf.deinit(alloc);
    try parsed.tree.serialize(parsed.spellings.items, parsed.focus, buf.writer(alloc));
    try hosts.saveBytes(path, buf.items);
    return .{ .rewrote = removed };
}

test "forgetHosts: one host's leaves go and the other host's keep their shape" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
    defer alloc.free(path);

    // Two hosts and three leaves, the plural default: a fixture with one
    // leaf per host could not tell "removed the right leaves" from
    // "removed a host". The cut is a `beside` over a `stacked`, and the
    // survivors must come out in the same shape.
    try hosts.saveBytes(path,
        \\mux-layout 1
        \\beside 0
        \\ leaf 60 box#0
        \\ stacked 40
        \\  leaf 50 --sock /tmp/a.sock#work
        \\  leaf 50 box#two
        \\focus 2
        \\
    );
    const out = try forgetHosts(alloc, path, &.{"box"}, "test");
    try std.testing.expectEqual(@as(usize, 2), out.rewrote);

    const after = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
    defer alloc.free(after);
    var line: []const u8 = "";
    var re = layout.parseReporting(alloc, after, &line) orelse return error.Unparsable;
    defer re.deinit(alloc);
    try std.testing.expectEqual(@as(usize, 1), re.spellings.items.len);
    try std.testing.expectEqualStrings("--sock /tmp/a.sock#work", re.spellings.items[0]);
    // The removed leaf held the focus record; it may not survive as an
    // index into a file that no longer has that many leaves.
    try std.testing.expect(re.focus == null or re.focus.? == 0);
}

test "forgetHosts: a host no leaf names writes nothing, and a file that is not a layout is left alone" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
    defer alloc.free(path);

    const good =
        \\mux-layout 1
        \\beside 0
        \\ leaf 60 box#0
        \\ leaf 40 --sock /tmp/a.sock#work
        \\
    ;
    try hosts.saveBytes(path, good);
    try std.testing.expect(try forgetHosts(alloc, path, &.{"elsewhere"}, "test") == .unchanged);
    const same = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
    defer alloc.free(same);
    try std.testing.expectEqualStrings(good, same);

    // Not a layout: the file is the user's to repair, and a rewrite here
    // would destroy the line they have to find.
    try hosts.saveBytes(path, "bogus 9\n");
    try std.testing.expect(try forgetHosts(alloc, path, &.{"box"}, "test") == .refused);
    const junk = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
    defer alloc.free(junk);
    try std.testing.expectEqualStrings("bogus 9\n", junk);

    // A missing file is a no-op, not an error: a device that never opened a
    // wall still runs `mux hosts rm`.
    try std.fs.cwd().deleteFile(path);
    try std.testing.expect(try forgetHosts(alloc, path, &.{"box"}, "test") == .unchanged);
}

test "forgetHosts: taking the last leaf deletes the file rather than writing one nothing can read" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
    defer alloc.free(path);

    try hosts.saveBytes(path,
        \\mux-layout 1
        \\beside 0
        \\ leaf 50 box#0
        \\ leaf 50 box#two
        \\
    );
    const out = try forgetHosts(alloc, path, &.{"box"}, "test");
    try std.testing.expectEqual(@as(usize, 2), out.rewrote);
    try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{}));
}