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
765 lines
31 KiB
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);
|
|
}
|