Is there a more straightforward approach to pass a function pointer to different structs?

I have a Path struct and PathFn that I’m using in CopyStrategy and LinkStrategy structs:

    // --- PATH STRATEGY ---
    /// CopyStrategy and LinkStrategy contexts.
    const PathFn = *const fn (ctx: *anyopaque, src: []const u8, anchor: []const u8) anyerror![]const u8;

    const PathResolvers = struct {
        /// !flatten + !mkdir
        fn resolveNone(ctx: anytype, src: []const u8, _: []const u8) ![]const u8 {
            return try Io.Dir.path.join(ctx.gpa, &.{ ctx.dest, src });
        }

        /// flatten + !mkdir
        fn resolveFlatten(ctx: anytype, src: []const u8, anchor: []const u8) ![]const u8 {
            const stripped = stripAfterAnchor(src, anchor);
            return try Io.Dir.path.join(ctx.gpa, &.{ ctx.dest, stripped });
        }

        /// !flatten + mkdir and flatten + mkdir
        fn resolveFlattenMkdir(ctx: anytype, src: []const u8, anchor: []const u8) ![]const u8 {
            const stripped = stripAfterAnchor(src, anchor);
            return try Io.Dir.path.join(ctx.gpa, &.{ ctx.dest, anchor, stripped });
        }
    };

CopyStrategy and LinkStrategy:

    // --- File STRATEGY ---
    const CpLogicFn = *const fn (ctx: *const CopyStrategy, src: []const u8, dst: []const u8) anyerror!void;

    const CopyStrategy = struct {
        io: Io,
        gpa: Allocator,
        buf: []u8,
        acl_list: []u8,
        acl_value: []u8,
        dest: []const u8,
        max_chunk: usize,
        falloc_switch: *bool,
        algo: ChecksumAlgo,

        path_resolver: PathFn,
        copy_logic: *const fn ( ctx: *const CopyStrategy, src: []const u8, dst: []const u8,) anyerror!void,

....
    // --- LINK STRATEGY ---
    const LnLogicFn = *const fn (ctx: *const LinkStrategy, src: []const u8, dst: []const u8) anyerror!void;

    const LinkStrategy = struct {
        io: Io,
        gpa: Allocator,
        buf: []u8,
        acl_list: []u8,
        acl_value: []u8,
        dest: []const u8,

        // Function pointers resolved once at build()
        path_resolver: PathFn,
        link_logic: *const fn (ctx: *const LinkStrategy, src: []const u8, dst: []const u8) anyerror!void,
....

All of that resides in CopyEngine struct where I have lookup tables, wrapper and a build function:

 /// Fixed signature for all path resolvers
    fn wrapPath(comptime T: type, comptime func: anytype) PathFn {
        return struct {
            fn wrapper(ptr: *anyopaque, src: []const u8, anchor: []const u8) anyerror![]const u8 {
                // Recover the original pointer type
                const self: *const T = @ptrCast(@alignCast(ptr));
                // Call the generic resolver
                return try func(self, src, anchor);
            }
        }.wrapper;
    }

    /// Wrapper for path strategy used in file copy strategy
    const cp_path_res_lookup = [4]PathFn{
        wrapPath(CopyStrategy, PathResolvers.resolveNone), // 00
        wrapPath(CopyStrategy, PathResolvers.resolveFlattenMkdir), // 01 (Consolidated)
        wrapPath(CopyStrategy, PathResolvers.resolveFlatten), // 10
        wrapPath(CopyStrategy, PathResolvers.resolveFlattenMkdir), // 11
    };

    /// Wrapper for path strategy used in link copy strategy
    const ln_path_res_lookup = [4]PathFn{
        wrapPath(LinkStrategy, PathResolvers.resolveNone), // 00
        wrapPath(LinkStrategy, PathResolvers.resolveFlattenMkdir), // 01 (Consolidated)
        wrapPath(LinkStrategy, PathResolvers.resolveFlatten), // 10
        wrapPath(LinkStrategy, PathResolvers.resolveFlattenMkdir), // 11
    };

    /// The master build function that sets up and resolves all runtime branching.
    pub fn build(
        io: Io,
        gpa: Allocator,
        buffers: *Buffers,
        buf: []u8,
        dest: []const u8,
        max_chunk: usize,
        acls: bool,
        checksum: bool,
        algo: ChecksumAlgo,
        mkdir: bool,
        flatten: bool,
        falloc_switch: *bool,
    ) CopyEngine {
        // Bitwise Indexing (flatten: bit 2, mkdir: bit 1, acls: bit 0)
        // 0b000 - 0b111
        const idx: u3 = (@as(u3, @intFromBool(flatten)) << 2) |
            (@as(u3, @intFromBool(mkdir)) << 1) |
            (@as(u3, @intFromBool(acls)));

        // ACL Strategy
        const acl_ctx: ?AclContext = if (acls) .{
            .io = io,
            .gpa = gpa,
            .dest_root = dest,
            .acl_list = buffers.acl_list.allocatedSlice(),
            .acl_value = buffers.acl_value.allocatedSlice(),
            // Setup of the ACL execution function
            .exec = acl_exec_lookup[idx],
        } else null;

        // Copy strategy indexing
        // (acls: bit 1, checksum: bit 0)
        const cp_idx: u2 = (@as(u2, @intFromBool(acls)) << 1) | @as(u2, @intFromBool(checksum));

        // Path resolver strategy indexing (2 bits: flatten, mkdir)
        // 00: None, 01: Mkdir, 10: Flatten, 11: FlattenMkdir
        const res_idx: u2 = (@as(u2, @intFromBool(flatten)) << 1) | @as(u2, @intFromBool(mkdir));

        return .{
            .gpa = gpa,
            .buffers = buffers,
            .mkpath_ctx = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                .acl_ctx = acl_ctx,
                // Setup of the path creation strategy
                .strat = mk_lookup[idx],
            },
            .copy_strategy = .{
                .io = io,
                .gpa = gpa,
                .buf = buf,
                .acl_list = buffers.acl_list.allocatedSlice(),
                .acl_value = buffers.acl_value.allocatedSlice(),
                .dest = dest,
                .max_chunk = max_chunk,
                .falloc_switch = falloc_switch,
                .algo = algo,
                // Setup of the path strategy
                .path_resolver = cp_path_res_lookup[res_idx],
                // Setup of the file copy strategy
                .copy_logic = cp_lookup[cp_idx],
            },
            .link_strategy = .{
                .io = io,
                .gpa = gpa,
                .buf = buf,
                .acl_list = buffers.acl_list.allocatedSlice(),
                .acl_value = buffers.acl_value.allocatedSlice(),
                .dest = dest,
                // Setup of the path strategy
                .path_resolver = ln_path_res_lookup[res_idx],
                // Setup of the link copy strategy
                .link_logic = if (acls)
                    @as(LnLogicFn, LinkStrategy.linkAcl)
                else
                    @as(LnLogicFn, LinkStrategy.link),
            },
            .arg_path_strategy = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                // Setup of the arg path destination strategy
                .strat = arg_path_lookup[res_idx],
            },
            .file_op = if (flatten) copyFlat else copyStandard,
            .link_op = if (flatten) linkFlat else linkStandard,
        };
    }

Functions where the resolver is used:

        /// Private function called to process files in a directory tree.
        /// Call from CopyEngine instance.
        fn recCp(
            ctx: *const CopyStrategy,
            files: *std.ArrayList([]const u8),
            start_files: usize,
            anchor: []const u8,
        ) !void {
            while (files.items.len > start_files) {
                const src_path = files.pop();
                defer ctx.gpa.free(src_path.?);
                std.debug.assert(src_path != null);

                // Zero branching for flatten/mkdir/acls here:
                const dest_path = try ctx.path_resolver(@ptrCast(@constCast(ctx)), src_path.?, anchor);
                defer ctx.gpa.free(dest_path);

                std.log.debug("Copying: {s} to {s}", .{ src_path.?, dest_path });
                ctx.copy_logic(ctx, src_path.?, dest_path) catch |err| {
                    std.log.err("Failed to copy: {s}\n{s}\n", .{
                        src_path.?,
                        @errorName(err),
                    });
                };
            }
        }
        /// Private function called to process links in a directory tree.
        /// Call from CopyEngine instance.
        fn recLn(ctx: *const LinkStrategy, sym_links: *std.ArrayList([]const u8), start_links: usize, anchor: []const u8) !void {
            while (sym_links.items.len > start_links) {
                const entry = sym_links.pop();
                defer ctx.gpa.free(entry.?);
                std.debug.assert(entry != null);

                // Zero branching here
                const sub_path = try ctx.path_resolver(@ptrCast(@constCast(ctx)), entry.?, anchor);
                defer ctx.gpa.free(sub_path);

                std.log.debug("Copying {s} to {s}", .{ entry.?, sub_path });
                // Direct jump to pre-selected logic (Acl or NoAcl)
                ctx.link_logic(ctx, entry.?, sub_path) catch |err| {
                    std.log.err("Failed to copy: {s}\n{s}\n", .{
                        entry.?,
                        @errorName(err),
                    });
                };
            }
        }

Is there a better solution than using a wrapper for pointer conversion?
My initial goal was to process all the runtime flags once during a setup, and everywhere else to have a branchless code in loops.

I don’t understand why you have a lookup table at all?

You have a comp time known fixed set of functions that you choose from based on runtime flags. You could just call them directly, choosing with a switch + enum or packed struct.

I thought bitwise ops are faster and it seemed easier to navigate than if else at the time of writing.

You can get the exact same bit representation and operations with an enum or packed struct, its just clearer what the data means.

I care more about the table of function pointers, that will be slower unless the compiler can (and it probably will) figure out what functions are called. At which point the code generation will be almost identical to just using a switch.
But the important part is your function pointer may trip up the compiler resulting in bad code gen, whereas that won’t happen with a switch.

As always, benchmarking and inspecting the output are better than my statements.

What I care most about is a switch is just a lot simpler, more readable, and more maintainable than your function pointer table shenanigans.

Would you care to show me an example? You mean something like this?

const StrategyKey = packed struct {
    acls: bool, // Bit 0
    mkdir: bool, // Bit 1
    flatten: bool, // Bit 2
};

const Strategy = enum {
    none,
    acl,
    mkdir,
    mkdir_acl,
    flatten,
    flatten_acl,
    mkdir_flat,
    mkdir_flat_acl,

    pub fn getMkPathFn(self: Strategy) CopyEngine.MkPathFn {
        return switch (self) {
            .none => CopyEngine.MkPathContext.mkNone,
            .acl => CopyEngine.MkPathContext.mkAcl,
            .mkdir, .mkdir_flat => CopyEngine.MkPathContext.mkMkdir,
            .mkdir_acl, .mkdir_flat_acl => CopyEngine.MkPathContext.mkMkdirAcl,
            .flatten => CopyEngine.MkPathContext.mkFlatten,
            .flatten_acl => CopyEngine.MkPathContext.mkFlattenAcl,
        };
    }
};

// Inside build():
        const key = StrategyKey{
            .acls = acls,
            .mkdir = mkdir,
            .flatten = flatten,
        };

        const mkpath_strat: Strategy = @enumFromInt(@as(u3, @bitCast(key)));

        return .{
            .gpa = gpa,
            .buffers = buffers,
            .mkpath_ctx = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                .acl_ctx = acl_ctx,
                // Setup of the path creation strategy
                // .strat = mk_lookup[idx],
                .strat = mkpath_strat.getMkPathFn(),