From b3b6e4155a8e1b1092d3fdb6121541f75c9b8236 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 16 Oct 2026 14:38:39 +0800 Subject: [PATCH] refactor(config): split compaction settings and share XDG app directories config.rs held the snapshot, the thinking request types, compaction threshold math, display formatters, and URL validation under one long helper section, and five modules paired each XDG variable with its $HOME fallback by hand. Behavior is unchanged. - Move the compaction types, their resolution, and the threshold math into config/compaction.rs, and test the threshold math directly. - Move ThinkingConfig, ThinkingDisplay, and BlockBinding to model.rs, since ModelSpec::thinking is their only constructor. - Split the config.rs helpers into resolution, env, base URL, and display sections, add a load_with test helper, and order the Config::load test sections by call order. - Rename the OAuth keychain-or-file enum to Backend, since Store already names the login-location struct, and state outcomes in its test names. - Add util::path::XdgBase and app_dir, which every config, data, and state lookup now uses, with one "ox" directory constant. --- crates/oxide-code/src/agent.rs | 4 +- crates/oxide-code/src/client/anthropic.rs | 18 +- .../oxide-code/src/client/anthropic/betas.rs | 4 +- .../src/client/anthropic/identity.rs | 4 +- .../src/client/anthropic/testing.rs | 5 +- .../oxide-code/src/client/anthropic/wire.rs | 3 +- crates/oxide-code/src/config.rs | 1748 ++++++----------- crates/oxide-code/src/config/compaction.rs | 350 ++++ crates/oxide-code/src/config/load.rs | 19 +- crates/oxide-code/src/config/oauth.rs | 89 +- crates/oxide-code/src/config/provenance.rs | 3 +- crates/oxide-code/src/config/schema.rs | 3 +- crates/oxide-code/src/driver/stdio.rs | 3 +- crates/oxide-code/src/engine.rs | 8 +- crates/oxide-code/src/model.rs | 57 +- crates/oxide-code/src/permission.rs | 27 +- crates/oxide-code/src/protocol.rs | 3 +- crates/oxide-code/src/session/store.rs | 4 +- crates/oxide-code/src/slash.rs | 10 +- crates/oxide-code/src/tui/app.rs | 15 +- .../oxide-code/src/tui/components/welcome.rs | 11 +- crates/oxide-code/src/util/log.rs | 60 +- crates/oxide-code/src/util/path.rs | 141 +- 23 files changed, 1195 insertions(+), 1394 deletions(-) create mode 100644 crates/oxide-code/src/config/compaction.rs diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index aa870214..b4f18579 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -12,7 +12,7 @@ use tokio::sync::mpsc; use tracing::warn; use crate::client::{Response, ResponseEvent, StopDetails, StopReason, TokenUsage, Wire}; -use crate::config::AutoCompactionConfig; +use crate::config::compaction::AutoCompactionConfig; use crate::file_tracker::{self, FileTracker}; use crate::message::{ContentBlock, Message, Role}; use crate::permission::SessionGrant; @@ -887,8 +887,8 @@ mod tests { use super::*; use crate::client::anthropic::testing::test_client; + use crate::config::Effort; use crate::config::auth::{Auth, AuthSource}; - use crate::config::{AutoCompactionConfig, Effort}; use crate::file_tracker::FileTracker; use crate::message::Role; use crate::model::ResolvedModelId; diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 91dbfef1..7767f8b1 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -28,7 +28,8 @@ use uuid::Uuid; use crate::client::{ResponseEvent, Wire}; use crate::config::auth::{Auth, AuthKind, AuthSource}; -use crate::config::{CompactionConfig, Config, Effort, is_first_party_base_url}; +use crate::config::compaction::CompactionConfig; +use crate::config::{Config, Effort, is_first_party_base_url}; use crate::message::{ContentBlock, Message, Role}; use crate::model::ModelSpec; use crate::prompt::SYSTEM_PROMPT_DYNAMIC_BOUNDARY; @@ -492,9 +493,8 @@ mod tests { use super::*; use crate::client::{Response, StopReason, TokenUsage}; use crate::config::auth::{Auth, AuthSource}; - use crate::config::{ - AutoCompactionConfig, CompactionConfig, Effort, PromptCacheTtl, test_thresholds, - }; + use crate::config::compaction::{CompactionConfig, test_thresholds}; + use crate::config::{Effort, PromptCacheTtl}; // ── Fixtures ── @@ -591,10 +591,7 @@ mod tests { TEST_MODEL, ); config.prompt_cache_ttl = PromptCacheTtl::FiveMin; - config.compaction = CompactionConfig::resolved_for_test(AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(123_456), - }); + config.compaction = CompactionConfig::with_threshold_for_test(123_456); let client = Client::new(config, None).unwrap(); assert_eq!(client.prompt_cache_ttl(), PromptCacheTtl::FiveMin); @@ -804,10 +801,7 @@ mod tests { fn set_model_clamps_threshold_above_new_context_window() { let mut config = test_config(OFFLINE_URL, api_key(), "custom-model"); config.max_tokens = None; - config.compaction = CompactionConfig::resolved_for_test(AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(1_500_000), - }); + config.compaction = CompactionConfig::with_threshold_for_test(1_500_000); let mut client = Client::new(config, Some("sid".to_owned())).unwrap(); client.set_model("claude-sonnet-5-5".to_owned()); diff --git a/crates/oxide-code/src/client/anthropic/betas.rs b/crates/oxide-code/src/client/anthropic/betas.rs index 69cd1591..1c8aba41 100644 --- a/crates/oxide-code/src/client/anthropic/betas.rs +++ b/crates/oxide-code/src/client/anthropic/betas.rs @@ -1,8 +1,8 @@ //! Per-request `anthropic-beta` header computation. +use crate::config::PromptCacheTtl; use crate::config::auth::AuthKind; -use crate::config::{PromptCacheTtl, ThinkingConfig, ThinkingDisplay}; -use crate::model::{Family, ModelSpec}; +use crate::model::{Family, ModelSpec, ThinkingConfig, ThinkingDisplay}; use super::wire::CacheControl; diff --git a/crates/oxide-code/src/client/anthropic/identity.rs b/crates/oxide-code/src/client/anthropic/identity.rs index ca209fc8..4e947a83 100644 --- a/crates/oxide-code/src/client/anthropic/identity.rs +++ b/crates/oxide-code/src/client/anthropic/identity.rs @@ -12,7 +12,7 @@ use tracing::warn; use uuid::Uuid; use crate::util::fs::{atomic_write_private, create_private_dir_all}; -use crate::util::path::data_dir; +use crate::util::path::{XdgBase, app_dir}; const FILE_NAME: &str = "user-id"; const ID_LEN: usize = 64; @@ -49,7 +49,7 @@ fn try_load_or_create_at(path: &Path) -> Result { } fn device_id_path() -> Option { - data_dir().map(|dir| dir.join(FILE_NAME)) + app_dir(XdgBase::Data).map(|dir| dir.join(FILE_NAME)) } fn read_existing(path: &Path) -> Result> { diff --git a/crates/oxide-code/src/client/anthropic/testing.rs b/crates/oxide-code/src/client/anthropic/testing.rs index c0a10ff9..65e0a96d 100644 --- a/crates/oxide-code/src/client/anthropic/testing.rs +++ b/crates/oxide-code/src/client/anthropic/testing.rs @@ -10,10 +10,9 @@ use wiremock::ResponseTemplate; use super::Client; use super::transport::MessagesRequest; use crate::config::auth::{Auth, AuthSource}; +use crate::config::compaction::CompactionConfig; use crate::config::gateway::GatewayCompat; -use crate::config::{ - CompactionConfig, Config, DEFAULT_THEME, PromptCacheTtl, StatusLineSegment, ThemeConfig, -}; +use crate::config::{Config, DEFAULT_THEME, PromptCacheTtl, StatusLineSegment, ThemeConfig}; use crate::model::ModelSpec; /// Minimal [`Config`] for unit / wiremock tests. diff --git a/crates/oxide-code/src/client/anthropic/wire.rs b/crates/oxide-code/src/client/anthropic/wire.rs index 79acba74..ed3545d2 100644 --- a/crates/oxide-code/src/client/anthropic/wire.rs +++ b/crates/oxide-code/src/client/anthropic/wire.rs @@ -5,8 +5,9 @@ use serde::ser::SerializeSeq; use serde::{Deserialize, Serialize, Serializer}; use crate::client::{StopDetails, StopReason}; -use crate::config::{Effort, ThinkingConfig}; +use crate::config::Effort; use crate::message::{ContentBlock, Message, Role}; +use crate::model::ThinkingConfig; use crate::tool::ToolDefinition; // ── Request types ── diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 0c6a5146..852c5490 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -5,6 +5,7 @@ //! [`auth::AuthSource`] order. pub(crate) mod auth; +pub(crate) mod compaction; pub(crate) mod gateway; pub(crate) mod load; pub(crate) mod login; @@ -23,21 +24,20 @@ use serde::{Deserialize, Serialize}; use tracing::warn; use self::auth::{Auth, AuthSource, ResolveError}; +use self::compaction::{AutoCompactionConfig, CompactionConfig}; use self::gateway::{GatewayCompat, GatewaySnapshot}; use self::load::Loaded; use self::merge::Origins; use self::provenance::{Provenance, Source, Sourced}; -use self::schema::{CompactionTable, FileConfig, PermissionTable}; +use self::schema::{FileConfig, PermissionTable}; use crate::model::ModelSpec; +use crate::permission::{Mode, Policy}; use crate::util::env; -use crate::util::path::{expand_user, tildify}; +use crate::util::path::expand_user; use crate::util::tls::load_extra_ca_certs; const DEFAULT_MODEL: &str = "claude-opus-5-5"; const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; -const AUTO_COMPACTION_OUTPUT_RESERVE_CAP: u32 = 20_000; -const AUTO_COMPACTION_BUFFER_TOKENS: u32 = 13_000; -const MIN_AUTO_COMPACTION_THRESHOLD_TOKENS: u32 = 50_000; pub(crate) const DEFAULT_THEME: &str = "mocha"; // ── ConfigSnapshot ── @@ -61,7 +61,7 @@ pub(crate) struct ConfigSnapshot { pub(crate) max_tool_rounds: Option, pub(crate) prompt_cache_ttl: PromptCacheTtl, pub(crate) compaction: CompactionConfig, - pub(crate) permission_mode: crate::permission::Mode, + pub(crate) permission_mode: Mode, pub(crate) show_thinking: bool, pub(crate) show_welcome: bool, pub(crate) status_line: Vec, @@ -107,38 +107,6 @@ impl ConfigSnapshot { } } -// ── ThinkingConfig ── - -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(crate) enum ThinkingConfig { - /// Model decides the thinking budget. - Adaptive { - #[serde(skip_serializing_if = "Option::is_none")] - display: Option, - block_binding: BlockBinding, - }, -} - -/// `thinking.display` values. Omitting the field returns no thinking text. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThinkingDisplay { - Summarized, - /// Only the progress notes written between tool calls come back as text. Requires the - /// `thinking-display-updates-2026-08-18` beta. - Updates, -} - -/// `thinking.block_binding`. `DropBlock` makes the API drop a replayed thinking block whose -/// conversation prefix changed since it was produced, plus every later one, where it would -/// otherwise reject the request. Requires the `thinking-binding-controls-2026-08-01` beta. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(tag = "prefix_mismatch_behavior", rename_all = "snake_case")] -pub(crate) enum BlockBinding { - DropBlock, -} - // ── Effort ── /// Intelligence-vs-latency tier sent as `output_config.effort`. @@ -312,84 +280,6 @@ impl FromStr for StatusLineSegment { } } -// ── CompactionConfig ── - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct CompactionConfig { - pub(crate) auto: AutoCompactionConfig, - policy: AutoCompactionPolicy, -} - -impl CompactionConfig { - #[cfg(test)] - pub(crate) const fn disabled() -> Self { - Self { - auto: AutoCompactionConfig::disabled(), - policy: AutoCompactionPolicy::Disabled, - } - } - - pub(crate) fn for_model(self, model: &ModelSpec, max_tokens: u32) -> Self { - resolve_compaction_policy(self.policy, model, max_tokens) - } - - #[cfg(test)] - pub(crate) const fn resolved_for_test(auto: AutoCompactionConfig) -> Self { - let policy = if auto.enabled { - match auto.threshold_tokens { - Some(tokens) => AutoCompactionPolicy::Tokens(tokens), - None => AutoCompactionPolicy::Default, - } - } else { - AutoCompactionPolicy::Disabled - }; - Self { auto, policy } - } - - #[cfg(test)] - pub(crate) fn default_for_test(model: &ModelSpec, max_tokens: u32) -> Self { - resolve_compaction_policy(AutoCompactionPolicy::Default, model, max_tokens) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct AutoCompactionConfig { - pub(crate) enabled: bool, - pub(crate) threshold_tokens: Option, -} - -impl AutoCompactionConfig { - pub(crate) const fn disabled() -> Self { - Self { - enabled: false, - threshold_tokens: None, - } - } - - pub(crate) const fn should_trigger(self, total_tokens: u32) -> bool { - match (self.enabled, self.threshold_tokens) { - (true, Some(threshold)) => total_tokens >= threshold, - _ => false, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AutoCompactionPolicy { - Disabled, - Default, - Tokens(u32), - Percent(ThresholdPercent), -} - -/// A share of the context window, validated to [`Self::RANGE`] when the config loads. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ThresholdPercent(u8); - -impl ThresholdPercent { - const RANGE: RangeInclusive = 1..=100; -} - // ── ThemeConfig ── /// `[tui.theme]` as configured. The TUI resolves it into a palette. @@ -425,7 +315,7 @@ pub(crate) struct Config { pub(crate) max_tool_rounds: Option, pub(crate) prompt_cache_ttl: PromptCacheTtl, pub(crate) compaction: CompactionConfig, - pub(crate) permission: crate::permission::Policy, + pub(crate) permission: Policy, /// Opts adaptive thinking into summarized text. The request's `thinking` field derives from /// this plus the model. pub(crate) show_thinking: bool, @@ -505,7 +395,7 @@ impl Config { .or_else(|| origins.sourced("client.prompt_cache_ttl", client.prompt_cache_ttl)) .unwrap_or_else(|| Sourced::fallback(PromptCacheTtl::OneHour)); - let compaction = resolve_compaction( + let compaction = compaction::resolve( &client.compaction, &origins, &model, @@ -582,67 +472,28 @@ impl Config { } } -// ── Helpers ── - -/// `OX_STATUS_LINE` > `tui.status_line` > the default roster. -fn resolve_status_line(file: Option>) -> Result> { - match env::string("OX_STATUS_LINE") { - Some(raw) => parse_status_line_segments(&raw) - .with_context(|| format!("invalid OX_STATUS_LINE {raw:?}")), - None => validate_status_line(file.unwrap_or_else(|| StatusLineSegment::DEFAULT.to_vec())) - .context("invalid tui.status_line"), - } -} - -fn parse_status_line_segments(raw: &str) -> Result> { - let parts: Vec<&str> = raw.split(',').map(str::trim).collect(); - let all_empty = parts.iter().all(|part| part.is_empty()); - if all_empty { - return validate_status_line(Vec::new()); - } - parts - .into_iter() - .map(|part| { - if part.is_empty() { - bail!("empty segment between commas. Remove the stray comma."); - } - part.parse::() - }) - .collect::>>() - .and_then(validate_status_line) -} +// ── Resolution ── -fn validate_status_line(segments: Vec) -> Result> { - if segments.is_empty() { - bail!( - "no segments. List at least one of {}, or remove it to use the default.", - StatusLineSegment::names() - ); +/// `OX_EXTRA_CA_CERTS` > `client.extra_ca_certs` > `NODE_EXTRA_CA_CERTS`. Node is often exported +/// shell-wide for npm, so like Node itself, an unreadable bundle there warns and is skipped. +fn resolve_extra_ca_certs(file: Option>) -> Result>> { + let explicit = env_string("OX_EXTRA_CA_CERTS") + .map(|raw| ("OX_EXTRA_CA_CERTS", raw)) + .or(file.map(|raw| ("client.extra_ca_certs", raw))); + if let Some((key, raw)) = explicit { + let path = expand_user(&raw.value).with_context(|| format!("invalid {key}"))?; + return Ok(Sourced::new(Some(path), raw.source)); } - Ok(segments) -} - -pub(crate) fn display_auth(auth: Option) -> String { - auth.map_or_else(|| "Not signed in".to_owned(), |source| source.to_string()) -} - -pub(crate) fn display_effort(effort: Option) -> String { - effort.map_or_else(|| "(no effort tier)".to_owned(), |e| e.to_string()) -} - -pub(crate) fn display_bool(flag: bool) -> &'static str { - if flag { "on" } else { "off" } -} - -pub(crate) fn display_max_tool_rounds(cap: Option) -> String { - cap.map_or_else(|| "unbounded".to_owned(), |n| n.to_string()) -} -pub(crate) fn display_auto_compaction(auto: AutoCompactionConfig) -> String { - match (auto.enabled, auto.threshold_tokens) { - (true, Some(threshold)) => format!("at {threshold} tokens"), - (true, None) => "off (no threshold)".to_owned(), - _ => "off".to_owned(), + let Some(raw) = env_string("NODE_EXTRA_CA_CERTS") else { + return Ok(Sourced::fallback(None)); + }; + match expand_user(&raw.value).and_then(|path| load_extra_ca_certs(&path).map(|_| path)) { + Ok(path) => Ok(Sourced::new(Some(path), raw.source)), + Err(e) => { + warn!("ignoring NODE_EXTRA_CA_CERTS: {e:#}"); + Ok(Sourced::fallback(None)) + } } } @@ -704,33 +555,42 @@ fn resolve_effort( }) } -/// Sourced from the threshold when one applies, otherwise from `auto_enabled`. -fn resolve_compaction( - file: &CompactionTable, - origins: &Origins, - model: &ModelSpec, - max_tokens: u32, -) -> Result> { - let auto_requested = env_bool("OX_COMPACTION_AUTO_ENABLED")? - .or_else(|| origins.sourced("client.compaction.auto_enabled", file.enabled)) - .unwrap_or_else(|| Sourced::fallback(true)); - // Parsed even when disabled, so a bad value fails the load as it does in a config file. - let env_thresholds = ( - env_number("OX_COMPACTION_AUTO_THRESHOLD_TOKENS", 0..=u32::MAX)?, - env_number( - "OX_COMPACTION_AUTO_THRESHOLD_PERCENT", - ThresholdPercent::RANGE, - )? - .map(|percent| percent.map(ThresholdPercent)), - ); - - let policy = if auto_requested.value { - resolve_auto_threshold(env_thresholds, file, origins)? - .unwrap_or_else(|| auto_requested.map(|_| AutoCompactionPolicy::Default)) - } else { - auto_requested.map(|_| AutoCompactionPolicy::Disabled) - }; - Ok(policy.map(|policy| resolve_compaction_policy(policy, model, max_tokens))) +/// `OX_STATUS_LINE` > `tui.status_line` > the default roster. +fn resolve_status_line(file: Option>) -> Result> { + match env::string("OX_STATUS_LINE") { + Some(raw) => parse_status_line_segments(&raw) + .with_context(|| format!("invalid OX_STATUS_LINE {raw:?}")), + None => validate_status_line(file.unwrap_or_else(|| StatusLineSegment::DEFAULT.to_vec())) + .context("invalid tui.status_line"), + } +} + +fn parse_status_line_segments(raw: &str) -> Result> { + let parts: Vec<&str> = raw.split(',').map(str::trim).collect(); + let all_empty = parts.iter().all(|part| part.is_empty()); + if all_empty { + return validate_status_line(Vec::new()); + } + parts + .into_iter() + .map(|part| { + if part.is_empty() { + bail!("empty segment between commas. Remove the stray comma."); + } + part.parse::() + }) + .collect::>>() + .and_then(validate_status_line) +} + +fn validate_status_line(segments: Vec) -> Result> { + if segments.is_empty() { + bail!( + "no segments. List at least one of {}, or remove it to use the default.", + StatusLineSegment::names() + ); + } + Ok(segments) } /// Resolves the permission policy from the merged file config plus the `OX_PERMISSION_MODE` @@ -741,127 +601,18 @@ fn resolve_permission( file: PermissionTable, origins: &Origins, cwd: &Path, -) -> Result<(crate::permission::Policy, Source)> { - let mode = env_parse::("OX_PERMISSION_MODE")? +) -> Result<(Policy, Source)> { + let mode = env_parse::("OX_PERMISSION_MODE")? .or_else(|| origins.sourced("permission.mode", file.mode)) - .unwrap_or_else(|| Sourced::fallback(crate::permission::Mode::default())); + .unwrap_or_else(|| Sourced::fallback(Mode::default())); let allow = file.allow.unwrap_or_default(); let deny = file.deny.unwrap_or_default(); - let policy = crate::permission::Policy::resolve(mode.value, &allow, &deny, cwd)?; + let policy = Policy::resolve(mode.value, &allow, &deny, cwd)?; Ok((policy, mode.source)) } -fn resolve_compaction_policy( - policy: AutoCompactionPolicy, - model: &ModelSpec, - max_tokens: u32, -) -> CompactionConfig { - let auto = resolve_auto_compaction(policy, model, max_tokens); - CompactionConfig { auto, policy } -} - -fn resolve_auto_compaction( - policy: AutoCompactionPolicy, - model: &ModelSpec, - max_tokens: u32, -) -> AutoCompactionConfig { - let threshold = match policy { - AutoCompactionPolicy::Disabled => return AutoCompactionConfig::disabled(), - AutoCompactionPolicy::Default => default_auto_threshold(model, max_tokens), - AutoCompactionPolicy::Tokens(tokens) => { - Some(threshold_from_tokens(tokens, model, max_tokens)) - } - AutoCompactionPolicy::Percent(percent) => { - threshold_from_percent(percent, model, max_tokens) - } - }; - AutoCompactionConfig { - enabled: threshold.is_some(), - threshold_tokens: threshold, - } -} - -/// The configured threshold policy, `None` when neither threshold is set. Env thresholds replace -/// the file thresholds as a pair. -fn resolve_auto_threshold( - env: (Option>, Option>), - file: &CompactionTable, - origins: &Origins, -) -> Result>> { - let (tokens, percent) = if env.0.is_some() || env.1.is_some() { - env - } else { - ( - origins.sourced( - "client.compaction.auto_threshold_tokens", - file.threshold_tokens, - ), - origins.sourced( - "client.compaction.auto_threshold_percent", - file.threshold_percent.map(ThresholdPercent), - ), - ) - }; - - match (tokens, percent) { - (Some(tokens), Some(_)) => { - // Both come from one place: the env pair, or one file since layers clear the pair. - let both = match &tokens.source { - Source::File(layer) => format!( - "{} sets both auto_threshold_tokens and auto_threshold_percent", - tildify(layer.path()) - ), - _ => "both OX_COMPACTION_AUTO_THRESHOLD_TOKENS and \ - OX_COMPACTION_AUTO_THRESHOLD_PERCENT are set" - .to_owned(), - }; - bail!("{both}. Set only one compaction threshold.") - } - (Some(tokens), None) => Ok(Some(tokens.map(AutoCompactionPolicy::Tokens))), - (None, Some(percent)) => Ok(Some(percent.map(AutoCompactionPolicy::Percent))), - (None, None) => Ok(None), - } -} - -fn threshold_from_tokens(tokens: u32, model: &ModelSpec, max_tokens: u32) -> u32 { - clamp_threshold_floor(clamp_threshold_ceil(tokens, model, max_tokens)) -} - -fn threshold_from_percent( - ThresholdPercent(percent): ThresholdPercent, - model: &ModelSpec, - max_tokens: u32, -) -> Option { - let context_window = model.context_window()?; - let raw = context_window.saturating_mul(u32::from(percent)) / 100; - Some(clamp_threshold_floor(clamp_threshold_ceil( - raw, model, max_tokens, - ))) -} - -/// Snaps `tokens` down to the model's safe trigger when one is known. -fn clamp_threshold_ceil(tokens: u32, model: &ModelSpec, max_tokens: u32) -> u32 { - default_auto_threshold(model, max_tokens).map_or(tokens, |max| tokens.min(max)) -} - -/// Snaps `tokens` up to [`MIN_AUTO_COMPACTION_THRESHOLD_TOKENS`]. -fn clamp_threshold_floor(tokens: u32) -> u32 { - tokens.max(MIN_AUTO_COMPACTION_THRESHOLD_TOKENS) -} - -fn default_auto_threshold(model: &ModelSpec, max_tokens: u32) -> Option { - model - .context_window() - .and_then(|window| default_auto_threshold_for_window(window, max_tokens)) -} - -fn default_auto_threshold_for_window(context_window: u32, max_tokens: u32) -> Option { - let reserve = max_tokens.min(AUTO_COMPACTION_OUTPUT_RESERVE_CAP); - context_window - .checked_sub(reserve)? - .checked_sub(AUTO_COMPACTION_BUFFER_TOKENS) -} +// ── Env Overrides ── fn env_string(var: &'static str) -> Option> { env::string(var).map(|value| Sourced::new(value, Source::Env(var))) @@ -902,28 +653,7 @@ fn aliased(var: &'static str, alias: &'static str) -> &'static str { } } -/// `OX_EXTRA_CA_CERTS` > `client.extra_ca_certs` > `NODE_EXTRA_CA_CERTS`. Node is often exported -/// shell-wide for npm, so like Node itself, an unreadable bundle there warns and is skipped. -fn resolve_extra_ca_certs(file: Option>) -> Result>> { - let explicit = env_string("OX_EXTRA_CA_CERTS") - .map(|raw| ("OX_EXTRA_CA_CERTS", raw)) - .or(file.map(|raw| ("client.extra_ca_certs", raw))); - if let Some((key, raw)) = explicit { - let path = expand_user(&raw.value).with_context(|| format!("invalid {key}"))?; - return Ok(Sourced::new(Some(path), raw.source)); - } - - let Some(raw) = env_string("NODE_EXTRA_CA_CERTS") else { - return Ok(Sourced::fallback(None)); - }; - match expand_user(&raw.value).and_then(|path| load_extra_ca_certs(&path).map(|_| path)) { - Ok(path) => Ok(Sourced::new(Some(path), raw.source)), - Err(e) => { - warn!("ignoring NODE_EXTRA_CA_CERTS: {e:#}"); - Ok(Sourced::fallback(None)) - } - } -} +// ── Base URL ── fn validate_base_url(raw: &str) -> Result<()> { let url = reqwest::Url::parse(raw).with_context(|| format!("invalid base URL {raw:?}"))?; @@ -956,10 +686,30 @@ pub(crate) fn is_first_party_base_url(base_url: &str) -> bool { }) } -/// Output of [`default_auto_threshold`] when `max_tokens` saturates the reserve cap. -#[cfg(test)] -pub(crate) mod test_thresholds { - pub(crate) const WINDOW_1M: u32 = 967_000; +// ── Display ── + +pub(crate) fn display_auth(auth: Option) -> String { + auth.map_or_else(|| "Not signed in".to_owned(), |source| source.to_string()) +} + +pub(crate) fn display_effort(effort: Option) -> String { + effort.map_or_else(|| "(no effort tier)".to_owned(), |e| e.to_string()) +} + +pub(crate) fn display_bool(flag: bool) -> &'static str { + if flag { "on" } else { "off" } +} + +pub(crate) fn display_max_tool_rounds(cap: Option) -> String { + cap.map_or_else(|| "unbounded".to_owned(), |n| n.to_string()) +} + +pub(crate) fn display_auto_compaction(auto: AutoCompactionConfig) -> String { + match (auto.enabled, auto.threshold_tokens) { + (true, Some(threshold)) => format!("at {threshold} tokens"), + (true, None) => "off (no threshold)".to_owned(), + _ => "off".to_owned(), + } } #[cfg(test)] @@ -969,8 +719,11 @@ mod tests { use tempfile::TempDir; + use super::compaction::test_thresholds; use super::provenance::Layer; use super::*; + use crate::util::path::tildify; + use crate::util::tls::TEST_CA_PEM; // ── ConfigSnapshot::notices ── @@ -993,17 +746,8 @@ mod tests { max_tokens: None, max_tool_rounds: Some(100), prompt_cache_ttl: PromptCacheTtl::FiveMin, - compaction: CompactionConfig::resolved_for_test(AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(42), - }), - permission: crate::permission::Policy::resolve( - crate::permission::Mode::Plan, - &[], - &[], - Path::new(""), - ) - .unwrap(), + compaction: CompactionConfig::with_threshold_for_test(42), + permission: Policy::resolve(Mode::Plan, &[], &[], Path::new("")).unwrap(), show_thinking: true, show_welcome: false, status_line: vec![ @@ -1105,29 +849,6 @@ mod tests { assert_eq!(snap.effort, Some(Effort::Medium)); } - // ── ThinkingConfig ── - - #[test] - fn thinking_config_adaptive_serializes_display_and_block_binding() { - let binding = serde_json::json!({"prefix_mismatch_behavior": "drop_block"}); - for (display, wire) in [ - (None, None), - (Some(ThinkingDisplay::Summarized), Some("summarized")), - (Some(ThinkingDisplay::Updates), Some("updates")), - ] { - let json = serde_json::to_value(&ThinkingConfig::Adaptive { - display, - block_binding: BlockBinding::DropBlock, - }) - .unwrap(); - let mut expected = serde_json::json!({"type": "adaptive", "block_binding": binding}); - if let Some(wire) = wire { - expected["display"] = wire.into(); - } - assert_eq!(json, expected); - } - } - // ── Effort ── #[test] @@ -1164,7 +885,7 @@ mod tests { // ── PromptCacheTtl ── #[test] - fn prompt_cache_ttl_wire_shape() { + fn prompt_cache_ttl_wire_omits_the_five_minute_server_default() { assert_eq!(PromptCacheTtl::FiveMin.wire(), None); assert_eq!(PromptCacheTtl::OneHour.wire(), Some("1h")); } @@ -1187,28 +908,6 @@ mod tests { assert_eq!(format!("{err:#}"), "expected one of 5m, 1h"); } - // ── CompactionConfig::for_model ── - - #[test] - fn for_model_re_resolves_a_percent_threshold_against_the_new_window() { - let policy = AutoCompactionPolicy::Percent(ThresholdPercent(40)); - let unknown = ModelSpec::resolve("custom-model".to_owned()); - let compaction = resolve_compaction_policy(policy, &unknown, 16_000); - assert!(!compaction.auto.enabled, "precondition: no window"); - - let known = ModelSpec::resolve("claude-opus-5-5".to_owned()); - let swapped = compaction.for_model(&known, 64_000); - - assert_eq!( - swapped.auto, - AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(400_000), - } - ); - assert_eq!(swapped.policy, policy); - } - // ── Config::load ── /// Env keys `Config::load` reads. `ANTHROPIC_API_KEY` defaults non-empty in [`env_vars`] so @@ -1293,7 +992,15 @@ mod tests { (key, Some(value.to_owned())) } - fn assert_auth(config: &Config, source: AuthSource, secret: &str) { + /// Loads a non-interactive session from the user config under `dir` and the baseline env + /// plus `overrides`. + async fn load_with(dir: &TempDir, overrides: &[(&'static str, &str)]) -> Result { + let overrides = overrides.iter().map(|&(key, value)| env(key, value)); + let vars = env_vars([xdg(dir)].into_iter().chain(overrides)); + temp_env::async_with_vars(vars, Config::load(dir.path(), false)).await + } + + fn assert_auth(config: &Config, source: AuthSource, secret: &str) { let auth = config.auth.as_ref().expect("the session must be signed in"); assert_eq!(auth.source(), source); assert_eq!(auth.secret(), secret); @@ -1302,10 +1009,7 @@ mod tests { #[tokio::test] async fn load_defaults_apply_when_no_config_and_no_env() { let dir = tempfile::tempdir().unwrap(); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); + let config = load_with(&dir, &[]).await.unwrap(); assert_eq!(config.model.id(), "claude-opus-5-5"); assert_eq!(config.base_url, DEFAULT_BASE_URL); assert_eq!(config.max_tokens, None); @@ -1328,7 +1032,7 @@ mod tests { assert!(config.gateway.structured_outputs); assert_eq!(config.theme.base, DEFAULT_THEME); assert!(config.theme.overrides.is_empty()); - assert_eq!(config.permission.mode(), crate::permission::Mode::Auto); + assert_eq!(config.permission.mode(), Mode::Auto); assert_eq!( config.provenance, Provenance { @@ -1352,10 +1056,7 @@ mod tests { accent = { bold = false } "##}, ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); + let config = load_with(&dir, &[]).await.unwrap(); assert_eq!(config.theme.base, "latte"); assert_eq!( config.theme.overrides, @@ -1371,19 +1072,20 @@ mod tests { #[tokio::test] async fn load_env_overrides_every_client_field() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "claude-sonnet-5-5"), - env("ANTHROPIC_BASE_URL", "https://example.invalid"), - env("OX_MAX_TOKENS", "64"), - env("OX_MAX_TOOL_ROUNDS", "200"), - env("OX_SHOW_THINKING", "1"), - env("OX_SHOW_WELCOME", "0"), - env("OX_STATUS_LINE", "run-state,model,current-dir"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); + let config = load_with( + &dir, + &[ + ("ANTHROPIC_MODEL", "claude-sonnet-5-5"), + ("ANTHROPIC_BASE_URL", "https://example.invalid"), + ("OX_MAX_TOKENS", "64"), + ("OX_MAX_TOOL_ROUNDS", "200"), + ("OX_SHOW_THINKING", "1"), + ("OX_SHOW_WELCOME", "0"), + ("OX_STATUS_LINE", "run-state,model,current-dir"), + ], + ) + .await + .unwrap(); assert_eq!(config.model.id(), "claude-sonnet-5-5"); assert_eq!(config.base_url, "https://example.invalid"); assert_eq!(config.max_tokens, Some(64)); @@ -1426,10 +1128,7 @@ mod tests { status_line = ["current-dir", "git-branch", "model-with-effort"] "#}, ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); + let config = load_with(&dir, &[]).await.unwrap(); assert_eq!(config.model.id(), "claude-haiku-5-5"); assert_eq!(config.base_url, "https://config-file.invalid"); assert_eq!( @@ -1470,91 +1169,6 @@ mod tests { ); } - #[tokio::test] - async fn load_status_line_invalid_segment_surfaces_parse_error() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("OX_STATUS_LINE", "model,nope")]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("invalid segment must propagate"); - let msg = format!("{err:#}"); - assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); - assert!(msg.contains("nope"), "{msg}"); - assert!(msg.contains("current-dir"), "{msg}"); - } - - #[tokio::test] - async fn load_status_line_stray_comma_in_env_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("OX_STATUS_LINE", "model,,run-state")]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("stray comma must surface as a typo"); - let msg = format!("{err:#}"); - assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); - assert!(msg.contains("empty segment"), "{msg}"); - assert!(msg.contains("stray comma"), "{msg}"); - } - - #[tokio::test] - async fn load_status_line_invalid_segment_in_file_surfaces_parse_error() { - let dir = tempfile::tempdir().unwrap(); - write_user_config( - dir.path(), - indoc::indoc! {r#" - [tui] - status_line = ["model", "nope"] - "#}, - ); - let err = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .expect_err("invalid TOML segment must propagate"); - let msg = format!("{err:#}"); - assert!(msg.contains("status_line"), "{msg}"); - assert!(msg.contains("unknown variant `nope`"), "{msg}"); - assert!(msg.contains("current-dir"), "{msg}"); - assert!(msg.contains("run-state"), "{msg}"); - } - - #[tokio::test] - async fn load_status_line_empty_env_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("OX_STATUS_LINE", ",")]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("empty status line must not launch without run-state"); - let msg = format!("{err:#}"); - assert!( - msg.starts_with(r#"invalid OX_STATUS_LINE ",": no segments. List at least one of"#), - "{msg}" - ); - assert!(msg.contains("run-state"), "{msg}"); - assert!(msg.ends_with("or remove it to use the default."), "{msg}"); - } - - #[tokio::test] - async fn load_status_line_empty_file_roster_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - write_user_config( - dir.path(), - indoc::indoc! {r" - [tui] - status_line = [] - "}, - ); - let err = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .expect_err("empty status line must not launch without run-state"); - let msg = format!("{err:#}"); - assert!( - msg.starts_with("invalid tui.status_line: no segments. List at least one of"), - "{msg}" - ); - assert!(msg.contains("run-state"), "{msg}"); - } - #[tokio::test] async fn load_env_beats_config_file_field_by_field() { let dir = tempfile::tempdir().unwrap(); @@ -1569,55 +1183,21 @@ mod tests { show_thinking = true "#}, ); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "claude-sonnet-5-5"), - // `max_tokens` env is unset — the file value must win. - env("OX_SHOW_THINKING", "0"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); + let config = load_with( + &dir, + &[ + ("ANTHROPIC_MODEL", "claude-sonnet-5-5"), + // `max_tokens` has no env value, so the file value must win. + ("OX_SHOW_THINKING", "0"), + ], + ) + .await + .unwrap(); assert_eq!(config.model.id(), "claude-sonnet-5-5"); assert_eq!(config.max_tokens, Some(128)); assert!(!config.show_thinking, "env `0` overrides file `true`"); } - #[tokio::test] - async fn load_env_api_key_beats_file_api_key() { - let dir = tempfile::tempdir().unwrap(); - write_user_config( - dir.path(), - indoc::indoc! {r#" - [client] - api_key = "sk-from-file" - "#}, - ); - let vars = env_vars(vec![xdg(&dir), env("ANTHROPIC_API_KEY", "sk-from-env")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_auth(&config, AuthSource::ApiKeyEnv, "sk-from-env"); - } - - #[tokio::test] - async fn load_file_api_key_used_when_env_is_empty() { - let dir = tempfile::tempdir().unwrap(); - let user = write_user_config( - dir.path(), - indoc::indoc! {r#" - [client] - api_key = "sk-from-file" - "#}, - ); - let vars = env_vars(vec![xdg(&dir), env("ANTHROPIC_API_KEY", "")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_auth(&config, AuthSource::ApiKeyFile, "sk-from-file"); - assert_eq!(config.provenance.auth, Source::File(user)); - } - #[tokio::test] async fn load_numeric_env_errors_name_the_variable_and_range() { let whole = format!("1 to {}", u32::MAX); @@ -1643,12 +1223,7 @@ mod tests { ] { let dir = tempfile::tempdir().unwrap(); // A bad threshold must fail the load even with auto compaction off. - let vars = env_vars(vec![ - xdg(&dir), - env("OX_COMPACTION_AUTO_ENABLED", "0"), - env(var, raw), - ]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[("OX_COMPACTION_AUTO_ENABLED", "0"), (var, raw)]) .await .expect_err("an invalid number must fail config load"); assert_eq!( @@ -1669,8 +1244,7 @@ mod tests { show_thinking = true "#}, ); - let vars = env_vars(vec![xdg(&dir), env("ANTHROPIC_API_KEY", "")]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[("ANTHROPIC_API_KEY", "")]) .await .expect_err("misplaced field must surface as an error"); let msg = format!("{err:#}"); @@ -1678,102 +1252,169 @@ mod tests { assert!(msg.contains("unknown field `show_thinking`"), "{msg}"); } - // ── Config::load: provenance ── + // ── Config::load: base URL validation ── #[tokio::test] - async fn load_records_the_layer_behind_each_setting() { + async fn load_rejects_plain_http_base_url_unless_loopback() { let dir = tempfile::tempdir().unwrap(); - let user = write_user_config( + let err = load_with(&dir, &[("ANTHROPIC_BASE_URL", "http://example.com")]) + .await + .expect_err("non-loopback http must be rejected"); + let msg = format!("{err:#}"); + assert!(msg.contains("https"), "{msg}"); + assert!(msg.contains("localhost"), "{msg}"); + } + + #[tokio::test] + async fn load_rejects_non_http_base_url_scheme() { + let dir = tempfile::tempdir().unwrap(); + let err = load_with(&dir, &[("ANTHROPIC_BASE_URL", "ftp://example.com")]) + .await + .expect_err("non-http schemes must be rejected"); + let msg = format!("{err:#}"); + assert!(msg.contains("http or https"), "{msg}"); + assert!(msg.contains("ftp"), "{msg}"); + } + + #[tokio::test] + async fn load_accepts_loopback_http_base_url_for_local_proxy() { + let dir = tempfile::tempdir().unwrap(); + let config = load_with(&dir, &[("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080")]) + .await + .unwrap(); + assert_eq!(config.base_url, "http://127.0.0.1:8080"); + } + + // ── Config::load: extra_ca_certs ── + + #[tokio::test] + async fn load_extra_ca_certs_default_is_none() { + let dir = tempfile::tempdir().unwrap(); + let config = load_with(&dir, &[]).await.unwrap(); + assert!(config.extra_ca_certs.is_none()); + } + + #[tokio::test] + async fn load_extra_ca_certs_env_beats_file_and_expands_tilde() { + let dir = tempfile::tempdir().unwrap(); + write_user_config( dir.path(), indoc::indoc! {r#" [client] - model = "claude-haiku-5-5" - effort = "high" - max_tool_rounds = 50 - - [client.compaction] - auto_threshold_tokens = 400000 + extra_ca_certs = "/etc/ssl/from-file.pem" "#}, ); - let drop_in = write_drop_in( + // Env supplies a `~`-prefixed path so the expansion branch is also covered. + let config = load_with(&dir, &[("OX_EXTRA_CA_CERTS", "~/certs/env.pem")]) + .await + .unwrap(); + let home = std::env::home_dir().expect("home directory"); + assert_eq!(config.extra_ca_certs, Some(home.join("certs/env.pem"))); + } + + #[tokio::test] + async fn load_extra_ca_certs_node_env_is_the_last_fallback() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("node.pem"); + std::fs::write(&bundle, TEST_CA_PEM).unwrap(); + let node = [("NODE_EXTRA_CA_CERTS", bundle.to_str().unwrap())]; + + let config = load_with(&dir, &node).await.unwrap(); + assert_eq!(config.extra_ca_certs.as_deref(), Some(bundle.as_path())); + + write_user_config( dir.path(), - "10-team.toml", indoc::indoc! {r#" [client] - effort = "low" - prompt_cache_ttl = "5m" - - [permission] - mode = "plan" + extra_ca_certs = "/etc/ssl/from-file.pem" "#}, ); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "claude-sonnet-5-5"), - env("ANTHROPIC_CUSTOM_HEADERS", "x-team: platform"), - env("CLAUDE_CODE_DISABLE_STRUCTURED_OUTPUTS", "1"), - env("OX_SHOW_THINKING", "1"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - - assert_eq!(config.effort, Some(Effort::Low)); + let config = load_with(&dir, &node).await.unwrap(); assert_eq!( - config.provenance, - Provenance { - layers: vec![user.clone(), drop_in.clone()], - model: Source::Env("ANTHROPIC_MODEL"), - effort: Source::File(drop_in.clone()), - auth: Source::Env("ANTHROPIC_API_KEY"), - custom_headers: Source::Env("ANTHROPIC_CUSTOM_HEADERS"), - structured_outputs: Source::Env("CLAUDE_CODE_DISABLE_STRUCTURED_OUTPUTS"), - max_tool_rounds: Source::File(user.clone()), - prompt_cache_ttl: Source::File(drop_in.clone()), - compaction: Source::File(user), - permission_mode: Source::File(drop_in), - show_thinking: Source::Env("OX_SHOW_THINKING"), - ..Provenance::default() - }, + config.extra_ca_certs, + Some(PathBuf::from("/etc/ssl/from-file.pem")), ); } #[tokio::test] - async fn load_env_sources_the_settings_it_overrides() { + async fn load_extra_ca_certs_skips_unreadable_node_bundle() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_BASE_URL", "https://gateway.example.com"), - env("ANTHROPIC_AUTH_TOKEN", "gateway-token"), - env("ANTHROPIC_MAX_TOKENS", "64"), - env("CLAUDE_CODE_ATTRIBUTION_HEADER", "0"), - env("OX_COMPACTION_AUTO_THRESHOLD_PERCENT", "40"), - env("OX_EXTRA_CA_CERTS", "/etc/ssl/ca.pem"), - env("OX_PERMISSION_MODE", "yolo"), - env("OX_PROMPT_CACHE_TTL", "5m"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let missing = dir.path().join("missing.pem"); + let config = load_with(&dir, &[("NODE_EXTRA_CA_CERTS", missing.to_str().unwrap())]) .await .unwrap(); + assert!(config.extra_ca_certs.is_none()); + } + #[tokio::test] + async fn load_extra_ca_certs_file_used_when_env_unset() { + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r#" + [client] + extra_ca_certs = "/etc/ssl/from-file.pem" + "#}, + ); + let config = load_with(&dir, &[]).await.unwrap(); assert_eq!( - config.provenance, - Provenance { - auth: Source::Env("ANTHROPIC_AUTH_TOKEN"), - base_url: Source::Env("ANTHROPIC_BASE_URL"), - extra_ca_certs: Source::Env("OX_EXTRA_CA_CERTS"), - attribution: Source::Env("CLAUDE_CODE_ATTRIBUTION_HEADER"), - max_tokens: Source::Env("ANTHROPIC_MAX_TOKENS"), - prompt_cache_ttl: Source::Env("OX_PROMPT_CACHE_TTL"), - compaction: Source::Env("OX_COMPACTION_AUTO_THRESHOLD_PERCENT"), - permission_mode: Source::Env("OX_PERMISSION_MODE"), - ..Provenance::default() - }, + config.extra_ca_certs, + Some(PathBuf::from("/etc/ssl/from-file.pem")), + ); + } + + #[tokio::test] + async fn load_extra_ca_certs_file_wins_when_env_is_empty() { + // `env::string` treats an empty env var as absent, so a file value must survive even + // when the caller exports `OX_EXTRA_CA_CERTS=""` (e.g., by unsetting via shell). + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r#" + [client] + extra_ca_certs = "/etc/ssl/from-file.pem" + "#}, + ); + let config = load_with(&dir, &[("OX_EXTRA_CA_CERTS", "")]).await.unwrap(); + assert_eq!( + config.extra_ca_certs, + Some(PathBuf::from("/etc/ssl/from-file.pem")), ); } // ── Config::load: auth ── + #[tokio::test] + async fn load_env_api_key_beats_file_api_key() { + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r#" + [client] + api_key = "sk-from-file" + "#}, + ); + let config = load_with(&dir, &[("ANTHROPIC_API_KEY", "sk-from-env")]) + .await + .unwrap(); + assert_auth(&config, AuthSource::ApiKeyEnv, "sk-from-env"); + } + + #[tokio::test] + async fn load_file_api_key_used_when_env_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let user = write_user_config( + dir.path(), + indoc::indoc! {r#" + [client] + api_key = "sk-from-file" + "#}, + ); + let config = load_with(&dir, &[("ANTHROPIC_API_KEY", "")]).await.unwrap(); + assert_auth(&config, AuthSource::ApiKeyFile, "sk-from-file"); + assert_eq!(config.provenance.auth, Source::File(user)); + } + #[tokio::test] async fn load_resolves_credentials_in_precedence_order() { // Each row drops the previous winner. `ANTHROPIC_API_KEY` stays unset throughout. @@ -1895,14 +1536,15 @@ mod tests { #[tokio::test] async fn load_custom_header_carrying_the_active_credential_fails() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_CUSTOM_HEADERS", "Authorization: Bearer other"), - env("ANTHROPIC_AUTH_TOKEN", "gateway-token"), - ]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("custom header must not replace the credential"); + let err = load_with( + &dir, + &[ + ("ANTHROPIC_CUSTOM_HEADERS", "Authorization: Bearer other"), + ("ANTHROPIC_AUTH_TOKEN", "gateway-token"), + ], + ) + .await + .expect_err("custom header must not replace the credential"); assert_eq!( format!("{err:#}"), "invalid ANTHROPIC_CUSTOM_HEADERS: authorization carries the Auth token \ @@ -1926,598 +1568,491 @@ mod tests { assert!(msg.contains("ANTHROPIC_AUTH_TOKEN"), "{msg}"); } + // ── Config::load: effort resolution ── + #[tokio::test] - async fn load_compaction_file_can_disable_default_on_auto_behavior() { - let dir = tempfile::tempdir().unwrap(); - let user = write_user_config( - dir.path(), - indoc::indoc! {r" - [client.compaction] - auto_enabled = false - "}, - ); - write_drop_in( - dir.path(), - "10-threshold.toml", - "client.compaction.auto_threshold_percent = 40", - ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) + async fn load_effort_defaults_to_per_model_value() { + for (model, expected) in [ + ("claude-opus-5-5", Some(Effort::Medium)), + ("claude-sonnet-5-5", Some(Effort::High)), + ("custom-model", None), + ] { + let dir = tempfile::tempdir().unwrap(); + let config = load_with(&dir, &[("ANTHROPIC_MODEL", model)]) .await .unwrap(); - assert!(!config.compaction.auto.enabled); - assert_eq!( - config.provenance.compaction, - Source::File(user), - "a disabled trigger ignores the threshold" - ); + assert_eq!(config.effort, expected, "model={model}"); + } } #[tokio::test] - async fn load_compaction_auto_env_beats_file() { + async fn load_effort_env_overrides_per_model_default() { + let dir = tempfile::tempdir().unwrap(); + let config = load_with( + &dir, + &[ + ("ANTHROPIC_MODEL", "claude-opus-5-5"), + ("ANTHROPIC_EFFORT", "low"), + ], + ) + .await + .unwrap(); + assert_eq!(config.effort, Some(Effort::Low)); + } + + #[tokio::test] + async fn load_effort_pick_is_dropped_on_unknown_model() { + let dir = tempfile::tempdir().unwrap(); + let config = load_with( + &dir, + &[ + ("ANTHROPIC_MODEL", "custom-model"), + ("ANTHROPIC_EFFORT", "max"), + ], + ) + .await + .unwrap(); + assert_eq!(config.effort, None); + assert_eq!(config.provenance.effort, Source::Default); + } + + #[tokio::test] + async fn load_effort_file_picks_up_when_env_unset() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), - indoc::indoc! {r" - [client.compaction] - auto_enabled = false - "}, + indoc::indoc! {r#" + [client] + model = "claude-sonnet-5-5" + effort = "medium" + "#}, ); - let vars = env_vars(vec![xdg(&dir), env("OX_COMPACTION_AUTO_ENABLED", "TRUE")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert!(config.compaction.auto.enabled); + let config = load_with(&dir, &[]).await.unwrap(); + assert_eq!(config.effort, Some(Effort::Medium)); } #[tokio::test] - async fn load_compaction_auto_threshold_tokens_sets_absolute_trigger() { + async fn load_effort_env_beats_file() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), - indoc::indoc! {r" - [client.compaction] - auto_threshold_tokens = 400000 - "}, + indoc::indoc! {r#" + [client] + model = "claude-opus-5-5" + effort = "low" + "#}, ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.compaction.auto.threshold_tokens, Some(400_000)); + let config = load_with(&dir, &[("OX_EFFORT", "max")]).await.unwrap(); + assert_eq!(config.effort, Some(Effort::Max)); } #[tokio::test] - async fn load_compaction_auto_threshold_percent_uses_context_window() { + async fn load_effort_invalid_env_lists_the_accepted_tiers() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "claude-opus-5-5"), - env("OX_COMPACTION_AUTO_THRESHOLD_PERCENT", "40"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[("OX_EFFORT", "insane")]) .await - .unwrap(); - assert_eq!(config.compaction.auto.threshold_tokens, Some(400_000)); + .expect_err("invalid effort must propagate"); + assert_eq!( + format!("{err:#}"), + r#"invalid OX_EFFORT "insane": expected one of low, medium, high, xhigh, max"#, + ); } #[tokio::test] - async fn load_compaction_rejects_ambiguous_auto_thresholds() { + async fn load_ox_names_beat_their_anthropic_aliases() { let dir = tempfile::tempdir().unwrap(); - let user = write_user_config( - dir.path(), - indoc::indoc! {r" - [client.compaction] - auto_threshold_tokens = 400000 - auto_threshold_percent = 40 - "}, - ); - let err = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .expect_err("ambiguous file thresholds must fail config load"); + let aliases = [("ANTHROPIC_EFFORT", "low"), ("ANTHROPIC_MAX_TOKENS", "200")]; + + let config = load_with(&dir, &aliases).await.unwrap(); + assert_eq!(config.effort, Some(Effort::Low)); + assert_eq!(config.max_tokens, Some(200)); + assert_eq!(config.provenance.effort, Source::Env("ANTHROPIC_EFFORT")); assert_eq!( - format!("{err:#}"), - format!( - "{} sets both auto_threshold_tokens and auto_threshold_percent. \ - Set only one compaction threshold.", - tildify(user.path()), - ), + config.provenance.max_tokens, + Source::Env("ANTHROPIC_MAX_TOKENS") ); - let vars = env_vars(vec![ - xdg(&dir), - env("OX_COMPACTION_AUTO_THRESHOLD_TOKENS", "400000"), - env("OX_COMPACTION_AUTO_THRESHOLD_PERCENT", "40"), - ]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let both = [ + &aliases[..], + &[("OX_EFFORT", "high"), ("OX_MAX_TOKENS", "100")], + ] + .concat(); + let config = load_with(&dir, &both).await.unwrap(); + assert_eq!(config.effort, Some(Effort::High)); + assert_eq!(config.max_tokens, Some(100)); + assert_eq!(config.provenance.effort, Source::Env("OX_EFFORT")); + assert_eq!(config.provenance.max_tokens, Source::Env("OX_MAX_TOKENS")); + } + + // ── Config::load: status_line ── + + #[tokio::test] + async fn load_status_line_invalid_segment_surfaces_parse_error() { + let dir = tempfile::tempdir().unwrap(); + let err = load_with(&dir, &[("OX_STATUS_LINE", "model,nope")]) .await - .expect_err("ambiguous env thresholds must fail config load"); - assert_eq!( - format!("{err:#}"), - "both OX_COMPACTION_AUTO_THRESHOLD_TOKENS and OX_COMPACTION_AUTO_THRESHOLD_PERCENT \ - are set. Set only one compaction threshold.", - ); + .expect_err("invalid segment must propagate"); + let msg = format!("{err:#}"); + assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); + assert!(msg.contains("nope"), "{msg}"); + assert!(msg.contains("current-dir"), "{msg}"); } #[tokio::test] - async fn load_compaction_clamps_zero_auto_threshold_tokens_up_to_floor() { + async fn load_status_line_stray_comma_in_env_is_rejected() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("OX_COMPACTION_AUTO_THRESHOLD_TOKENS", "0"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[("OX_STATUS_LINE", "model,,run-state")]) .await - .unwrap(); - assert_eq!(config.compaction.auto.threshold_tokens, Some(50_000)); + .expect_err("stray comma must surface as a typo"); + let msg = format!("{err:#}"); + assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); + assert!(msg.contains("empty segment"), "{msg}"); + assert!(msg.contains("stray comma"), "{msg}"); } #[tokio::test] - async fn load_compaction_clamps_too_low_auto_threshold_tokens_up_to_floor() { + async fn load_status_line_invalid_segment_in_file_surfaces_parse_error() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), - indoc::indoc! {r" - [client.compaction] - auto_threshold_tokens = 49999 - "}, + indoc::indoc! {r#" + [tui] + status_line = ["model", "nope"] + "#}, ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.compaction.auto.threshold_tokens, Some(50_000)); + let err = load_with(&dir, &[]) + .await + .expect_err("invalid TOML segment must propagate"); + let msg = format!("{err:#}"); + assert!(msg.contains("status_line"), "{msg}"); + assert!(msg.contains("unknown variant `nope`"), "{msg}"); + assert!(msg.contains("current-dir"), "{msg}"); + assert!(msg.contains("run-state"), "{msg}"); } #[tokio::test] - async fn load_compaction_clamps_too_low_auto_threshold_percent_up_to_floor() { + async fn load_status_line_empty_env_is_rejected() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "claude-opus-5-5"), - env("OX_COMPACTION_AUTO_THRESHOLD_PERCENT", "4"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[("OX_STATUS_LINE", ",")]) .await - .unwrap(); - assert_eq!(config.compaction.auto.threshold_tokens, Some(50_000)); + .expect_err("empty status line must not launch without run-state"); + let msg = format!("{err:#}"); + assert!( + msg.starts_with(r#"invalid OX_STATUS_LINE ",": no segments. List at least one of"#), + "{msg}" + ); + assert!(msg.contains("run-state"), "{msg}"); + assert!(msg.ends_with("or remove it to use the default."), "{msg}"); } #[tokio::test] - async fn load_compaction_clamps_threshold_above_model_safe_window() { + async fn load_status_line_empty_file_roster_is_rejected() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), - indoc::indoc! {r#" - [client] - model = "claude-sonnet-5-5" - - [client.compaction] - auto_threshold_tokens = 990000 - "#}, + indoc::indoc! {r" + [tui] + status_line = [] + "}, ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!( - config.compaction.auto.threshold_tokens, - Some(test_thresholds::WINDOW_1M), - ); - } - - #[tokio::test] - async fn load_compaction_percent_for_unknown_model_disables_auto_trigger() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "custom-model"), - env("OX_COMPACTION_AUTO_THRESHOLD_PERCENT", "40"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[]) .await - .unwrap(); - assert!(!config.compaction.auto.enabled); - assert_eq!(config.compaction.auto.threshold_tokens, None); + .expect_err("empty status line must not launch without run-state"); + let msg = format!("{err:#}"); + assert!( + msg.starts_with("invalid tui.status_line: no segments. List at least one of"), + "{msg}" + ); + assert!(msg.contains("run-state"), "{msg}"); } - // ── resolve_permission ── + // ── Config::load: prompt_cache_ttl ── #[tokio::test] - async fn load_permission_mode_env_beats_file() { + async fn load_prompt_cache_ttl_env_overrides_default() { let dir = tempfile::tempdir().unwrap(); - write_user_config( - dir.path(), - indoc::indoc! {r#" - [permission] - mode = "plan" - "#}, - ); - let vars = env_vars(vec![xdg(&dir), env("OX_PERMISSION_MODE", "yolo")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let config = load_with(&dir, &[("OX_PROMPT_CACHE_TTL", "5m")]) .await .unwrap(); - assert_eq!(config.permission.mode(), crate::permission::Mode::Yolo); - } - - #[tokio::test] - async fn load_permission_invalid_mode_env_errors() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("OX_PERMISSION_MODE", "bypass")]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("invalid mode must propagate"); - assert_eq!( - format!("{err:#}"), - r#"invalid OX_PERMISSION_MODE "bypass": expected one of auto, plan, yolo"#, - ); + assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::FiveMin); } #[tokio::test] - async fn load_permission_invalid_rule_in_file_errors() { + async fn load_prompt_cache_ttl_file_picks_up_when_env_unset() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), indoc::indoc! {r#" - [permission] - allow = ["edit(src/**/[)"] + [client] + prompt_cache_ttl = "5m" "#}, ); - let vars = env_vars(vec![xdg(&dir)]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("malformed rule must propagate"); - assert!(format!("{err:#}").contains("invalid path glob")); - } - - // ── Config::load: extra_ca_certs ── - - #[tokio::test] - async fn load_extra_ca_certs_default_is_none() { - let dir = tempfile::tempdir().unwrap(); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); - assert!(config.extra_ca_certs.is_none()); + let config = load_with(&dir, &[]).await.unwrap(); + assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::FiveMin); } #[tokio::test] - async fn load_extra_ca_certs_env_beats_file_and_expands_tilde() { + async fn load_prompt_cache_ttl_env_beats_file() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), indoc::indoc! {r#" [client] - extra_ca_certs = "/etc/ssl/from-file.pem" + prompt_cache_ttl = "5m" "#}, ); - // Env supplies a `~`-prefixed path so the expansion branch is also covered. - let vars = env_vars(vec![xdg(&dir), env("OX_EXTRA_CA_CERTS", "~/certs/env.pem")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let config = load_with(&dir, &[("OX_PROMPT_CACHE_TTL", "1h")]) .await .unwrap(); - let home = std::env::home_dir().expect("home directory"); - assert_eq!(config.extra_ca_certs, Some(home.join("certs/env.pem"))); + assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::OneHour); } #[tokio::test] - async fn load_extra_ca_certs_node_env_is_the_last_fallback() { + async fn load_prompt_cache_ttl_invalid_env_surfaces_parse_error() { let dir = tempfile::tempdir().unwrap(); - let bundle = dir.path().join("node.pem"); - std::fs::write(&bundle, crate::util::tls::TEST_CA_PEM).unwrap(); - let node = env("NODE_EXTRA_CA_CERTS", bundle.to_str().unwrap()); - - let vars = env_vars(vec![xdg(&dir), node.clone()]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.extra_ca_certs, Some(bundle)); - - write_user_config( - dir.path(), - indoc::indoc! {r#" - [client] - extra_ca_certs = "/etc/ssl/from-file.pem" - "#}, - ); - let vars = env_vars(vec![xdg(&dir), node]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[("OX_PROMPT_CACHE_TTL", "forever")]) .await - .unwrap(); + .expect_err("invalid ttl must propagate"); assert_eq!( - config.extra_ca_certs, - Some(PathBuf::from("/etc/ssl/from-file.pem")), + format!("{err:#}"), + r#"invalid OX_PROMPT_CACHE_TTL "forever": expected one of 5m, 1h"#, ); } - #[tokio::test] - async fn load_extra_ca_certs_skips_unreadable_node_bundle() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing.pem"); - let vars = env_vars(vec![ - xdg(&dir), - env("NODE_EXTRA_CA_CERTS", missing.to_str().unwrap()), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert!(config.extra_ca_certs.is_none()); - } + // ── Config::load: compaction ── #[tokio::test] - async fn load_extra_ca_certs_file_used_when_env_unset() { + async fn load_compaction_file_can_disable_default_on_auto_behavior() { let dir = tempfile::tempdir().unwrap(); - write_user_config( + let user = write_user_config( dir.path(), - indoc::indoc! {r#" - [client] - extra_ca_certs = "/etc/ssl/from-file.pem" - "#}, + indoc::indoc! {r" + [client.compaction] + auto_enabled = false + "}, ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); + write_drop_in( + dir.path(), + "10-threshold.toml", + "client.compaction.auto_threshold_percent = 40", + ); + let config = load_with(&dir, &[]).await.unwrap(); + assert!(!config.compaction.auto.enabled); assert_eq!( - config.extra_ca_certs, - Some(PathBuf::from("/etc/ssl/from-file.pem")), + config.provenance.compaction, + Source::File(user), + "a disabled trigger ignores the threshold" ); } #[tokio::test] - async fn load_extra_ca_certs_file_wins_when_env_is_empty() { - // `env::string` treats an empty env var as absent, so a file value must survive even - // when the caller exports `OX_EXTRA_CA_CERTS=""` (e.g., by unsetting via shell). + async fn load_compaction_auto_env_beats_file() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), - indoc::indoc! {r#" - [client] - extra_ca_certs = "/etc/ssl/from-file.pem" - "#}, + indoc::indoc! {r" + [client.compaction] + auto_enabled = false + "}, ); - let vars = env_vars(vec![xdg(&dir), env("OX_EXTRA_CA_CERTS", "")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let config = load_with(&dir, &[("OX_COMPACTION_AUTO_ENABLED", "TRUE")]) .await .unwrap(); - assert_eq!( - config.extra_ca_certs, - Some(PathBuf::from("/etc/ssl/from-file.pem")), - ); - } - - // ── Config::load: base URL validation ── - - #[tokio::test] - async fn load_rejects_plain_http_base_url_unless_loopback() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_BASE_URL", "http://example.com"), - ]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("non-loopback http must be rejected"); - let msg = format!("{err:#}"); - assert!(msg.contains("https"), "{msg}"); - assert!(msg.contains("localhost"), "{msg}"); - } - - #[tokio::test] - async fn load_rejects_non_http_base_url_scheme() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_BASE_URL", "ftp://example.com"), - ]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("non-http schemes must be rejected"); - let msg = format!("{err:#}"); - assert!(msg.contains("http or https"), "{msg}"); - assert!(msg.contains("ftp"), "{msg}"); - } - - #[tokio::test] - async fn load_accepts_loopback_http_base_url_for_local_proxy() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.base_url, "http://127.0.0.1:8080"); - } - - // ── Config::load: effort resolution ── - - #[tokio::test] - async fn load_effort_defaults_to_per_model_value() { - for (model, expected) in [ - ("claude-opus-5-5", Some(Effort::Medium)), - ("claude-sonnet-5-5", Some(Effort::High)), - ("custom-model", None), - ] { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("ANTHROPIC_MODEL", model)]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.effort, expected, "model={model}"); - } + assert!(config.compaction.auto.enabled); } #[tokio::test] - async fn load_effort_env_overrides_per_model_default() { + async fn load_compaction_auto_threshold_tokens_sets_absolute_trigger() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "claude-opus-5-5"), - env("ANTHROPIC_EFFORT", "low"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.effort, Some(Effort::Low)); + write_user_config( + dir.path(), + indoc::indoc! {r" + [client.compaction] + auto_threshold_tokens = 400000 + "}, + ); + let config = load_with(&dir, &[]).await.unwrap(); + assert_eq!(config.compaction.auto.threshold_tokens, Some(400_000)); } #[tokio::test] - async fn load_effort_pick_is_dropped_on_unknown_model() { + async fn load_compaction_rejects_ambiguous_auto_thresholds() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_MODEL", "custom-model"), - env("ANTHROPIC_EFFORT", "max"), - ]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let user = write_user_config( + dir.path(), + indoc::indoc! {r" + [client.compaction] + auto_threshold_tokens = 400000 + auto_threshold_percent = 40 + "}, + ); + let err = load_with(&dir, &[]) .await - .unwrap(); - assert_eq!(config.effort, None); - assert_eq!(config.provenance.effort, Source::Default); - } + .expect_err("ambiguous file thresholds must fail config load"); + assert_eq!( + format!("{err:#}"), + format!( + "{} sets both auto_threshold_tokens and auto_threshold_percent. \ + Set only one compaction threshold.", + tildify(user.path()), + ), + ); - #[tokio::test] - async fn load_effort_file_picks_up_when_env_unset() { - let dir = tempfile::tempdir().unwrap(); - write_user_config( - dir.path(), - indoc::indoc! {r#" - [client] - model = "claude-sonnet-5-5" - effort = "medium" - "#}, + let err = load_with( + &dir, + &[ + ("OX_COMPACTION_AUTO_THRESHOLD_TOKENS", "400000"), + ("OX_COMPACTION_AUTO_THRESHOLD_PERCENT", "40"), + ], + ) + .await + .expect_err("ambiguous env thresholds must fail config load"); + assert_eq!( + format!("{err:#}"), + "both OX_COMPACTION_AUTO_THRESHOLD_TOKENS and OX_COMPACTION_AUTO_THRESHOLD_PERCENT \ + are set. Set only one compaction threshold.", ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.effort, Some(Effort::Medium)); } + // ── Config::load: permission ── + #[tokio::test] - async fn load_effort_env_beats_file() { + async fn load_permission_mode_env_beats_file() { let dir = tempfile::tempdir().unwrap(); write_user_config( dir.path(), indoc::indoc! {r#" - [client] - model = "claude-opus-5-5" - effort = "low" + [permission] + mode = "plan" "#}, ); - let vars = env_vars(vec![xdg(&dir), env("OX_EFFORT", "max")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let config = load_with(&dir, &[("OX_PERMISSION_MODE", "yolo")]) .await .unwrap(); - assert_eq!(config.effort, Some(Effort::Max)); + assert_eq!(config.permission.mode(), Mode::Yolo); } #[tokio::test] - async fn load_effort_invalid_env_lists_the_accepted_tiers() { + async fn load_permission_invalid_mode_env_errors() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("OX_EFFORT", "insane")]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) + let err = load_with(&dir, &[("OX_PERMISSION_MODE", "bypass")]) .await - .expect_err("invalid effort must propagate"); + .expect_err("invalid mode must propagate"); assert_eq!( format!("{err:#}"), - r#"invalid OX_EFFORT "insane": expected one of low, medium, high, xhigh, max"#, + r#"invalid OX_PERMISSION_MODE "bypass": expected one of auto, plan, yolo"#, ); } #[tokio::test] - async fn load_ox_names_beat_their_anthropic_aliases() { + async fn load_permission_invalid_rule_in_file_errors() { let dir = tempfile::tempdir().unwrap(); - let aliases = [ - xdg(&dir), - env("ANTHROPIC_EFFORT", "low"), - env("ANTHROPIC_MAX_TOKENS", "200"), - ]; - - let config = - temp_env::async_with_vars(env_vars(aliases.clone()), Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.effort, Some(Effort::Low)); - assert_eq!(config.max_tokens, Some(200)); - assert_eq!(config.provenance.effort, Source::Env("ANTHROPIC_EFFORT")); - assert_eq!( - config.provenance.max_tokens, - Source::Env("ANTHROPIC_MAX_TOKENS") + write_user_config( + dir.path(), + indoc::indoc! {r#" + [permission] + allow = ["edit(src/**/[)"] + "#}, ); - - let both = aliases - .into_iter() - .chain([env("OX_EFFORT", "high"), env("OX_MAX_TOKENS", "100")]); - let config = temp_env::async_with_vars(env_vars(both), Config::load(dir.path(), false)) + let err = load_with(&dir, &[]) .await - .unwrap(); - assert_eq!(config.effort, Some(Effort::High)); - assert_eq!(config.max_tokens, Some(100)); - assert_eq!(config.provenance.effort, Source::Env("OX_EFFORT")); - assert_eq!(config.provenance.max_tokens, Source::Env("OX_MAX_TOKENS")); + .expect_err("malformed rule must propagate"); + assert!(format!("{err:#}").contains("invalid path glob")); } - // ── Config::load: prompt_cache_ttl ── - - #[tokio::test] - async fn load_prompt_cache_ttl_env_overrides_default() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("OX_PROMPT_CACHE_TTL", "5m")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::FiveMin); - } + // ── Config::load: provenance ── #[tokio::test] - async fn load_prompt_cache_ttl_file_picks_up_when_env_unset() { + async fn load_records_the_layer_behind_each_setting() { let dir = tempfile::tempdir().unwrap(); - write_user_config( + let user = write_user_config( dir.path(), indoc::indoc! {r#" [client] - prompt_cache_ttl = "5m" + model = "claude-haiku-5-5" + effort = "high" + max_tool_rounds = 50 + + [client.compaction] + auto_threshold_tokens = 400000 "#}, ); - let config = - temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::FiveMin); - } - - #[tokio::test] - async fn load_prompt_cache_ttl_env_beats_file() { - let dir = tempfile::tempdir().unwrap(); - write_user_config( + let drop_in = write_drop_in( dir.path(), + "10-team.toml", indoc::indoc! {r#" [client] + effort = "low" prompt_cache_ttl = "5m" + + [permission] + mode = "plan" "#}, ); - let vars = env_vars(vec![xdg(&dir), env("OX_PROMPT_CACHE_TTL", "1h")]); - let config = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .unwrap(); - assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::OneHour); + let config = load_with( + &dir, + &[ + ("ANTHROPIC_MODEL", "claude-sonnet-5-5"), + ("ANTHROPIC_CUSTOM_HEADERS", "x-team: platform"), + ("CLAUDE_CODE_DISABLE_STRUCTURED_OUTPUTS", "1"), + ("OX_SHOW_THINKING", "1"), + ], + ) + .await + .unwrap(); + + assert_eq!(config.effort, Some(Effort::Low)); + assert_eq!( + config.provenance, + Provenance { + layers: vec![user.clone(), drop_in.clone()], + model: Source::Env("ANTHROPIC_MODEL"), + effort: Source::File(drop_in.clone()), + auth: Source::Env("ANTHROPIC_API_KEY"), + custom_headers: Source::Env("ANTHROPIC_CUSTOM_HEADERS"), + structured_outputs: Source::Env("CLAUDE_CODE_DISABLE_STRUCTURED_OUTPUTS"), + max_tool_rounds: Source::File(user.clone()), + prompt_cache_ttl: Source::File(drop_in.clone()), + compaction: Source::File(user), + permission_mode: Source::File(drop_in), + show_thinking: Source::Env("OX_SHOW_THINKING"), + ..Provenance::default() + }, + ); } #[tokio::test] - async fn load_prompt_cache_ttl_invalid_env_surfaces_parse_error() { + async fn load_env_sources_the_settings_it_overrides() { let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![xdg(&dir), env("OX_PROMPT_CACHE_TTL", "forever")]); - let err = temp_env::async_with_vars(vars, Config::load(dir.path(), false)) - .await - .expect_err("invalid ttl must propagate"); + let config = load_with( + &dir, + &[ + ("ANTHROPIC_BASE_URL", "https://gateway.example.com"), + ("ANTHROPIC_AUTH_TOKEN", "gateway-token"), + ("ANTHROPIC_MAX_TOKENS", "64"), + ("CLAUDE_CODE_ATTRIBUTION_HEADER", "0"), + ("OX_COMPACTION_AUTO_THRESHOLD_PERCENT", "40"), + ("OX_EXTRA_CA_CERTS", "/etc/ssl/ca.pem"), + ("OX_PERMISSION_MODE", "yolo"), + ("OX_PROMPT_CACHE_TTL", "5m"), + ], + ) + .await + .unwrap(); + assert_eq!( - format!("{err:#}"), - r#"invalid OX_PROMPT_CACHE_TTL "forever": expected one of 5m, 1h"#, + config.provenance, + Provenance { + auth: Source::Env("ANTHROPIC_AUTH_TOKEN"), + base_url: Source::Env("ANTHROPIC_BASE_URL"), + extra_ca_certs: Source::Env("OX_EXTRA_CA_CERTS"), + attribution: Source::Env("CLAUDE_CODE_ATTRIBUTION_HEADER"), + max_tokens: Source::Env("ANTHROPIC_MAX_TOKENS"), + prompt_cache_ttl: Source::Env("OX_PROMPT_CACHE_TTL"), + compaction: Source::Env("OX_COMPACTION_AUTO_THRESHOLD_PERCENT"), + permission_mode: Source::Env("OX_PERMISSION_MODE"), + ..Provenance::default() + }, ); } @@ -2546,7 +2081,7 @@ mod tests { assert_eq!(snap.max_tool_rounds, Some(100)); assert_eq!(snap.prompt_cache_ttl, PromptCacheTtl::FiveMin); assert_eq!(snap.compaction.auto.threshold_tokens, Some(42)); - assert_eq!(snap.permission_mode, crate::permission::Mode::Plan); + assert_eq!(snap.permission_mode, Mode::Plan); assert!(snap.show_thinking); assert!(!snap.show_welcome); assert_eq!( @@ -2560,6 +2095,26 @@ mod tests { assert_eq!(snap.provenance, cfg.provenance); } + // ── is_first_party_base_url ── + + #[test] + fn is_first_party_base_url_accepts_official_hosts() { + assert!(is_first_party_base_url("https://api.anthropic.com")); + assert!(is_first_party_base_url("https://api.anthropic.com/")); + assert!(is_first_party_base_url("https://api-staging.anthropic.com")); + assert!(is_first_party_base_url("https://API.ANTHROPIC.COM")); + } + + #[test] + fn is_first_party_base_url_rejects_proxies_and_malformed_urls() { + assert!(!is_first_party_base_url("https://api.openai.com")); + assert!(!is_first_party_base_url("https://proxy.example.com")); + assert!(!is_first_party_base_url("https://anthropic.com.evil.io")); + assert!(!is_first_party_base_url("http://127.0.0.1:8080")); + assert!(!is_first_party_base_url("")); + assert!(!is_first_party_base_url("not-a-url")); + } + // ── display_auth ── #[test] @@ -2613,61 +2168,4 @@ mod tests { "off (no threshold)", ); } - - // ── AutoCompactionConfig::should_trigger ── - - #[test] - fn should_trigger_requires_enabled_threshold_and_enough_tokens() { - let enabled = AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(10), - }; - assert!(enabled.should_trigger(10)); - assert!(!enabled.should_trigger(9)); - assert!(!AutoCompactionConfig::disabled().should_trigger(100)); - assert!( - !AutoCompactionConfig { - enabled: true, - threshold_tokens: None, - } - .should_trigger(100) - ); - } - - // ── is_first_party_base_url ── - - #[test] - fn is_first_party_base_url_accepts_official_hosts() { - assert!(is_first_party_base_url("https://api.anthropic.com")); - assert!(is_first_party_base_url("https://api.anthropic.com/")); - assert!(is_first_party_base_url("https://api-staging.anthropic.com")); - assert!(is_first_party_base_url("https://API.ANTHROPIC.COM")); - } - - #[test] - fn is_first_party_base_url_rejects_proxies_and_malformed_urls() { - assert!(!is_first_party_base_url("https://api.openai.com")); - assert!(!is_first_party_base_url("https://proxy.example.com")); - assert!(!is_first_party_base_url("https://anthropic.com.evil.io")); - assert!(!is_first_party_base_url("http://127.0.0.1:8080")); - assert!(!is_first_party_base_url("")); - assert!(!is_first_party_base_url("not-a-url")); - } - - // ── test_thresholds ── - - #[test] - fn test_thresholds_pin_default_auto_threshold_per_window() { - for info in crate::model::MODELS { - let model = ModelSpec::resolve(info.id.to_owned()); - assert_eq!( - default_auto_threshold(&model, 64_000), - Some(test_thresholds::WINDOW_1M), - "{}", - info.id, - ); - } - let unknown = ModelSpec::resolve("custom-model".to_owned()); - assert_eq!(default_auto_threshold(&unknown, 64_000), None); - } } diff --git a/crates/oxide-code/src/config/compaction.rs b/crates/oxide-code/src/config/compaction.rs new file mode 100644 index 00000000..a2b8db8c --- /dev/null +++ b/crates/oxide-code/src/config/compaction.rs @@ -0,0 +1,350 @@ +//! Compaction settings: whether auto-compaction runs and the token count that triggers it, derived +//! from the configured threshold and the model's context window. + +use std::ops::RangeInclusive; + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +use super::merge::Origins; +use super::provenance::{Source, Sourced}; +use super::schema::CompactionTable; +use super::{env_bool, env_number}; +use crate::model::ModelSpec; +use crate::util::path::tildify; + +const AUTO_COMPACTION_OUTPUT_RESERVE_CAP: u32 = 20_000; +const AUTO_COMPACTION_BUFFER_TOKENS: u32 = 13_000; +const MIN_AUTO_COMPACTION_THRESHOLD_TOKENS: u32 = 50_000; + +// ── CompactionConfig ── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CompactionConfig { + pub(crate) auto: AutoCompactionConfig, + policy: AutoCompactionPolicy, +} + +impl CompactionConfig { + #[cfg(test)] + pub(crate) const fn disabled() -> Self { + Self { + auto: AutoCompactionConfig::disabled(), + policy: AutoCompactionPolicy::Disabled, + } + } + + pub(crate) fn for_model(self, model: &ModelSpec, max_tokens: u32) -> Self { + resolve_policy(self.policy, model, max_tokens) + } + + #[cfg(test)] + pub(crate) const fn with_threshold_for_test(tokens: u32) -> Self { + Self { + auto: AutoCompactionConfig { + enabled: true, + threshold_tokens: Some(tokens), + }, + policy: AutoCompactionPolicy::Tokens(tokens), + } + } + + #[cfg(test)] + pub(crate) fn default_for_test(model: &ModelSpec, max_tokens: u32) -> Self { + resolve_policy(AutoCompactionPolicy::Default, model, max_tokens) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AutoCompactionConfig { + pub(crate) enabled: bool, + pub(crate) threshold_tokens: Option, +} + +impl AutoCompactionConfig { + pub(crate) const fn disabled() -> Self { + Self { + enabled: false, + threshold_tokens: None, + } + } + + pub(crate) const fn should_trigger(self, total_tokens: u32) -> bool { + match (self.enabled, self.threshold_tokens) { + (true, Some(threshold)) => total_tokens >= threshold, + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AutoCompactionPolicy { + Disabled, + Default, + Tokens(u32), + Percent(ThresholdPercent), +} + +/// A share of the context window, validated to [`Self::RANGE`] when the config loads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ThresholdPercent(u8); + +impl ThresholdPercent { + pub(super) const RANGE: RangeInclusive = 1..=100; +} + +// ── Resolution ── + +/// Sourced from the threshold when one applies, otherwise from `auto_enabled`. +pub(super) fn resolve( + file: &CompactionTable, + origins: &Origins, + model: &ModelSpec, + max_tokens: u32, +) -> Result> { + let auto_requested = env_bool("OX_COMPACTION_AUTO_ENABLED")? + .or_else(|| origins.sourced("client.compaction.auto_enabled", file.enabled)) + .unwrap_or_else(|| Sourced::fallback(true)); + // Parsed even when disabled, so a bad value fails the load as it does in a config file. + let env_tokens = env_number("OX_COMPACTION_AUTO_THRESHOLD_TOKENS", 0..=u32::MAX)?; + let env_percent = env_number( + "OX_COMPACTION_AUTO_THRESHOLD_PERCENT", + ThresholdPercent::RANGE, + )? + .map(|percent| percent.map(ThresholdPercent)); + + let policy = if auto_requested.value { + resolve_auto_threshold(env_tokens, env_percent, file, origins)? + .unwrap_or_else(|| auto_requested.map(|_| AutoCompactionPolicy::Default)) + } else { + auto_requested.map(|_| AutoCompactionPolicy::Disabled) + }; + Ok(policy.map(|policy| resolve_policy(policy, model, max_tokens))) +} + +fn resolve_policy( + policy: AutoCompactionPolicy, + model: &ModelSpec, + max_tokens: u32, +) -> CompactionConfig { + let auto = resolve_auto_compaction(policy, model, max_tokens); + CompactionConfig { auto, policy } +} + +fn resolve_auto_compaction( + policy: AutoCompactionPolicy, + model: &ModelSpec, + max_tokens: u32, +) -> AutoCompactionConfig { + let threshold = match policy { + AutoCompactionPolicy::Disabled => return AutoCompactionConfig::disabled(), + AutoCompactionPolicy::Default => default_auto_threshold(model, max_tokens), + AutoCompactionPolicy::Tokens(tokens) => { + Some(threshold_from_tokens(tokens, model, max_tokens)) + } + AutoCompactionPolicy::Percent(percent) => { + threshold_from_percent(percent, model, max_tokens) + } + }; + AutoCompactionConfig { + enabled: threshold.is_some(), + threshold_tokens: threshold, + } +} + +/// The configured threshold policy, `None` when neither threshold is set. Env thresholds replace +/// the file thresholds as a pair. +fn resolve_auto_threshold( + env_tokens: Option>, + env_percent: Option>, + file: &CompactionTable, + origins: &Origins, +) -> Result>> { + let (tokens, percent) = if env_tokens.is_some() || env_percent.is_some() { + (env_tokens, env_percent) + } else { + ( + origins.sourced( + "client.compaction.auto_threshold_tokens", + file.threshold_tokens, + ), + origins.sourced( + "client.compaction.auto_threshold_percent", + file.threshold_percent.map(ThresholdPercent), + ), + ) + }; + + match (tokens, percent) { + (Some(tokens), Some(_)) => { + // Both come from one place: the env pair, or one file since layers clear the pair. + let both = match &tokens.source { + Source::File(layer) => format!( + "{} sets both auto_threshold_tokens and auto_threshold_percent", + tildify(layer.path()) + ), + _ => "both OX_COMPACTION_AUTO_THRESHOLD_TOKENS and \ + OX_COMPACTION_AUTO_THRESHOLD_PERCENT are set" + .to_owned(), + }; + bail!("{both}. Set only one compaction threshold.") + } + (Some(tokens), None) => Ok(Some(tokens.map(AutoCompactionPolicy::Tokens))), + (None, Some(percent)) => Ok(Some(percent.map(AutoCompactionPolicy::Percent))), + (None, None) => Ok(None), + } +} + +// ── Threshold Math ── + +fn threshold_from_tokens(tokens: u32, model: &ModelSpec, max_tokens: u32) -> u32 { + clamp_threshold_floor(clamp_threshold_ceil(tokens, model, max_tokens)) +} + +fn threshold_from_percent( + ThresholdPercent(percent): ThresholdPercent, + model: &ModelSpec, + max_tokens: u32, +) -> Option { + let context_window = model.context_window()?; + let raw = context_window.saturating_mul(u32::from(percent)) / 100; + Some(clamp_threshold_floor(clamp_threshold_ceil( + raw, model, max_tokens, + ))) +} + +/// Snaps `tokens` down to the model's safe trigger when one is known. +fn clamp_threshold_ceil(tokens: u32, model: &ModelSpec, max_tokens: u32) -> u32 { + default_auto_threshold(model, max_tokens).map_or(tokens, |max| tokens.min(max)) +} + +/// Snaps `tokens` up to [`MIN_AUTO_COMPACTION_THRESHOLD_TOKENS`]. +fn clamp_threshold_floor(tokens: u32) -> u32 { + tokens.max(MIN_AUTO_COMPACTION_THRESHOLD_TOKENS) +} + +fn default_auto_threshold(model: &ModelSpec, max_tokens: u32) -> Option { + model + .context_window() + .and_then(|window| default_auto_threshold_for_window(window, max_tokens)) +} + +fn default_auto_threshold_for_window(context_window: u32, max_tokens: u32) -> Option { + let reserve = max_tokens.min(AUTO_COMPACTION_OUTPUT_RESERVE_CAP); + context_window + .checked_sub(reserve)? + .checked_sub(AUTO_COMPACTION_BUFFER_TOKENS) +} + +/// Output of [`default_auto_threshold`] when `max_tokens` saturates the reserve cap. +#[cfg(test)] +pub(crate) mod test_thresholds { + pub(crate) const WINDOW_1M: u32 = 967_000; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::MODELS; + + // ── CompactionConfig::for_model ── + + #[test] + fn for_model_re_resolves_a_percent_threshold_against_the_new_window() { + let policy = AutoCompactionPolicy::Percent(ThresholdPercent(40)); + let unknown = ModelSpec::resolve("custom-model".to_owned()); + let compaction = resolve_policy(policy, &unknown, 16_000); + assert!(!compaction.auto.enabled, "precondition: no window"); + + let known = ModelSpec::resolve("claude-opus-5-5".to_owned()); + let swapped = compaction.for_model(&known, 64_000); + + assert_eq!( + swapped.auto, + AutoCompactionConfig { + enabled: true, + threshold_tokens: Some(400_000), + } + ); + assert_eq!(swapped.policy, policy); + } + + // ── AutoCompactionConfig::should_trigger ── + + #[test] + fn should_trigger_requires_enabled_threshold_and_enough_tokens() { + let enabled = AutoCompactionConfig { + enabled: true, + threshold_tokens: Some(10), + }; + assert!(enabled.should_trigger(10)); + assert!(!enabled.should_trigger(9)); + assert!(!AutoCompactionConfig::disabled().should_trigger(100)); + assert!( + !AutoCompactionConfig { + enabled: true, + threshold_tokens: None, + } + .should_trigger(100) + ); + } + + // ── resolve_policy ── + + #[test] + fn resolve_policy_bounds_the_threshold_by_the_floor_and_the_model_window() { + let (opus, unknown) = ("claude-opus-5-5", "custom-model"); + for (id, policy, threshold) in [ + (opus, AutoCompactionPolicy::Tokens(400_000), Some(400_000)), + (opus, AutoCompactionPolicy::Tokens(0), Some(50_000)), + (opus, AutoCompactionPolicy::Tokens(49_999), Some(50_000)), + ( + opus, + AutoCompactionPolicy::Tokens(990_000), + Some(test_thresholds::WINDOW_1M), + ), + ( + opus, + AutoCompactionPolicy::Percent(ThresholdPercent(40)), + Some(400_000), + ), + ( + opus, + AutoCompactionPolicy::Percent(ThresholdPercent(4)), + Some(50_000), + ), + ( + unknown, + AutoCompactionPolicy::Percent(ThresholdPercent(40)), + None, + ), + ] { + let model = ModelSpec::resolve(id.to_owned()); + assert_eq!( + resolve_policy(policy, &model, 64_000).auto, + AutoCompactionConfig { + enabled: threshold.is_some(), + threshold_tokens: threshold, + }, + "{id} {policy:?}", + ); + } + } + + // ── test_thresholds ── + + #[test] + fn test_thresholds_pin_default_auto_threshold_per_window() { + for info in MODELS { + let model = ModelSpec::resolve(info.id.to_owned()); + assert_eq!( + default_auto_threshold(&model, 64_000), + Some(test_thresholds::WINDOW_1M), + "{}", + info.id, + ); + } + let unknown = ModelSpec::resolve("custom-model".to_owned()); + assert_eq!(default_auto_threshold(&unknown, 64_000), None); + } +} diff --git a/crates/oxide-code/src/config/load.rs b/crates/oxide-code/src/config/load.rs index 105f9501..48d9f842 100644 --- a/crates/oxide-code/src/config/load.rs +++ b/crates/oxide-code/src/config/load.rs @@ -11,9 +11,8 @@ use super::merge::{Origins, merge}; use super::provenance::Layer; use super::schema::FileConfig; use crate::permission::parse_rules; -use crate::util::path::xdg_dir; +use crate::util::path::{XdgBase, app_dir}; -const USER_CONFIG_DIR: &str = "ox"; const CONFIG_FILE: &str = "config.toml"; const DROP_IN_DIR: &str = "config.d"; const PROJECT_DIR: &str = ".ox"; @@ -31,7 +30,7 @@ pub(super) struct Loaded { /// layers win key by key, and a project file may not set credentials, endpoints, or permission /// grants. pub(super) fn load(cwd: &Path) -> Result { - let layers = discover(user_config_dir().as_deref(), Some(cwd))?; + let layers = discover(app_dir(XdgBase::Config).as_deref(), Some(cwd))?; load_layers(layers) } @@ -122,16 +121,7 @@ pub(super) fn project_config_path(dir: &Path) -> PathBuf { } pub(crate) fn user_config_path() -> Option { - user_config_dir().map(|dir| dir.join(CONFIG_FILE)) -} - -fn user_config_dir() -> Option { - xdg_dir( - std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), - std::env::home_dir(), - Path::new(".config"), - Path::new(USER_CONFIG_DIR), - ) + app_dir(XdgBase::Config).map(|dir| dir.join(CONFIG_FILE)) } // ── Reading ── @@ -222,6 +212,7 @@ mod tests { use super::*; use crate::config::provenance::Source; use crate::config::{Effort, StatusLineSegment}; + use crate::permission::Mode; fn write(path: &Path, body: &str) -> PathBuf { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -516,7 +507,7 @@ mod tests { assert_eq!(client.max_tokens, Some(4096)); assert_eq!(client.max_tool_rounds, Some(100)); assert_eq!(client.compaction.threshold_percent, Some(40)); - assert_eq!(config.permission.mode, Some(crate::permission::Mode::Plan)); + assert_eq!(config.permission.mode, Some(Mode::Plan)); assert_eq!( config.permission.deny.as_deref(), Some(&["bash(rm:*)".to_owned()][..]) diff --git a/crates/oxide-code/src/config/oauth.rs b/crates/oxide-code/src/config/oauth.rs index 213a697b..8c06b54c 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -12,7 +12,7 @@ use tracing::{debug, warn}; #[cfg(target_os = "macos")] use crate::util::env; use crate::util::fs::{atomic_write_private, create_private_dir_all}; -use crate::util::path::data_dir; +use crate::util::path::{XdgBase, app_dir}; use crate::util::tls::apply_extra_ca_certs; pub(crate) const OAUTH_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; @@ -129,30 +129,30 @@ impl OAuthCredential { /// Credentials parsed from one store, keeping the raw JSON so write-back preserves unknown fields. struct StoredCredentials<'a> { - source: Source<'a>, + backend: Backend<'a>, json: String, oauth: OAuthCredential, } impl<'a> StoredCredentials<'a> { - fn parse(login: Login, source: Source<'a>, json: String) -> Result { + fn parse(login: Login, backend: Backend<'a>, json: String) -> Result { let file: CredentialsFile = serde_json::from_str(&json) - .with_context(|| format!("failed to parse the {login} in {source}"))?; + .with_context(|| format!("failed to parse the {login} in {backend}"))?; Ok(Self { - source, + backend, json, oauth: file.claude_ai_oauth, }) } } -/// The store credentials were read from, and therefore the only one their refresh updates. -enum Source<'a> { +/// The backend credentials were read from, and therefore the only one their refresh updates. +enum Backend<'a> { Keychain(&'a dyn Keychain), File(&'a Path), } -impl fmt::Display for Source<'_> { +impl fmt::Display for Backend<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Keychain(_) => f.write_str("the macOS Keychain"), @@ -176,7 +176,7 @@ impl Store<'static> { fn locate(login: Login) -> Result { match login { Login::Ox => { - let dir = data_dir().context( + let dir = app_dir(XdgBase::Data).context( "could not determine the data directory that holds the oxide-code login", )?; Ok(Self { @@ -320,7 +320,7 @@ fn load_credentials<'a>(store: &'a Store<'a>) -> Result, L if let Some(keychain) = store.keychain { keychain_failure = match keychain.read() { Ok(Some(json)) => { - match StoredCredentials::parse(login, Source::Keychain(keychain), json) { + match StoredCredentials::parse(login, Backend::Keychain(keychain), json) { Ok(creds) => return Ok(creds), Err(e) => Some(LoginError::Unreadable(login, e)), } @@ -345,7 +345,7 @@ fn read_credentials(login: Login, path: &Path) -> Result return Err(e).with_context(|| format!("failed to read {}", path.display())), }; enforce_private_mode(path); - StoredCredentials::parse(login, Source::File(path), json).map(Some) + StoredCredentials::parse(login, Backend::File(path), json).map(Some) } #[cfg(unix)] @@ -551,9 +551,9 @@ fn write_refreshed_credentials( apply_tokens(oauth, response)?; let serialized = serde_json::to_string_pretty(&json)?; - match creds.source { - Source::Keychain(keychain) => keychain.write(&serialized), - Source::File(path) => atomic_write_private(path, serialized.as_bytes()), + match creds.backend { + Backend::Keychain(keychain) => keychain.write(&serialized), + Backend::File(path) => atomic_write_private(path, serialized.as_bytes()), } } @@ -645,11 +645,12 @@ mod tests { use super::*; use crate::util::path::testing::{HOME_VAR, abs}; + use crate::util::tls::TEST_CA_PEM; // ── OAuthCredential::expires_at_ms ── #[test] - fn expires_at_ms_positive_value() { + fn expires_at_ms_keeps_a_positive_value() { let cred = OAuthCredential { access_token: String::new(), refresh_token: None, @@ -1261,23 +1262,10 @@ mod tests { // ── read_credentials ── #[test] - fn read_credentials_valid_with_refresh_token() { + fn read_credentials_parses_tokens_and_expiry() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("creds.json"); - - std::fs::write( - &path, - indoc::indoc! {r#" - { - "claudeAiOauth": { - "accessToken": "tok", - "refreshToken": "ref", - "expiresAt": 9999999999999 - } - } - "#}, - ) - .unwrap(); + write_creds(&path, "tok", Some("ref"), 9_999_999_999_999); let creds = read_credentials(Login::ClaudeCode, &path).unwrap().unwrap(); assert_eq!(creds.oauth.access_token, "tok"); @@ -1286,24 +1274,13 @@ mod tests { } #[test] - fn read_credentials_missing_refresh_token_field() { + fn read_credentials_without_refresh_token_keeps_access_token() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("creds.json"); - - std::fs::write( - &path, - indoc::indoc! {r#" - { - "claudeAiOauth": { - "accessToken": "tok", - "expiresAt": 9999999999999 - } - } - "#}, - ) - .unwrap(); + write_creds(&path, "tok", None, 9_999_999_999_999); let creds = read_credentials(Login::ClaudeCode, &path).unwrap().unwrap(); + assert_eq!(creds.oauth.access_token, "tok"); assert!(creds.oauth.refresh_token.is_none()); } @@ -1317,7 +1294,7 @@ mod tests { } #[test] - fn read_credentials_invalid_json() { + fn read_credentials_invalid_json_fails_naming_the_login_and_path() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("creds.json"); std::fs::write(&path, "not json").unwrap(); @@ -1334,17 +1311,26 @@ mod tests { } #[test] - fn read_credentials_missing_oauth_key() { + fn read_credentials_without_oauth_key_fails_naming_the_missing_field() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("creds.json"); std::fs::write(&path, r#"{"other": "data"}"#).unwrap(); - assert!(read_credentials(Login::ClaudeCode, &path).is_err()); + let err = read_credentials(Login::ClaudeCode, &path) + .err() + .expect("credentials without claudeAiOauth must fail"); + assert!( + format!("{err:#}").starts_with(&format!( + "failed to parse the Claude Code login in {}: missing field `claudeAiOauth`", + path.display() + )), + "{err:#}" + ); } // ── is_near_expiry ── #[test] - fn is_near_expiry_far_future() { + fn is_near_expiry_far_future_is_not_near() { assert!(!is_near_expiry(u64::MAX)); } @@ -1356,12 +1342,12 @@ mod tests { // ── is_expired ── #[test] - fn is_expired_far_future() { + fn is_expired_far_future_is_not_expired() { assert!(!is_expired(u64::MAX)); } #[test] - fn is_expired_zero() { + fn is_expired_zero_is_expired() { assert!(is_expired(0)); } @@ -1462,8 +1448,7 @@ mod tests { .await; let pem = tempfile::NamedTempFile::new().unwrap(); - std::io::Write::write_all(&mut pem.as_file(), crate::util::tls::TEST_CA_PEM.as_bytes()) - .unwrap(); + std::io::Write::write_all(&mut pem.as_file(), TEST_CA_PEM.as_bytes()).unwrap(); let response = refresh_oauth_token(&server.uri(), "refresh", Some(pem.path())) .await diff --git a/crates/oxide-code/src/config/provenance.rs b/crates/oxide-code/src/config/provenance.rs index 51b89b97..ec1f5eab 100644 --- a/crates/oxide-code/src/config/provenance.rs +++ b/crates/oxide-code/src/config/provenance.rs @@ -5,6 +5,7 @@ use std::fmt; use std::path::{Path, PathBuf}; use super::load::project_config_path; +use crate::util::env; use crate::util::path::tildify; // ── Layer ── @@ -83,7 +84,7 @@ pub(crate) enum Source { impl Source { /// [`Source::Env`] when `var` is set and non-empty, matching how env vars override. pub(super) fn env_if_set(var: &'static str) -> Self { - crate::util::env::string(var).map_or(Self::Default, |_| Self::Env(var)) + env::string(var).map_or(Self::Default, |_| Self::Env(var)) } } diff --git a/crates/oxide-code/src/config/schema.rs b/crates/oxide-code/src/config/schema.rs index 28a4767c..95c5248f 100644 --- a/crates/oxide-code/src/config/schema.rs +++ b/crates/oxide-code/src/config/schema.rs @@ -6,7 +6,8 @@ use std::ops::RangeInclusive; use serde::de::{self, Unexpected}; use serde::{Deserialize, Deserializer}; -use super::{Effort, PromptCacheTtl, StatusLineSegment, ThresholdPercent}; +use super::compaction::ThresholdPercent; +use super::{Effort, PromptCacheTtl, StatusLineSegment}; use crate::permission::Mode; use crate::util::env::whole_number_in; diff --git a/crates/oxide-code/src/driver/stdio.rs b/crates/oxide-code/src/driver/stdio.rs index 42834eb9..1c8fa548 100644 --- a/crates/oxide-code/src/driver/stdio.rs +++ b/crates/oxide-code/src/driver/stdio.rs @@ -198,7 +198,8 @@ mod tests { use indoc::indoc; use super::*; - use crate::config::{AutoCompactionConfig, Effort}; + use crate::config::Effort; + use crate::config::compaction::AutoCompactionConfig; use crate::model::ModelSpec; use crate::permission::{GateTarget, PermissionClass, Policy}; use crate::protocol::{CapturingSink, FailingSink}; diff --git a/crates/oxide-code/src/engine.rs b/crates/oxide-code/src/engine.rs index 92ce86f8..ece1eb5e 100644 --- a/crates/oxide-code/src/engine.rs +++ b/crates/oxide-code/src/engine.rs @@ -818,7 +818,8 @@ mod tests { api_key, completion_body, sse_body, sse_response, test_client, test_config, text_stream_body, }; - use crate::config::{AutoCompactionConfig, CompactionConfig, PromptCacheTtl}; + use crate::config::PromptCacheTtl; + use crate::config::compaction::CompactionConfig; use crate::file_tracker::testing::seed_full_read; use crate::message::Role; use crate::permission::rule::Rule; @@ -878,10 +879,7 @@ mod tests { fn auto_compacting_client(base_url: String) -> Client { let mut config = test_config(base_url, api_key(), MODEL); - config.compaction = CompactionConfig::resolved_for_test(AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(50_000), - }); + config.compaction = CompactionConfig::with_threshold_for_test(50_000); Client::new(config, Some("sid".to_owned())).unwrap() } diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index 2940981a..74e63671 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -11,7 +11,7 @@ use std::fmt::Write; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::config::{BlockBinding, Effort, ThinkingConfig, ThinkingDisplay}; +use crate::config::Effort; pub(crate) use pricing::TokenCostRates; use pricing::{HAIKU_5_5_RATES, OPUS_5_5_RATES, SONNET_5_5_RATES}; @@ -230,6 +230,38 @@ impl<'de> Deserialize<'de> for ModelSpec { } } +// ── ThinkingConfig ── + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum ThinkingConfig { + /// Model decides the thinking budget. + Adaptive { + #[serde(skip_serializing_if = "Option::is_none")] + display: Option, + block_binding: BlockBinding, + }, +} + +/// `thinking.display` values. Omitting the field returns no thinking text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ThinkingDisplay { + Summarized, + /// Only the progress notes written between tool calls come back as text. Requires the + /// `thinking-display-updates-2026-08-18` beta. + Updates, +} + +/// `thinking.block_binding`. `DropBlock` makes the API drop a replayed thinking block whose +/// conversation prefix changed since it was produced, plus every later one, where it would +/// otherwise reject the request. Requires the `thinking-binding-controls-2026-08-01` beta. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(tag = "prefix_mismatch_behavior", rename_all = "snake_case")] +pub(crate) enum BlockBinding { + DropBlock, +} + // ── ResolvedModelId ── /// A model id a frontend picked, such as the `/model` resolver's match. The engine accepts any id, @@ -514,4 +546,27 @@ mod tests { assert_eq!(spec(info.id).unsupported_notice(), None, "{}", info.id); } } + + // ── ThinkingConfig ── + + #[test] + fn thinking_config_adaptive_serializes_display_and_block_binding() { + let binding = serde_json::json!({"prefix_mismatch_behavior": "drop_block"}); + for (display, wire) in [ + (None, None), + (Some(ThinkingDisplay::Summarized), Some("summarized")), + (Some(ThinkingDisplay::Updates), Some("updates")), + ] { + let json = serde_json::to_value(&ThinkingConfig::Adaptive { + display, + block_binding: BlockBinding::DropBlock, + }) + .unwrap(); + let mut expected = serde_json::json!({"type": "adaptive", "block_binding": binding}); + if let Some(wire) = wire { + expected["display"] = wire.into(); + } + assert_eq!(json, expected); + } + } } diff --git a/crates/oxide-code/src/permission.rs b/crates/oxide-code/src/permission.rs index e832f042..0ea5f2cf 100644 --- a/crates/oxide-code/src/permission.rs +++ b/crates/oxide-code/src/permission.rs @@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; use crate::permission::rule::{MatchDiscipline, Rule}; -use crate::util::path::{lexical_normalize, xdg_dir}; +use crate::util::path::{XdgBase, app_dir, lexical_normalize}; // ── Mode ── @@ -184,9 +184,6 @@ const PROTECTED_NESTED_FILES: &[(&str, &str)] = &[ (".claude", "settings.local.json"), ]; -/// The subdirectory oxide-code keeps under each XDG base directory. -const APP_DIR: &str = "ox"; - /// Symlinks followed while resolving one path before it counts as a loop, Linux's `MAXSYMLINKS`. const MAX_SYMLINK_HOPS: usize = 40; @@ -514,23 +511,11 @@ fn protected_roots(cwd: &Path) -> Vec { /// oxide-code's own config, data, and state directories, resolved like a target so a symlinked /// XDG base still compares equal. An edit there could rewrite its config or session history. fn app_dirs() -> Vec { - let home = std::env::home_dir(); - [ - ("XDG_CONFIG_HOME", ".config"), - ("XDG_DATA_HOME", ".local/share"), - ("XDG_STATE_HOME", ".local/state"), - ] - .into_iter() - .filter_map(|(var, fallback)| { - xdg_dir( - std::env::var_os(var).map(PathBuf::from), - home.clone(), - Path::new(fallback), - Path::new(APP_DIR), - ) - }) - .map(|dir| resolve(&dir).unwrap_or(dir)) - .collect() + XdgBase::ALL + .into_iter() + .filter_map(app_dir) + .map(|dir| resolve(&dir).unwrap_or(dir)) + .collect() } /// Whether the resolved `cwd` is a project directory whose contents may auto-allow edits. `/`, diff --git a/crates/oxide-code/src/protocol.rs b/crates/oxide-code/src/protocol.rs index bb496279..21bf0249 100644 --- a/crates/oxide-code/src/protocol.rs +++ b/crates/oxide-code/src/protocol.rs @@ -9,8 +9,9 @@ use std::path::PathBuf; use anyhow::Result; use serde::{Deserialize, Serialize}; +use crate::config::Effort; use crate::config::auth::AuthSource; -use crate::config::{AutoCompactionConfig, Effort}; +use crate::config::compaction::AutoCompactionConfig; use crate::model::{ModelSpec, ResolvedModelId}; use crate::permission::SessionGrant; use crate::permission::rule::Rule; diff --git a/crates/oxide-code/src/session/store.rs b/crates/oxide-code/src/session/store.rs index 752d8b1e..58ef17d0 100644 --- a/crates/oxide-code/src/session/store.rs +++ b/crates/oxide-code/src/session/store.rs @@ -22,7 +22,7 @@ use crate::message::Message; use crate::prompt::SessionContext; use crate::tool::ToolMetadata; use crate::util::fs::create_private_dir_all; -use crate::util::path::data_dir; +use crate::util::path::{XdgBase, app_dir}; const SESSIONS_DIR: &str = "sessions"; @@ -37,7 +37,7 @@ pub(crate) struct SessionStore { impl SessionStore { pub(crate) fn open() -> Result { - let sessions_dir = data_dir() + let sessions_dir = app_dir(XdgBase::Data) .map(|dir| dir.join(SESSIONS_DIR)) .context("cannot determine session storage directory")?; diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index 332355bb..8437ef7e 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -112,9 +112,8 @@ fn classify_in(commands: &[&dyn registry::SlashCommand], parsed: &Parsed) -> Sla /// Fully-populated `LiveSessionInfo` for per-command tests. #[cfg(test)] pub(crate) fn test_session_info() -> LiveSessionInfo { - use crate::config::{ - AutoCompactionConfig, CompactionConfig, ConfigSnapshot, Effort, PromptCacheTtl, - }; + use crate::config::compaction::CompactionConfig; + use crate::config::{ConfigSnapshot, Effort, PromptCacheTtl}; // Real MODELS row so the display name resolves to a known label. LiveSessionInfo { @@ -139,10 +138,7 @@ pub(crate) fn test_session_info() -> LiveSessionInfo { max_tokens: None, max_tool_rounds: None, prompt_cache_ttl: PromptCacheTtl::OneHour, - compaction: CompactionConfig::resolved_for_test(AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(155_000), - }), + compaction: CompactionConfig::with_threshold_for_test(155_000), permission_mode: crate::permission::Mode::Auto, show_thinking: false, show_welcome: true, diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index ce501743..d396cc6d 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -28,7 +28,8 @@ use super::modal::{Modal, ModalAction, ModalStack}; use super::terminal::{Tui, synchronized_update, write_status_hyperlinks}; use super::theme::Theme; use crate::config::auth::AuthSource; -use crate::config::{AutoCompactionConfig, Effort, StatusLineSegment, display_auto_compaction}; +use crate::config::compaction::AutoCompactionConfig; +use crate::config::{Effort, StatusLineSegment, display_auto_compaction}; use crate::model::ModelSpec; use crate::permission::SessionGrant; use crate::protocol::{Event, Interaction, Op}; @@ -1050,7 +1051,7 @@ mod tests { use tokio::sync::mpsc; use super::*; - use crate::config::test_thresholds; + use crate::config::compaction::test_thresholds; use crate::permission::rule::Rule; use crate::protocol::{Decision, ToolCall, ToolResult, UsageSnapshot}; use crate::tool::{ApprovalBody, ToolResultView}; @@ -1102,9 +1103,8 @@ mod tests { fn test_session_info() -> LiveSessionInfo { // `test-model` is intentionally unknown so `display_name` falls back to the literal // id, keeping insta snapshots stable. - use crate::config::{ - AutoCompactionConfig, CompactionConfig, ConfigSnapshot, Effort, PromptCacheTtl, - }; + use crate::config::compaction::CompactionConfig; + use crate::config::{ConfigSnapshot, Effort, PromptCacheTtl}; LiveSessionInfo { cwd: "~/test".to_owned(), @@ -1128,10 +1128,7 @@ mod tests { max_tokens: None, max_tool_rounds: None, prompt_cache_ttl: PromptCacheTtl::OneHour, - compaction: CompactionConfig::resolved_for_test(AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(155_000), - }), + compaction: CompactionConfig::with_threshold_for_test(155_000), permission_mode: crate::permission::Mode::Auto, show_thinking: false, show_welcome: true, diff --git a/crates/oxide-code/src/tui/components/welcome.rs b/crates/oxide-code/src/tui/components/welcome.rs index a62fb0f8..83a300d5 100644 --- a/crates/oxide-code/src/tui/components/welcome.rs +++ b/crates/oxide-code/src/tui/components/welcome.rs @@ -338,10 +338,8 @@ mod tests { use ratatui::backend::TestBackend; use super::*; - use crate::config::{ - AutoCompactionConfig, CompactionConfig, ConfigSnapshot, Effort, PromptCacheTtl, - test_thresholds, - }; + use crate::config::compaction::{CompactionConfig, test_thresholds}; + use crate::config::{ConfigSnapshot, Effort, PromptCacheTtl}; use crate::slash::LiveSessionInfo; const TEST_SEED: u64 = 0x00C0_FFEE; @@ -369,10 +367,7 @@ mod tests { max_tokens: None, max_tool_rounds: None, prompt_cache_ttl: PromptCacheTtl::OneHour, - compaction: CompactionConfig::resolved_for_test(AutoCompactionConfig { - enabled: true, - threshold_tokens: Some(test_thresholds::WINDOW_1M), - }), + compaction: CompactionConfig::with_threshold_for_test(test_thresholds::WINDOW_1M), permission_mode: crate::permission::Mode::Auto, show_thinking: false, show_welcome: true, diff --git a/crates/oxide-code/src/util/log.rs b/crates/oxide-code/src/util/log.rs index 364a62b6..c39ed403 100644 --- a/crates/oxide-code/src/util/log.rs +++ b/crates/oxide-code/src/util/log.rs @@ -10,9 +10,8 @@ use tracing_appender::non_blocking::{NonBlocking, WorkerGuard}; use tracing_subscriber::EnvFilter; use crate::util::fs::create_private_dir_all; -use crate::util::path::xdg_dir; +use crate::util::path::{XdgBase, app_dir}; -const APP_DIR: &str = "ox"; const LOG_SUBDIR: &str = "log"; const LOG_FILE: &str = "oxide-code.log"; const BACKUP_LOG_FILE: &str = "oxide-code.log.1"; @@ -50,26 +49,12 @@ fn build_log_target(tui_mode: bool) -> Result if !tui_mode { return Ok(None); } - let dir = resolve_log_dir().context("cannot determine log directory")?; + let dir = app_dir(XdgBase::State) + .map(|dir| dir.join(LOG_SUBDIR)) + .context("cannot determine log directory")?; Ok(Some(open_file_appender(&dir)?)) } -fn resolve_log_dir() -> Option { - log_dir_from( - std::env::var_os("XDG_STATE_HOME").map(PathBuf::from), - std::env::home_dir(), - ) -} - -fn log_dir_from(xdg: Option, home: Option) -> Option { - xdg_dir( - xdg, - home, - Path::new(".local/state"), - &Path::new(APP_DIR).join(LOG_SUBDIR), - ) -} - fn open_file_appender(dir: &Path) -> Result<(NonBlocking, WorkerGuard)> { create_private_dir_all(dir)?; let file = RotatingFile::open(dir, MAX_LOG_BYTES)?; @@ -154,7 +139,7 @@ mod tests { use std::io::Write; use super::*; - use crate::util::path::testing::{HOME_VAR, abs}; + use crate::util::path::testing::HOME_VAR; // ── make_filter ── @@ -220,41 +205,6 @@ mod tests { ); } - // ── log_dir_from ── - - #[test] - fn log_dir_from_uses_xdg_state_home_when_set_and_absolute() { - let resolved = log_dir_from( - Some(PathBuf::from(abs("/run/user/1000/state"))), - Some(PathBuf::from("/home/u")), - ); - assert_eq!( - resolved, - Some(PathBuf::from(abs("/run/user/1000/state/ox/log"))) - ); - } - - #[test] - fn log_dir_from_falls_back_to_home_local_state_when_xdg_unset() { - let resolved = log_dir_from(None, Some(PathBuf::from("/home/u"))); - assert_eq!(resolved, Some(PathBuf::from("/home/u/.local/state/ox/log"))); - } - - #[test] - fn log_dir_from_ignores_relative_xdg_and_uses_home_fallback() { - // Relative `$XDG_STATE_HOME` would resolve against cwd; `xdg_dir` rejects it. - let resolved = log_dir_from( - Some(PathBuf::from("relative/state")), - Some(PathBuf::from("/home/u")), - ); - assert_eq!(resolved, Some(PathBuf::from("/home/u/.local/state/ox/log"))); - } - - #[test] - fn log_dir_from_is_none_without_xdg_or_home() { - assert!(log_dir_from(None, None).is_none()); - } - // ── open_file_appender ── #[test] diff --git a/crates/oxide-code/src/util/path.rs b/crates/oxide-code/src/util/path.rs index 59d04d34..9bc0cbee 100644 --- a/crates/oxide-code/src/util/path.rs +++ b/crates/oxide-code/src/util/path.rs @@ -4,31 +4,47 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Result, anyhow}; -/// Resolves an XDG base directory with a `$HOME`-rooted fallback. -/// -/// `$XDG_*_HOME` is honoured only when absolute (the spec rejects relative values, which would -/// otherwise resolve against the process cwd). Returns `None` when neither input can produce an -/// absolute base. -pub(crate) fn xdg_dir( - xdg: Option, - home: Option, - home_fallback: &Path, - subdir: &Path, -) -> Option { - let base = xdg - .filter(|p| p.is_absolute()) - .or_else(|| home.map(|h| h.join(home_fallback)))?; - Some(base.join(subdir)) +/// The directory oxide-code keeps under each XDG base directory. +const APP_DIR: &str = "ox"; + +/// An XDG base directory that oxide-code keeps files under. +#[derive(Debug, Clone, Copy)] +pub(crate) enum XdgBase { + Config, + Data, + State, } -/// oxide-code's data directory, `$XDG_DATA_HOME/ox` or `~/.local/share/ox`. -pub(crate) fn data_dir() -> Option { - xdg_dir( - std::env::var_os("XDG_DATA_HOME").map(PathBuf::from), - std::env::home_dir(), - Path::new(".local/share"), - Path::new("ox"), - ) +impl XdgBase { + pub(crate) const ALL: [Self; 3] = [Self::Config, Self::Data, Self::State]; + + const fn var(self) -> &'static str { + match self { + Self::Config => "XDG_CONFIG_HOME", + Self::Data => "XDG_DATA_HOME", + Self::State => "XDG_STATE_HOME", + } + } + + /// The spec's default under `$HOME`. + const fn home_default(self) -> &'static str { + match self { + Self::Config => ".config", + Self::Data => ".local/share", + Self::State => ".local/state", + } + } +} + +/// oxide-code's directory under `base`, such as `$XDG_DATA_HOME/ox` or `~/.local/share/ox`. The +/// variable counts only when absolute, since the spec rejects relative values, which would +/// otherwise resolve against the process cwd. `None` without a home directory to fall back to. +pub(crate) fn app_dir(base: XdgBase) -> Option { + let root = std::env::var_os(base.var()) + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .or_else(|| std::env::home_dir().map(|home| home.join(base.home_default())))?; + Some(root.join(APP_DIR)) } /// Replaces a `$HOME` prefix with `~/`, falling back to the full path. @@ -106,58 +122,45 @@ mod tests { use super::testing::{HOME_VAR, abs}; use super::*; - // ── xdg_dir ── - - #[test] - fn xdg_dir_prefers_absolute_xdg_over_home_fallback() { - let result = xdg_dir( - Some(PathBuf::from(abs("/custom/data"))), - Some(PathBuf::from("/home/u")), - Path::new(".local/share"), - Path::new("ox/sessions"), - ); - assert_eq!(result, Some(PathBuf::from(abs("/custom/data/ox/sessions")))); - } - - #[test] - fn xdg_dir_falls_back_to_home_when_xdg_unset() { - let result = xdg_dir( - None, - Some(PathBuf::from("/home/u")), - Path::new(".config"), - Path::new("ox/config.toml"), - ); - assert_eq!( - result, - Some(PathBuf::from("/home/u/.config/ox/config.toml")) - ); - } + // ── app_dir ── #[test] - fn xdg_dir_ignores_relative_xdg_and_uses_home() { - let result = xdg_dir( - Some(PathBuf::from("relative")), - Some(PathBuf::from("/home/u")), - Path::new(".local/share"), - Path::new("ox/sessions"), - ); - assert_eq!( - result, - Some(PathBuf::from("/home/u/.local/share/ox/sessions")) - ); + fn app_dir_prefers_the_base_variable_then_its_home_default() { + let (home, xdg) = (abs("/home/u"), abs("/xdg")); + for (base, var, home_default) in [ + (XdgBase::Config, "XDG_CONFIG_HOME", ".config"), + (XdgBase::Data, "XDG_DATA_HOME", ".local/share"), + (XdgBase::State, "XDG_STATE_HOME", ".local/state"), + ] { + let (set, unset) = temp_env::with_vars( + [(HOME_VAR, Some(home.as_str())), (var, Some(xdg.as_str()))], + || { + ( + app_dir(base), + temp_env::with_var_unset(var, || app_dir(base)), + ) + }, + ); + assert_eq!(set, Some(Path::new(&xdg).join("ox")), "{var}"); + assert_eq!( + unset, + Some(Path::new(&home).join(home_default).join("ox")), + "{var}" + ); + } } #[test] - fn xdg_dir_is_none_without_home_or_xdg() { - assert!( - xdg_dir( - None, - None, - Path::new(".local/share"), - Path::new("ox/sessions") - ) - .is_none() + fn app_dir_ignores_a_relative_base_variable() { + let home = abs("/home/u"); + let dir = temp_env::with_vars( + [ + (HOME_VAR, Some(home.as_str())), + ("XDG_STATE_HOME", Some("relative/state")), + ], + || app_dir(XdgBase::State), ); + assert_eq!(dir, Some(Path::new(&home).join(".local/state/ox"))); } // ── tildify ──