zfin/src/Config.zig
Emil Lerch 7fb674f467 enrich enrich command, remove AlphaVantage
This (huge) commit pulls out AlphaVantage in favor of utilizing
Wikidata and SEC EDGAR data sources (both free). It uses some
built-in heuristics to fill in gaps, and it is not 100% (never
will be), but should get close enough to allow hand-editing of
metadata.srf afterwords without too much labor
2026-05-30 10:40:34 -07:00

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);
}