//! 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/` 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); }