DEV Community

katyu
katyu

Posted on

Learn Zig by writing own simple Redis client

Let's explore Zig together by writing a basic Redis client capable of GET/SET operations. We'll start with Redis protocol basics, set up our environment, then dive into Zig code. No prior Zig experience needed - we're figuring this out together

First things first. The Redis protocol

The whole idea is quite simple:

1. Argument count prefixed with *
2. Each argument's length prefixed with $
3. CRLF (\r\n) line terminators
Enter fullscreen mode Exit fullscreen mode

Which looks like this:

*2\r\n
$3\r\n
GET\r\n
$11\r\n
key_is_here\r\n
Enter fullscreen mode Exit fullscreen mode

Send it to Redis, and if there is a value, it will be your value (with its length first):

$10\r\n
your_value\r\n
Enter fullscreen mode Exit fullscreen mode

Or in case of SET:

*3\r\n
$3\r\n
SET\r\n
$11\r\n
key_is_here\r\n
$10\r\n
your_value\r\n
Enter fullscreen mode Exit fullscreen mode

it will be a simple "OK": +OK\r\n

(If you need more details, read it here if you must https://redis.io/docs/latest/develop/reference/protocol-spec/)

Now we need to get a connection to Redis, form commands and parse responses. Speaking of Redis, let's set up a minimal env to work with.

Once in Docker, always in Docker

Someone may argue, but I always prefer to keep it clean and in Docker. You may have your Redis up and running already, if not, let's do it quickly.

Assuming, you have Docker set up already, have the following Dockerfile

FROM redis:7.2
# Copy custom redis.conf if you want to customize configuration
COPY redis.conf /usr/local/etc/redis/redis.conf
# Modify the redis.conf to allow external connections
RUN echo "bind 0.0.0.0" >> /usr/local/etc/redis/redis.conf
# Expose Redis port
EXPOSE 6379
# Start Redis with the custom config
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]
Enter fullscreen mode Exit fullscreen mode

with a redis.conf to adjust if needed:

bind 0.0.0.0
protected-mode no
port 6379
Enter fullscreen mode Exit fullscreen mode

Build and run - it should be fast

docker build -t my-redis .
docker run -d -p 6379:6379 --name redis-dev my-redis
Enter fullscreen mode Exit fullscreen mode

If you come from the Python world, you may want to run a simple script to check, that your Redis is really running and accessible:

import redis

r = redis.StrictRedis(host='localhost', port=6379, db=0)
r.set('foo', 'bar')
value = r.get('foo')
print(value)
Enter fullscreen mode Exit fullscreen mode

Trying to get in...

All of that was just to prepare us for the main dish. Now it's time to set up our minimal Zig project.

You can take a look here https://zig.guide/getting-started/installation or here https://ziglang.org/learn/getting-started/ but eventually you should be able to init the project, build it and get:

All your codebase are belong to us.
Run `zig build test` to run the tests.
Enter fullscreen mode Exit fullscreen mode

Don't forget to run tests, take time to explore. Make sure you are comfortable to build and run the minimal init project, making a few changes, rebuild, rerun, etc.

Serious work is starting here

Let's bring in the networking package. Zig's standard library provides everything we need for TCP communication and it's std.net:

const std = @import("std");
const net = std.net;
const mem = std.mem;
Enter fullscreen mode Exit fullscreen mode

Have a closer look here https://github.com/ziglang/zig/blob/master/lib/std/net.zig

With these imports, establishing a connection is straightforward. Two lines of code are all we need:

const address = try net.Address.parseIp(host, port);
const stream = try net.tcpConnectToAddress(address);
Enter fullscreen mode Exit fullscreen mode

What's this stream thing we're getting back? It's Zig's abstraction over network communication, providing methods for reading and writing data. Our Redis struct takes ownership of this stream:

pub const Redis = struct {
    stream: net.Stream,
    allocator: std.mem.Allocator,
};
Enter fullscreen mode Exit fullscreen mode

What's Allocator? Zig's allocators are like memory managers. The std.mem.Allocator type provides flexible memory allocation strategies with page, fixed, etc. Read quickly here: https://zig.guide/standard-library/allocators

We'll use the page allocator because it's simplest/default one (ask your OS for entire pages of memory). Seems it's not the most optimal way for prod-like code. Better read more about it, but for now:

const allocator = std.heap.page_allocator;
Enter fullscreen mode Exit fullscreen mode

You might also noticed the try keyword appearing in our code. This is Zig's error handling mechanism in action. When a function can fail, it returns an error union, and try unwraps it elegantly. If something goes wrong, the error propagates up. But sometimes we want to handle errors explicitly, and that's where catch comes in:

const response_type = reader.readByte() catch |err| {
    return mapErrorCases(err);
};
Enter fullscreen mode Exit fullscreen mode

By the way, you've probably noticed type annotations everywhere. That's because Zig is statically typed (in case you missed it), meaning all types must be known at compile time. Look at this function signature:

pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !Redis
Enter fullscreen mode Exit fullscreen mode

Here, []const u8 represents a string (slice of bytes), u16 is an unsigned 16-bit integer, and the !Redis return type indicates this function returns either a Redis instance or an error.

Prepare requests and parse response

Now let's focus on the main objective - GET/SET commands for Redis.

First, preparing the commands, we may have a simple abstraction to help us:

pub const Command = struct {
    args: std.ArrayList([]const u8),

    pub fn init(allocator: mem.Allocator) Command {
        return .{ .args = std.ArrayList([]const u8).init(allocator) };
    }

    pub fn add(self: *Command, arg: []const u8) !void {
        try self.args.append(arg);
    }

    pub fn encode(self: Command, allocator: mem.Allocator) ![]u8 {
        return encodeCommand(allocator, self.args.items);
    }
};
Enter fullscreen mode Exit fullscreen mode

where arg: []const u8 will be actually our string GET or SET and their args.

Once we have our command, we need to transform it into the Redis protocol format. We encode them like this (you get more context from the code below, when we use it):

pub fn encodeCommand(allocator: mem.Allocator, args: []const []const u8) ![]u8 {
    var list = std.ArrayList(u8).init(allocator);
    defer list.deinit();

    try std.fmt.format(list.writer(), "*{d}\r\n", .{args.len});

    for (args) |arg| {
        try std.fmt.format(list.writer(), "${d}\r\n", .{arg.len});
        try list.appendSlice(arg);
        try list.appendSlice("\r\n");
    }

    return list.toOwnedSlice();
}
Enter fullscreen mode Exit fullscreen mode

When Redis sends back data, we need to read it line by line. Here's our custom line reader that handles the CRLF sequence properly:

pub fn readLine(allocator: mem.Allocator, reader: net.Stream.Reader) ![]const u8 {
    var list = std.ArrayList(u8).init(allocator);
    defer list.deinit();

    while (true) {
        const byte = reader.readByte() catch |err| {
            return mapErrorCases(err);
        };

        if (byte == '\r') {
            const next_byte = try reader.readByte();
            if (next_byte == '\n') {
                break;
            }
            try list.append(byte);
            try list.append(next_byte);
        } else {
            try list.append(byte);
        }
    }

    return list.toOwnedSlice();
}
Enter fullscreen mode Exit fullscreen mode

defer ensures resources are released when we exit the current scope.

Then comes to interpreting Redis responses.

Redis responses start with:

'+' Simple strings
'$' Bulk strings
'-' Errors
Enter fullscreen mode Exit fullscreen mode

Our decoder handles each case accordingly, let's see the code and then explain it in details:

pub fn decodeResponse(allocator: mem.Allocator, reader: net.Stream.Reader) ![]const u8 {
    const response_type = reader.readByte() catch |err| {
        return mapErrorCases(err);
    };

    switch (response_type) {
        '+' => {
            return try readLine(allocator, reader);
        },
        '$' => {
            const line = try readLine(allocator, reader);
            defer allocator.free(line);

            const line_length = std.fmt.parseInt(i64, line, 10) catch {
                return RedisError.InvalidResponse;
            };

            if (line_length == -1) {
                return RedisError.NullResponse;
            }

            const buffer = try allocator.alloc(u8, @intCast(line_length));

            const bytes_read = try reader.readAll(buffer);
            if (bytes_read != line_length) {
                return RedisError.ReadError;
            }

            const cr = try reader.readByte();
            const lf = try reader.readByte();
            if (cr != '\r' or lf != '\n') {
                return RedisError.InvalidResponse;
            }

            return buffer;
        },
        '-' => {
            const err_msg = try readLine(allocator, reader);
            defer allocator.free(err_msg);
            return RedisError.InvalidResponse;
        },
        else => {
            return RedisError.UnknownResponseType;
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

If the response_type is '+', it indicates a simple string response.
The function reads the rest of the line using readLine and returns it.

If the response_type is '$', it indicates a bulk string response.
The function reads the length of the bulk string from the next line.

If the length is -1, there is null response, otherwise, it allocates a buffer of the specified length and reads the bulk string into the buffer.

It then reads the trailing CRLF characters to ensure the response is correctly formatted. It returns the buffer containing the bulk string

And finally, if the response_type is '-', it indicates an error response.

One of Zig's distinctive features is its approach to error handling. The language requires us to handle all possible error cases explicitly. That's why we have this comprehensive error mapping:

pub fn mapErrorCases(err: anyerror) RedisError {
    return switch (err) {
        error.EndOfStream => RedisError.EmptyResponse,
        error.NotOpenForReading => RedisError.ConnectionError,
        error.SystemResources => RedisError.ConnectionError,
        error.WouldBlock => RedisError.ReadError,
        error.ConnectionResetByPeer => RedisError.ConnectionError,
        error.BrokenPipe => RedisError.ConnectionError,
        error.ConnectionTimedOut => RedisError.ConnectionError,
        error.InputOutput => RedisError.ReadError,
        error.Unexpected => RedisError.ReadError,
        error.AccessDenied => RedisError.ReadError,
        error.OperationAborted => RedisError.ReadError,
        error.IsDir => RedisError.ReadError,
        error.SocketNotConnected => RedisError.ConnectionError,
        else => RedisError.InvalidResponse,
    };
}
Enter fullscreen mode Exit fullscreen mode

While this level of error handling might seem excessive coming from other languages, seems it's a fundamental part of Zig's philosophy. Or maybe I got something wrong and best practices slightly different.

The compiler ensures we've thought about every possible failure mode, making our code more reliable and easier to maintain in the long run. Perks of being statically typed.

Put together the client

Now we can implement our Redis client by combining all the components we've created. In Zig, we define our client as a struct with associated methods:

pub const Redis = struct {
    stream: net.Stream,
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !Redis {
        const address = try net.Address.parseIp(host, port);
        const stream = try net.tcpConnectToAddress(address);

        return Redis{
            .stream = stream,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *Redis) void {
        self.stream.close();
    }

    ...
};
Enter fullscreen mode Exit fullscreen mode

The struct introduces several Zig patterns. First, notice how the init function serves as our constructor, returning a new Redis instance. The self parameter in deinit is similar to this in other languages, representing the instance itself. The asterisk (*) before Redis in the deinit signature indicates we're receiving a pointer to the struct.

Methods that interact with Redis follow a similar pattern:

    pub fn get(self: *Redis, key: []const u8) ![]const u8 {
        var cmd = Command.init(self.allocator);
        try cmd.add("GET");
        try cmd.add(key);

        try self.writeCommand(&cmd);
        return self.readResponse();
    }

    pub fn set(self: *Redis, key: []const u8, value: []const u8) ![]const u8 {
        var cmd = Command.init(self.allocator);
        try cmd.add("SET");
        try cmd.add(key);
        try cmd.add(value);

        try self.writeCommand(&cmd);
        return self.readResponse();
    }
Enter fullscreen mode Exit fullscreen mode

These methods demonstrate Zig's approach to memory and error handling. Each operation that might fail returns an error union (denoted by the ! in the return type). The writeCommand and readResponse helper methods handle the low-level communication:

    fn writeCommand(self: *Redis, cmd: *Command) !void {
        const encoded = try cmd.encode(self.allocator);
        defer self.allocator.free(encoded);
        try self.stream.writer().writeAll(encoded);
    }

    fn readResponse(self: *Redis) ![]const u8 {
        return decodeResponse(self.allocator, self.stream.reader());
    }
Enter fullscreen mode Exit fullscreen mode

The Final Usage

With our Redis client implementation complete, we can create a command-line interface to interact with it. The main function serves as the entry point for our application, handling argument parsing and command execution:

pub fn main() !void {
    const host = "127.0.0.1";
    const port = 6379;

    const allocator = std.heap.page_allocator;

    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();

    _ = args.next();

    const command = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient GET <key>\nUsage: rclient SET <key> <value>\n", .{});

    var redis = Redis.init(allocator, host, port) catch |err| {
        switch (err) {
            error.SystemResources => return std.io.getStdOut().writer().print("System resources error for: {s}:{d}\n", .{ host, port }),
            // and many more... Full implementation handles all connection errors - see final code for complete list
        }
    };
    defer redis.deinit();

    ...
}
Enter fullscreen mode Exit fullscreen mode

The orelse operator provides a clean way to handle the case where no command is provided. The defer keyword we saw before, it's for cleanup.

The command execution logic follows:

    if (std.mem.eql(u8, command, "GET")) {
        const key = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient GET <key>\n", .{});

        const response = try redis.get(key);
        try std.io.getStdOut().writer().print("{s}: {s}\n", .{ key, response });
    } else if (std.mem.eql(u8, command, "SET")) {
        const key = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient SET <key> <value>\n", .{});
        const value = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient SET <key> <value>\n", .{});

        const response = try redis.set(key, value);
        try std.io.getStdOut().writer().print("{s}\n", .{response});
    } else {
        try std.io.getStdOut().writer().print("Usage: rclient GET <key>\nUsage: rclient SET <key> <value>\n", .{});
    }
Enter fullscreen mode Exit fullscreen mode

Now we can test our client. After building the project with zig build, we can execute Redis commands:

./rclient SET mykey "myvalue"
OK

./rclient GET mykey
mykey: myvalue
Enter fullscreen mode Exit fullscreen mode

The error handling we implemented earlier ensures that we get meaningful messages when things go wrong:

# When Redis is not running
./rclient GET mykey
Connection refused for: 127.0.0.1:6379

# When using incorrect syntax
./rclient GET
Usage: rclient GET <key>
Enter fullscreen mode Exit fullscreen mode

To Sum Up

We've explored a bit about Redis protocol, set up and env with Docker, created a minimal Zig project and write one-file code for minimal Redis client. Not too bad for a start.

We haven't touched any testing (unit), it would take much more space. But don't ignore it. As you could see from the very init project template, testing is crucial part of Zig's culture/approach/philosophy. Better write test right away. Static types + tests = reliable app.

A quick one would look like:

test "basic SET/GET" {
    var redis = try Redis.init(std.testing.allocator, "127.0.0.1", 6379);
    defer redis.deinit();

    const res = try redis.set("test", "value");
    try std.testing.expectEqualStrings("OK", res);
}
Enter fullscreen mode Exit fullscreen mode

I will continue exploring Zig and maybe even write a few other articles on the way. May even make it interesting enough. And if I missed something or done it in a wrong ineffective way - let me know.

Put all together

Better in git, but for now as is:

const std = @import("std");
const net = std.net;
const mem = std.mem;

// Let's define some errors, Redis specifics to map possible responses after
pub const RedisError = error{
    InvalidResponse,
    EmptyResponse,
    NullResponse,
    UnknownResponseType,
    ConnectionError,
    ReadError,
} || mem.Allocator.Error;

// Looks like we have to map all the errors from errors? There are ways to make it simpler,
// though keeping it explicit for now
pub fn mapErrorCases(err: anyerror) RedisError {
    return switch (err) {
        error.EndOfStream => RedisError.EmptyResponse,
        error.NotOpenForReading => RedisError.ConnectionError,
        error.SystemResources => RedisError.ConnectionError,
        error.WouldBlock => RedisError.ReadError,
        error.ConnectionResetByPeer => RedisError.ConnectionError,
        error.BrokenPipe => RedisError.ConnectionError,
        error.ConnectionTimedOut => RedisError.ConnectionError,
        error.InputOutput => RedisError.ReadError,
        error.Unexpected => RedisError.ReadError,
        error.AccessDenied => RedisError.ReadError,
        error.OperationAborted => RedisError.ReadError,
        error.IsDir => RedisError.ReadError,
        error.SocketNotConnected => RedisError.ConnectionError,
        else => RedisError.InvalidResponse,
    };
}

pub fn readLine(allocator: mem.Allocator, reader: net.Stream.Reader) ![]const u8 {
    // I guess we can use some fixed buffer here,
    // but let's keep it simple and still use dynamic one
    var list = std.ArrayList(u8).init(allocator);
    defer list.deinit();

    while (true) {
        const byte = reader.readByte() catch |err| {
            return mapErrorCases(err);
        };

        if (byte == '\r') {
            const next_byte = try reader.readByte();
            if (next_byte == '\n') {
                break;
            }
            try list.append(byte);
            try list.append(next_byte);
        } else {
            try list.append(byte);
        }
    }

    return list.toOwnedSlice();
}

pub fn decodeResponse(allocator: mem.Allocator, reader: net.Stream.Reader) ![]const u8 {
    const response_type = reader.readByte() catch |err| {
        return mapErrorCases(err);
    };

    switch (response_type) {
        '+' => {
            return try readLine(allocator, reader);
        },
        '$' => {
            const line = try readLine(allocator, reader);
            defer allocator.free(line);

            const line_length = std.fmt.parseInt(i64, line, 10) catch {
                return RedisError.InvalidResponse;
            };

            if (line_length == -1) {
                return RedisError.NullResponse;
            }

            // TODO: Production code should validate response sizes and use streaming reads
            const buffer = try allocator.alloc(u8, @intCast(line_length));

            const bytes_read = try reader.readAll(buffer);
            if (bytes_read != line_length) {
                return RedisError.ReadError;
            }

            const cr = try reader.readByte();
            const lf = try reader.readByte();
            if (cr != '\r' or lf != '\n') {
                return RedisError.InvalidResponse;
            }

            return buffer;
        },
        '-' => {
            const err_msg = try readLine(allocator, reader);
            defer allocator.free(err_msg);
            return RedisError.InvalidResponse;
        },
        else => {
            return RedisError.UnknownResponseType;
        },
    }
}

pub fn encodeCommand(allocator: mem.Allocator, args: []const []const u8) ![]u8 {
    var list = std.ArrayList(u8).init(allocator);
    defer list.deinit();

    try std.fmt.format(list.writer(), "*{d}\r\n", .{args.len});

    for (args) |arg| {
        try std.fmt.format(list.writer(), "${d}\r\n", .{arg.len});
        try list.appendSlice(arg);
        try list.appendSlice("\r\n");
    }

    return list.toOwnedSlice();
}

pub const Command = struct {
    args: std.ArrayList([]const u8),

    pub fn init(allocator: mem.Allocator) Command {
        return .{ .args = std.ArrayList([]const u8).init(allocator) };
    }

    pub fn add(self: *Command, arg: []const u8) !void {
        try self.args.append(arg);
    }

    pub fn encode(self: Command, allocator: mem.Allocator) ![]u8 {
        return encodeCommand(allocator, self.args.items);
    }
};

pub const Redis = struct {
    stream: net.Stream,
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !Redis {
        const address = try net.Address.parseIp(host, port);
        const stream = try net.tcpConnectToAddress(address);

        return Redis{
            .stream = stream,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *Redis) void {
        // Is there anything else to do here?
        self.stream.close();
    }

    fn writeCommand(self: *Redis, cmd: *Command) !void {
        const encoded = try cmd.encode(self.allocator);
        defer self.allocator.free(encoded);
        try self.stream.writer().writeAll(encoded);
    }

    fn readResponse(self: *Redis) ![]const u8 {
        return decodeResponse(self.allocator, self.stream.reader());
    }

    pub fn get(self: *Redis, key: []const u8) ![]const u8 {
        var cmd = Command.init(self.allocator);
        try cmd.add("GET");
        try cmd.add(key);

        try self.writeCommand(&cmd);
        return self.readResponse();
    }

    pub fn set(self: *Redis, key: []const u8, value: []const u8) ![]const u8 {
        var cmd = Command.init(self.allocator);
        try cmd.add("SET");
        try cmd.add(key);
        try cmd.add(value);

        try self.writeCommand(&cmd);
        return self.readResponse();
    }
};

// Here is our console demo app. Looks a bit ugly, feels like there are easier way to handle input, but it's a good start.
pub fn main() !void {
    // Keep it simple, connect only to local one and to default port
    const host = "127.0.0.1";
    const port = 6379;

    const allocator = std.heap.page_allocator;

    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();

    _ = args.next();

    // Love this "orelse", otherwise there is so much boilerplate to handle input
    const command = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient GET <key>\nUsage: rclient SET <key> <value>\n", .{});

    // So we can get a nice "Connection refused for: 127.0.0.1:6379". You can check by shutting down your Redis instance.
    var redis = Redis.init(allocator, host, port) catch |err| {
        switch (err) {
            error.SystemResources => return std.io.getStdOut().writer().print("System resources error for: {s}:{d}\n", .{ host, port }),
            error.WouldBlock => return std.io.getStdOut().writer().print("Operation would block for: {s}:{d}\n", .{ host, port }),
            error.ConnectionResetByPeer => return std.io.getStdOut().writer().print("Connection reset by peer for: {s}:{d}\n", .{ host, port }),
            error.Unexpected => return std.io.getStdOut().writer().print("Unexpected error for: {s}:{d}\n", .{ host, port }),
            error.InvalidIPAddressFormat => return std.io.getStdOut().writer().print("Invalid IP address format for: {s}:{d}\n", .{ host, port }),
            error.PermissionDenied => return std.io.getStdOut().writer().print("Permission denied for: {s}:{d}\n", .{ host, port }),
            error.AddressFamilyNotSupported => return std.io.getStdOut().writer().print("Address family not supported for: {s}:{d}\n", .{ host, port }),
            error.ProtocolFamilyNotAvailable => return std.io.getStdOut().writer().print("Protocol family not available for: {s}:{d}\n", .{ host, port }),
            error.ProcessFdQuotaExceeded => return std.io.getStdOut().writer().print("Process file descriptor quota exceeded for: {s}:{d}\n", .{ host, port }),
            error.SystemFdQuotaExceeded => return std.io.getStdOut().writer().print("System file descriptor quota exceeded for: {s}:{d}\n", .{ host, port }),
            error.ProtocolNotSupported => return std.io.getStdOut().writer().print("Protocol not supported for: {s}:{d}\n", .{ host, port }),
            error.SocketTypeNotSupported => return std.io.getStdOut().writer().print("Socket type not supported for: {s}:{d}\n", .{ host, port }),
            error.AddressInUse => return std.io.getStdOut().writer().print("Address in use for: {s}:{d}\n", .{ host, port }),
            error.AddressNotAvailable => return std.io.getStdOut().writer().print("Address not available for: {s}:{d}\n", .{ host, port }),
            error.ConnectionRefused => return std.io.getStdOut().writer().print("Connection refused for: {s}:{d}\n", .{ host, port }),
            error.NetworkUnreachable => return std.io.getStdOut().writer().print("Network unreachable for: {s}:{d}\n", .{ host, port }),
            error.ConnectionTimedOut => return std.io.getStdOut().writer().print("Connection timed out for: {s}:{d}\n", .{ host, port }),
            error.FileNotFound => return std.io.getStdOut().writer().print("File not found for: {s}:{d}\n", .{ host, port }),
            error.ConnectionPending => return std.io.getStdOut().writer().print("Connection pending for: {s}:{d}\n", .{ host, port }),
        }
    };
    defer redis.deinit();

    if (std.mem.eql(u8, command, "GET")) {
        const key = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient GET <key>\n", .{});

        const response = try redis.get(key);
        try std.io.getStdOut().writer().print("{s}: {s}\n", .{ key, response });
    } else if (std.mem.eql(u8, command, "SET")) {
        const key = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient SET <key> <value>\n", .{});
        const value = args.next() orelse return std.io.getStdOut().writer().print("Usage: rclient SET <key> <value>\n", .{});

        const response = try redis.set(key, value);
        try std.io.getStdOut().writer().print("{s}\n", .{response});
    } else {
        try std.io.getStdOut().writer().print("Usage: rclient GET <key>\nUsage: rclient SET <key> <value>\n", .{});
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay