mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-28 07:45:20 +00:00
cli: rework +ssh-cache internals and user interface (#12814)
This change primarily focused on a revised +ssh-cache user interface,
but it also reworks a bunch of the internals.
The primary CLI improvement is support for positional arguments and a
consistent list output format that includes both the ISO-formatted
timestamp and relative age.
ghostty +ssh-cache # List all cached destinations
ghostty +ssh-cache user@example.com # Show that destination
ghostty +ssh-cache example.com # Show all users on that host
ghostty +ssh-cache --add=user@example.com # Manually add a destination
ghostty +ssh-cache --remove=user@example.com # Remove a destination
ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
ghostty +ssh-cache --clear # Clear entire cache
Notable, we now support a --prune operation that replaces the previous
--expire-days flag that was never actually hooked up to anything (!!).
--prune also supports a wider range of Duration-based values.
We're also much more consistent with error codes: 0=success, 1=failure,
2=usage.
While working on those changes, I also reworked the cache internals,
particularly the code around timestamp handling and errors. For example,
I dropped the explicit error sets because they were growing unwieldy,
and in practice we only matched on a subset of those errors.
Lastly, overall test coverage should be much improved, especially around
the time- and allocation-related operations.
---
*AI Disclosure:* I made a lot of iterative, AI-assisted (Claude Opus
4.7) correctness passes over this work. It was particularly helpful in
tracing through the various failure modes, and it wrote those unit tests
in the process.
This commit is contained in:
@@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024;
|
||||
/// Path to a file where the cache is stored.
|
||||
path: []const u8,
|
||||
|
||||
pub const DefaultPathError = Allocator.Error || error{
|
||||
/// The general error that is returned for any filesystem error
|
||||
/// that may have resulted in the XDG lookup failing.
|
||||
XdgLookupFailed,
|
||||
};
|
||||
|
||||
pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
|
||||
|
||||
/// Returns the default path for the cache for a given program.
|
||||
///
|
||||
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
|
||||
@@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
|
||||
pub fn defaultPath(
|
||||
alloc: Allocator,
|
||||
program: []const u8,
|
||||
) DefaultPathError![]const u8 {
|
||||
) ![]const u8 {
|
||||
const state_dir: []const u8 = xdg.state(
|
||||
alloc,
|
||||
.{ .subdir = program },
|
||||
@@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void {
|
||||
};
|
||||
}
|
||||
|
||||
pub const AddResult = enum { added, updated };
|
||||
|
||||
pub const AddError = std.fs.Dir.MakeError ||
|
||||
std.fs.Dir.StatFileError ||
|
||||
std.fs.File.OpenError ||
|
||||
std.fs.File.ChmodError ||
|
||||
std.io.Reader.LimitedAllocError ||
|
||||
FixupPermissionsError ||
|
||||
ReadEntriesError ||
|
||||
WriteCacheFileError ||
|
||||
Error;
|
||||
|
||||
/// Add or update a hostname entry in the cache.
|
||||
/// Returns AddResult.added for new entries or AddResult.updated for existing ones.
|
||||
/// Add or update an entry in the cache, recording `timestamp` (Unix seconds).
|
||||
/// The cache file is created if it doesn't exist with secure permissions (0600).
|
||||
pub fn add(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) AddError!AddResult {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
key: []const u8,
|
||||
timestamp: i64,
|
||||
) !void {
|
||||
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||
|
||||
// Create cache directory if needed
|
||||
if (std.fs.path.dirname(self.path)) |dir| {
|
||||
@@ -107,58 +87,49 @@ pub fn add(
|
||||
// Lock
|
||||
// Causes a compile failure in the Zig std library on Windows, see:
|
||||
// https://github.com/ziglang/zig/issues/18430
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
|
||||
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Add or update entry
|
||||
const gop = try entries.getOrPut(hostname);
|
||||
const result: AddResult = if (!gop.found_existing) add: {
|
||||
const hostname_copy = try alloc.dupe(u8, hostname);
|
||||
errdefer alloc.free(hostname_copy);
|
||||
// Update the timestamp of an existing entry, or insert a new one. For a
|
||||
// new entry, dupe both strings up front so a failed allocation never
|
||||
// leaves a half-built slot (borrowed key, undefined value) for the
|
||||
// `deinitEntries` defer to walk.
|
||||
if (entries.getPtr(key)) |existing| {
|
||||
existing.timestamp = timestamp;
|
||||
} else {
|
||||
const key_copy = try alloc.dupe(u8, key);
|
||||
errdefer alloc.free(key_copy);
|
||||
const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty");
|
||||
errdefer alloc.free(terminfo_copy);
|
||||
|
||||
gop.key_ptr.* = hostname_copy;
|
||||
gop.value_ptr.* = .{
|
||||
.hostname = gop.key_ptr.*,
|
||||
.timestamp = std.time.timestamp(),
|
||||
try entries.put(key_copy, .{
|
||||
.hostname = key_copy,
|
||||
.timestamp = timestamp,
|
||||
.terminfo_version = terminfo_copy,
|
||||
};
|
||||
break :add .added;
|
||||
} else update: {
|
||||
// Update timestamp for existing entry
|
||||
gop.value_ptr.timestamp = std.time.timestamp();
|
||||
break :update .updated;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
try self.writeCacheFile(entries, null);
|
||||
return result;
|
||||
try self.writeCacheFile(entries);
|
||||
}
|
||||
|
||||
pub const RemoveError = std.fs.File.OpenError ||
|
||||
FixupPermissionsError ||
|
||||
ReadEntriesError ||
|
||||
WriteCacheFileError ||
|
||||
Error;
|
||||
|
||||
/// Remove a hostname entry from the cache.
|
||||
/// No error is returned if the hostname doesn't exist or the cache file is missing.
|
||||
/// Remove an entry from the cache. Returns true if an entry was removed,
|
||||
/// false if the key wasn't present (or the cache file is missing).
|
||||
pub fn remove(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) RemoveError!void {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
key: []const u8,
|
||||
) !bool {
|
||||
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||
|
||||
// Open our file
|
||||
const file = std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{ .mode = .read_write },
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return,
|
||||
error.FileNotFound => return false,
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
@@ -167,7 +138,7 @@ pub fn remove(
|
||||
// Lock
|
||||
// Causes a compile failure in the Zig std library on Windows, see:
|
||||
// https://github.com/ziglang/zig/issues/18430
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
|
||||
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||
|
||||
// Read existing entries
|
||||
@@ -175,27 +146,73 @@ pub fn remove(
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Remove the entry if it exists and ensure we free the memory
|
||||
if (entries.fetchRemove(hostname)) |kv| {
|
||||
const removed = if (entries.fetchRemove(key)) |kv| removed: {
|
||||
assert(kv.key.ptr == kv.value.hostname.ptr);
|
||||
alloc.free(kv.value.hostname);
|
||||
alloc.free(kv.value.terminfo_version);
|
||||
break :removed true;
|
||||
} else false;
|
||||
|
||||
try self.writeCacheFile(entries);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// Remove all entries older than `max_age_s` seconds and return how many
|
||||
/// were pruned. Returns zero (and nothing written) if the cache file is
|
||||
/// missing.
|
||||
pub fn prune(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
max_age_s: u64,
|
||||
) !usize {
|
||||
const file = std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{ .mode = .read_write },
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return 0,
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
try fixupPermissions(file);
|
||||
|
||||
// Lock
|
||||
// Causes a compile failure in the Zig std library on Windows, see:
|
||||
// https://github.com/ziglang/zig/issues/18430
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
|
||||
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||
|
||||
// Read existing entries
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Drop expired entries from the map, then persist what remains.
|
||||
const now = std.time.timestamp();
|
||||
var expired: std.ArrayList([]const u8) = .empty;
|
||||
defer expired.deinit(alloc);
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
const age_s = now -| kv.value_ptr.timestamp;
|
||||
if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*);
|
||||
}
|
||||
for (expired.items) |key| {
|
||||
const kv = entries.fetchRemove(key).?;
|
||||
assert(kv.key.ptr == kv.value.hostname.ptr);
|
||||
alloc.free(kv.value.hostname);
|
||||
alloc.free(kv.value.terminfo_version);
|
||||
}
|
||||
|
||||
try self.writeCacheFile(entries, null);
|
||||
try self.writeCacheFile(entries);
|
||||
return expired.items.len;
|
||||
}
|
||||
|
||||
pub const ContainsError = std.fs.File.OpenError ||
|
||||
ReadEntriesError ||
|
||||
error{HostnameIsInvalid};
|
||||
|
||||
/// Check if a hostname exists in the cache.
|
||||
/// Check if a key exists in the cache.
|
||||
/// Returns false if the cache file doesn't exist.
|
||||
pub fn contains(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) ContainsError!bool {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
key: []const u8,
|
||||
) !bool {
|
||||
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||
|
||||
// Open our file
|
||||
const file = std.fs.openFileAbsolute(
|
||||
@@ -211,12 +228,10 @@ pub fn contains(
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
return entries.contains(hostname);
|
||||
return entries.contains(key);
|
||||
}
|
||||
|
||||
pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError);
|
||||
|
||||
fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
|
||||
fn fixupPermissions(file: std.fs.File) !void {
|
||||
// Windows does not support chmod
|
||||
if (comptime builtin.os.tag == .windows) return;
|
||||
|
||||
@@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
|
||||
}
|
||||
}
|
||||
|
||||
pub const WriteCacheFileError = std.fs.Dir.OpenError ||
|
||||
std.fs.AtomicFile.InitError ||
|
||||
std.fs.AtomicFile.FlushError ||
|
||||
std.fs.AtomicFile.FinishError ||
|
||||
Entry.FormatError ||
|
||||
error{InvalidCachePath};
|
||||
|
||||
fn writeCacheFile(
|
||||
self: DiskCache,
|
||||
entries: std.StringHashMap(Entry),
|
||||
expire_days: ?u32,
|
||||
) WriteCacheFileError!void {
|
||||
) !void {
|
||||
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
|
||||
const cache_basename = std.fs.path.basename(self.path);
|
||||
|
||||
@@ -255,8 +262,6 @@ fn writeCacheFile(
|
||||
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
// Only write non-expired entries
|
||||
if (kv.value_ptr.isExpired(expire_days)) continue;
|
||||
try kv.value_ptr.format(&atomic_file.file_writer.interface);
|
||||
}
|
||||
|
||||
@@ -299,12 +304,10 @@ pub fn deinitEntries(
|
||||
entries.deinit();
|
||||
}
|
||||
|
||||
pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError;
|
||||
|
||||
fn readEntries(
|
||||
alloc: Allocator,
|
||||
file: std.fs.File,
|
||||
) ReadEntriesError!std.StringHashMap(Entry) {
|
||||
) !std.StringHashMap(Entry) {
|
||||
var reader = file.reader(&.{});
|
||||
const content = try reader.interface.allocRemaining(
|
||||
alloc,
|
||||
@@ -365,7 +368,7 @@ fn readEntries(
|
||||
}
|
||||
|
||||
// Supports both standalone hostnames and user@hostname format
|
||||
fn isValidCacheKey(key: []const u8) bool {
|
||||
pub fn isValidCacheKey(key: []const u8) bool {
|
||||
if (key.len == 0) return false;
|
||||
|
||||
// Check for user@hostname format
|
||||
@@ -463,33 +466,23 @@ test "disk cache operations" {
|
||||
const path = try tmp.dir.realpathAlloc(alloc, "cache");
|
||||
defer alloc.free(path);
|
||||
|
||||
// Setup our cache
|
||||
// Setup our cache. Adding the same key twice exercises both the new
|
||||
// and existing-entry paths.
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
try testing.expectEqual(
|
||||
AddResult.added,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
AddResult.updated,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
try testing.expect(
|
||||
try cache.contains(alloc, "example.com"),
|
||||
);
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
try testing.expect(try cache.contains(alloc, "example.com"));
|
||||
|
||||
// List
|
||||
var entries = try cache.list(alloc);
|
||||
deinitEntries(alloc, &entries);
|
||||
|
||||
// Remove
|
||||
try cache.remove(alloc, "example.com");
|
||||
try testing.expect(
|
||||
!(try cache.contains(alloc, "example.com")),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
AddResult.added,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
// Remove reports that it removed the entry, and a second remove of the
|
||||
// same key reports nothing to remove.
|
||||
try testing.expect(try cache.remove(alloc, "example.com"));
|
||||
try testing.expect(!try cache.remove(alloc, "example.com"));
|
||||
try testing.expect(!(try cache.contains(alloc, "example.com")));
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
}
|
||||
|
||||
test "disk cache cleans up temp files" {
|
||||
@@ -505,8 +498,8 @@ test "disk cache cleans up temp files" {
|
||||
defer alloc.free(cache_path);
|
||||
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com"));
|
||||
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org"));
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
try cache.add(alloc, "example.org", std.time.timestamp());
|
||||
|
||||
// Verify only the cache file exists and no temp files left behind
|
||||
var count: usize = 0;
|
||||
@@ -518,6 +511,55 @@ test "disk cache cleans up temp files" {
|
||||
try testing.expectEqual(1, count);
|
||||
}
|
||||
|
||||
test "disk cache prune" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
|
||||
defer alloc.free(tmp_path);
|
||||
const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
|
||||
defer alloc.free(cache_path);
|
||||
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
|
||||
// Back-date one entry an hour old and one 100 days old.
|
||||
const day = std.time.s_per_day;
|
||||
const hour = std.time.s_per_hour;
|
||||
const now = std.time.timestamp();
|
||||
try cache.add(alloc, "recent.com", now - hour);
|
||||
try cache.add(alloc, "old.com", now - 100 * day);
|
||||
|
||||
// Prune entries older than 90 days: only old.com goes.
|
||||
try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day));
|
||||
try testing.expect(try cache.contains(alloc, "recent.com"));
|
||||
try testing.expect(!try cache.contains(alloc, "old.com"));
|
||||
|
||||
// Pruning again removes nothing.
|
||||
try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day));
|
||||
|
||||
// Sub-day granularity: a 30-minute max age prunes the hour-old entry.
|
||||
try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min));
|
||||
try testing.expect(!try cache.contains(alloc, "recent.com"));
|
||||
}
|
||||
|
||||
test "disk cache prune missing file" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
|
||||
defer alloc.free(tmp_path);
|
||||
const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
|
||||
defer alloc.free(cache_path);
|
||||
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30));
|
||||
}
|
||||
|
||||
test "disk cache reads duplicate keys" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
@@ -600,6 +642,39 @@ test "disk cache reads survive allocation failure" {
|
||||
}
|
||||
}
|
||||
|
||||
test "disk cache add survives allocation failure" {
|
||||
const testing = std.testing;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, ".");
|
||||
defer testing.allocator.free(tmp_path);
|
||||
const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" });
|
||||
defer testing.allocator.free(path);
|
||||
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
|
||||
// Fail the Nth allocation for every N until add completes. A failed add
|
||||
// must not leak or leave a half-built map entry. The FailingAllocator
|
||||
// is backed by testing.allocator to catch either. Each iteration starts
|
||||
// from a clean cache file.
|
||||
var fail_index: usize = 0;
|
||||
while (true) : (fail_index += 1) {
|
||||
std.fs.cwd().deleteFile(path) catch {};
|
||||
var failing = std.testing.FailingAllocator.init(
|
||||
testing.allocator,
|
||||
.{ .fail_index = fail_index },
|
||||
);
|
||||
const alloc = failing.allocator();
|
||||
|
||||
if (cache.add(alloc, "user@example.com", 100)) |_| {
|
||||
if (!failing.has_induced_failure) break;
|
||||
} else |err| {
|
||||
try testing.expectEqual(error.OutOfMemory, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test isValidHost {
|
||||
const testing = std.testing;
|
||||
|
||||
|
||||
@@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn isExpired(self: Entry, expire_days_: ?u32) bool {
|
||||
const expire_days = expire_days_ orelse return false;
|
||||
const now = std.time.timestamp();
|
||||
const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day);
|
||||
return age_days > expire_days;
|
||||
}
|
||||
|
||||
test "cache entry expiration" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
const fresh_entry: Entry = .{
|
||||
.hostname = "test.com",
|
||||
.timestamp = now - std.time.s_per_day, // 1 day old
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!fresh_entry.isExpired(90));
|
||||
|
||||
const old_entry: Entry = .{
|
||||
.hostname = "old.com",
|
||||
.timestamp = now - (std.time.s_per_day * 100), // 100 days old
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(old_entry.isExpired(90));
|
||||
|
||||
// Test never-expire case
|
||||
try testing.expect(!old_entry.isExpired(null));
|
||||
}
|
||||
|
||||
test "cache entry expiration exact boundary" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
// Exactly at expiration boundary
|
||||
const boundary_entry: Entry = .{
|
||||
.hostname = "example.com",
|
||||
.timestamp = now - (std.time.s_per_day * 30),
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!boundary_entry.isExpired(30));
|
||||
try testing.expect(boundary_entry.isExpired(29));
|
||||
}
|
||||
|
||||
test "cache entry expiration large timestamp" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
const boundary_entry: Entry = .{
|
||||
.hostname = "example.com",
|
||||
.timestamp = now + (std.time.s_per_day * 30),
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!boundary_entry.isExpired(30));
|
||||
}
|
||||
|
||||
test "cache entry parsing valid formats" {
|
||||
const testing = std.testing;
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ fn runInner(
|
||||
|
||||
// Attempt to cache (if needed) on a successful ssh execution.
|
||||
if (exit_code == 0) if (session.to_cache) |entry| {
|
||||
if (entry.cache.add(alloc, entry.dest)) |_| {
|
||||
if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| {
|
||||
verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest});
|
||||
} else |err| {
|
||||
log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = std.fs;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const args = @import("args.zig");
|
||||
const Action = @import("ghostty.zig").Action;
|
||||
const Duration = @import("../config.zig").Config.Duration;
|
||||
pub const Entry = @import("ssh-cache/Entry.zig");
|
||||
pub const DiskCache = @import("ssh-cache/DiskCache.zig");
|
||||
|
||||
@@ -10,8 +11,7 @@ pub const Options = struct {
|
||||
clear: bool = false,
|
||||
add: ?[]const u8 = null,
|
||||
remove: ?[]const u8 = null,
|
||||
host: ?[]const u8 = null,
|
||||
@"expire-days": ?u32 = null,
|
||||
prune: ?Duration = null,
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
_ = self;
|
||||
@@ -25,27 +25,36 @@ pub const Options = struct {
|
||||
|
||||
/// Manage the SSH terminfo cache for automatic remote host setup.
|
||||
///
|
||||
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
|
||||
/// Ghostty automatically installs its terminfo on remote hosts. This command
|
||||
/// manages the cache of successful installations to avoid redundant uploads.
|
||||
/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records
|
||||
/// each success in this cache so it doesn't re-upload on later connections.
|
||||
/// (`+ssh` runs automatically from the shell integration when
|
||||
/// `shell-integration-features` includes `ssh-terminfo`.) This command
|
||||
/// inspects and maintains that cache.
|
||||
///
|
||||
/// The cache stores hostnames (or user@hostname combinations) along with timestamps.
|
||||
/// Entries older than the expiration period are automatically removed during cache
|
||||
/// operations. By default, entries never expire.
|
||||
/// The cache stores destinations (a hostname or user@hostname) along with
|
||||
/// timestamps.
|
||||
///
|
||||
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
|
||||
/// If multiple are specified, one of the actions will be executed but
|
||||
/// it isn't guaranteed which one. This is entirely unsafe so you should split
|
||||
/// multiple actions into separate commands.
|
||||
/// A positional destination queries the cache: `user@hostname` shows that
|
||||
/// exact entry, while a bare `hostname` shows every cached entry for that
|
||||
/// host regardless of user. With no destination and no action, the entire
|
||||
/// cache is listed. A query that matches nothing exits 1.
|
||||
///
|
||||
/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be
|
||||
/// specified, and not together with a positional destination; combining them
|
||||
/// is an error.
|
||||
///
|
||||
/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`,
|
||||
/// `y`) and removes every entry older than it, e.g. `--prune=30d`,
|
||||
/// `--prune=6h`, `--prune=1y`.
|
||||
///
|
||||
/// Examples:
|
||||
/// ghostty +ssh-cache # List all cached hosts
|
||||
/// ghostty +ssh-cache --host=example.com # Check if host is cached
|
||||
/// ghostty +ssh-cache --add=example.com # Manually add host to cache
|
||||
/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
|
||||
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
|
||||
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
||||
/// ghostty +ssh-cache # List all cached destinations
|
||||
/// ghostty +ssh-cache user@example.com # Show that destination
|
||||
/// ghostty +ssh-cache example.com # Show all users on that host
|
||||
/// ghostty +ssh-cache --add=user@example.com # Manually add a destination
|
||||
/// ghostty +ssh-cache --remove=user@example.com # Remove a destination
|
||||
/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
|
||||
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||
pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
|
||||
defer arena.deinit();
|
||||
@@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
|
||||
{
|
||||
var iter = try args.argsIterator(alloc_gpa);
|
||||
defer iter.deinit();
|
||||
try args.parse(Options, alloc_gpa, &opts, &iter);
|
||||
}
|
||||
|
||||
var stdout_buffer: [1024]u8 = undefined;
|
||||
var stdout_file: std.fs.File = .stdout();
|
||||
var stdout_writer = stdout_file.writer(&stdout_buffer);
|
||||
@@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
var stderr_writer = stderr_file.writer(&stderr_buffer);
|
||||
const stderr = &stderr_writer.interface;
|
||||
|
||||
const result = runInner(alloc, opts, stdout, stderr);
|
||||
// The cache is queried by a positional destination (`user@host` or a
|
||||
// bare `host`). `args.parse` rejects non-`--` tokens, so we lift the
|
||||
// positional out here and parse only the remaining flags. `--host=X`
|
||||
// is accepted as a deprecated spelling of the positional (it was the
|
||||
// original shipped flag name).
|
||||
var query: ?[]const u8 = null;
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
{
|
||||
var iter = try args.argsIterator(alloc_gpa);
|
||||
defer iter.deinit();
|
||||
while (iter.next()) |arg| {
|
||||
const is_host_flag = std.mem.startsWith(u8, arg, "--host=");
|
||||
if (is_host_flag) {
|
||||
try stderr.print(
|
||||
"Warning: --host is deprecated; pass the destination " ++
|
||||
"directly, e.g. `ghostty +ssh-cache {s}`.\n",
|
||||
.{arg["--host=".len..]},
|
||||
);
|
||||
}
|
||||
const dest: ?[]const u8 = if (is_host_flag)
|
||||
arg["--host=".len..]
|
||||
else if (!std.mem.startsWith(u8, arg, "-"))
|
||||
arg
|
||||
else
|
||||
null;
|
||||
|
||||
if (dest) |d| {
|
||||
if (query != null) {
|
||||
try stderr.print(
|
||||
"Error: only one destination may be specified.\n",
|
||||
.{},
|
||||
);
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
}
|
||||
query = try alloc.dupe(u8, d);
|
||||
} else {
|
||||
try flags.append(alloc, try alloc.dupe(u8, arg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var iter = args.sliceIterator(flags.items);
|
||||
args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) {
|
||||
error.InvalidField => {
|
||||
try stderr.print("Error: unknown flag.\n", .{});
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
},
|
||||
error.InvalidValue, error.ValueRequired => {
|
||||
try stderr.print("Error: invalid flag value.\n", .{});
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
const result = runInner(alloc, opts, query, stdout, stderr);
|
||||
|
||||
// Flushing *shouldn't* fail but...
|
||||
stdout.flush() catch {};
|
||||
@@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
pub fn runInner(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
query: ?[]const u8,
|
||||
stdout: *std.Io.Writer,
|
||||
stderr: *std.Io.Writer,
|
||||
) !u8 {
|
||||
// At most one action may be specified, and a query (positional
|
||||
// destination) is itself an action.
|
||||
const action_count =
|
||||
@as(usize, @intFromBool(opts.clear)) +
|
||||
@intFromBool(opts.add != null) +
|
||||
@intFromBool(opts.remove != null) +
|
||||
@intFromBool(opts.prune != null) +
|
||||
@intFromBool(query != null);
|
||||
if (action_count > 1) {
|
||||
try stderr.print(
|
||||
"Error: only one of a destination, --clear, --add, --remove, " ++
|
||||
"or --prune may be specified.\n",
|
||||
.{},
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Setup our disk cache to the standard location
|
||||
const cache_path = try DiskCache.defaultPath(alloc, "ghostty");
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
|
||||
if (opts.clear) {
|
||||
try cache.clear();
|
||||
try stdout.print("Cache cleared.\n", .{});
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.add) |host| {
|
||||
const result = cache.add(alloc, host) catch |err| switch (err) {
|
||||
DiskCache.Error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
DiskCache.Error.CacheIsLocked => {
|
||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
||||
return 1;
|
||||
if (opts.add) |dest| {
|
||||
cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) {
|
||||
error.InvalidCacheKey => {
|
||||
try stderr.print(
|
||||
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||
.{dest},
|
||||
);
|
||||
return 2;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to add '{s}' to cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
.{ dest, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
switch (result) {
|
||||
.added => try stdout.print("Added '{s}' to cache.\n", .{host}),
|
||||
.updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}),
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.remove) |host| {
|
||||
cache.remove(alloc, host) catch |err| switch (err) {
|
||||
DiskCache.Error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
DiskCache.Error.CacheIsLocked => {
|
||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
||||
return 1;
|
||||
if (opts.remove) |dest| {
|
||||
const removed = cache.remove(alloc, dest) catch |err| switch (err) {
|
||||
error.InvalidCacheKey => {
|
||||
try stderr.print(
|
||||
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||
.{dest},
|
||||
);
|
||||
return 2;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to remove '{s}' from cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
.{ dest, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
try stdout.print("Removed '{s}' from cache.\n", .{host});
|
||||
// Silence on success; a no-op removal is an error (exit 1).
|
||||
if (!removed) {
|
||||
try stderr.print("Error: '{s}' is not in the cache.\n", .{dest});
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.host) |host| {
|
||||
const cached = cache.contains(alloc, host) catch |err| switch (err) {
|
||||
error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to check host '{s}' in cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
if (cached) {
|
||||
try stdout.print(
|
||||
"'{s}' has Ghostty terminfo installed.\n",
|
||||
.{host},
|
||||
if (opts.prune) |max_age| {
|
||||
const max_age_s = max_age.duration / std.time.ns_per_s;
|
||||
if (max_age_s == 0) {
|
||||
try stderr.print(
|
||||
"Error: --prune requires a duration of at least one second.\n",
|
||||
.{},
|
||||
);
|
||||
return 0;
|
||||
} else {
|
||||
try stdout.print(
|
||||
"'{s}' does not have Ghostty terminfo installed.\n",
|
||||
.{host},
|
||||
);
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
const pruned = cache.prune(alloc, max_age_s) catch |err| {
|
||||
try stderr.print("Error: Unable to prune cache. Error: {}\n", .{err});
|
||||
return 1;
|
||||
};
|
||||
try stdout.print("Pruned cache entries: {d}\n", .{pruned});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Default action: list all hosts
|
||||
var entries = try cache.list(alloc);
|
||||
defer DiskCache.deinitEntries(alloc, &entries);
|
||||
|
||||
// A positional query filters the listing: an exact `user@host` match,
|
||||
// or every entry on a bare `host`.
|
||||
if (query) |q| {
|
||||
if (!DiskCache.isValidCacheKey(q)) {
|
||||
try stderr.print(
|
||||
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||
.{q},
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
var matches: std.StringHashMap(Entry) = .init(alloc);
|
||||
defer matches.deinit();
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
const key = kv.key_ptr.*;
|
||||
if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*);
|
||||
}
|
||||
|
||||
if (matches.count() == 0) return 1;
|
||||
try listEntries(alloc, &matches, stdout);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// List all destinations by default.
|
||||
try listEntries(alloc, &entries, stdout);
|
||||
return 0;
|
||||
}
|
||||
@@ -187,10 +272,7 @@ fn listEntries(
|
||||
entries: *const std.StringHashMap(Entry),
|
||||
writer: *std.Io.Writer,
|
||||
) !void {
|
||||
if (entries.count() == 0) {
|
||||
try writer.print("No hosts in cache.\n", .{});
|
||||
return;
|
||||
}
|
||||
if (entries.count() == 0) return;
|
||||
|
||||
// Sort entries by hostname for consistent output
|
||||
var items: std.ArrayList(Entry) = .empty;
|
||||
@@ -207,22 +289,200 @@ fn listEntries(
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
try writer.print("Cached hosts ({d}):\n", .{items.items.len});
|
||||
const now = std.time.timestamp();
|
||||
|
||||
// Align the timestamp column by padding destinations to the widest.
|
||||
var widest: usize = 0;
|
||||
for (items.items) |entry| {
|
||||
const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
|
||||
if (age_days == 0) {
|
||||
try writer.print(" {s} (today)\n", .{entry.hostname});
|
||||
} else if (age_days == 1) {
|
||||
try writer.print(" {s} (yesterday)\n", .{entry.hostname});
|
||||
} else {
|
||||
try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
|
||||
}
|
||||
widest = @max(widest, entry.hostname.len);
|
||||
}
|
||||
|
||||
const now = std.time.timestamp();
|
||||
for (items.items) |entry| {
|
||||
try writer.print("{s}", .{entry.hostname});
|
||||
try writer.splatByteAll(' ', widest - entry.hostname.len + 2);
|
||||
|
||||
var iso_buf: [20]u8 = undefined;
|
||||
var age_buf: [32]u8 = undefined;
|
||||
try writer.print("{s} ({s})\n", .{
|
||||
formatTimestamp(&iso_buf, entry.timestamp),
|
||||
relativeAge(&age_buf, now, entry.timestamp),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cache `key` matches a positional `query`. A `user@host` query
|
||||
/// (containing `@`) matches one exact key; a bare `host` query matches every
|
||||
/// key on that host regardless of user, comparing against the key's host
|
||||
/// component (everything after its first `@`, or the whole key if userless).
|
||||
fn matchesQuery(key: []const u8, query: []const u8) bool {
|
||||
if (std.mem.indexOfScalar(u8, query, '@') != null) {
|
||||
return std.mem.eql(u8, key, query);
|
||||
}
|
||||
|
||||
const at = std.mem.indexOfScalar(u8, key, '@');
|
||||
const host = if (at) |i| key[i + 1 ..] else key;
|
||||
return std.mem.eql(u8, host, query);
|
||||
}
|
||||
|
||||
test matchesQuery {
|
||||
const testing = std.testing;
|
||||
|
||||
// Exact user@host: only the identical key.
|
||||
try testing.expect(matchesQuery("user@example.com", "user@example.com"));
|
||||
try testing.expect(!matchesQuery("root@example.com", "user@example.com"));
|
||||
try testing.expect(!matchesQuery("example.com", "user@example.com"));
|
||||
|
||||
// Bare host: every key on that host, plus a keyless entry for it.
|
||||
try testing.expect(matchesQuery("user@example.com", "example.com"));
|
||||
try testing.expect(matchesQuery("root@example.com", "example.com"));
|
||||
try testing.expect(matchesQuery("example.com", "example.com"));
|
||||
try testing.expect(!matchesQuery("user@other.com", "example.com"));
|
||||
}
|
||||
|
||||
/// Format a Unix timestamp as an ISO-8601 UTC string
|
||||
/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes.
|
||||
/// Out-of-range input is clamped so this can't crash on a garbage cache line.
|
||||
fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 {
|
||||
// Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch`
|
||||
// accumulates the year in a `u16` (panics beyond that), and the buffer
|
||||
// only fits a 4-digit year.
|
||||
const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799));
|
||||
|
||||
const epoch = std.time.epoch;
|
||||
const epoch_secs: epoch.EpochSeconds = .{ .secs = secs };
|
||||
const day = epoch_secs.getEpochDay();
|
||||
const year_day = day.calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
const ds = epoch_secs.getDaySeconds();
|
||||
return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
|
||||
year_day.year,
|
||||
month_day.month.numeric(),
|
||||
month_day.day_index + 1,
|
||||
ds.getHoursIntoDay(),
|
||||
ds.getMinutesIntoHour(),
|
||||
ds.getSecondsIntoMinute(),
|
||||
}) catch unreachable;
|
||||
}
|
||||
|
||||
test formatTimestamp {
|
||||
const testing = std.testing;
|
||||
var buf: [20]u8 = undefined;
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"2026-05-05T22:49:33Z",
|
||||
formatTimestamp(&buf, 1778021373),
|
||||
);
|
||||
|
||||
// Epoch.
|
||||
try testing.expectEqualStrings(
|
||||
"1970-01-01T00:00:00Z",
|
||||
formatTimestamp(&buf, 0),
|
||||
);
|
||||
|
||||
// Out-of-range inputs clamp instead of overflowing the [20]u8 /
|
||||
// panicking inside std: negatives floor at the epoch, huge values cap
|
||||
// at the last second of year 9999.
|
||||
try testing.expectEqualStrings(
|
||||
"1970-01-01T00:00:00Z",
|
||||
formatTimestamp(&buf, -5),
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
"9999-12-31T23:59:59Z",
|
||||
formatTimestamp(&buf, std.math.maxInt(i64)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Format the age of `timestamp` (relative to `now`, both Unix seconds)
|
||||
/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s
|
||||
/// unit vocabulary but keeps only the single largest unit for scannability.
|
||||
/// A non-positive age (timestamp at or after `now`) is "now".
|
||||
fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 {
|
||||
// Saturating so a garbage timestamp can't overflow; clamp at 0 so a
|
||||
// future timestamp becomes a zero age rather than going negative.
|
||||
const age: u64 = @intCast(@max(0, now -| timestamp));
|
||||
if (age == 0) return "now";
|
||||
|
||||
// Round down to the largest unit that fits, so Duration.format emits
|
||||
// only that unit (e.g. 19d -> 2w, 90m -> 1h).
|
||||
const units = [_]u64{
|
||||
365 * std.time.s_per_day, // y
|
||||
std.time.s_per_week, // w
|
||||
std.time.s_per_day, // d
|
||||
std.time.s_per_hour, // h
|
||||
std.time.s_per_min, // m
|
||||
1, // s
|
||||
};
|
||||
const unit = for (units) |u| {
|
||||
if (age >= u) break u;
|
||||
} else 1;
|
||||
|
||||
// Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g.
|
||||
// hugely negative, timestamp otherwise yields an age near i64-max).
|
||||
const max_age = std.math.maxInt(u64) / std.time.ns_per_s;
|
||||
const rounded = @min(age, max_age) / unit * unit;
|
||||
const d: Duration = .{ .duration = rounded * std.time.ns_per_s };
|
||||
return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable;
|
||||
}
|
||||
|
||||
test relativeAge {
|
||||
const testing = std.testing;
|
||||
var buf: [32]u8 = undefined;
|
||||
const now: i64 = 2_000_000_000; // fixed reference
|
||||
const min = std.time.s_per_min;
|
||||
const hour = std.time.s_per_hour;
|
||||
const day = std.time.s_per_day;
|
||||
|
||||
// Out-of-range timestamps don't crash: a huge future one saturates to
|
||||
// a non-positive age ("now"); a negative one is a large but real age.
|
||||
try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64)));
|
||||
try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100));
|
||||
|
||||
// A huge age (garbage timestamp) saturates the ns conversion instead of
|
||||
// overflowing; it must not crash and must fit the buffer.
|
||||
try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago"));
|
||||
|
||||
// Future timestamp (clock skew) and same-instant read "now".
|
||||
try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100));
|
||||
try testing.expectEqualStrings("now", relativeAge(&buf, now, now));
|
||||
|
||||
// Only the single largest unit is kept (smaller units rounded away).
|
||||
try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30));
|
||||
try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min));
|
||||
try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m
|
||||
try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour));
|
||||
try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h
|
||||
try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day));
|
||||
try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w
|
||||
}
|
||||
|
||||
test {
|
||||
_ = DiskCache;
|
||||
_ = Entry;
|
||||
}
|
||||
|
||||
test "runInner rejects multiple actions" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var stdout: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer stdout.deinit();
|
||||
var stderr: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer stderr.deinit();
|
||||
|
||||
// The check runs before any cache access, so it never touches disk.
|
||||
const code = try runInner(alloc, .{
|
||||
.add = "example.com",
|
||||
.remove = "other.com",
|
||||
}, null, &stdout.writer, &stderr.writer);
|
||||
|
||||
try testing.expectEqual(@as(u8, 2), code);
|
||||
try testing.expectEqualStrings("", stdout.written());
|
||||
try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
|
||||
|
||||
// A positional query is itself an action: query + a flag conflicts.
|
||||
stderr.clearRetainingCapacity();
|
||||
const code2 = try runInner(alloc, .{
|
||||
.clear = true,
|
||||
}, "example.com", &stdout.writer, &stderr.writer);
|
||||
try testing.expectEqual(@as(u8, 2), code2);
|
||||
try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user