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 ──