summaryrefslogtreecommitdiff
path: root/src/config_file.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-01 08:21:00 -0600
committert <t@tjp.lol>2026-06-01 11:20:56 -0600
commit1beefefc69beee214430eb5bd2528a4f5692d2a8 (patch)
treea459dbde5da17babe7d872999341d2006ebfe02e /src/config_file.zig
parent5c016cfdbcb122e2b4f0496ef946642d68953c4b (diff)
Rename system extension layer to base for clarity
Replace all references to the "system" layer with "base" to better reflect its role as the foundational extension/tool layer in panto's hierarchy. The layer hierarchy now consistently uses: project > user > base. Includes: - Update agent README and build.zig documentation - Refactor tool registry and config module to support layered lookups - Add TestHarness abstraction for cleaner test setup - Improve JSON serialization with wire-encoded tool names - Add glob pattern matching for tool/extension discovery
Diffstat (limited to 'src/config_file.zig')
-rw-r--r--src/config_file.zig977
1 files changed, 977 insertions, 0 deletions
diff --git a/src/config_file.zig b/src/config_file.zig
new file mode 100644
index 0000000..533eb48
--- /dev/null
+++ b/src/config_file.zig
@@ -0,0 +1,977 @@
+//! Layered `config.toml` loader for the panto CLI.
+//!
+//! Four files are read and merged, lowest precedence first:
+//!
+//! 1. base — `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`
+//! 2. user — `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml`
+//! 3. project — `./.panto/config.toml`
+//! 4. local — `./.panto/config.local.toml`
+//!
+//! The `local` layer is intended to be git-ignored: it lets an individual
+//! developer apply more-opinionated overrides on top of the team-shared
+//! `project` layer without committing them.
+//!
+//! Merge semantics: **tables merge recursively; scalars and arrays from a
+//! higher-precedence layer overwrite wholesale.** A project file that sets
+//! `tools.deny = [...]` replaces the array entirely — it does not append to
+//! a user-level `tools.deny`. Tables (notably `providers`) accumulate: a
+//! provider defined only at the base layer survives even when the project
+//! layer adds a different provider.
+//!
+//! After merge, the document is resolved into a `Config`:
+//!
+//! - Every `providers.<name>` becomes a `Provider`. Its API key is taken
+//! from `api_key` if present, else from the environment variable named
+//! by `api_key_env_var`. A provider whose only key source is an absent
+//! env var is **silently dropped** — this is what lets the base layer
+//! ship default OpenAI/Anthropic providers that simply don't appear when
+//! the user hasn't exported the corresponding key.
+//! - `defaults.model` is parsed as `<provider_name>:<model_alias>`.
+//! - `tools` / `extensions` allow- and deny-lists are captured verbatim
+//! (as glob pattern lists). A pattern appearing in both allow and deny
+//! for the same kind is a hard error.
+//!
+//! Model *aliases* (the `<model_alias>` half of a model reference) are
+//! resolved separately against `models.toml` — this module only validates
+//! that the reference names a known provider.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+const toml = @import("toml");
+const panto = @import("panto");
+
+const glob = @import("glob.zig");
+const models_toml = @import("models_toml.zig");
+
+// Document-building helpers live in the toml library's `value` module and
+// are not re-exported at its root. `tableIterator` *is* re-exported, so we
+// use `toml.tableIterator` directly but reach through `value_mod` for the
+// constructors/mutators.
+const tvalue = toml.value_mod;
+
+pub const APIStyle = panto.config.APIStyle;
+
+// ===========================================================================
+// Resolved config model
+// ===========================================================================
+
+/// One fully-resolved provider. The API key has already been read (from the
+/// inline value or the named env var); a provider that never resolved a key
+/// is absent from `Config.providers` entirely.
+pub const Provider = struct {
+ /// The user-chosen provider name (the `providers.<name>` key). This is
+ /// the name used on the left of a `<provider>:<model>` reference.
+ name: []const u8,
+ style: APIStyle,
+ base_url: []const u8,
+ api_key: []const u8,
+
+ pub fn deinit(self: Provider, alloc: Allocator) void {
+ alloc.free(self.name);
+ alloc.free(self.base_url);
+ alloc.free(self.api_key);
+ }
+};
+
+/// A parsed `<provider>:<model>` reference. Both halves borrow from the
+/// owning `Config` (the `default_model` storage); do not free separately.
+pub const ModelRef = struct {
+ provider: []const u8,
+ model: []const u8,
+};
+
+/// Tool/extension availability policy. Empty `allow` means "allow all"
+/// (subject to `deny`). A name is permitted when it matches at least one
+/// allow pattern (or allow is empty) AND matches no deny pattern.
+pub const Policy = struct {
+ allow: [][]const u8,
+ deny: [][]const u8,
+
+ pub fn deinit(self: Policy, alloc: Allocator) void {
+ for (self.allow) |p| alloc.free(p);
+ alloc.free(self.allow);
+ for (self.deny) |p| alloc.free(p);
+ alloc.free(self.deny);
+ }
+
+ /// True if `name` is permitted under this policy.
+ pub fn permits(self: Policy, name: []const u8) bool {
+ for (self.deny) |pat| {
+ if (glob.match(pat, name)) return false;
+ }
+ if (self.allow.len == 0) return true;
+ for (self.allow) |pat| {
+ if (glob.match(pat, name)) return true;
+ }
+ return false;
+ }
+};
+
+/// Build a `panto.config.ProviderConfig` for a chosen `<provider>:<alias>` model
+/// reference, combining the resolved provider (transport/auth) with the
+/// model definition from `models.toml` (wire name + knobs).
+///
+/// `ref` selects the provider and alias. `defs` supplies per-model knobs;
+/// a missing alias falls back to using the alias verbatim as the wire
+/// model name with default knobs. Returns the assembled `Config` plus the
+/// wire model id (borrowed from `defs`/`ref` — valid as long as both
+/// outlive the returned config's use). The `panto.config.ProviderConfig` itself
+/// borrows the provider/model strings; the caller must keep `cfg` (the
+/// `Config`) and `defs` alive for its lifetime.
+pub fn buildProviderConfig(
+ cfg: *const Config,
+ defs: *const models_toml.ModelRegistry,
+ ref: ModelRef,
+) ResolveError!panto.config.ProviderConfig {
+ const prov = cfg.provider(ref.provider) orelse return error.UnknownProvider;
+
+ const def_opt = defs.get(ref.provider, ref.model);
+ const wire_model: []const u8 = if (def_opt) |d| d.model else ref.model;
+ const reasoning: panto.config.ReasoningEffort = if (def_opt) |d| d.reasoning else .default;
+
+ switch (prov.style) {
+ .openai_chat => return .{ .openai_chat = .{
+ .api_key = prov.api_key,
+ .base_url = prov.base_url,
+ .model = wire_model,
+ .reasoning = reasoning,
+ } },
+ .anthropic_messages => return .{ .anthropic_messages = .{
+ .api_key = prov.api_key,
+ .base_url = prov.base_url,
+ .model = wire_model,
+ .api_version = if (def_opt) |d| (d.api_version orelse "2023-06-01") else "2023-06-01",
+ .max_tokens = if (def_opt) |d| (d.max_tokens orelse 4096) else 4096,
+ } },
+ }
+}
+
+pub const Config = struct {
+ allocator: Allocator,
+ providers: []Provider,
+ /// `defaults.model` storage, owned. `default_model_ref` slices into it.
+ default_model: ?[]const u8,
+ default_model_ref: ?ModelRef,
+ tools: Policy,
+ extensions: Policy,
+
+ pub fn deinit(self: *Config) void {
+ for (self.providers) |p| p.deinit(self.allocator);
+ self.allocator.free(self.providers);
+ if (self.default_model) |m| self.allocator.free(m);
+ self.tools.deinit(self.allocator);
+ self.extensions.deinit(self.allocator);
+ }
+
+ /// Look up a resolved provider by name. Returns null if absent (e.g.
+ /// dropped because its env var wasn't set).
+ pub fn provider(self: *const Config, name: []const u8) ?*const Provider {
+ for (self.providers) |*p| {
+ if (std.mem.eql(u8, p.name, name)) return p;
+ }
+ return null;
+ }
+
+ /// Choose the model reference to start with. Precedence:
+ /// 1. an explicit `model_override` (e.g. a future `--model` flag),
+ /// 2. `defaults.model` from config,
+ /// 3. if exactly one provider resolved AND it has exactly one model
+ /// alias in `defs`, use that,
+ /// 4. otherwise error (`NoModelSelected`).
+ ///
+ /// The returned ref borrows from `self`/`defs`/`model_override`.
+ pub fn selectModel(
+ self: *const Config,
+ defs: *const models_toml.ModelRegistry,
+ model_override: ?[]const u8,
+ ) ResolveError!ModelRef {
+ if (model_override) |m| {
+ return parseModelRef(m) catch return error.NoModelSelected;
+ }
+ if (self.default_model_ref) |ref| return ref;
+
+ // Single-provider, single-alias convenience.
+ if (self.providers.len == 1) {
+ const only = self.providers[0].name;
+ var found: ?ModelRef = null;
+ for (defs.entries.items) |d| {
+ if (std.mem.eql(u8, d.provider, only)) {
+ if (found != null) {
+ found = null; // more than one alias; ambiguous.
+ break;
+ }
+ found = .{ .provider = d.provider, .model = d.alias };
+ }
+ }
+ if (found) |ref| return ref;
+ }
+ return error.NoModelSelected;
+ }
+};
+
+pub const Error = error{
+ NoHomeDirectory,
+ InvalidConfigToml,
+ InvalidProvider,
+ InvalidModelRef,
+ UnknownDefaultProvider,
+ PolicyConflict,
+} || Allocator.Error || FileError;
+
+pub const ResolveError = error{
+ NoModelSelected,
+ UnknownProvider,
+};
+
+/// The filesystem errors that can surface while reading a config layer.
+/// `FileNotFound` is handled by the caller (skip the layer); any other
+/// I/O failure is collapsed to `error.ConfigReadFailed` so this module
+/// keeps a small, stable error set.
+pub const FileError = Io.Cancelable || error{ FileNotFound, ConfigReadFailed };
+
+// ===========================================================================
+// Public entry points
+// ===========================================================================
+
+/// Resolve and merge the four config layers from their standard paths,
+/// then build a `Config`. Missing files are skipped silently. `cwd` is the
+/// project root (used for the `./.panto/config.toml` and
+/// `./.panto/config.local.toml` layers).
+pub fn load(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ cwd: []const u8,
+) Error!Config {
+ const paths = try layerPaths(allocator, environ_map, cwd);
+ defer paths.deinit(allocator);
+
+ return loadFromPaths(allocator, io, environ_map, &.{
+ paths.base,
+ paths.user,
+ paths.project,
+ paths.local,
+ });
+}
+
+const LayerPaths = struct {
+ base: []u8,
+ user: []u8,
+ project: []u8,
+ local: []u8,
+
+ fn deinit(self: LayerPaths, alloc: Allocator) void {
+ alloc.free(self.base);
+ alloc.free(self.user);
+ alloc.free(self.project);
+ alloc.free(self.local);
+ }
+};
+
+fn layerPaths(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ cwd: []const u8,
+) Error!LayerPaths {
+ const base = try baseConfigPath(allocator, environ_map);
+ errdefer allocator.free(base);
+ const user = try userConfigPath(allocator, environ_map);
+ errdefer allocator.free(user);
+ const project = try std.fs.path.join(allocator, &.{ cwd, ".panto", "config.toml" });
+ errdefer allocator.free(project);
+ const local = try std.fs.path.join(allocator, &.{ cwd, ".panto", "config.local.toml" });
+ return .{ .base = base, .user = user, .project = project, .local = local };
+}
+
+/// `$PANTO_HOME/config.toml`, falling back to
+/// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`.
+///
+/// `PANTO_HOME` is checked first so this always agrees with where the
+/// bootstrap stages the default base config (see `panto_home.resolveHome`).
+pub fn baseConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 {
+ if (environ_map.get("PANTO_HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, "config.toml" });
+ }
+ if (environ_map.get("XDG_DATA_HOME")) |xdg| {
+ return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "config.toml" });
+ }
+ return error.NoHomeDirectory;
+}
+
+/// `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml`.
+pub fn userConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 {
+ if (environ_map.get("XDG_CONFIG_HOME")) |xdg| {
+ return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, ".config", "panto", "config.toml" });
+ }
+ return error.NoHomeDirectory;
+}
+
+/// Load from explicit layer paths, lowest precedence first. Useful for
+/// tests. Missing files are skipped.
+pub fn loadFromPaths(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ paths: []const []const u8,
+) Error!Config {
+ // The merged document is built into its own arena-backed Document so we
+ // never have to reason about which source layer a given Value came from.
+ const merged = try tvalue.createDocument(allocator);
+ defer merged.deinit();
+
+ for (paths) |path| {
+ const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) {
+ error.FileNotFound => continue,
+ else => return err,
+ };
+ defer allocator.free(bytes);
+
+ const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml;
+ defer doc.deinit();
+ try mergeTable(merged.allocator(), merged.root, doc.root);
+ }
+
+ return resolve(allocator, environ_map, merged.root);
+}
+
+// ===========================================================================
+// Merge
+// ===========================================================================
+
+/// Deep-merge `src` table into `dst` table (both must be `.table`).
+/// Tables recurse; everything else (scalars, arrays) overwrites. Values are
+/// deep-copied into `alloc` (the merged document's arena) so they outlive
+/// the source document.
+fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void {
+ std.debug.assert(dst.* == .table);
+ if (src.* != .table) return;
+
+ var it = toml.tableIterator(src);
+ while (it.next()) |entry| {
+ const existing = dst.get(entry.key);
+ if (existing != null and existing.?.* == .table and entry.value.* == .table) {
+ // Both sides are tables — recurse to accumulate keys.
+ try mergeTable(alloc, @constCast(existing.?), entry.value);
+ } else {
+ // Overwrite (or insert) with a deep copy of the source value.
+ const copy = try cloneValue(alloc, entry.value);
+ const key_copy = try alloc.dupe(u8, entry.key);
+ try tvalue.tableSet(alloc, dst, key_copy, copy);
+ }
+ }
+}
+
+fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value {
+ const out = try alloc.create(toml.Value);
+ switch (src.*) {
+ .table => {
+ out.* = .{ .table = .{} };
+ var it = toml.tableIterator(src);
+ while (it.next()) |entry| {
+ const child = try cloneValue(alloc, entry.value);
+ const key_copy = try alloc.dupe(u8, entry.key);
+ try tvalue.tableSet(alloc, out, key_copy, child);
+ }
+ },
+ .array => |*a| {
+ out.* = .{ .array = .{} };
+ for (a.items.items) |*item| {
+ const child = try cloneValue(alloc, item);
+ try tvalue.arrayAppend(alloc, out, child.*);
+ }
+ },
+ .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) },
+ else => out.* = src.*,
+ }
+ return out;
+}
+
+// ===========================================================================
+// Resolve
+// ===========================================================================
+
+fn resolve(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ root: *const toml.Value,
+) Error!Config {
+ var providers: std.ArrayList(Provider) = .empty;
+ errdefer {
+ for (providers.items) |p| p.deinit(allocator);
+ providers.deinit(allocator);
+ }
+
+ if (root.get("providers")) |providers_tbl| {
+ if (providers_tbl.* == .table) {
+ var it = toml.tableIterator(providers_tbl);
+ while (it.next()) |entry| {
+ const maybe = try resolveProvider(allocator, environ_map, entry.key, entry.value);
+ if (maybe) |p| try providers.append(allocator, p);
+ }
+ }
+ }
+
+ // Tool / extension policies.
+ var tools = try resolvePolicy(allocator, root, "tools");
+ errdefer tools.deinit(allocator);
+ var extensions = try resolvePolicy(allocator, root, "extensions");
+ errdefer extensions.deinit(allocator);
+
+ // Default model.
+ var default_model: ?[]const u8 = null;
+ errdefer if (default_model) |m| allocator.free(m);
+ var default_model_ref: ?ModelRef = null;
+ if (root.get("defaults")) |defaults_tbl| {
+ if (defaults_tbl.get("model")) |model_v| {
+ if (model_v.* == .string) {
+ default_model = try allocator.dupe(u8, model_v.string);
+ default_model_ref = try parseModelRef(default_model.?);
+ }
+ }
+ }
+
+ const providers_slice = try providers.toOwnedSlice(allocator);
+ errdefer {
+ for (providers_slice) |p| p.deinit(allocator);
+ allocator.free(providers_slice);
+ }
+
+ // Validate the default model names a provider that actually resolved.
+ if (default_model_ref) |ref| {
+ var found = false;
+ for (providers_slice) |p| {
+ if (std.mem.eql(u8, p.name, ref.provider)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) return error.UnknownDefaultProvider;
+ }
+
+ return .{
+ .allocator = allocator,
+ .providers = providers_slice,
+ .default_model = default_model,
+ .default_model_ref = default_model_ref,
+ .tools = tools,
+ .extensions = extensions,
+ };
+}
+
+/// Resolve one `providers.<name>` entry. Returns null (provider dropped) if
+/// the only key source is an env var that isn't set.
+fn resolveProvider(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ name: []const u8,
+ val: *const toml.Value,
+) Error!?Provider {
+ if (val.* != .table) return error.InvalidProvider;
+
+ const style_str = (val.get("style") orelse return error.InvalidProvider).asString() orelse
+ return error.InvalidProvider;
+ const style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider;
+
+ const base_url = (val.get("base_url") orelse return error.InvalidProvider).asString() orelse
+ return error.InvalidProvider;
+
+ // api_key wins over api_key_env_var.
+ const api_key: []const u8 = blk: {
+ if (val.get("api_key")) |k| {
+ if (k.asString()) |s| break :blk s;
+ }
+ if (val.get("api_key_env_var")) |ev| {
+ if (ev.asString()) |env_name| {
+ if (environ_map.get(env_name)) |actual| break :blk actual;
+ }
+ }
+ // No usable key — silently omit this provider.
+ return null;
+ };
+
+ return .{
+ .name = try allocator.dupe(u8, name),
+ .style = style,
+ .base_url = try allocator.dupe(u8, base_url),
+ .api_key = try allocator.dupe(u8, api_key),
+ };
+}
+
+fn resolvePolicy(allocator: Allocator, root: *const toml.Value, key: []const u8) Error!Policy {
+ var allow: [][]const u8 = &.{};
+ var deny: [][]const u8 = &.{};
+ errdefer {
+ for (allow) |p| allocator.free(p);
+ allocator.free(allow);
+ for (deny) |p| allocator.free(p);
+ allocator.free(deny);
+ }
+
+ if (root.get(key)) |tbl| {
+ if (tbl.* == .table) {
+ allow = try stringArray(allocator, tbl, "allow");
+ deny = try stringArray(allocator, tbl, "deny");
+ }
+ }
+
+ // A pattern present in both allow and deny is contradictory.
+ for (allow) |a| {
+ for (deny) |d| {
+ if (std.mem.eql(u8, a, d)) return error.PolicyConflict;
+ }
+ }
+
+ return .{ .allow = allow, .deny = deny };
+}
+
+fn stringArray(allocator: Allocator, tbl: *const toml.Value, key: []const u8) Error![][]const u8 {
+ const arr_v = tbl.get(key) orelse return &.{};
+ if (arr_v.* != .array) return &.{};
+
+ var list: std.ArrayList([]const u8) = .empty;
+ errdefer {
+ for (list.items) |s| allocator.free(s);
+ list.deinit(allocator);
+ }
+ for (arr_v.array.items.items) |*item| {
+ if (item.* == .string) {
+ try list.append(allocator, try allocator.dupe(u8, item.string));
+ }
+ }
+ return try list.toOwnedSlice(allocator);
+}
+
+/// Parse `<provider>:<model>`. Both halves must be non-empty and there must
+/// be exactly one separating colon.
+pub fn parseModelRef(ref: []const u8) Error!ModelRef {
+ const colon = std.mem.indexOfScalar(u8, ref, ':') orelse return error.InvalidModelRef;
+ const provider = ref[0..colon];
+ const model = ref[colon + 1 ..];
+ if (provider.len == 0 or model.len == 0) return error.InvalidModelRef;
+ if (std.mem.indexOfScalar(u8, model, ':') != null) return error.InvalidModelRef;
+ return .{ .provider = provider, .model = model };
+}
+
+// ===========================================================================
+// File / parse helpers
+// ===========================================================================
+
+fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) (Allocator.Error || FileError)![]u8 {
+ const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => return error.FileNotFound,
+ error.Canceled => return error.Canceled,
+ else => return error.ConfigReadFailed,
+ };
+ defer file.close(io);
+ const len = file.length(io) catch return error.ConfigReadFailed;
+ const bytes = try allocator.alloc(u8, @intCast(len));
+ errdefer allocator.free(bytes);
+ _ = file.readPositionalAll(io, bytes, 0) catch |err| switch (err) {
+ error.Canceled => return error.Canceled,
+ else => return error.ConfigReadFailed,
+ };
+ return bytes;
+}
+
+fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document {
+ const result = toml.parseWithError(allocator, source, .{});
+ switch (result) {
+ .err => |e| {
+ if (!@import("builtin").is_test) {
+ std.log.err(
+ "config.toml: parse error at line {d}, column {d}: {s}",
+ .{ e.line, e.column, e.message },
+ );
+ }
+ return error.InvalidConfigToml;
+ },
+ .ok => |doc| return doc,
+ }
+}
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+fn emptyEnv(a: Allocator) std.process.Environ.Map {
+ return std.process.Environ.Map.init(a);
+}
+
+test "parseModelRef: splits provider and model" {
+ const ref = try parseModelRef("anthropic:claude-sonnet-4-6");
+ try testing.expectEqualStrings("anthropic", ref.provider);
+ try testing.expectEqualStrings("claude-sonnet-4-6", ref.model);
+}
+
+test "parseModelRef: rejects malformed refs" {
+ try testing.expectError(error.InvalidModelRef, parseModelRef("no-colon"));
+ try testing.expectError(error.InvalidModelRef, parseModelRef(":model"));
+ try testing.expectError(error.InvalidModelRef, parseModelRef("provider:"));
+ try testing.expectError(error.InvalidModelRef, parseModelRef("a:b:c"));
+}
+
+test "Policy.permits: empty allow means allow-all minus deny" {
+ const a = testing.allocator;
+ var deny = try a.alloc([]const u8, 1);
+ deny[0] = try a.dupe(u8, "std.shell");
+ const policy: Policy = .{ .allow = &.{}, .deny = deny };
+ defer policy.deinit(a);
+
+ try testing.expect(policy.permits("std.read"));
+ try testing.expect(!policy.permits("std.shell"));
+}
+
+test "Policy.permits: allow gates everything not matched" {
+ const a = testing.allocator;
+ var allow = try a.alloc([]const u8, 1);
+ allow[0] = try a.dupe(u8, "std.*");
+ const policy: Policy = .{ .allow = allow, .deny = &.{} };
+ defer policy.deinit(a);
+
+ try testing.expect(policy.permits("std.read"));
+ try testing.expect(!policy.permits("other.read"));
+}
+
+test "resolve: env-backed provider is dropped when env var is absent" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ try testing.expectEqual(@as(usize, 0), cfg.providers.len);
+}
+
+test "resolve: env-backed provider resolves when env var is set" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ try testing.expectEqual(@as(usize, 1), cfg.providers.len);
+ const p = cfg.provider("anthropic").?;
+ try testing.expectEqual(APIStyle.anthropic_messages, p.style);
+ try testing.expectEqualStrings("sk-ant-xyz", p.api_key);
+}
+
+test "resolve: inline api_key wins over env var" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("KEY_FROM_ENV", "env-value");
+
+ const src =
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "inline-value"
+ \\api_key_env_var = "KEY_FROM_ENV"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ const p = cfg.provider("openai").?;
+ try testing.expectEqualStrings("inline-value", p.api_key);
+}
+
+test "resolve: unknown default-model provider is an error" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[defaults]
+ \\model = "ghost:some-model"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root));
+}
+
+test "resolvePolicy: pattern in both allow and deny is a conflict" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[tools]
+ \\allow = ["std.*"]
+ \\deny = ["std.*"]
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ try testing.expectError(error.PolicyConflict, resolve(a, &env, doc.root));
+}
+
+test "loadFromPaths: layers merge with tables accumulating and scalars overriding" {
+ const a = testing.allocator;
+ const io = testing.io;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const tmp_n = try tmp.dir.realPath(io, &path_buf);
+ const tmp_path = path_buf[0..tmp_n];
+
+ // Base layer: defines anthropic provider + a default model.
+ const sys_path = try std.fs.path.join(a, &.{ tmp_path, "base.toml" });
+ defer a.free(sys_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "base.toml", .data =
+ \\[defaults]
+ \\model = "anthropic:sonnet"
+ \\
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key = "sys-key"
+ \\
+ \\[tools]
+ \\allow = ["std.*"]
+ });
+
+ // Project layer: adds an openai provider (table accumulates), and
+ // overrides the default model (scalar overwrites).
+ const proj_path = try std.fs.path.join(a, &.{ tmp_path, "project.toml" });
+ defer a.free(proj_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data =
+ \\[defaults]
+ \\model = "openai:gpt"
+ \\
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "proj-key"
+ });
+
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ var cfg = try loadFromPaths(a, io, &env, &.{ sys_path, proj_path });
+ defer cfg.deinit();
+
+ // Both providers survive (table accumulation).
+ try testing.expectEqual(@as(usize, 2), cfg.providers.len);
+ try testing.expect(cfg.provider("anthropic") != null);
+ try testing.expect(cfg.provider("openai") != null);
+
+ // Default model overridden by the project layer.
+ try testing.expectEqualStrings("openai:gpt", cfg.default_model.?);
+ try testing.expectEqualStrings("openai", cfg.default_model_ref.?.provider);
+ try testing.expectEqualStrings("gpt", cfg.default_model_ref.?.model);
+
+ // tools.allow from the base layer is preserved (no project override).
+ try testing.expect(cfg.tools.permits("std.read"));
+}
+
+test "loadFromPaths: local layer overrides project layer" {
+ const a = testing.allocator;
+ const io = testing.io;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const tmp_n = try tmp.dir.realPath(io, &path_buf);
+ const tmp_path = path_buf[0..tmp_n];
+
+ // Project layer: shared default model + provider.
+ const proj_path = try std.fs.path.join(a, &.{ tmp_path, "project.toml" });
+ defer a.free(proj_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data =
+ \\[defaults]
+ \\model = "openai:gpt"
+ \\
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "proj-key"
+ });
+
+ // Local layer (git-ignored): a developer's more-opinionated override of
+ // the default model. Scalar overwrites; the provider table accumulates.
+ const local_path = try std.fs.path.join(a, &.{ tmp_path, "local.toml" });
+ defer a.free(local_path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "local.toml", .data =
+ \\[defaults]
+ \\model = "openai:gpt-local"
+ });
+
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ var cfg = try loadFromPaths(a, io, &env, &.{ proj_path, local_path });
+ defer cfg.deinit();
+
+ // Local layer wins for the default model.
+ try testing.expectEqualStrings("openai:gpt-local", cfg.default_model.?);
+ // Provider from the project layer survives (table accumulation).
+ try testing.expect(cfg.provider("openai") != null);
+}
+
+test "buildProviderConfig: anthropic ref pulls knobs from model defs" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("ANTHROPIC_API_KEY", "sk-ant");
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+ try models.entries.append(a, .{
+ .provider = try a.dupe(u8, "anthropic"),
+ .alias = try a.dupe(u8, "sonnet"),
+ .model = try a.dupe(u8, "claude-sonnet-4-20250514"),
+ .reasoning = .high,
+ .max_tokens = 8192,
+ .api_version = try a.dupe(u8, "2023-06-01"),
+ });
+
+ const ref = try parseModelRef("anthropic:sonnet");
+ const pc = try buildProviderConfig(&cfg, &models, ref);
+ try testing.expectEqual(APIStyle.anthropic_messages, pc.style());
+ try testing.expectEqualStrings("claude-sonnet-4-20250514", pc.anthropic_messages.model);
+ try testing.expectEqual(@as(u32, 8192), pc.anthropic_messages.max_tokens);
+ try testing.expectEqualStrings("sk-ant", pc.anthropic_messages.api_key);
+}
+
+test "buildProviderConfig: missing alias uses the alias verbatim as wire model" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ const src =
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "sk-test"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+
+ const ref = try parseModelRef("openai:gpt-4o");
+ const pc = try buildProviderConfig(&cfg, &models, ref);
+ try testing.expectEqual(APIStyle.openai_chat, pc.style());
+ try testing.expectEqualStrings("gpt-4o", pc.openai_chat.model);
+ try testing.expectEqual(panto.config.ReasoningEffort.default, pc.openai_chat.reasoning);
+}
+
+test "buildProviderConfig: unknown provider errors" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ const empty = try parseDoc(a, "");
+ defer empty.deinit();
+ var cfg = try resolve(a, &env, empty.root);
+ defer cfg.deinit();
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+ try testing.expectError(error.UnknownProvider, buildProviderConfig(&cfg, &models, .{ .provider = "ghost", .model = "x" }));
+}
+
+test "selectModel: default_model wins; single-provider fallback otherwise" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ // Config with one provider and a default model.
+ const src =
+ \\[defaults]
+ \\model = "openai:gpt"
+ \\
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\api_key = "sk"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+
+ const ref = try cfg.selectModel(&models, null);
+ try testing.expectEqualStrings("openai", ref.provider);
+ try testing.expectEqualStrings("gpt", ref.model);
+
+ // Override beats default.
+ const ref2 = try cfg.selectModel(&models, "openai:other");
+ try testing.expectEqualStrings("other", ref2.model);
+}
+
+test "selectModel: no default and no providers errors" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ const empty = try parseDoc(a, "");
+ defer empty.deinit();
+ var cfg = try resolve(a, &env, empty.root);
+ defer cfg.deinit();
+ var models = models_toml.ModelRegistry.init(a);
+ defer models.deinit();
+ try testing.expectError(error.NoModelSelected, cfg.selectModel(&models, null));
+}
+
+test "loadFromPaths: missing files are skipped" {
+ const a = testing.allocator;
+ const io = testing.io;
+ var env = emptyEnv(a);
+ defer env.deinit();
+
+ var cfg = try loadFromPaths(a, io, &env, &.{
+ "/nonexistent/base.toml",
+ "/nonexistent/project.toml",
+ });
+ defer cfg.deinit();
+ try testing.expectEqual(@as(usize, 0), cfg.providers.len);
+ try testing.expect(cfg.default_model == null);
+}