zfin/src/Config.zig

765 lines
31 KiB
Zig

//! Runtime configuration: API keys, cache directory, resolved data paths.
//!
//! File-as-struct: the file *is* the `Config` type. Callers reach it via
//! `const Config = @import("Config.zig");` and use it anywhere a struct
//! type is expected (`Config.fromEnv(alloc)`, `var c: Config = ...`,
//! `Config.ResolvedPath`, `Config.default_portfolio_filename`, etc.).
//!
//! Configuration sources, in priority order:
//! 1. Process environment variables
//! 2. `.env` file in the current working directory
//! 3. `.env` file in `$ZFIN_HOME`
//!
//! Cache directory defaults follow XDG: `$ZFIN_CACHE_DIR` > `$XDG_CACHE_HOME/zfin`
//! > `$HOME/.cache/zfin`.
const std = @import("std");
const EnvMap = std.StringHashMap([]const u8);
/// Default pattern for the portfolio file when no explicit -p/--portfolio
/// is provided. Looked up via `resolveUserFiles` (cwd -> ZFIN_HOME). The
/// `*` is intentional - multiple files matching `portfolio*.srf` are all
/// loaded and union-merged. A user with just one `portfolio.srf` is
/// unaffected (the glob still matches that single file). Every command
/// that loads a portfolio should fall back to this so behavior stays
/// consistent.
pub const default_portfolio_filename = "portfolio*.srf";
/// Default filename for the watchlist file. Same resolution rules as
/// `default_portfolio_filename`.
pub const default_watchlist_filename = "watchlist.srf";
// ── Fields ───────────────────────────────────────────────────
twelvedata_key: ?[]const u8 = null,
polygon_key: ?[]const u8 = null,
fmp_key: ?[]const u8 = null,
tiingo_key: ?[]const u8 = null,
openfigi_key: ?[]const u8 = null,
/// User contact email used as the User-Agent / From header for
/// open-data providers that require politeness identification
/// (Wikidata SPARQL, EDGAR). No API-key authentication semantics -
/// just identifies the operator. Sourced from `ZFIN_USER_EMAIL`.
user_email: ?[]const u8 = null,
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
server_url: ?[]const u8 = null,
cache_dir: []const u8,
cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join
zfin_home: ?[]const u8 = null,
allocator: ?std.mem.Allocator = null,
/// Raw .env file contents (keys/values in env_map point into this).
env_buf: ?[]const u8 = null,
/// Parsed KEY=VALUE pairs from .env file (fallback when process env is missing a key).
env_map: ?EnvMap = null,
/// Process-level environment variable map (from Juicy Main). First-priority
/// lookup source before falling back to the .env file.
environ_map: ?*const std.process.Environ.Map = null,
// ── Construction / teardown ──────────────────────────────────
pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std.process.Environ.Map) @This() {
var self = @This(){
// SAFETY: assigned unconditionally below (line ~95) from
// either ZFIN_CACHE_DIR or the XDG fallback before this
// function returns, so callers never observe `undefined`.
.cache_dir = undefined,
.allocator = allocator,
.environ_map = environ_map,
};
// Try loading .env file from the current working directory
self.env_buf = std.Io.Dir.cwd().readFileAlloc(io, ".env", allocator, .limited(4096)) catch null;
if (self.env_buf) |buf| {
self.env_map = parseEnvFile(allocator, buf);
}
self.zfin_home = self.resolve("ZFIN_HOME");
// Try loading .env file from ZFIN_HOME as well (cwd .env takes priority)
if (self.env_buf == null) {
if (self.zfin_home) |home| {
const env_path = std.fs.path.join(allocator, &.{ home, ".env" }) catch null;
if (env_path) |p| {
defer allocator.free(p);
self.env_buf = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(4096)) catch null;
if (self.env_buf) |buf| {
self.env_map = parseEnvFile(allocator, buf);
}
}
}
}
self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY");
self.polygon_key = self.resolve("POLYGON_API_KEY");
self.fmp_key = self.resolve("FMP_API_KEY");
self.tiingo_key = self.resolve("TIINGO_API_KEY");
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
self.user_email = self.resolve("ZFIN_USER_EMAIL");
self.server_url = self.resolve("ZFIN_SERVER");
const env_cache = self.resolve("ZFIN_CACHE_DIR");
self.cache_dir = env_cache orelse blk: {
self.cache_dir_owned = true;
// XDG Base Directory: $XDG_CACHE_HOME/zfin, falling back to $HOME/.cache/zfin
const xdg = self.resolve("XDG_CACHE_HOME");
const base = xdg orelse fallback: {
const home = self.resolve("HOME") orelse "/tmp";
break :fallback std.fs.path.join(allocator, &.{ home, ".cache" }) catch @panic("OOM");
};
const base_allocated = xdg == null;
defer if (base_allocated) allocator.free(base);
break :blk std.fs.path.join(allocator, &.{ base, "zfin" }) catch @panic("OOM");
};
return self;
}
pub fn deinit(self: *@This()) void {
if (self.allocator) |a| {
if (self.env_map) |*m| {
var map = m.*;
map.deinit();
}
if (self.env_buf) |buf| a.free(buf);
if (self.cache_dir_owned) {
a.free(self.cache_dir);
}
}
}
// ── File resolution ──────────────────────────────────────────
pub const ResolvedPath = struct {
path: []const u8,
owned: bool,
pub fn deinit(self: ResolvedPath, allocator: std.mem.Allocator) void {
if (self.owned) allocator.free(self.path);
}
};
/// Resolve a user file. ZFIN_HOME is exclusive when set: only
/// `$ZFIN_HOME/<rel_path>` is checked. When ZFIN_HOME is unset,
/// fall back to cwd-relative resolution.
///
/// The exclusivity is intentional: setting ZFIN_HOME is the
/// user's "this is where my data lives" declaration, and silently
/// falling back to cwd undermines that. Running from a project
/// directory that incidentally ships a `portfolio.srf` would
/// otherwise shadow the user's canonical data - exactly the
/// surprising behavior we want to rule out. If a user wants
/// cwd-based resolution for a one-off run, they can `unset
/// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`).
///
/// Returns the path to use; caller must call `deinit()` on the result.
pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator, rel_path: []const u8) ?ResolvedPath {
if (self.zfin_home) |home| {
const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null;
if (std.Io.Dir.cwd().access(io, full, .{})) |_| {
return .{ .path = full, .owned = true };
} else |_| {
allocator.free(full);
}
// ZFIN_HOME is set but doesn't have the file. Don't look
// in cwd - that would be the surprising-shadow case.
return null;
}
if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| {
return .{ .path = rel_path, .owned = false };
} else |_| {}
return null;
}
/// Returns true if `pattern` contains glob metacharacters (`*`, `?`).
/// Callers use this to decide between literal-name resolution
/// (`resolveUserFile`) and glob expansion (`resolveUserFiles`).
///
/// Brackets (`[...]`) are NOT treated as glob characters because
/// `globMatch` doesn't support them. If we claimed `[` was a glob
/// metachar, a pattern like `foo[a-z].srf` would route to the glob
/// path, fail to match anything (the matcher treats `[` as a literal),
/// and silently drop the pattern. Treating `[` as literal keeps
/// behavior predictable: a literal-with-`[` falls through to the
/// "literal file path" code path and gets a useful "Cannot read"
/// error if the file doesn't exist.
pub fn isGlobPattern(pattern: []const u8) bool {
for (pattern) |c| {
if (c == '*' or c == '?') return true;
}
return false;
}
/// Match a filename against a glob pattern. Supports `*` (any run of
/// chars, including empty) and `?` (exactly one char). Brackets and
/// `**` are not supported - `[` is matched as a literal character.
/// Match is anchored at both ends.
pub fn globMatch(pattern: []const u8, name: []const u8) bool {
return globMatchInner(pattern, name);
}
fn globMatchInner(pattern: []const u8, name: []const u8) bool {
var pi: usize = 0;
var ni: usize = 0;
// Backtrack state for `*`: `star_pi` is the index of the `*` in
// the pattern; `match_ni` is the position in `name` we backtracked
// to plus the chars matched so far.
var star_pi: ?usize = null;
var match_ni: usize = 0;
while (ni < name.len) {
if (pi < pattern.len) {
const pc = pattern[pi];
if (pc == '?') {
pi += 1;
ni += 1;
continue;
}
if (pc == '*') {
star_pi = pi;
match_ni = ni;
pi += 1;
continue;
}
if (pc == name[ni]) {
pi += 1;
ni += 1;
continue;
}
}
// Mismatch (or pattern exhausted) - backtrack to last `*` if any.
if (star_pi) |sp| {
pi = sp + 1;
match_ni += 1;
ni = match_ni;
continue;
}
return false;
}
// Eat trailing `*`s.
while (pi < pattern.len and pattern[pi] == '*') : (pi += 1) {}
return pi == pattern.len;
}
/// A list of resolved paths returned by `resolveUserFiles`. Holds the
/// allocator and a contiguous slice of `ResolvedPath`. Caller must
/// `deinit()` once finished.
pub const ResolvedPaths = struct {
paths: []const ResolvedPath,
allocator: std.mem.Allocator,
pub fn deinit(self: ResolvedPaths) void {
for (self.paths) |rp| rp.deinit(self.allocator);
self.allocator.free(self.paths);
}
/// Convenience: return just the path strings as a slice. Slices
/// point into the `paths[i].path` fields, so they live as long as
/// the `ResolvedPaths` does.
pub fn paths_slice(self: ResolvedPaths, allocator: std.mem.Allocator) ![]const []const u8 {
const out = try allocator.alloc([]const u8, self.paths.len);
for (self.paths, 0..) |rp, i| out[i] = rp.path;
return out;
}
};
/// Resolve a portfolio-like path that may contain glob metacharacters.
///
/// Resolution rules:
/// - When ZFIN_HOME is set, search EXCLUSIVELY there. cwd is not
/// consulted. Setting ZFIN_HOME is the user's declaration of
/// "this is where my data lives"; silently falling back to
/// cwd would let an incidental `portfolio.srf` in a project
/// directory shadow the user's real data.
/// - When ZFIN_HOME is unset, search cwd.
/// - Literal patterns (no glob metachar) -> 0 or 1 path via
/// `resolveUserFile`. Glob patterns -> expansion against the
/// selected directory.
/// - Returns an empty slice (not null) when the pattern has
/// no matches in the selected directory.
///
/// Caller must call `deinit()` on the returned `ResolvedPaths`.
pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths {
if (!isGlobPattern(pattern)) {
// Literal-path fast path: reuse the singular resolver,
// which itself enforces the ZFIN_HOME-exclusive rule.
if (self.resolveUserFile(io, allocator, pattern)) |r| {
const arr = try allocator.alloc(ResolvedPath, 1);
arr[0] = r;
return .{ .paths = arr, .allocator = allocator };
}
return .{ .paths = &.{}, .allocator = allocator };
}
// Glob expansion. ZFIN_HOME exclusive when set.
if (self.zfin_home) |home| {
if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| {
return .{ .paths = matches, .allocator = allocator };
}
// ZFIN_HOME directory doesn't exist or can't be read.
// Surface as no-match rather than fall back to cwd.
return .{ .paths = &.{}, .allocator = allocator };
}
// ZFIN_HOME unset - cwd is the only option.
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
return .{ .paths = matches, .allocator = allocator };
}
return .{ .paths = &.{}, .allocator = allocator };
}
/// Where the directory we're scanning lives. Determines whether
/// resulting paths are bare filenames (cwd) or fully joined
/// (ZFIN_HOME), which in turn drives `ResolvedPath.owned`.
const GlobDirKind = enum { cwd_relative, home_relative };
/// Expand `pattern` against the directory at `dir_path`. Returns a
/// sorted slice of `ResolvedPath`, or null if the directory can't be
/// opened. Empty slice (non-null) means "directory exists, zero matches".
fn expandGlob(
io: std.Io,
allocator: std.mem.Allocator,
dir_path: []const u8,
pattern: []const u8,
kind: GlobDirKind,
) !?[]ResolvedPath {
var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return null;
defer dir.close(io);
var matches: std.ArrayList(ResolvedPath) = .empty;
errdefer {
for (matches.items) |rp| rp.deinit(allocator);
matches.deinit(allocator);
}
var it = dir.iterate();
while (try it.next(io)) |entry| {
if (entry.kind != .file) continue;
if (!globMatch(pattern, entry.name)) continue;
const rp: ResolvedPath = switch (kind) {
.cwd_relative => .{
.path = try allocator.dupe(u8, entry.name),
.owned = true,
},
.home_relative => .{
.path = try std.fs.path.join(allocator, &.{ dir_path, entry.name }),
.owned = true,
},
};
try matches.append(allocator, rp);
}
const out = try matches.toOwnedSlice(allocator);
// Sort the resolved paths by their path string for determinism.
const PathLess = struct {
fn lt(_: void, a: ResolvedPath, b: ResolvedPath) bool {
return std.mem.lessThan(u8, a.path, b.path);
}
};
std.mem.sort(ResolvedPath, out, {}, PathLess.lt);
return out;
}
// ── Queries ──────────────────────────────────────────────────
pub fn hasAnyKey(self: @This()) bool {
return self.twelvedata_key != null or
self.polygon_key != null or
self.fmp_key != null or
self.tiingo_key != null;
}
// ── Internals ────────────────────────────────────────────────
/// Look up a key: process environment first, then .env file fallback.
fn resolve(self: *@This(), key: []const u8) ?[]const u8 {
if (self.environ_map) |em| {
if (em.get(key)) |v| return v;
}
if (self.env_map) |m| return m.get(key);
return null;
}
/// Parse all KEY=VALUE pairs from .env content into a HashMap.
/// Values are slices into the original buffer (no extra allocations per entry).
fn parseEnvFile(allocator: std.mem.Allocator, data: []const u8) ?EnvMap {
var map = EnvMap.init(allocator);
var iter = std.mem.splitScalar(u8, data, '\n');
while (iter.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
if (std.mem.indexOfScalar(u8, trimmed, '=')) |eq| {
const k = std.mem.trim(u8, trimmed[0..eq], &std.ascii.whitespace);
const v = std.mem.trim(u8, trimmed[eq + 1 ..], &std.ascii.whitespace);
map.put(k, v) catch return null;
}
}
return map;
}
// ── Tests ────────────────────────────────────────────────────
const testing = std.testing;
test "default_portfolio_filename / default_watchlist_filename are the expected literals" {
// Guards against accidental rename; these constants are referenced by
// every command that loads a portfolio or watchlist, and changing them
// silently would break user setups. The portfolio default is a glob -
// intentional, so users with multiple portfolio_*.srf files get them
// all loaded by default.
try testing.expectEqualStrings("portfolio*.srf", default_portfolio_filename);
try testing.expectEqualStrings("watchlist.srf", default_watchlist_filename);
}
test "parseEnvFile: basic KEY=VALUE pairs" {
const data = "FOO=bar\nBAZ=qux\n";
var map = parseEnvFile(testing.allocator, data).?;
defer map.deinit();
try testing.expectEqualStrings("bar", map.get("FOO").?);
try testing.expectEqualStrings("qux", map.get("BAZ").?);
try testing.expect(map.get("MISSING") == null);
}
test "parseEnvFile: trims whitespace around key and value" {
const data = " SPACED_KEY = spaced value \n";
var map = parseEnvFile(testing.allocator, data).?;
defer map.deinit();
try testing.expectEqualStrings("spaced value", map.get("SPACED_KEY").?);
}
test "parseEnvFile: ignores comments and blank lines" {
const data =
\\# this is a comment
\\
\\FOO=bar
\\# another comment
\\
\\BAZ=qux
\\
;
var map = parseEnvFile(testing.allocator, data).?;
defer map.deinit();
try testing.expectEqual(@as(usize, 2), map.count());
try testing.expectEqualStrings("bar", map.get("FOO").?);
try testing.expectEqualStrings("qux", map.get("BAZ").?);
}
test "parseEnvFile: lines without '=' are skipped" {
const data =
\\NOT_A_PAIR
\\FOO=bar
\\ALSO NOT A PAIR
\\BAZ=qux
\\
;
var map = parseEnvFile(testing.allocator, data).?;
defer map.deinit();
try testing.expectEqual(@as(usize, 2), map.count());
try testing.expectEqualStrings("bar", map.get("FOO").?);
try testing.expectEqualStrings("qux", map.get("BAZ").?);
}
test "parseEnvFile: value containing '=' keeps everything after the first '='" {
const data = "URL=https://example.com/path?q=1&r=2\n";
var map = parseEnvFile(testing.allocator, data).?;
defer map.deinit();
try testing.expectEqualStrings("https://example.com/path?q=1&r=2", map.get("URL").?);
}
test "parseEnvFile: empty input yields empty map" {
var map = parseEnvFile(testing.allocator, "").?;
defer map.deinit();
try testing.expectEqual(@as(usize, 0), map.count());
}
test "hasAnyKey: false when all keys are null" {
const c: @This() = .{ .cache_dir = "/tmp" };
try testing.expect(!c.hasAnyKey());
}
test "hasAnyKey: true when any single provider key is set" {
// Each key should independently flip the result to true. Iterating
// through each variant catches a future field addition that forgets
// to update hasAnyKey().
const KeyField = enum { tiingo, twelvedata, polygon, fmp };
for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .fmp }) |which| {
var c: @This() = .{ .cache_dir = "/tmp" };
switch (which) {
.tiingo => c.tiingo_key = "abc",
.twelvedata => c.twelvedata_key = "abc",
.polygon => c.polygon_key = "abc",
.fmp => c.fmp_key = "abc",
}
try testing.expect(c.hasAnyKey());
}
}
test "hasAnyKey: openfigi / server_url don't count as provider keys" {
// hasAnyKey is about candle/quote providers; OpenFIGI is CUSIP lookup
// and server_url is the sync server. Neither should flip hasAnyKey.
var c: @This() = .{ .cache_dir = "/tmp" };
c.openfigi_key = "fg";
c.server_url = "https://example.com";
try testing.expect(!c.hasAnyKey());
}
test "ResolvedPath.deinit: frees when owned, no-op when not owned" {
const allocator = testing.allocator;
// Not-owned: a static literal must NOT be freed. The testing allocator
// would panic if we tried to free a non-allocation - success here is
// the test returning normally.
const rp_static: ResolvedPath = .{ .path = "portfolio.srf", .owned = false };
rp_static.deinit(allocator);
// Owned: allocator-allocated path is freed by deinit.
const owned = try allocator.dupe(u8, "/tmp/portfolio.srf");
const rp_owned: ResolvedPath = .{ .path = owned, .owned = true };
rp_owned.deinit(allocator);
// (If this leaked, the test allocator would fail the test.)
}
test "resolve: env_map fallback when environ_map is null (skips process env)" {
// Setting environ_map=null disables the process-env branch, so the
// lookup must come from env_map alone. This exercises the .env-only
// code path without depending on host environment variables.
var map = EnvMap.init(testing.allocator);
defer map.deinit();
try map.put("FOO", "from-env-file");
var c: @This() = .{
.cache_dir = "/tmp",
.env_map = map,
.allocator = null,
};
try testing.expectEqualStrings("from-env-file", c.resolve("FOO").?);
try testing.expect(c.resolve("MISSING") == null);
}
test "resolve: environ_map=null and env_map=null returns null" {
var c: @This() = .{ .cache_dir = "/tmp" };
try testing.expect(c.resolve("ANYTHING") == null);
}
// ── Glob tests ────────────────────────────────────────────────
test "isGlobPattern: detects metacharacters" {
try testing.expect(isGlobPattern("portfolio*.srf"));
try testing.expect(isGlobPattern("portfolio_?.srf"));
try testing.expect(!isGlobPattern("portfolio.srf"));
try testing.expect(!isGlobPattern(""));
try testing.expect(!isGlobPattern("foo/bar.srf"));
// Brackets are NOT treated as a glob char - see doc-comment.
// A literal filename containing `[` falls through to the
// literal-path resolver, not the glob path.
try testing.expect(!isGlobPattern("foo[abc].srf"));
}
test "globMatch: literal match" {
try testing.expect(globMatch("portfolio.srf", "portfolio.srf"));
try testing.expect(!globMatch("portfolio.srf", "portfolio_a.srf"));
try testing.expect(!globMatch("portfolio.srf", "portfolio.SRF"));
}
test "globMatch: simple star" {
try testing.expect(globMatch("portfolio*.srf", "portfolio.srf"));
try testing.expect(globMatch("portfolio*.srf", "portfolio_main.srf"));
try testing.expect(globMatch("portfolio*.srf", "portfolio_other.srf"));
try testing.expect(!globMatch("portfolio*.srf", "watchlist.srf"));
try testing.expect(!globMatch("portfolio*.srf", "portfolio.txt"));
}
test "globMatch: leading and trailing stars" {
try testing.expect(globMatch("*.srf", "portfolio.srf"));
try testing.expect(globMatch("*.srf", "a.srf"));
try testing.expect(!globMatch("*.srf", "a.txt"));
try testing.expect(globMatch("*", "anything"));
try testing.expect(globMatch("*", ""));
try testing.expect(globMatch("portfolio*", "portfolio"));
try testing.expect(globMatch("portfolio*", "portfolio_x"));
}
test "globMatch: question mark matches exactly one char" {
try testing.expect(globMatch("?.srf", "a.srf"));
try testing.expect(!globMatch("?.srf", "ab.srf"));
try testing.expect(!globMatch("?.srf", ".srf"));
try testing.expect(globMatch("portfolio_?.srf", "portfolio_1.srf"));
try testing.expect(!globMatch("portfolio_?.srf", "portfolio_12.srf"));
}
test "globMatch: multiple stars" {
try testing.expect(globMatch("*foo*", "foo"));
try testing.expect(globMatch("*foo*", "afoob"));
try testing.expect(globMatch("*foo*", "abfoocd"));
try testing.expect(!globMatch("*foo*", "fo"));
try testing.expect(globMatch("a*b*c", "abc"));
try testing.expect(globMatch("a*b*c", "axxbyyc"));
try testing.expect(!globMatch("a*b*c", "abd"));
}
test "globMatch: edge cases" {
try testing.expect(globMatch("", ""));
try testing.expect(!globMatch("", "x"));
try testing.expect(!globMatch("x", ""));
try testing.expect(globMatch("**", ""));
try testing.expect(globMatch("**", "anything"));
}
test "resolveUserFiles: literal name resolves to single path (or empty)" {
const allocator = testing.allocator;
const io = std.testing.io;
// No env / no zfin_home - literal name not in cwd -> empty result.
const c: @This() = .{ .cache_dir = "/tmp" };
var result = try c.resolveUserFiles(io, allocator, "definitely-does-not-exist-zfin.srf");
defer result.deinit();
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolveUserFiles: glob pattern, no matches" {
const allocator = testing.allocator;
const io = std.testing.io;
// Use a pattern guaranteed not to match anything in cwd.
const c: @This() = .{ .cache_dir = "/tmp" };
var result = try c.resolveUserFiles(io, allocator, "zfin-nope-*.srf-xyz");
defer result.deinit();
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
const allocator = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Use a pattern unlikely to match anything in the project's
// cwd. With ZFIN_HOME -> cwd priority, ZFIN_HOME would win
// anyway when both have matches, but the test is cleanest
// if cwd contributes nothing (the zfintest_pf prefix won't
// collide with the project's portfolio*.srf files in the
// repo root).
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_b.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_a.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
defer allocator.free(dir_path);
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
var result = try c.resolveUserFiles(io, allocator, "zfintest_pf*.srf");
defer result.deinit();
try testing.expectEqual(@as(usize, 3), result.paths.len);
// Sorted: zfintest_pf.srf, zfintest_pf_a.srf, zfintest_pf_b.srf
try testing.expect(std.mem.endsWith(u8, result.paths[0].path, "zfintest_pf.srf"));
try testing.expect(std.mem.endsWith(u8, result.paths[1].path, "zfintest_pf_a.srf"));
try testing.expect(std.mem.endsWith(u8, result.paths[2].path, "zfintest_pf_b.srf"));
}
test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" {
// Pin the rule: when ZFIN_HOME is set, we ONLY look there.
// cwd is not a fallback. The motivating bug: a project
// directory that incidentally ships a `portfolio.srf` would
// shadow the user's canonical data when running zfin from
// that directory. ZFIN_HOME-exclusive rules that out.
//
// Verified by giving the resolver a ZFIN_HOME that doesn't
// match a pattern, then confirming the result is empty -
// even though the test runner's cwd (the repo root) DOES
// have a portfolio*.srf file.
const allocator = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// ZFIN_HOME has no portfolio*.srf - only an unrelated file.
try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
defer allocator.free(dir_path);
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf");
defer result.deinit();
// Zero matches in ZFIN_HOME -> zero results, full stop.
// cwd is NOT consulted, even though the test runner's cwd
// (the repo root) typically has a `portfolio-semilatest.srf`.
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolveUserFiles: cwd used only when ZFIN_HOME is unset" {
// Counter-test for the exclusivity rule: with ZFIN_HOME
// unset, cwd IS consulted. Exercise this via expandGlob
// directly (testing the cwd code path means mutating the
// process cwd, which is risky in a parallel runner). The
// resolveUserFiles wrapper just routes to one of these
// two modes based on whether `zfin_home` is non-null.
const allocator = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "other.txt", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = dir_path_buf[0..dir_path_len];
// Glob expansion against a known directory (mimicking the
// cwd path of resolveUserFiles).
const matches_opt = try expandGlob(io, allocator, dir_path, "portfolio_*.srf", .home_relative);
try testing.expect(matches_opt != null);
const matches = matches_opt.?;
defer {
for (matches) |rp| rp.deinit(allocator);
allocator.free(matches);
}
try testing.expectEqual(@as(usize, 2), matches.len);
try testing.expect(std.mem.endsWith(u8, matches[0].path, "portfolio_a.srf"));
try testing.expect(std.mem.endsWith(u8, matches[1].path, "portfolio_b.srf"));
}
test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" {
// Same exclusivity rule, but for the no-glob path through
// `resolveUserFile`. ZFIN_HOME without the file -> null,
// even when the file might exist in cwd.
const allocator = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// ZFIN_HOME is empty (no portfolio.srf inside).
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
defer allocator.free(dir_path);
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
// The repo root (test cwd) has `portfolio-semilatest.srf`,
// but we ask for a different name to keep the test
// deterministic regardless of cwd contents.
const result = c.resolveUserFile(io, allocator, "portfolio-semilatest.srf");
// ZFIN_HOME doesn't have it; cwd is not consulted; null.
try testing.expect(result == null);
}
test "expandGlob: missing directory returns null" {
const allocator = testing.allocator;
const io = std.testing.io;
const result = try expandGlob(io, allocator, "/zfin-test-no-such-dir-xyz", "*.srf", .home_relative);
try testing.expect(result == null);
}