#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use bcode_plugin_models::PluginSelection;
use bcode_plugin_sdk::path::display_from_current_dir;
use bcode_skill_models::SkillId;
pub use hyperchad_docs_config::{ConfigDocSchema, FieldDoc, NestedFieldDoc};
use hyperchad_docs_config_derive::{ConfigDoc, ConfigDocEnum};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::{env, fs};
use thiserror::Error;
pub const DEFAULT_CONFIG_FILE_NAME: &str = "bcode.toml";
pub const BCODE_CONFIG_ENV: &str = "BCODE_CONFIG";
pub const BCODE_CONFIG_TOML_ENV: &str = "BCODE_CONFIG_TOML";
pub mod shell_presentation;
pub use shell_presentation::{
SHELL_OUTPUT_PRESENTATION_POLICY_VERSION, ShellOutputPresentationPolicy,
};
pub mod contexts;
pub mod edit;
pub mod judgement;
pub mod milestones;
pub mod recovery;
pub mod shared_pools;
pub use milestones::MilestonesConfig;
pub mod startup;
pub use judgement::{JudgementConfig, JudgementMode};
pub const BCODE_MODEL_PROFILE_ENV: &str = "BCODE_MODEL_PROFILE";
pub const BCODE_AUTH_PROFILE_ENV: &str = "BCODE_AUTH_PROFILE";
pub trait ConfigEnvironment {
fn var(&self, name: &str) -> Option<String>;
fn var_os(&self, name: &str) -> Option<OsString>;
fn current_dir(&self) -> PathBuf;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ProcessConfigEnvironment;
impl ConfigEnvironment for ProcessConfigEnvironment {
fn var(&self, name: &str) -> Option<String> {
env::var(name).ok()
}
fn var_os(&self, name: &str) -> Option<OsString> {
env::var_os(name)
}
fn current_dir(&self) -> PathBuf {
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigEnvironmentSnapshot {
vars: BTreeMap<String, OsString>,
current_dir: PathBuf,
}
impl ConfigEnvironmentSnapshot {
#[must_use]
pub const fn new(vars: BTreeMap<String, OsString>, current_dir: PathBuf) -> Self {
Self { vars, current_dir }
}
#[must_use]
pub fn from_process() -> Self {
Self {
vars: env::vars_os()
.map(|(name, value)| (name.to_string_lossy().into_owned(), value))
.collect(),
current_dir: env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}
#[must_use]
pub fn isolated(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let config_home = root.join("config");
let state_home = root.join("state");
let mut vars = BTreeMap::new();
vars.insert("HOME".to_string(), root.join("home").into_os_string());
vars.insert("XDG_CONFIG_HOME".to_string(), config_home.into_os_string());
vars.insert("XDG_STATE_HOME".to_string(), state_home.into_os_string());
vars.insert(
BCODE_CONFIG_TOML_ENV.to_string(),
"[tools.shell.env]\nmode = \"inherit\"\n".into(),
);
Self {
vars,
current_dir: root,
}
}
pub fn set_var(&mut self, name: impl Into<String>, value: impl Into<OsString>) {
self.vars.insert(name.into(), value.into());
}
pub fn remove_var(&mut self, name: &str) {
self.vars.remove(name);
}
pub fn set_current_dir(&mut self, current_dir: impl Into<PathBuf>) {
self.current_dir = current_dir.into();
}
}
impl ConfigEnvironment for ConfigEnvironmentSnapshot {
fn var(&self, name: &str) -> Option<String> {
self.vars
.get(name)
.and_then(|value| value.clone().into_string().ok())
}
fn var_os(&self, name: &str) -> Option<OsString> {
self.vars.get(name).cloned()
}
fn current_dir(&self) -> PathBuf {
self.current_dir.clone()
}
}
const DEFAULT_MODEL_PROVIDER_PLUGIN_ID: &str = "bcode.openai-compatible";
const DEFAULT_MODEL_PROVIDER_PLUGIN_IDS: &[&str] = &["bcode.openai-compatible", "bcode.bedrock"];
struct ProviderEnvironmentSpec {
plugin_id: &'static str,
aliases: &'static [&'static str],
signal_env_vars: &'static [&'static str],
model_env_vars: &'static [&'static str],
config_auth_is_configured: fn(&BcodeConfig) -> bool,
}
const PROVIDER_ENVIRONMENT_SPECS: &[ProviderEnvironmentSpec] = &[
ProviderEnvironmentSpec {
plugin_id: "bcode.bedrock",
aliases: &["bedrock", "aws-bedrock", "aws_bedrock", "bcode.bedrock"],
signal_env_vars: &[
"AWS_BEARER_TOKEN_BEDROCK",
"BCODE_BEDROCK_MODEL",
"BCODE_BEDROCK_MODELS",
"BCODE_BEDROCK_REGION",
"BCODE_BEDROCK_AWS_PROFILE",
"BCODE_BEDROCK_ENDPOINT_URL",
"BEDROCK_MODEL",
"BEDROCK_MODELS",
"BEDROCK_ENDPOINT_URL",
],
model_env_vars: &["BCODE_BEDROCK_MODEL", "BEDROCK_MODEL"],
config_auth_is_configured: no_config_auth_signal,
},
ProviderEnvironmentSpec {
plugin_id: "bcode.openai-compatible",
aliases: &[
"openai",
"openai-compatible",
"openai_compatible",
"xai",
"grok",
"bcode.openai-compatible",
],
signal_env_vars: &[
"BCODE_XAI_API_KEY",
"XAI_API_KEY",
"BCODE_XAI_MODEL",
"XAI_MODEL",
"BCODE_XAI_MODELS",
"XAI_MODELS",
"BCODE_XAI_BASE_URL",
"XAI_BASE_URL",
"BCODE_OPENAI_API_KEY",
"OPENAI_API_KEY",
"BCODE_OPENAI_MODEL",
"OPENAI_MODEL",
"BCODE_OPENAI_MODELS",
"OPENAI_MODELS",
"BCODE_OPENAI_BASE_URL",
"OPENAI_BASE_URL",
"BCODE_OPENAI_CODEX_ACCESS_TOKEN",
"BCODE_OPENAI_CODEX_REFRESH_TOKEN",
"BCODE_OPENAI_CODEX_ID_TOKEN",
],
model_env_vars: &[
"BCODE_XAI_MODEL",
"XAI_MODEL",
"BCODE_OPENAI_MODEL",
"OPENAI_MODEL",
],
config_auth_is_configured: openai_config_auth_is_configured,
},
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BcodeConfig {
#[serde(skip)]
pub startup_selection_applied: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contexts: Option<Box<contexts::ContextConfig>>,
#[serde(default, skip_serializing)]
pub composition: CompositionConfig,
#[serde(default)]
pub plugins: PluginConfig,
#[serde(default)]
pub judgement: JudgementConfig,
#[serde(default)]
pub milestones: MilestonesConfig,
#[serde(default)]
pub model: ModelConfig,
#[serde(default)]
pub agent: BTreeMap<String, bcode_agent_policy_models::AgentConfig>,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub onboarding: OnboardingConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub metrics: MetricsConfig,
#[serde(default)]
pub skills: SkillsConfig,
#[serde(default)]
pub workflows: WorkflowsConfig,
#[serde(default)]
pub invariants: InvariantsConfig,
#[serde(default)]
pub system_prompt: SystemPromptConfig,
#[serde(default)]
pub prompt_profile: PromptProfileConfig,
#[serde(default)]
pub presentation: PresentationConfig,
#[serde(default)]
pub tui: TuiConfig,
#[serde(default)]
pub session_import: SessionImportConfig,
#[serde(default)]
pub session_search: SessionSearchConfig,
#[serde(default)]
pub session_storage: SessionStorageConfig,
#[serde(default)]
pub state: StateConfig,
#[serde(default)]
pub client: ClientConfig,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub worktree: WorktreeConfig,
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default = "empty_toml_table")]
pub web_search: toml::Value,
}
impl Default for BcodeConfig {
fn default() -> Self {
Self {
startup_selection_applied: false,
active_context: None,
contexts: None,
composition: CompositionConfig::default(),
plugins: PluginConfig::default(),
model: ModelConfig::default(),
judgement: JudgementConfig::default(),
milestones: MilestonesConfig::default(),
agent: BTreeMap::new(),
auth: AuthConfig::default(),
onboarding: OnboardingConfig::default(),
observability: ObservabilityConfig::default(),
metrics: MetricsConfig::default(),
skills: SkillsConfig::default(),
workflows: WorkflowsConfig::default(),
invariants: InvariantsConfig::default(),
system_prompt: SystemPromptConfig::default(),
prompt_profile: PromptProfileConfig::default(),
presentation: PresentationConfig::default(),
tui: TuiConfig::default(),
session_import: SessionImportConfig::default(),
session_search: SessionSearchConfig::default(),
session_storage: SessionStorageConfig::default(),
state: StateConfig::default(),
client: ClientConfig::default(),
daemon: DaemonConfig::default(),
worktree: WorktreeConfig::default(),
tools: ToolsConfig::default(),
web_search: empty_toml_table(),
}
}
}
impl ConfigDocSchema for BcodeConfig {
fn section_name() -> &'static str {
"bcode"
}
fn section_description() -> &'static str {
"Top-level Bcode configuration."
}
fn field_docs() -> Vec<FieldDoc> {
vec![
schema_section_doc::<contexts::ContextConfig>(
"contexts",
"User-defined configuration contexts and selection.",
),
schema_section_doc::<CompositionConfig>(
"composition",
"Config composition metadata and profile selection.",
),
schema_section_doc::<PluginConfig>("plugins", "Bundled and external plugin selection."),
schema_section_doc::<MilestonesConfig>(
"milestones",
"Explicit opt-in paid session milestone generation.",
),
schema_section_doc::<ModelConfig>(
"model",
"Model provider, profile, alias, metadata, retry, and compaction settings.",
),
dynamic_map_section_doc::<bcode_agent_policy_models::AgentConfig>(
"agent",
"Per-agent permission and tool policy configuration.",
"<agent-id>",
),
schema_section_doc::<AuthConfig>(
"auth",
"Provider authentication profiles, pools, and runtime subscription behavior.",
),
schema_section_doc::<OnboardingConfig>(
"onboarding",
"First-run setup and automatic external credential discovery preferences.",
),
schema_section_doc::<ObservabilityConfig>(
"observability",
"Logging, tracing, and telemetry controls.",
),
schema_section_doc::<SkillsConfig>(
"skills",
"Skill discovery, activation, source, disabled-skill, and prompt catalog settings.",
),
schema_section_doc::<WorkflowsConfig>(
"workflows",
"Workflow package discovery roots and source policy.",
),
schema_section_doc::<InvariantsConfig>(
"invariants",
"Repository invariant guidance mode and selector settings.",
),
schema_section_doc::<SystemPromptConfig>(
"system_prompt",
"System prompt mode and section controls.",
),
schema_section_doc::<PromptProfileConfig>(
"prompt_profile",
"Model-scoped system prompt and tool-description profiles.",
),
schema_section_doc::<PresentationConfig>(
"presentation",
"Renderer-neutral presentation behavior.",
),
schema_section_doc::<TuiConfig>("tui", "Terminal UI behavior and appearance."),
schema_section_doc::<SessionImportConfig>(
"session_import",
"External session import plugin settings.",
),
schema_section_doc::<SessionSearchConfig>(
"session_search",
"Global derived session-search enablement.",
),
schema_section_doc::<SessionStorageConfig>(
"session_storage",
"Lossless storage tiering policy; automatic transitions are not activated yet.",
),
schema_section_doc::<StateConfig>(
"state",
"Durable state location roots, named profiles, and aggregated discovery selection.",
),
schema_section_doc::<ClientConfig>("client", "Client connection and request settings."),
schema_section_doc::<DaemonConfig>(
"daemon",
"Daemon lifecycle and connection settings.",
),
schema_section_doc::<WorktreeConfig>(
"worktree",
"Worktree creation and naming defaults.",
),
schema_section_doc::<ToolsConfig>(
"tools",
"Built-in tool behavior and environment controls.",
),
section_doc(
"web_search",
"Provider-specific web search plugin configuration.",
web_search_field_docs(),
),
]
}
fn default_values() -> BTreeMap<String, String> {
BTreeMap::new()
}
}
const fn section_doc(
toml_key: &'static str,
description: &'static str,
fields: Vec<FieldDoc>,
) -> FieldDoc {
section_doc_with_defaults(toml_key, description, fields, BTreeMap::new())
}
const fn section_doc_with_defaults(
toml_key: &'static str,
description: &'static str,
fields: Vec<FieldDoc>,
defaults: BTreeMap<String, String>,
) -> FieldDoc {
FieldDoc {
toml_key,
type_display: "table",
description,
enum_values: None,
nested: Some(NestedFieldDoc::Inline { fields, defaults }),
}
}
fn schema_section_doc<T: ConfigDocSchema>(
toml_key: &'static str,
description: &'static str,
) -> FieldDoc {
section_doc_with_defaults(toml_key, description, T::field_docs(), T::default_values())
}
fn dynamic_map_section_doc<T: ConfigDocSchema>(
toml_key: &'static str,
description: &'static str,
key_placeholder: &'static str,
) -> FieldDoc {
section_doc(
toml_key,
description,
vec![FieldDoc {
toml_key: "",
type_display: "map",
description,
enum_values: None,
nested: Some(NestedFieldDoc::Map {
key_placeholder,
value_fields: T::field_docs(),
value_defaults: T::default_values(),
}),
}],
)
}
const fn config_field(
toml_key: &'static str,
type_display: &'static str,
description: &'static str,
) -> FieldDoc {
FieldDoc {
toml_key,
type_display,
description,
enum_values: None,
nested: None,
}
}
fn web_search_field_docs() -> Vec<FieldDoc> {
vec![config_field(
"<provider-key>",
"value",
"Provider-specific web search plugin option. Supported keys depend on the enabled web-search plugin.",
)]
}
fn empty_toml_table() -> toml::Value {
toml::Value::Table(toml::Table::new())
}
impl BcodeConfig {
#[must_use]
pub fn resolved_model_selection(&self) -> ResolvedModelSelection {
self.resolved_model_selection_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn resolved_model_profile(&self, profile_name: &str) -> Option<ResolvedModelSelection> {
self.model.profiles.get(profile_name)?;
let mut scoped = self.clone();
scoped.model.profile = Some(profile_name.to_string());
scoped.model.provider_plugin_id = None;
scoped.model.model_id = None;
Some(
scoped.resolved_model_selection_with_environment(&ConfigEnvironmentSnapshot::new(
BTreeMap::new(),
PathBuf::from("."),
)),
)
}
#[must_use]
pub fn resolved_model_selection_with_environment(
&self,
environment: &impl ConfigEnvironment,
) -> ResolvedModelSelection {
if let Some(profile_name) = &self.model.profile
&& !self.model.profiles.contains_key(profile_name)
{
return ResolvedModelSelection {
model_profile: Some(profile_name.clone()),
..ResolvedModelSelection::default()
};
}
let mut selection = ResolvedModelSelection {
provider_plugin_id: self.model.provider_plugin_id.clone(),
provider_source: self
.model
.provider_plugin_id
.as_ref()
.map(|_| ModelSelectionSource::Config),
model_id: self.model.model_id.clone(),
model_source: self
.model
.model_id
.as_ref()
.map(|_| ModelSelectionSource::Config),
selected_model_id: self.model.model_id.clone(),
model_profile: self.model.profile.clone(),
auth_profile: self.model.auth_profile.clone(),
auth_pool: self.model.auth_pool.clone(),
settings: self.model.settings.clone(),
request: BTreeMap::new(),
reasoning: self.model.reasoning.clone(),
};
if let Some(profile_name) = &self.model.profile
&& let Some(profile) = self.model.profiles.get(profile_name)
{
selection.provider_plugin_id = Some(profile.provider_plugin_id.clone());
selection.provider_source = Some(ModelSelectionSource::Profile {
name: profile_name.clone(),
});
if profile.model_id.is_some() {
selection.model_id.clone_from(&profile.model_id);
selection.model_source = Some(ModelSelectionSource::Profile {
name: profile_name.clone(),
});
}
if profile.auth_profile.is_some() {
selection.auth_profile.clone_from(&profile.auth_profile);
}
if profile.auth_pool.is_some() {
selection.auth_pool.clone_from(&profile.auth_pool);
}
selection
.settings
.extend(profile.settings.iter().map(|(k, v)| (k.clone(), v.clone())));
selection.request = provider_request_values_from_json(&profile.request);
selection.reasoning = merge_reasoning_config(&self.model.reasoning, &profile.reasoning);
}
self.apply_model_alias(&mut selection);
if selection.provider_plugin_id.is_none()
&& let Some(config_provider) = self.provider_plugin_id_from_config_auth()
{
selection.provider_plugin_id = Some(config_provider);
selection.provider_source = Some(ModelSelectionSource::AuthConfig);
}
if let Some((env_provider, provider_source)) =
provider_plugin_id_and_source_from_config_environment(environment)
{
let provider_changed =
selection.provider_plugin_id.as_deref() != Some(env_provider.as_str());
selection.provider_plugin_id = Some(env_provider.clone());
selection.provider_source = Some(provider_source);
if let Some((model_id, model_source)) =
model_id_and_source_from_config_environment(environment, &env_provider)
{
selection.model_id = Some(model_id);
selection.model_source = Some(model_source);
} else if provider_changed {
selection.model_id = None;
selection.model_source = None;
}
if provider_changed {
selection.model_profile = None;
selection.auth_profile = None;
selection.auth_pool = None;
selection.settings.clear();
}
}
if let Some(provider_plugin_id) = &selection.provider_plugin_id
&& let Some((model_id, model_source)) =
model_id_and_source_from_config_environment(environment, provider_plugin_id)
{
selection.model_id = Some(model_id);
selection.model_source = Some(model_source);
}
if let Some(profile) = environment.var(BCODE_AUTH_PROFILE_ENV)
&& !profile.trim().is_empty()
{
selection.auth_profile = self.qualify_auth_override(profile);
}
self.apply_model_metadata_override(&mut selection);
selection
}
fn qualify_auth_override(&self, profile: String) -> Option<String> {
match &self.active_context {
Some(context) => contexts::qualify(context, &profile).ok(),
None => Some(profile),
}
}
fn apply_model_metadata_override(&self, selection: &mut ResolvedModelSelection) {
let Some(model_id) = selection.model_id.as_deref() else {
return;
};
let Some(metadata) = self.model.metadata.get(model_id) else {
return;
};
if let Some(provider_plugin_id) = metadata.provider_plugin_id.as_deref()
&& selection.provider_plugin_id.as_deref() != Some(provider_plugin_id)
{
return;
}
if let Some(context_window) = metadata.context_window {
selection.settings.insert(
format!("model_metadata.{model_id}.context_window"),
context_window.to_string(),
);
}
if let Some(max_output_tokens) = metadata.max_output_tokens {
selection.settings.insert(
format!("model_metadata.{model_id}.max_output_tokens"),
max_output_tokens.to_string(),
);
}
if let Some(reasoning) = reasoning_capabilities_from_config(&metadata.reasoning) {
insert_model_reasoning_settings(&mut selection.settings, model_id, &reasoning);
}
}
#[must_use]
pub fn model_selection_display_name(
&self,
provider: Option<&str>,
model: Option<&str>,
requested_model: Option<&str>,
) -> Option<String> {
let active = self.resolved_model_selection();
let matches_active = active.provider_plugin_id.as_deref() == provider
&& active.model_id.as_deref() == model
&& requested_model
.is_none_or(|requested| Some(requested) == active.selected_model_id.as_deref());
if matches_active
&& let Some(profile) = self
.model
.profile
.as_ref()
.and_then(|name| self.model.profiles.get(name))
&& let Some(label) = profile
.display_name
.as_ref()
.filter(|name| !name.trim().is_empty())
{
return Some(label.clone());
}
let selected = requested_model.or_else(|| {
matches_active
.then_some(active.selected_model_id.as_deref())
.flatten()
})?;
let alias = self.model.aliases.get(selected)?;
if Some(alias.model_id.as_str()) != model
|| alias
.provider_plugin_id
.as_deref()
.is_some_and(|id| Some(id) != provider)
{
return None;
}
alias
.display_name
.as_ref()
.filter(|name| !name.trim().is_empty())
.cloned()
}
fn apply_model_alias(&self, selection: &mut ResolvedModelSelection) {
let Some(selected_model_id) = selection.model_id.clone() else {
return;
};
let Some(alias) = self.model.aliases.get(&selected_model_id) else {
selection.selected_model_id = Some(selected_model_id);
return;
};
selection.selected_model_id = Some(selected_model_id.clone());
if let Some(provider_plugin_id) = &alias.provider_plugin_id {
selection.provider_plugin_id = Some(provider_plugin_id.clone());
selection.provider_source = Some(ModelSelectionSource::Alias {
name: selected_model_id.clone(),
});
}
selection.model_id = Some(alias.model_id.clone());
selection.model_source = Some(ModelSelectionSource::Alias {
name: selected_model_id.clone(),
});
let mut request = provider_request_values_from_json(&alias.request);
request.extend(selection.request.clone());
selection.request = request;
}
fn provider_plugin_id_from_config_auth(&self) -> Option<String> {
PROVIDER_ENVIRONMENT_SPECS
.iter()
.find(|spec| (spec.config_auth_is_configured)(self))
.map(|spec| spec.plugin_id.to_string())
}
}
const fn no_config_auth_signal(_config: &BcodeConfig) -> bool {
false
}
fn openai_config_auth_is_configured(config: &BcodeConfig) -> bool {
config
.auth
.openai
.as_ref()
.is_some_and(|auth| auth.backend == "sshenv")
}
fn provider_request_values_from_json(
values: &BTreeMap<String, serde_json::Value>,
) -> BTreeMap<String, bcode_model::ProviderRequestValue> {
values
.iter()
.map(|(key, value)| {
(
key.clone(),
bcode_model::ProviderRequestValue::from(value.clone()),
)
})
.collect()
}
#[must_use]
pub fn provider_plugin_id_from_environment() -> Option<String> {
provider_plugin_id_from_config_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn provider_plugin_id_from_config_environment(
environment: &impl ConfigEnvironment,
) -> Option<String> {
first_env_value(environment, ["BCODE_MODEL_PROVIDER", "BCODE_PROVIDER"])
.and_then(|value| normalize_provider_plugin_id(&value))
.or_else(|| implicit_provider_plugin_id_from_environment(environment))
}
fn provider_plugin_id_and_source_from_config_environment(
environment: &impl ConfigEnvironment,
) -> Option<(String, ModelSelectionSource)> {
for variable in ["BCODE_MODEL_PROVIDER", "BCODE_PROVIDER"] {
if let Some(value) = environment
.var(variable)
.filter(|value| !value.trim().is_empty())
&& let Some(provider) = normalize_provider_plugin_id(&value)
{
return Some((
provider,
ModelSelectionSource::Environment {
variable: variable.to_string(),
},
));
}
}
PROVIDER_ENVIRONMENT_SPECS.iter().find_map(|spec| {
spec.signal_env_vars.iter().find_map(|variable| {
environment
.var(variable)
.filter(|value| !value.trim().is_empty())
.map(|_| {
(
spec.plugin_id.to_string(),
ModelSelectionSource::Environment {
variable: (*variable).to_string(),
},
)
})
})
})
}
fn model_id_and_source_from_config_environment(
environment: &impl ConfigEnvironment,
provider_plugin_id: &str,
) -> Option<(String, ModelSelectionSource)> {
PROVIDER_ENVIRONMENT_SPECS
.iter()
.find(|spec| spec.plugin_id == provider_plugin_id)
.and_then(|spec| {
spec.model_env_vars.iter().find_map(|variable| {
environment
.var(variable)
.filter(|value| !value.trim().is_empty())
.map(|value| {
(
value,
ModelSelectionSource::Environment {
variable: (*variable).to_string(),
},
)
})
})
})
}
fn implicit_provider_plugin_id_from_environment(
environment: &impl ConfigEnvironment,
) -> Option<String> {
PROVIDER_ENVIRONMENT_SPECS.iter().find_map(|spec| {
first_env_value_from_slice(environment, spec.signal_env_vars)
.map(|_| spec.plugin_id.to_string())
})
}
fn normalize_provider_plugin_id(value: &str) -> Option<String> {
let value = value.trim().to_ascii_lowercase();
PROVIDER_ENVIRONMENT_SPECS
.iter()
.find(|spec| spec.aliases.contains(&value.as_str()))
.map(|spec| spec.plugin_id.to_string())
}
fn first_env_value<const N: usize>(
environment: &impl ConfigEnvironment,
names: [&str; N],
) -> Option<String> {
first_env_value_from_slice(environment, &names)
}
fn first_env_value_from_slice(
environment: &impl ConfigEnvironment,
names: &[&str],
) -> Option<String> {
names.iter().find_map(|name| match environment.var(name) {
Some(value) if !value.trim().is_empty() => Some(value),
_ => None,
})
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "composition")]
#[serde(default)]
pub struct CompositionConfig {
pub active_profile: Option<String>,
pub layer_order: Vec<String>,
#[config_doc(nested, map_key = "<profile>")]
pub profiles: BTreeMap<String, CompositionProfile>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "composition_profile")]
#[serde(default)]
pub struct CompositionProfile {
pub extends: Vec<String>,
#[config_doc(value_type = "table")]
pub patch: toml::Table,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompositionResolution {
pub selected_profile: Option<String>,
pub layer_order: Vec<String>,
pub available_profiles: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigLaunchContext {
pub config_files: Vec<PathBuf>,
pub inline_overrides: bool,
pub provider_config_directory: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recovery_sources: Option<recovery::ConfigRecoverySources>,
}
#[must_use]
pub fn config_launch_context(cwd: &Path) -> ConfigLaunchContext {
let overrides = effective_config_overrides(&ProcessConfigEnvironment);
let recovery_sources = recovery::sources_from_overrides(
cwd,
&default_config_paths_with_environment(&ProcessConfigEnvironment),
&overrides,
);
ConfigLaunchContext {
recovery_sources: Some(recovery_sources),
config_files: [
overrides.base_config_path,
overrides.env_config_path,
overrides.cli_config_path,
]
.into_iter()
.flatten()
.map(|path| {
if path.is_absolute() {
path
} else {
cwd.join(path)
}
})
.collect(),
inline_overrides: overrides.env_config_toml.is_some()
|| overrides.cli_config_toml.is_some(),
provider_config_directory: default_config_dir().join("providers"),
}
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct ConfigLoadOverrides {
pub exclusive_config_path: Option<PathBuf>,
pub base_config_path: Option<PathBuf>,
pub env_config_path: Option<PathBuf>,
pub env_config_toml: Option<String>,
pub cli_config_path: Option<PathBuf>,
pub cli_config_toml: Option<String>,
}
impl std::fmt::Debug for ConfigLoadOverrides {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ConfigLoadOverrides")
.field(
"exclusive_config_path",
&self.exclusive_config_path.is_some(),
)
.field("base_config_path", &self.base_config_path.is_some())
.field("env_config_path", &self.env_config_path.is_some())
.field("env_config_toml", &self.env_config_toml.is_some())
.field("cli_config_path", &self.cli_config_path.is_some())
.field("cli_config_toml", &self.cli_config_toml.is_some())
.finish()
}
}
impl ConfigLoadOverrides {
#[must_use]
pub fn from_env_with_cli(
cli_config_path: Option<PathBuf>,
cli_config_toml: Option<String>,
) -> Self {
Self::from_config_environment_with_cli(
&ProcessConfigEnvironment,
cli_config_path,
cli_config_toml,
)
}
#[must_use]
pub fn from_config_environment_with_cli(
environment: &impl ConfigEnvironment,
cli_config_path: Option<PathBuf>,
cli_config_toml: Option<String>,
) -> Self {
Self {
exclusive_config_path: None,
base_config_path: None,
env_config_path: environment.var_os(BCODE_CONFIG_ENV).map(PathBuf::from),
env_config_toml: merge_config_toml_overrides(
environment.var(BCODE_CONFIG_TOML_ENV),
environment
.var(BCODE_MODEL_PROFILE_ENV)
.filter(|profile| !profile.trim().is_empty())
.map(|profile| model_profile_override_toml(&profile)),
),
cli_config_path,
cli_config_toml,
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.exclusive_config_path.is_none()
&& self.base_config_path.is_none()
&& self.env_config_path.is_none()
&& self.env_config_toml.is_none()
&& self.cli_config_path.is_none()
&& self.cli_config_toml.is_none()
}
#[must_use]
pub fn with_base_config_path(mut self, path: Option<PathBuf>) -> Self {
self.base_config_path = path;
self
}
#[must_use]
pub fn with_cli_config_toml(mut self, toml: Option<String>) -> Self {
self.cli_config_toml = merge_config_toml_overrides(self.cli_config_toml, toml);
self
}
}
#[must_use]
pub fn model_profile_override_toml(profile: &str) -> String {
format!("[model]\nprofile = {}\n", toml_string(profile))
}
#[must_use]
pub fn worktree_base_ref_override_toml(base_ref: WorktreeBaseRefConfig) -> String {
let value = match base_ref {
WorktreeBaseRefConfig::Auto => "auto",
WorktreeBaseRefConfig::DefaultBranch => "default_branch",
WorktreeBaseRefConfig::Head => "head",
};
format!("[worktree]\nbase_ref = {}\n", toml_string(value))
}
fn merge_config_toml_overrides(left: Option<String>, right: Option<String>) -> Option<String> {
match (left, right) {
(Some(mut left), Some(right)) => {
if !left.ends_with('\n') {
left.push('\n');
}
left.push_str(&right);
Some(left)
}
(Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
}
}
fn process_config_overrides() -> &'static std::sync::RwLock<Option<ConfigLoadOverrides>> {
static OVERRIDES: std::sync::OnceLock<std::sync::RwLock<Option<ConfigLoadOverrides>>> =
std::sync::OnceLock::new();
OVERRIDES.get_or_init(|| std::sync::RwLock::new(None))
}
#[derive(Debug)]
pub struct ConfigOverrideGuard {
previous: Option<ConfigLoadOverrides>,
}
impl Drop for ConfigOverrideGuard {
fn drop(&mut self) {
let mut guard = process_config_overrides()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(*guard).clone_from(&self.previous);
}
}
#[must_use]
pub fn push_process_config_overrides(overrides: ConfigLoadOverrides) -> ConfigOverrideGuard {
let mut guard = process_config_overrides()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let previous = guard.clone();
*guard = Some(overrides);
drop(guard);
ConfigOverrideGuard { previous }
}
pub fn push_process_inline_config_overlay(
overlay: &str,
) -> Result<ConfigOverrideGuard, ConfigError> {
let mut overrides = effective_config_overrides(&ProcessConfigEnvironment);
let mut value = overrides.cli_config_toml.as_deref().map_or_else(
|| Ok(toml::Value::Table(toml::Table::new())),
|raw| parse_raw_toml_config(raw, "current inline selection"),
)?;
merge_toml_value(
&mut value,
parse_raw_toml_config(overlay, "request overlay")?,
);
overrides.cli_config_toml =
Some(
toml::to_string(&value).map_err(|error| ConfigError::Composition {
message: format!("request overlay serialization failed: {error}"),
})?,
);
Ok(push_process_config_overrides(overrides))
}
pub fn process_exclusive_config_path() -> Option<PathBuf> {
process_config_overrides()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(|overrides| overrides.exclusive_config_path.clone())
}
fn canonical_profile_id(profile_id: &str) -> String {
profile_id.trim().to_ascii_lowercase()
}
pub fn merge_config_values(base: &mut toml::Value, overlay: toml::Value) {
merge_toml_value(base, overlay);
}
fn merge_toml_value(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
for (key, value) in overlay_table {
if let Some(existing) = base_table.get_mut(&key) {
merge_toml_value(existing, value);
} else {
base_table.insert(key, value);
}
}
}
(base_slot, overlay_value) => *base_slot = overlay_value,
}
}
fn parse_composition_config(root: &toml::Table) -> Result<CompositionConfig, ConfigError> {
let Some(value) = root.get("composition") else {
return Ok(CompositionConfig::default());
};
value
.clone()
.try_into()
.map_err(|source| ConfigError::Composition {
message: format!("invalid [composition] config: {source}"),
})
}
fn resolve_profile_patch(
requested_profile_id: &str,
profiles: &BTreeMap<String, CompositionProfile>,
) -> Result<toml::Table, ConfigError> {
fn resolve_inner(
requested_profile_id: &str,
profiles: &BTreeMap<String, CompositionProfile>,
stack: &mut Vec<String>,
cache: &mut BTreeMap<String, toml::Table>,
) -> Result<toml::Table, ConfigError> {
let canonical_id = canonical_profile_id(requested_profile_id);
if let Some(resolved) = cache.get(&canonical_id) {
return Ok(resolved.clone());
}
if stack.contains(&canonical_id) {
let mut cycle = stack.clone();
cycle.push(canonical_id.clone());
return Err(ConfigError::Composition {
message: format!("profile inheritance cycle detected: {}", cycle.join(" -> ")),
});
}
let Some(profile) = profiles.get(&canonical_id) else {
return Err(ConfigError::Composition {
message: format!(
"profile '{requested_profile_id}' is not defined (known profiles: {})",
profiles.keys().cloned().collect::<Vec<_>>().join(", ")
),
});
};
stack.push(canonical_id.clone());
let mut resolved = toml::Table::new();
for parent_id in &profile.extends {
let parent_patch = resolve_inner(parent_id, profiles, stack, cache)?;
let mut merged = toml::Value::Table(resolved);
merge_toml_value(&mut merged, toml::Value::Table(parent_patch));
resolved = merged.as_table().cloned().unwrap_or_default();
}
let mut merged = toml::Value::Table(resolved);
merge_toml_value(&mut merged, toml::Value::Table(profile.patch.clone()));
let resolved = merged.as_table().cloned().unwrap_or_default();
stack.pop();
cache.insert(canonical_id, resolved.clone());
Ok(resolved)
}
resolve_inner(
requested_profile_id,
profiles,
&mut Vec::new(),
&mut BTreeMap::new(),
)
}
fn resolve_composed_config_value(
raw: &toml::Value,
) -> Result<(toml::Value, CompositionResolution), ConfigError> {
let mut raw_table = raw
.as_table()
.cloned()
.ok_or_else(|| ConfigError::Composition {
message: "config root must be a table".to_string(),
})?;
let composition = parse_composition_config(&raw_table)?;
raw_table.remove("composition");
let mut profiles = BTreeMap::new();
for (profile_id, profile) in composition.profiles {
let canonical_id = canonical_profile_id(&profile_id);
if canonical_id.is_empty() {
return Err(ConfigError::Composition {
message: "composition profile id must not be empty".to_string(),
});
}
if profiles.insert(canonical_id, profile).is_some() {
return Err(ConfigError::Composition {
message: "composition profile IDs must be distinct after normalization".to_owned(),
});
}
}
let active_profile = composition
.active_profile
.as_deref()
.map(canonical_profile_id);
let layer_order = if composition.layer_order.is_empty() {
if active_profile.is_some() {
vec![
"defaults".to_string(),
"profile:active".to_string(),
"config".to_string(),
]
} else {
vec!["defaults".to_string(), "config".to_string()]
}
} else {
composition.layer_order
};
let mut resolved = toml::Value::try_from(BcodeConfig::default()).map_err(|source| {
ConfigError::Composition {
message: format!("failed to serialize default config: {source}"),
}
})?;
for layer in &layer_order {
match layer.as_str() {
"defaults" => {}
"config" => merge_toml_value(&mut resolved, toml::Value::Table(raw_table.clone())),
"profile:active" => {
let Some(active_profile) = active_profile.as_deref() else {
return Err(ConfigError::Composition {
message: "layer 'profile:active' requires composition.active_profile"
.to_string(),
});
};
let patch = resolve_profile_patch(active_profile, &profiles)?;
merge_toml_value(&mut resolved, toml::Value::Table(patch));
}
_ if layer.starts_with("profile:") => {
let profile_id = layer.trim_start_matches("profile:");
let patch = resolve_profile_patch(profile_id, &profiles)?;
merge_toml_value(&mut resolved, toml::Value::Table(patch));
}
unknown => {
return Err(ConfigError::Composition {
message: format!("unknown composition layer '{unknown}'"),
});
}
}
}
contexts::resolve(&mut resolved)?;
let mut available_profiles = profiles.keys().cloned().collect::<Vec<_>>();
available_profiles.sort();
Ok((
resolved,
CompositionResolution {
selected_profile: active_profile,
layer_order,
available_profiles,
},
))
}
#[must_use]
pub fn bedrock_environment_is_configured() -> bool {
bedrock_environment_is_configured_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn bedrock_environment_is_configured_with_environment(
environment: &impl ConfigEnvironment,
) -> bool {
PROVIDER_ENVIRONMENT_SPECS
.iter()
.find(|spec| spec.plugin_id == "bcode.bedrock")
.is_some_and(|spec| first_env_value_from_slice(environment, spec.signal_env_vars).is_some())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "onboarding")]
pub struct OnboardingConfig {
#[serde(default = "default_true")]
pub credential_discovery: bool,
#[serde(default = "default_true")]
pub open_browser: bool,
}
impl Default for OnboardingConfig {
fn default() -> Self {
Self {
credential_discovery: true,
open_browser: true,
}
}
}
impl OnboardingConfig {
#[must_use]
pub fn browser_opening_enabled(&self, environment: &impl ConfigEnvironment) -> bool {
self.open_browser
&& environment
.var_os("BCODE_NO_BROWSER")
.is_none_or(|value| value == "0" || value == "false")
}
#[must_use]
pub fn credential_discovery_enabled(
&self,
cli_disabled: bool,
environment: &impl ConfigEnvironment,
) -> bool {
self.credential_discovery
&& !cli_disabled
&& environment
.var_os("BCODE_NO_CREDENTIAL_DISCOVERY")
.is_none_or(|value| value == "0" || value == "false")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "invariants")]
pub struct InvariantsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub mode: InvariantGuidanceMode,
#[serde(default = "default_invariant_max_selected")]
pub max_selected: std::num::NonZeroUsize,
#[serde(default = "default_true")]
pub include_task_summary: bool,
#[config_doc(nested)]
#[serde(default)]
pub selector: InvariantSelectorConfig,
}
impl Default for InvariantsConfig {
fn default() -> Self {
Self {
enabled: true,
mode: InvariantGuidanceMode::Full,
max_selected: default_invariant_max_selected(),
include_task_summary: true,
selector: InvariantSelectorConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum InvariantGuidanceMode {
#[default]
Full,
Relevant,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "selector")]
pub struct InvariantSelectorConfig {
#[serde(default)]
pub model_profile: Option<String>,
#[serde(default)]
pub initial_timeout_ms: Option<std::num::NonZeroU64>,
#[serde(default)]
pub background_timeout_ms: Option<std::num::NonZeroU64>,
#[serde(default)]
pub wait_for_background_on_next_prompt: bool,
#[serde(default)]
pub cancel_on_timeout: bool,
#[serde(default)]
pub cancel_stale: bool,
#[serde(default)]
pub on_timeout: InvariantSelectorTimeoutPolicy,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum InvariantSelectorTimeoutPolicy {
#[default]
Full,
Previous,
Deterministic,
None,
}
const fn default_invariant_max_selected() -> std::num::NonZeroUsize {
std::num::NonZeroUsize::new(6).expect("invariant selection count is non-zero")
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "prompt_profile")]
pub struct PromptProfileConfig {
#[config_doc(nested)]
#[serde(default)]
pub bundled: BundledPromptProfileConfig,
#[config_doc(nested)]
#[serde(default)]
pub default: PromptProfileLayerConfig,
#[serde(default)]
pub provider: BTreeMap<String, PromptProfileLayerConfig>,
#[serde(default)]
pub family: BTreeMap<String, PromptProfileLayerConfig>,
#[serde(default)]
pub catalog_entry: BTreeMap<String, PromptProfileLayerConfig>,
#[serde(default)]
pub model: BTreeMap<String, PromptProfileLayerConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "bundled")]
pub struct BundledPromptProfileConfig {
#[serde(default)]
pub disabled: BTreeSet<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "profile_layer")]
pub struct PromptProfileLayerConfig {
#[serde(default)]
pub system_prompt_mode: PromptProfileTextMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default)]
pub tool_description: BTreeMap<String, ToolDescriptionOverrideConfig>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum PromptProfileTextMode {
#[default]
Append,
Prepend,
Replace,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "tool_description")]
pub struct ToolDescriptionOverrideConfig {
#[serde(default)]
pub mode: PromptProfileTextMode,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "system_prompt")]
pub struct SystemPromptConfig {
#[serde(default)]
pub mode: SystemPromptMode,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub repository_instructions_max_chars: Option<std::num::NonZeroUsize>,
#[serde(default)]
pub repository_invariants_max_chars: Option<std::num::NonZeroUsize>,
#[serde(default = "default_git_status_max_chars")]
pub git_status_max_chars: std::num::NonZeroUsize,
#[config_doc(nested)]
#[serde(default)]
pub sections: SystemPromptSectionsConfig,
}
impl Default for SystemPromptConfig {
fn default() -> Self {
Self {
mode: SystemPromptMode::Default,
text: None,
repository_instructions_max_chars: None,
repository_invariants_max_chars: None,
git_status_max_chars: default_git_status_max_chars(),
sections: SystemPromptSectionsConfig::default(),
}
}
}
const fn default_git_status_max_chars() -> std::num::NonZeroUsize {
std::num::NonZeroUsize::new(4_000).expect("Git status limit is non-zero")
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum SystemPromptMode {
#[default]
Default,
Replace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[allow(clippy::struct_excessive_bools)]
#[config_doc(section = "sections")]
pub struct SystemPromptSectionsConfig {
#[serde(default = "default_true")]
pub current_datetime: bool,
#[serde(default = "default_true")]
pub execution_environment: bool,
#[serde(default = "default_true")]
pub hosting_environment: bool,
#[serde(default = "default_true")]
pub locale: bool,
#[serde(default = "default_true")]
pub git_revision: bool,
#[serde(default = "default_true")]
pub runtime_mode: bool,
#[serde(default)]
pub toolchain_context: bool,
#[serde(default = "default_true")]
pub worktree_context: bool,
#[serde(default = "default_true")]
pub config_profile: bool,
#[serde(default = "default_true")]
pub config_selection: bool,
#[serde(default = "default_true")]
pub repository_invariants: bool,
#[serde(default = "default_true")]
pub repository_context: bool,
#[serde(default = "default_true")]
pub dynamic_repository_context: bool,
#[serde(default = "default_true")]
pub agent_suffix: bool,
#[serde(default = "default_true")]
pub user_authority: bool,
#[serde(default = "default_true")]
pub skill_catalog: bool,
#[serde(default = "default_true")]
pub model_profile: bool,
}
impl Default for SystemPromptSectionsConfig {
fn default() -> Self {
Self {
current_datetime: true,
execution_environment: true,
hosting_environment: true,
locale: true,
git_revision: true,
runtime_mode: true,
toolchain_context: false,
worktree_context: true,
config_profile: true,
config_selection: true,
repository_invariants: true,
repository_context: true,
dynamic_repository_context: true,
agent_suffix: true,
user_authority: true,
skill_catalog: true,
model_profile: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[allow(clippy::struct_excessive_bools)]
#[config_doc(section = "skills")]
pub struct SkillsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub auto_activate: SkillAutoActivateMode,
#[serde(default = "default_true")]
pub include_repo_skills: bool,
#[serde(default = "default_true")]
pub include_generic_repo_skills: bool,
#[serde(default = "default_true")]
pub include_user_skills: bool,
#[serde(default = "default_true")]
pub include_compat_claude_skills: bool,
#[serde(default)]
pub max_context_bytes: Option<std::num::NonZeroUsize>,
#[serde(default = "default_skill_file_bytes")]
pub max_skill_file_bytes: std::num::NonZeroU64,
#[serde(default = "default_skill_preview_chars")]
pub preview_max_chars: std::num::NonZeroUsize,
#[serde(default)]
pub follow_symlinks: bool,
#[config_doc(nested)]
#[serde(default)]
pub sources: SkillSourceConfig,
#[config_doc(nested)]
#[serde(default)]
pub disabled: DisabledSkillsConfig,
#[config_doc(nested)]
#[serde(default)]
pub prompt: SkillPromptConfig,
#[config_doc(nested)]
#[serde(default)]
pub model_policy: SkillModelPolicyConfig,
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
enabled: true,
auto_activate: SkillAutoActivateMode::Suggest,
include_repo_skills: true,
include_generic_repo_skills: true,
include_user_skills: true,
include_compat_claude_skills: true,
max_context_bytes: None,
max_skill_file_bytes: default_skill_file_bytes(),
preview_max_chars: default_skill_preview_chars(),
follow_symlinks: true,
sources: SkillSourceConfig::default(),
disabled: DisabledSkillsConfig::default(),
prompt: SkillPromptConfig::default(),
model_policy: SkillModelPolicyConfig::default(),
}
}
}
impl SkillsConfig {
#[must_use]
pub fn disabled_skill_ids(&self) -> BTreeSet<SkillId> {
self.disabled
.ids
.iter()
.cloned()
.map(SkillId::new)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "workflows")]
pub struct WorkflowsConfig {
#[serde(default = "default_true")]
pub include_repo_workflows: bool,
#[serde(default = "default_true")]
pub include_user_workflows: bool,
#[serde(default)]
pub run_edit_plugins: BTreeSet<String>,
#[serde(default)]
pub run_publication_plugins: BTreeSet<String>,
#[serde(default)]
pub run_publication_local_clients: bool,
#[serde(default = "default_workflow_admission_timeout_ms")]
pub admission_timeout_ms: std::num::NonZeroU64,
#[serde(default = "default_workflow_continuation_workers")]
pub continuation_workers: std::num::NonZeroUsize,
#[serde(default)]
pub paths: Vec<PathBuf>,
}
const fn default_workflow_admission_timeout_ms() -> std::num::NonZeroU64 {
std::num::NonZeroU64::new(30_000).unwrap()
}
const fn default_workflow_continuation_workers() -> std::num::NonZeroUsize {
std::num::NonZeroUsize::new(16).unwrap()
}
impl Default for WorkflowsConfig {
fn default() -> Self {
Self {
include_repo_workflows: true,
include_user_workflows: true,
run_edit_plugins: BTreeSet::new(),
run_publication_plugins: BTreeSet::new(),
run_publication_local_clients: false,
admission_timeout_ms: default_workflow_admission_timeout_ms(),
continuation_workers: default_workflow_continuation_workers(),
paths: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum SkillAutoActivateMode {
Off,
#[default]
Suggest,
On,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "model_policy")]
pub struct SkillModelPolicyConfig {
#[serde(default)]
pub required_override: SkillRequiredModelOverride,
#[serde(default)]
pub required_unresolved: SkillUnresolvedModelBehavior,
#[serde(default = "default_preferred_unresolved_model_behavior")]
pub preferred_unresolved: SkillUnresolvedModelBehavior,
#[serde(default)]
pub provider_fallback: Vec<String>,
#[serde(default)]
pub skill: BTreeMap<String, SkillModelPolicyOverrideConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "skill")]
pub struct SkillModelPolicyOverrideConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_override: Option<SkillRequiredModelOverride>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_unresolved: Option<SkillUnresolvedModelBehavior>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_unresolved: Option<SkillUnresolvedModelBehavior>,
#[serde(default)]
pub provider_fallback: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum SkillRequiredModelOverride {
#[default]
Deny,
Allow,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum SkillUnresolvedModelBehavior {
#[default]
Deny,
Warn,
Ignore,
}
const fn default_preferred_unresolved_model_behavior() -> SkillUnresolvedModelBehavior {
SkillUnresolvedModelBehavior::Warn
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "prompt")]
pub struct SkillPromptConfig {
#[serde(default)]
pub catalog: SkillPromptCatalogMode,
#[serde(default)]
pub max_bytes: Option<std::num::NonZeroUsize>,
#[serde(default)]
pub max_description_chars: Option<std::num::NonZeroUsize>,
#[serde(default = "default_true")]
pub include_sources: bool,
#[serde(default)]
pub include_keywords: bool,
}
impl Default for SkillPromptConfig {
fn default() -> Self {
Self {
catalog: SkillPromptCatalogMode::Summary,
max_bytes: None,
max_description_chars: None,
include_sources: true,
include_keywords: false,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum SkillPromptCatalogMode {
Off,
NamesOnly,
#[default]
Summary,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "sources")]
pub struct SkillSourceConfig {
#[serde(default)]
pub paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "disabled")]
pub struct DisabledSkillsConfig {
#[serde(default)]
pub ids: BTreeSet<String>,
}
const fn default_skill_file_bytes() -> std::num::NonZeroU64 {
std::num::NonZeroU64::new(256 * 1024).expect("skill file limit is non-zero")
}
const fn default_skill_preview_chars() -> std::num::NonZeroUsize {
std::num::NonZeroUsize::new(2_000).expect("skill preview limit is non-zero")
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "observability")]
pub struct ObservabilityConfig {
#[serde(default)]
pub level: ObservabilityLevel,
#[serde(default)]
pub persist_model_requests: bool,
#[serde(default = "default_true")]
pub persist_tool_io: bool,
#[serde(default = "default_max_trace_blob_bytes")]
pub max_blob_bytes: usize,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
level: ObservabilityLevel::Standard,
persist_model_requests: false,
persist_tool_io: true,
max_blob_bytes: default_max_trace_blob_bytes(),
}
}
}
impl ObservabilityConfig {
#[must_use]
pub const fn enabled(&self) -> bool {
!matches!(self.level, ObservabilityLevel::Off)
}
#[must_use]
pub const fn debug_enabled(&self) -> bool {
matches!(
self.level,
ObservabilityLevel::Debug | ObservabilityLevel::Raw
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum ObservabilityLevel {
Off,
#[default]
Standard,
Debug,
Raw,
}
const fn default_max_trace_blob_bytes() -> usize {
10 * 1024 * 1024
}
const fn default_metrics_segment_max_bytes() -> u64 {
8 * 1024 * 1024
}
const fn default_metrics_total_max_bytes() -> u64 {
128 * 1024 * 1024
}
const fn default_metrics_recent_read_max_bytes() -> u64 {
16 * 1024 * 1024
}
const fn default_metrics_max_recent_events() -> usize {
10_000
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "metrics")]
pub struct MetricsConfig {
#[serde(default)]
pub startup_reports: bool,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub persist_events: bool,
#[serde(default = "default_metrics_segment_max_bytes")]
pub segment_max_bytes: u64,
#[serde(default = "default_metrics_total_max_bytes")]
pub total_max_bytes: u64,
#[serde(default = "default_metrics_recent_read_max_bytes")]
pub recent_read_max_bytes: u64,
#[serde(default = "default_metrics_max_recent_events")]
pub max_recent_events: usize,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
startup_reports: false,
enabled: false,
persist_events: false,
segment_max_bytes: default_metrics_segment_max_bytes(),
total_max_bytes: default_metrics_total_max_bytes(),
recent_read_max_bytes: default_metrics_recent_read_max_bytes(),
max_recent_events: default_metrics_max_recent_events(),
}
}
}
#[cfg(test)]
mod startup_config_tests {
#[test]
fn startup_reports_are_explicit_and_round_trip() {
let default: super::MetricsConfig = toml::from_str("").unwrap();
assert!(!default.startup_reports);
let enabled: super::MetricsConfig = toml::from_str("startup_reports = true").unwrap();
assert!(enabled.startup_reports);
assert!(!enabled.enabled);
let serialized = toml::to_string(&enabled).unwrap();
assert_eq!(
toml::from_str::<super::MetricsConfig>(&serialized).unwrap(),
enabled
);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "session_import")]
pub struct SessionImportConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub auto_discover_on_startup: bool,
#[serde(default = "default_true")]
pub hide_already_imported: bool,
#[config_doc(nested)]
#[serde(default)]
pub pi: PiSessionImportConfig,
#[config_doc(nested)]
#[serde(default)]
pub opencode: OpenCodeSessionImportConfig,
#[config_doc(nested)]
#[serde(default)]
pub chatgpt: ChatGptSessionImportConfig,
}
impl Default for SessionImportConfig {
fn default() -> Self {
Self {
enabled: true,
auto_discover_on_startup: true,
hide_already_imported: true,
pi: PiSessionImportConfig::default(),
opencode: OpenCodeSessionImportConfig::default(),
chatgpt: ChatGptSessionImportConfig::default(),
}
}
}
impl SessionImportConfig {
#[must_use]
pub fn chatgpt_sync_enabled(&self, profile: &str) -> bool {
self.enabled
&& self.chatgpt.enabled
&& self
.chatgpt
.profiles
.get(profile)
.is_none_or(|policy| policy.enabled)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "chatgpt")]
#[serde(default, deny_unknown_fields)]
pub struct ChatGptSessionImportConfig {
pub enabled: bool,
pub profiles: BTreeMap<String, ChatGptSessionImportProfileConfig>,
}
impl Default for ChatGptSessionImportConfig {
fn default() -> Self {
Self {
enabled: true,
profiles: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "chatgpt_profile")]
#[serde(default, deny_unknown_fields)]
pub struct ChatGptSessionImportProfileConfig {
pub enabled: bool,
}
impl Default for ChatGptSessionImportProfileConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[cfg(test)]
mod chatgpt_import_policy_tests {
use super::SessionImportConfig;
#[test]
fn default_on_includes_new_profiles_and_round_trips() {
let policy: SessionImportConfig = toml::from_str("").unwrap();
assert_eq!(policy, SessionImportConfig::default());
for profile in ["openai", "openai-2", "new-profile"] {
assert!(policy.chatgpt_sync_enabled(profile));
}
assert_eq!(
toml::from_str::<SessionImportConfig>(&toml::to_string(&policy).unwrap()).unwrap(),
policy
);
}
#[test]
fn exclusions_are_exact_and_global_disables_win() {
let mut policy: SessionImportConfig = toml::from_str(
"[chatgpt.profiles.openai-2]\nenabled = false\n[chatgpt.profiles.openai]\nenabled = true"
).unwrap();
assert!(policy.chatgpt_sync_enabled("openai"));
assert!(!policy.chatgpt_sync_enabled("openai-2"));
assert!(policy.chatgpt_sync_enabled("openai-20"));
policy.chatgpt.enabled = false;
assert!(!policy.chatgpt_sync_enabled("openai"));
policy.chatgpt.enabled = true;
policy.enabled = false;
assert!(!policy.chatgpt_sync_enabled("openai"));
}
#[test]
fn discovery_timing_is_not_an_authorization_override() {
let policy: SessionImportConfig =
toml::from_str("auto_discover_on_startup = false").unwrap();
assert!(policy.chatgpt_sync_enabled("openai"));
}
#[test]
fn invalid_privacy_settings_fail_instead_of_silently_enabling() {
for input in [
"[chatgpt]\nenabeld = false",
"[chatgpt]\nenabled = 'false'",
"[chatgpt.profiles.openai]\nenabeld = false",
] {
assert!(toml::from_str::<SessionImportConfig>(input).is_err());
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "session_search")]
pub struct SessionSearchConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for SessionSearchConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "session_storage")]
#[serde(default, deny_unknown_fields)]
pub struct SessionStorageConfig {
pub enabled: bool,
pub light_after_days: u32,
pub deep_after_days: u32,
pub maintenance_interval_secs: u32,
pub artifact_timeout_secs: u32,
pub minimum_saved_bytes: u64,
}
impl Default for SessionStorageConfig {
fn default() -> Self {
Self {
enabled: true,
light_after_days: 5,
deep_after_days: 30,
maintenance_interval_secs: 60,
artifact_timeout_secs: 30,
minimum_saved_bytes: 4096,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "state")]
#[serde(default)]
pub struct StateConfig {
pub root: Option<PathBuf>,
pub sessions_root: Option<PathBuf>,
#[config_doc(nested, map_key = "<profile>")]
pub profile: BTreeMap<String, StateProfileConfig>,
pub default_profile: Option<String>,
pub readable_profiles: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "state_profile")]
#[serde(default)]
pub struct StateProfileConfig {
pub root: Option<PathBuf>,
pub sessions_root: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "client")]
pub struct ClientConfig {
#[serde(default = "default_client_request_timeout_secs")]
pub request_timeout_secs: u64,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
request_timeout_secs: default_client_request_timeout_secs(),
}
}
}
const fn default_client_request_timeout_secs() -> u64 {
15
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "daemon")]
pub struct DaemonConfig {
#[serde(default = "default_true")]
pub idle_shutdown: bool,
#[serde(default = "default_daemon_idle_shutdown_after_secs")]
pub idle_shutdown_after_secs: u64,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
idle_shutdown: true,
idle_shutdown_after_secs: default_daemon_idle_shutdown_after_secs(),
}
}
}
const fn default_daemon_idle_shutdown_after_secs() -> u64 {
15 * 60
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "pi")]
pub struct PiSessionImportConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub paths: Vec<PathBuf>,
#[serde(default)]
pub path_mode: SessionImportPathMode,
}
impl Default for PiSessionImportConfig {
fn default() -> Self {
Self {
enabled: true,
paths: Vec::new(),
path_mode: SessionImportPathMode::DefaultsAndCustom,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "opencode")]
pub struct OpenCodeSessionImportConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub paths: Vec<PathBuf>,
#[serde(default)]
pub path_mode: SessionImportPathMode,
}
impl Default for OpenCodeSessionImportConfig {
fn default() -> Self {
Self {
enabled: true,
paths: Vec::new(),
path_mode: SessionImportPathMode::DefaultsAndCustom,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum SessionImportPathMode {
DefaultsOnly,
CustomOnly,
#[default]
DefaultsAndCustom,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "worktree")]
pub struct WorktreeConfig {
#[serde(default = "default_worktree_root")]
pub root: PathBuf,
#[serde(default = "default_worktree_branch_prefix")]
pub branch_prefix: String,
#[serde(default)]
pub base_ref: WorktreeBaseRefConfig,
#[config_doc(nested)]
#[serde(default)]
pub setup: WorktreeSetupConfig,
}
impl Default for WorktreeConfig {
fn default() -> Self {
Self {
root: default_worktree_root(),
branch_prefix: default_worktree_branch_prefix(),
base_ref: WorktreeBaseRefConfig::default(),
setup: WorktreeSetupConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum WorktreeBaseRefConfig {
#[default]
Auto,
DefaultBranch,
Head,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "setup")]
pub struct WorktreeSetupConfig {
#[serde(default = "default_worktree_setup_enabled")]
pub enabled: bool,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub direnv_allow: bool,
}
impl Default for WorktreeSetupConfig {
fn default() -> Self {
Self {
enabled: default_worktree_setup_enabled(),
profile: None,
direnv_allow: false,
}
}
}
fn default_worktree_root() -> PathBuf {
PathBuf::from(".bcode").join("worktrees")
}
fn default_worktree_branch_prefix() -> String {
"bcode/".to_string()
}
const fn default_worktree_setup_enabled() -> bool {
true
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "presentation")]
pub struct PresentationConfig {
#[config_doc(nested)]
#[serde(default)]
pub streaming: PresentationStreamingConfig,
#[config_doc(nested)]
#[serde(default)]
pub completion: CompletionConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "completion")]
pub struct CompletionConfig {
#[serde(default)]
pub match_mode: CompletionMatchMode,
#[serde(default)]
pub sort_order: CompletionSortOrder,
#[serde(default)]
pub filtered_sort_order: CompletionFilteredSortOrder,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum CompletionMatchMode {
#[default]
Fuzzy,
Prefix,
Substring,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum CompletionSortOrder {
#[default]
Alphabetical,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum CompletionFilteredSortOrder {
#[default]
Relevance,
Alphabetical,
Inherit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "streaming")]
pub struct PresentationStreamingConfig {
#[serde(default = "default_streaming_presentation_enabled")]
pub enabled: bool,
#[serde(default)]
pub curve: StreamingInterpolationCurveConfig,
#[serde(default = "default_streaming_presentation_graphemes_per_second")]
pub graphemes_per_second: u32,
#[serde(default = "default_streaming_presentation_max_lag_ms")]
pub max_lag_ms: u64,
}
impl PresentationStreamingConfig {
#[must_use]
pub const fn policy(self) -> bcode_session_view_models::StreamingPresentationPolicy {
bcode_session_view_models::StreamingPresentationPolicy {
enabled: self.enabled,
curve: match self.curve {
StreamingInterpolationCurveConfig::Linear => {
bcode_session_view_models::StreamingInterpolationCurve::Linear
}
StreamingInterpolationCurveConfig::EaseIn => {
bcode_session_view_models::StreamingInterpolationCurve::EaseIn
}
StreamingInterpolationCurveConfig::EaseOut => {
bcode_session_view_models::StreamingInterpolationCurve::EaseOut
}
StreamingInterpolationCurveConfig::EaseInOut => {
bcode_session_view_models::StreamingInterpolationCurve::EaseInOut
}
},
graphemes_per_second: self.graphemes_per_second,
max_lag_ms: self.max_lag_ms,
}
.normalized()
}
}
impl Default for PresentationStreamingConfig {
fn default() -> Self {
Self {
enabled: default_streaming_presentation_enabled(),
curve: StreamingInterpolationCurveConfig::default(),
graphemes_per_second: default_streaming_presentation_graphemes_per_second(),
max_lag_ms: default_streaming_presentation_max_lag_ms(),
}
}
}
const fn default_streaming_presentation_enabled() -> bool {
true
}
const fn default_streaming_presentation_graphemes_per_second() -> u32 {
bcode_session_view_models::StreamingPresentationPolicy::DEFAULT_GRAPHEMES_PER_SECOND
}
const fn default_streaming_presentation_max_lag_ms() -> u64 {
bcode_session_view_models::StreamingPresentationPolicy::DEFAULT_MAX_LAG_MS
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum StreamingInterpolationCurveConfig {
#[default]
Linear,
EaseIn,
EaseOut,
EaseInOut,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "session_resurrection")]
pub struct SessionResurrectionConfig {
#[serde(default)]
pub mode: SessionResurrectionMode,
#[serde(default)]
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum SessionResurrectionMode {
#[default]
Auto,
Enabled,
Disabled,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "tui")]
pub struct TuiConfig {
#[config_doc(nested)]
#[serde(default)]
pub session_resurrection: SessionResurrectionConfig,
#[config_doc(nested)]
#[serde(default)]
pub render: TuiRenderConfig,
#[config_doc(nested)]
#[serde(default)]
pub keybindings: TuiKeyBindingConfig,
#[config_doc(nested)]
#[serde(default)]
pub diff_viewer: TuiDiffViewerConfig,
#[config_doc(nested)]
#[serde(default)]
pub interactions: TuiInteractionConfig,
#[config_doc(nested)]
#[serde(default)]
pub mouse: TuiMouseConfig,
#[config_doc(nested)]
#[serde(default)]
pub selection: TuiSelectionConfig,
#[config_doc(nested)]
#[serde(default)]
pub markdown: TuiMarkdownConfig,
#[config_doc(nested)]
#[serde(default)]
pub visual_adapters: TuiVisualAdapterConfig,
#[config_doc(nested)]
#[serde(default)]
pub thinking: TuiThinkingConfig,
#[config_doc(nested)]
#[serde(default)]
pub theme: TuiThemeConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "visual_adapters")]
pub struct TuiVisualAdapterConfig {
#[serde(default)]
pub preferred: Vec<String>,
#[serde(default)]
pub disabled: BTreeSet<String>,
}
impl TuiVisualAdapterConfig {
#[must_use]
pub fn order_routes(
&self,
routes: Vec<bcode_plugin_models::PluginVisualAdapterRoute>,
) -> Vec<bcode_plugin_models::PluginVisualAdapterRoute> {
let preferred = self
.preferred
.iter()
.enumerate()
.map(|(index, adapter)| (adapter.as_str(), index))
.collect::<BTreeMap<_, _>>();
let mut routes = routes
.into_iter()
.filter(|route| !self.disabled.contains(&route.adapter_reference()))
.collect::<Vec<_>>();
routes.sort_by_key(|route| {
preferred
.get(route.adapter_reference().as_str())
.copied()
.unwrap_or(usize::MAX)
});
routes
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "render")]
pub struct TuiRenderConfig {
#[serde(default = "default_tui_render_max_fps")]
pub max_fps: u16,
}
impl TuiRenderConfig {
#[must_use]
pub fn frame_interval(self) -> Option<std::time::Duration> {
(self.max_fps != 0).then(|| {
std::time::Duration::from_secs_f64(1.0 / f64::from(self.max_fps.clamp(1, 240)))
})
}
}
impl Default for TuiRenderConfig {
fn default() -> Self {
Self {
max_fps: default_tui_render_max_fps(),
}
}
}
const fn default_tui_render_max_fps() -> u16 {
60
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "interactions")]
pub struct TuiInteractionConfig {
#[serde(default)]
pub placement: TuiInteractionPlacement,
#[serde(default)]
pub offscreen_focus: TuiInteractionOffscreenFocus,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiInteractionPlacement {
#[default]
Transcript,
Pinned,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiInteractionOffscreenFocus {
#[default]
Retain,
Suspend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "diff_viewer")]
pub struct TuiDiffViewerConfig {
#[serde(default)]
pub layout: TuiDiffViewerLayout,
#[serde(default = "default_diff_viewer_breakpoint")]
pub side_by_side_breakpoint: u16,
}
impl Default for TuiDiffViewerConfig {
fn default() -> Self {
Self {
layout: TuiDiffViewerLayout::Auto,
side_by_side_breakpoint: default_diff_viewer_breakpoint(),
}
}
}
const fn default_diff_viewer_breakpoint() -> u16 {
120
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiDiffViewerLayout {
#[default]
Auto,
Unified,
SideBySide,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiAccentTransitionCurve {
Linear,
EaseIn,
#[default]
EaseOut,
EaseInOut,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "theme")]
pub struct TuiThemeConfig {
#[serde(default = "default_tui_theme_name")]
pub name: String,
#[serde(default)]
pub overlays: Vec<String>,
#[serde(default)]
pub variant: TuiThemeVariant,
#[serde(default)]
pub paths: Vec<PathBuf>,
#[serde(default)]
pub agent_accent: TuiAgentAccentPolicy,
#[serde(default)]
pub accent_transition: TuiAccentTransitionMode,
#[serde(default = "default_tui_accent_transition_ms")]
pub accent_transition_ms: u64,
#[serde(default)]
pub accent_transition_curve: TuiAccentTransitionCurve,
}
impl TuiThemeConfig {
#[must_use]
pub const fn effective_accent_transition_ms(&self) -> u64 {
if matches!(self.accent_transition, TuiAccentTransitionMode::Immediate) {
0
} else {
self.accent_transition_ms
}
}
}
impl Default for TuiThemeConfig {
fn default() -> Self {
Self {
name: default_tui_theme_name(),
overlays: Vec::new(),
variant: TuiThemeVariant::Auto,
paths: Vec::new(),
agent_accent: TuiAgentAccentPolicy::AgentWithThemeFallback,
accent_transition: TuiAccentTransitionMode::Transition,
accent_transition_ms: default_tui_accent_transition_ms(),
accent_transition_curve: TuiAccentTransitionCurve::EaseOut,
}
}
}
fn default_tui_theme_name() -> String {
"terminal-native".to_owned()
}
const fn default_tui_accent_transition_ms() -> u64 {
220
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiThemeVariant {
#[default]
Auto,
Dark,
Light,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiAgentAccentPolicy {
ThemeOnly,
#[default]
AgentWithThemeFallback,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiAccentTransitionMode {
Immediate,
#[default]
Transition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "thinking")]
pub struct TuiThinkingConfig {
#[serde(default)]
pub show: bool,
#[serde(default)]
pub mode: TuiThinkingMode,
}
impl Default for TuiThinkingConfig {
fn default() -> Self {
Self {
show: true,
mode: TuiThinkingMode::All,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "kebab-case")]
pub enum ToolDefaultMode {
#[default]
Agent,
None,
All,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "tools")]
pub struct ToolsConfig {
#[serde(default)]
pub default: ToolDefaultMode,
#[serde(default)]
pub enabled: BTreeSet<String>,
#[serde(default)]
pub disabled: BTreeSet<String>,
#[config_doc(nested)]
#[serde(default)]
pub execution: ToolExecutionConfig,
#[config_doc(nested)]
#[serde(default)]
pub shell: ShellToolConfig,
#[config_doc(nested)]
#[serde(default)]
pub question: QuestionToolConfig,
}
impl Default for ToolsConfig {
fn default() -> Self {
Self {
default: ToolDefaultMode::Agent,
enabled: BTreeSet::new(),
disabled: BTreeSet::new(),
execution: ToolExecutionConfig::default(),
shell: ShellToolConfig::default(),
question: QuestionToolConfig::default(),
}
}
}
const fn default_tool_execution_parallel() -> bool {
true
}
const fn default_tool_preparation_timeout_ms() -> u64 {
30_000
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "execution")]
pub struct ToolExecutionConfig {
#[serde(default = "default_tool_execution_parallel")]
pub parallel: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<std::num::NonZeroUsize>,
#[serde(default = "default_tool_preparation_timeout_ms")]
pub preparation_timeout_ms: u64,
#[serde(default = "default_tool_host_retry_initial_delay_ms")]
pub host_retry_initial_delay_ms: u64,
#[serde(default = "default_tool_host_retry_max_delay_ms")]
pub host_retry_max_delay_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_max_retries: Option<u64>,
}
const fn default_tool_host_retry_initial_delay_ms() -> u64 {
250
}
const fn default_tool_host_retry_max_delay_ms() -> u64 {
30_000
}
#[cfg(test)]
mod tool_host_retry_config_tests {
use super::ToolExecutionConfig;
#[test]
fn old_execution_config_keeps_unlimited_host_retry_default() {
let config: ToolExecutionConfig = toml::from_str("parallel = false").unwrap();
assert!(!config.parallel);
assert_eq!(config.host_max_retries, None);
assert_eq!(config.host_retry_initial_delay_ms, 250);
assert_eq!(config.host_retry_max_delay_ms, 30_000);
}
#[test]
fn explicit_retry_limits_roundtrip() {
for retries in [0, 3] {
let config: ToolExecutionConfig = toml::from_str(&format!(
"host_max_retries = {retries}\nhost_retry_initial_delay_ms = 4\nhost_retry_max_delay_ms = 50"
)).unwrap();
assert_eq!(config.host_max_retries, Some(retries));
assert_eq!(
toml::from_str::<ToolExecutionConfig>(&toml::to_string(&config).unwrap()).unwrap(),
config
);
}
}
}
impl ToolExecutionConfig {
#[must_use]
pub fn runtime_options(&self) -> bcode_tool::ToolExecutionOptions {
bcode_tool::ToolExecutionOptions {
parallel: self.parallel,
max_concurrency: self.max_concurrency,
preparation_timeout_ms: std::num::NonZeroU64::new(self.preparation_timeout_ms)
.unwrap_or(std::num::NonZeroU64::MIN),
}
}
}
impl Default for ToolExecutionConfig {
fn default() -> Self {
Self {
parallel: default_tool_execution_parallel(),
max_concurrency: None,
preparation_timeout_ms: default_tool_preparation_timeout_ms(),
host_retry_initial_delay_ms: default_tool_host_retry_initial_delay_ms(),
host_retry_max_delay_ms: default_tool_host_retry_max_delay_ms(),
host_max_retries: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "question")]
pub struct QuestionToolConfig {
#[serde(default = "default_question_enabled")]
pub enabled: bool,
#[serde(default = "default_question_ask_aggressiveness")]
pub ask_aggressiveness: u8,
}
impl Default for QuestionToolConfig {
fn default() -> Self {
Self {
enabled: default_question_enabled(),
ask_aggressiveness: default_question_ask_aggressiveness(),
}
}
}
const fn default_question_enabled() -> bool {
true
}
const fn default_question_ask_aggressiveness() -> u8 {
5
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "shell")]
pub struct ShellToolConfig {
#[config_doc(nested)]
#[serde(default)]
pub env: ShellToolEnvConfig,
#[config_doc(nested)]
#[serde(default)]
pub output: ShellToolOutputConfig,
#[serde(default = "default_shell_max_output_bytes")]
pub max_output_bytes: usize,
#[serde(default = "default_shell_inline_output_bytes")]
pub inline_output_bytes: usize,
}
impl Default for ShellToolConfig {
fn default() -> Self {
Self {
env: ShellToolEnvConfig::default(),
output: ShellToolOutputConfig::default(),
max_output_bytes: default_shell_max_output_bytes(),
inline_output_bytes: default_shell_inline_output_bytes(),
}
}
}
const fn default_shell_max_output_bytes() -> usize {
10 * 1024 * 1024
}
const fn default_shell_inline_output_bytes() -> usize {
16 * 1024
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "env")]
pub struct ShellToolEnvConfig {
#[serde(default)]
pub mode: ShellToolEnvMode,
#[serde(default)]
pub auto_fallback: ShellToolEnvAutoFallback,
#[serde(default = "default_hide_direnv_prelude")]
pub hide_direnv_prelude: bool,
}
impl Default for ShellToolEnvConfig {
fn default() -> Self {
Self {
mode: ShellToolEnvMode::Auto,
auto_fallback: ShellToolEnvAutoFallback::Error,
hide_direnv_prelude: default_hide_direnv_prelude(),
}
}
}
const fn default_hide_direnv_prelude() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "output")]
pub struct ShellToolOutputConfig {
#[serde(default = "default_shell_format_commands")]
pub format_commands: bool,
#[config_doc(nested)]
#[serde(default)]
pub presentation: ShellOutputPresentationPolicy,
#[config_doc(skip)]
#[serde(default)]
pub prelude_gates: Vec<ShellToolPreludeGateConfig>,
}
impl Default for ShellToolOutputConfig {
fn default() -> Self {
Self {
format_commands: default_shell_format_commands(),
presentation: ShellOutputPresentationPolicy::default(),
prelude_gates: Vec::new(),
}
}
}
impl ShellToolOutputConfig {
#[must_use]
pub fn resolve_presentation_policy(
&self,
format_commands: Option<bool>,
) -> ShellOutputPresentationPolicy {
let mut policy = self.presentation.clone();
policy.enabled &= format_commands.unwrap_or(self.format_commands);
policy
}
}
const fn default_shell_format_commands() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShellToolPreludeGateConfig {
pub name: String,
pub marker: String,
#[serde(default = "default_shell_prelude_gate_enabled")]
pub enabled: bool,
#[serde(default = "default_shell_prelude_gate_hide_from")]
pub hide_from: BTreeSet<ShellToolPreludeGateTarget>,
}
impl Default for ShellToolPreludeGateConfig {
fn default() -> Self {
Self {
name: String::new(),
marker: String::new(),
enabled: default_shell_prelude_gate_enabled(),
hide_from: default_shell_prelude_gate_hide_from(),
}
}
}
const fn default_shell_prelude_gate_enabled() -> bool {
true
}
fn default_shell_prelude_gate_hide_from() -> BTreeSet<ShellToolPreludeGateTarget> {
BTreeSet::from([
ShellToolPreludeGateTarget::Live,
ShellToolPreludeGateTarget::Replay,
ShellToolPreludeGateTarget::Clean,
])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShellToolPreludeGateTarget {
Live,
Replay,
Clean,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum ShellToolEnvMode {
#[default]
Auto,
Inherit,
Direnv,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum ShellToolEnvAutoFallback {
#[default]
Error,
Inherit,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiThinkingMode {
#[default]
All,
Summary,
Raw,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "markdown")]
pub struct TuiMarkdownConfig {
#[serde(default)]
pub network_images: bool,
#[serde(default = "default_tui_markdown_mermaid")]
pub mermaid: bool,
}
impl Default for TuiMarkdownConfig {
fn default() -> Self {
Self {
network_images: false,
mermaid: default_tui_markdown_mermaid(),
}
}
}
const fn default_tui_markdown_mermaid() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "selection")]
pub struct TuiSelectionConfig {
#[serde(default = "default_tui_selection_auto_copy_on_mouse_release")]
pub auto_copy_on_mouse_release: bool,
#[serde(default = "default_tui_selection_clear_after_copy")]
pub clear_after_copy: bool,
#[serde(default = "default_tui_selection_clear_delay_ms")]
pub clear_delay_ms: u64,
}
impl Default for TuiSelectionConfig {
fn default() -> Self {
Self {
auto_copy_on_mouse_release: default_tui_selection_auto_copy_on_mouse_release(),
clear_after_copy: default_tui_selection_clear_after_copy(),
clear_delay_ms: default_tui_selection_clear_delay_ms(),
}
}
}
const fn default_tui_selection_auto_copy_on_mouse_release() -> bool {
true
}
const fn default_tui_selection_clear_after_copy() -> bool {
true
}
const fn default_tui_selection_clear_delay_ms() -> u64 {
1000
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "mouse")]
pub struct TuiMouseConfig {
#[serde(default = "default_tui_selection_autoscroll")]
pub selection_autoscroll: bool,
#[serde(default = "default_tui_mouse_scroll_rows")]
pub scroll_rows: usize,
#[serde(default = "default_mouse_multi_click_ms")]
pub multi_click_ms: u64,
#[serde(default)]
pub multi_click_max_distance: u16,
#[serde(default)]
pub double_click_select: TuiMouseClickSelection,
#[serde(default = "default_triple_click_select")]
pub triple_click_select: TuiMouseClickSelection,
}
impl TuiMouseConfig {
#[must_use]
pub const fn effective_scroll_rows(self) -> usize {
if self.scroll_rows == 0 {
1
} else {
self.scroll_rows
}
}
}
impl Default for TuiMouseConfig {
fn default() -> Self {
Self {
selection_autoscroll: default_tui_selection_autoscroll(),
scroll_rows: default_tui_mouse_scroll_rows(),
multi_click_ms: default_mouse_multi_click_ms(),
multi_click_max_distance: 0,
double_click_select: TuiMouseClickSelection::Word,
triple_click_select: default_triple_click_select(),
}
}
}
const fn default_tui_selection_autoscroll() -> bool {
true
}
const fn default_tui_mouse_scroll_rows() -> usize {
3
}
const fn default_mouse_multi_click_ms() -> u64 {
500
}
const fn default_triple_click_select() -> TuiMouseClickSelection {
TuiMouseClickSelection::All
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum TuiMouseClickSelection {
Disabled,
#[default]
Word,
Line,
All,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, ConfigDoc)]
#[config_doc(section = "keybindings")]
pub struct TuiKeyBindingConfig {
#[config_doc(map_key = "<key-stroke>")]
#[serde(default)]
pub chat: BTreeMap<String, String>,
#[config_doc(map_key = "<key-stroke>")]
#[serde(default)]
pub permission: BTreeMap<String, String>,
#[config_doc(map_key = "<key-stroke>")]
#[serde(default)]
pub session_picker: BTreeMap<String, String>,
#[config_doc(map_key = "<key-stroke>")]
#[serde(default)]
pub skill_picker: BTreeMap<String, String>,
#[serde(default)]
pub scopes: BTreeMap<String, BTreeMap<String, String>>,
#[config_doc(skip)]
#[serde(skip)]
pub legacy_actions: BTreeMap<String, Vec<String>>,
}
impl TuiKeyBindingConfig {
#[must_use]
pub fn is_empty(&self) -> bool {
self.chat.is_empty()
&& self.permission.is_empty()
&& self.session_picker.is_empty()
&& self.skill_picker.is_empty()
&& self.scopes.is_empty()
&& self.legacy_actions.is_empty()
}
}
impl TuiKeyBindingConfig {
#[must_use]
pub fn scoped_overrides(&self) -> BTreeMap<String, BTreeMap<String, String>> {
self.scoped_overrides_with(str::to_owned)
}
#[must_use]
pub fn scoped_overrides_with(
&self,
canonicalize: impl Fn(&str) -> String,
) -> BTreeMap<String, BTreeMap<String, String>> {
let mut scopes = self.scopes.clone();
for (scope, bindings) in [
("chat", &self.chat),
("permission", &self.permission),
("session_picker", &self.session_picker),
("skill_picker", &self.skill_picker),
] {
scopes
.entry(scope.into())
.or_default()
.extend(bindings.clone());
}
for bindings in scopes.values_mut() {
for action in bindings.values_mut() {
if !action.is_empty() {
*action = canonicalize(action);
}
}
}
scopes
}
}
impl<'de> Deserialize<'de> for TuiKeyBindingConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let raw = BTreeMap::<String, toml::Value>::deserialize(deserializer)?;
let mut config = Self::default();
for (key, value) in raw {
match key.as_str() {
"skill_picker" => {
config.skill_picker = parse_tui_keybinding_section(value, "skill_picker")?;
}
"scopes" => config.scopes = value.try_into().map_err(D::Error::custom)?,
"chat" => config.chat = parse_tui_keybinding_section(value, "chat")?,
"permission" => {
config.permission = parse_tui_keybinding_section(value, "permission")?;
}
"session_picker" | "sessionPicker" => {
config.session_picker = parse_tui_keybinding_section(value, "session_picker")?;
}
legacy_action => {
if let toml::Value::Array(values) = value {
let mut keys = Vec::new();
for item in values {
let toml::Value::String(key) = item else {
return Err(D::Error::custom(format!(
"legacy tui keybinding '{legacy_action}' must be an array of strings"
)));
};
keys.push(key);
}
config
.legacy_actions
.insert(legacy_action.to_string(), keys);
} else {
return Err(D::Error::custom(format!(
"unknown tui keybinding section or invalid legacy entry '{legacy_action}'"
)));
}
}
}
}
Ok(config)
}
}
fn parse_tui_keybinding_section<E>(
value: toml::Value,
section: &str,
) -> Result<BTreeMap<String, String>, E>
where
E: serde::de::Error,
{
let toml::Value::Table(table) = value else {
return Err(E::custom(format!(
"tui.keybindings.{section} must be a table of key = action entries"
)));
};
let mut bindings = BTreeMap::new();
for (key, value) in table {
let toml::Value::String(action) = value else {
return Err(E::custom(format!(
"tui.keybindings.{section}.{key} must be a string action ID"
)));
};
bindings.insert(key, action);
}
Ok(bindings)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "auth")]
pub struct AuthConfig {
#[config_doc(nested)]
#[serde(default)]
pub openai: Option<AuthProviderConfig>,
#[config_doc(nested, map_key = "<profile>")]
#[serde(default)]
pub profiles: BTreeMap<String, AuthProfileConfig>,
#[config_doc(nested, map_key = "<provider-id>")]
#[serde(default)]
pub bindings: BTreeMap<String, AuthBindingConfig>,
#[config_doc(nested, map_key = "<pool>")]
#[serde(default)]
pub pools: BTreeMap<String, AuthPoolConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "auth_pool")]
pub struct AuthPoolConfig {
#[serde(default)]
pub automatic_provider: Option<String>,
#[serde(default)]
pub automatic_group: Option<String>,
#[serde(default)]
pub provider_plugin_id: Option<String>,
#[serde(default)]
pub strategy: AuthPoolStrategy,
#[serde(default)]
pub preferred_profile: Option<String>,
#[config_doc(list_index = "<index>")]
#[serde(default)]
pub profiles: Vec<String>,
#[config_doc(nested)]
#[serde(default)]
pub priming: AuthPoolPrimingConfig,
#[config_doc(nested)]
#[serde(default)]
pub quota: AuthPoolQuotaConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum AuthPoolStrategy {
#[default]
Failover,
RoundRobin,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "priming")]
pub struct AuthPoolPrimingConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub include_primary: bool,
#[serde(default)]
pub reprime_after: Option<String>,
#[serde(default = "default_priming_provider_windows")]
pub provider_windows: bool,
#[serde(default = "default_priming_fallback_reprime_after")]
pub fallback_reprime_after: Option<String>,
#[config_doc(skip)]
#[serde(default)]
pub required_windows: BTreeMap<String, Vec<String>>,
}
const fn default_priming_provider_windows() -> bool {
true
}
#[allow(clippy::unnecessary_wraps)]
fn default_priming_fallback_reprime_after() -> Option<String> {
Some("7d".to_string())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "quota")]
pub struct AuthPoolQuotaConfig {
#[serde(default)]
pub unknown_cooldown: Option<String>,
#[serde(default)]
pub rate_limit_cooldown: Option<String>,
#[serde(default)]
pub weekly_cooldown: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "auth_binding")]
pub struct AuthBindingConfig {
#[serde(default)]
pub profile: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "auth_profile")]
pub struct AuthProfileConfig {
pub backend: String,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub owner_plugin_id: Option<String>,
#[serde(default)]
pub scheme: Option<String>,
#[config_doc(nested, map_key = "<credential>")]
#[serde(default)]
pub map: BTreeMap<String, AuthCredentialMapping>,
#[serde(default)]
pub settings: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "auth_credential_mapping")]
pub struct AuthCredentialMapping {
#[serde(default)]
pub env: Option<String>,
#[serde(default)]
pub key: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "auth_provider")]
pub struct AuthProviderConfig {
pub backend: String,
#[serde(default)]
pub mode: AuthMode,
pub profile: String,
#[serde(default)]
pub vault: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
#[default]
ApiKey,
#[serde(rename = "chatgpt", alias = "chat_gpt")]
ChatGpt,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "model")]
#[serde(deny_unknown_fields)]
pub struct ModelConfig {
#[serde(default)]
pub provider_plugin_id: Option<String>,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub auth_profile: Option<String>,
#[serde(default)]
pub auth_pool: Option<String>,
#[config_doc(map_key = "<setting>")]
#[serde(default)]
pub settings: BTreeMap<String, String>,
#[config_doc(values("low", "medium", "high"))]
#[serde(default)]
pub default_thinking_level: Option<bcode_model::ReasoningEffort>,
#[config_doc(nested)]
#[serde(default)]
pub reasoning: ReasoningConfig,
#[serde(default)]
pub max_tool_rounds: Option<u32>,
#[config_doc(nested)]
#[serde(default)]
pub context_strategy: ContextStrategyConfig,
#[config_doc(nested)]
#[serde(default)]
pub prompt_cache: PromptCacheConfig,
#[config_doc(nested)]
#[serde(default)]
pub conversation_reuse: ConversationReuseConfig,
#[config_doc(nested)]
#[serde(default)]
pub tool_output: ToolOutputConfig,
#[config_doc(nested)]
#[serde(default)]
pub streaming: StreamingConfig,
#[config_doc(nested)]
#[serde(default)]
pub retry: ModelRetryConfig,
#[config_doc(nested)]
#[serde(default)]
pub compaction: CompactionConfig,
#[serde(default)]
pub profile: Option<String>,
#[config_doc(nested, map_key = "<profile>")]
#[serde(default)]
pub profiles: BTreeMap<String, ModelProfileConfig>,
#[config_doc(nested, map_key = "<alias>")]
#[serde(default)]
pub aliases: BTreeMap<String, ModelAliasConfig>,
#[config_doc(nested, map_key = "<provider-or-model>")]
#[serde(default)]
pub metadata: BTreeMap<String, ModelMetadataConfig>,
#[config_doc(nested, map_key = "<provider>")]
#[serde(default)]
pub ignored: BTreeMap<String, ModelIgnoreConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "model_ignore")]
pub struct ModelIgnoreConfig {
#[serde(default)]
pub models: BTreeSet<String>,
#[serde(default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelIgnoreSource {
Config,
State,
Both,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelIgnoreMatch {
pub source: ModelIgnoreSource,
pub rule: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EffectiveModelIgnoreRules {
pub config: ModelIgnoreConfig,
pub state: ModelIgnoreConfig,
}
impl EffectiveModelIgnoreRules {
#[must_use]
pub fn is_ignored(&self, model_id: &str) -> Option<ModelIgnoreMatch> {
let config_rule = ignore_rule_match(&self.config, model_id);
let state_rule = ignore_rule_match(&self.state, model_id);
match (config_rule, state_rule) {
(Some(config), Some(state)) => Some(ModelIgnoreMatch {
source: ModelIgnoreSource::Both,
rule: format!("{config}; {state}"),
}),
(Some(rule), None) => Some(ModelIgnoreMatch {
source: ModelIgnoreSource::Config,
rule,
}),
(None, Some(rule)) => Some(ModelIgnoreMatch {
source: ModelIgnoreSource::State,
rule,
}),
(None, None) => None,
}
}
}
fn ignore_rule_match(rules: &ModelIgnoreConfig, model_id: &str) -> Option<String> {
if rules.models.contains(model_id) {
return Some(model_id.to_string());
}
rules
.patterns
.iter()
.find(|pattern| model_id.contains(pattern.as_str()))
.map(|pattern| format!("*{pattern}*"))
}
impl ModelConfig {
#[must_use]
pub fn effective_max_tool_rounds(&self) -> Option<u32> {
self.max_tool_rounds.filter(|rounds| *rounds > 0)
}
#[must_use]
pub const fn effective_prompt_cache_mode(&self) -> bcode_model::PromptCacheMode {
match self.context_strategy.mode {
ContextStrategyMode::ProviderReuse => self.prompt_cache.mode,
ContextStrategyMode::ExplicitCachedTranscript => {
bcode_model::PromptCacheMode::Aggressive
}
}
}
#[must_use]
pub const fn effective_conversation_reuse_mode(&self) -> bcode_model::ConversationReuseMode {
match self.context_strategy.mode {
ContextStrategyMode::ProviderReuse => self.conversation_reuse.mode,
ContextStrategyMode::ExplicitCachedTranscript => {
bcode_model::ConversationReuseMode::Off
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "context_strategy")]
pub struct ContextStrategyConfig {
#[serde(default)]
pub mode: ContextStrategyMode,
}
impl Default for ContextStrategyConfig {
fn default() -> Self {
Self {
mode: ContextStrategyMode::ProviderReuse,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum ContextStrategyMode {
#[default]
ProviderReuse,
ExplicitCachedTranscript,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "reasoning")]
pub struct ReasoningConfig {
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub effort_values: Vec<String>,
#[serde(default)]
pub summary_values: Vec<String>,
#[serde(default)]
pub default_effort: Option<String>,
#[serde(default)]
pub default_summary: Option<String>,
#[serde(default)]
pub visible_summary_supported: Option<bool>,
#[serde(default)]
pub raw_reasoning_supported: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "prompt_cache")]
pub struct PromptCacheConfig {
#[config_doc(values("off", "auto", "aggressive"))]
#[serde(default)]
pub mode: bcode_model::PromptCacheMode,
}
impl Default for PromptCacheConfig {
fn default() -> Self {
Self {
mode: bcode_model::PromptCacheMode::Auto,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "tool_output")]
pub struct ToolOutputConfig {
#[serde(default = "default_tool_output_context_chars")]
pub context_chars: usize,
#[serde(default = "default_fallback_tool_argument_chars")]
pub fallback_argument_chars: std::num::NonZeroUsize,
}
impl Default for ToolOutputConfig {
fn default() -> Self {
Self {
context_chars: default_tool_output_context_chars(),
fallback_argument_chars: default_fallback_tool_argument_chars(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "streaming")]
pub struct StreamingConfig {
#[serde(default = "default_streaming_no_progress_warning_secs")]
pub no_progress_warning_secs: u64,
#[serde(default = "default_streaming_no_progress_timeout_secs")]
pub no_progress_timeout_secs: u64,
}
impl Default for StreamingConfig {
fn default() -> Self {
Self {
no_progress_warning_secs: default_streaming_no_progress_warning_secs(),
no_progress_timeout_secs: default_streaming_no_progress_timeout_secs(),
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "retry")]
pub struct ModelRetryConfig {
#[serde(default = "default_model_retry_enabled")]
pub enabled: bool,
#[serde(default = "default_overload_retry_enabled")]
pub overload_enabled: bool,
#[serde(default)]
pub overload_retry_forever: Option<bool>,
#[serde(default)]
pub max_overload_retries: Option<u64>,
#[serde(default = "default_overload_initial_delay_ms")]
pub overload_initial_delay_ms: u64,
#[serde(default = "default_overload_max_delay_ms")]
pub overload_max_delay_ms: u64,
#[serde(default = "default_no_progress_timeout_retry_enabled")]
pub no_progress_timeout_enabled: bool,
#[serde(default)]
pub no_progress_timeout_retry_forever: Option<bool>,
#[serde(default)]
pub max_no_progress_timeout_retries: Option<u64>,
#[serde(default = "default_no_progress_timeout_initial_delay_ms")]
pub no_progress_timeout_initial_delay_ms: u64,
#[serde(default = "default_no_progress_timeout_max_delay_ms")]
pub no_progress_timeout_max_delay_ms: u64,
#[serde(default = "default_remote_catalog_retry_rules_enabled")]
pub remote_catalog_rules_enabled: bool,
#[config_doc(nested, list_index = "<index>")]
#[serde(default)]
pub rules: Vec<ModelRetryRuleConfig>,
}
const fn default_remote_catalog_retry_rules_enabled() -> bool {
true
}
pub type ModelRetryRuleConfig = bcode_model::ProviderRetryRule;
pub type ModelRetryRuleMatchConfig = bcode_model::ProviderRetryRuleMatch;
impl Default for ModelRetryConfig {
fn default() -> Self {
Self {
enabled: default_model_retry_enabled(),
overload_enabled: default_overload_retry_enabled(),
overload_retry_forever: None,
max_overload_retries: None,
overload_initial_delay_ms: default_overload_initial_delay_ms(),
overload_max_delay_ms: default_overload_max_delay_ms(),
no_progress_timeout_enabled: default_no_progress_timeout_retry_enabled(),
no_progress_timeout_retry_forever: None,
max_no_progress_timeout_retries: None,
no_progress_timeout_initial_delay_ms: default_no_progress_timeout_initial_delay_ms(),
no_progress_timeout_max_delay_ms: default_no_progress_timeout_max_delay_ms(),
remote_catalog_rules_enabled: default_remote_catalog_retry_rules_enabled(),
rules: Vec::new(),
}
}
}
const fn default_model_retry_enabled() -> bool {
true
}
const fn default_overload_retry_enabled() -> bool {
true
}
const fn default_overload_initial_delay_ms() -> u64 {
2_000
}
const fn default_overload_max_delay_ms() -> u64 {
600_000
}
const fn default_no_progress_timeout_retry_enabled() -> bool {
true
}
const fn default_no_progress_timeout_initial_delay_ms() -> u64 {
2_000
}
const fn default_no_progress_timeout_max_delay_ms() -> u64 {
600_000
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "compaction")]
pub struct CompactionConfig {
#[serde(default)]
pub mode: CompactionMode,
#[serde(default)]
pub backend: CompactionBackend,
#[serde(default = "default_proactive_compaction_threshold_percent")]
pub proactive_threshold_percent: u8,
#[serde(default = "default_compaction_keep_recent_tokens")]
pub keep_recent_tokens: u32,
#[config_doc(nested, map_key = "<model-id>")]
#[serde(default)]
pub models: BTreeMap<String, ModelCompactionOverrideConfig>,
#[serde(default)]
pub context_chars: usize,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
mode: CompactionMode::Auto,
backend: CompactionBackend::Auto,
proactive_threshold_percent: default_proactive_compaction_threshold_percent(),
keep_recent_tokens: default_compaction_keep_recent_tokens(),
models: BTreeMap::new(),
context_chars: 0,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "model_compaction_override")]
pub struct ModelCompactionOverrideConfig {
#[serde(default)]
pub provider_plugin_id: Option<String>,
#[serde(default)]
pub mode: Option<CompactionMode>,
#[serde(default)]
pub backend: Option<CompactionBackend>,
#[serde(default)]
pub proactive_threshold_tokens: Option<u64>,
#[serde(default)]
pub proactive_threshold_percent: Option<u8>,
#[serde(default)]
pub keep_recent_tokens: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProactiveCompactionThreshold {
Percent(u8),
Tokens(u64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EffectiveCompactionConfig {
pub mode: CompactionMode,
pub backend: CompactionBackend,
pub proactive_threshold: ProactiveCompactionThreshold,
pub keep_recent_tokens: u32,
}
impl CompactionConfig {
#[must_use]
pub fn effective_for(
&self,
provider_plugin_id: Option<&str>,
model_id: Option<&str>,
) -> EffectiveCompactionConfig {
let matched = model_id
.and_then(|model_id| self.models.get(model_id))
.filter(|policy| {
policy.provider_plugin_id.as_deref().is_none()
|| policy.provider_plugin_id.as_deref() == provider_plugin_id
});
EffectiveCompactionConfig {
mode: matched.and_then(|policy| policy.mode).unwrap_or(self.mode),
backend: matched
.and_then(|policy| policy.backend)
.unwrap_or(self.backend),
proactive_threshold: matched
.and_then(|policy| policy.proactive_threshold_tokens)
.map_or_else(
|| {
ProactiveCompactionThreshold::Percent(
matched
.and_then(|policy| policy.proactive_threshold_percent)
.unwrap_or(self.proactive_threshold_percent),
)
},
ProactiveCompactionThreshold::Tokens,
),
keep_recent_tokens: matched
.and_then(|policy| policy.keep_recent_tokens)
.unwrap_or(self.keep_recent_tokens),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum CompactionMode {
#[default]
Auto,
Off,
OnOverflow,
Proactive,
ProactiveAndOverflow,
}
impl CompactionMode {
#[must_use]
pub const fn is_proactive_enabled(self) -> bool {
matches!(self, Self::Proactive | Self::ProactiveAndOverflow)
}
#[must_use]
pub const fn is_overflow_recovery_enabled(self) -> bool {
matches!(
self,
Self::Auto | Self::OnOverflow | Self::ProactiveAndOverflow
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "snake_case")]
pub enum CompactionBackend {
#[default]
Auto,
ProviderNative,
Local,
}
const fn default_tool_output_context_chars() -> usize {
4_000
}
const fn default_fallback_tool_argument_chars() -> std::num::NonZeroUsize {
std::num::NonZeroUsize::new(6_000).expect("fallback tool argument limit is non-zero")
}
const fn default_streaming_no_progress_warning_secs() -> u64 {
30
}
const fn default_streaming_no_progress_timeout_secs() -> u64 {
300
}
const fn default_proactive_compaction_threshold_percent() -> u8 {
90
}
const fn default_compaction_keep_recent_tokens() -> u32 {
20_000
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "conversation_reuse")]
pub struct ConversationReuseConfig {
#[config_doc(values("off", "auto"))]
#[serde(default = "default_conversation_reuse_mode")]
pub mode: bcode_model::ConversationReuseMode,
}
impl Default for ConversationReuseConfig {
fn default() -> Self {
Self {
mode: default_conversation_reuse_mode(),
}
}
}
const fn default_conversation_reuse_mode() -> bcode_model::ConversationReuseMode {
bcode_model::ConversationReuseMode::Auto
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "model_profile")]
pub struct ModelProfileConfig {
#[serde(default)]
pub display_name: Option<String>,
pub provider_plugin_id: String,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub auth_profile: Option<String>,
#[serde(default)]
pub auth_pool: Option<String>,
#[config_doc(map_key = "<setting>")]
#[serde(default)]
pub settings: BTreeMap<String, String>,
#[config_doc(nested)]
#[serde(default)]
pub reasoning: ReasoningConfig,
#[config_doc(map_key = "<request-key>", value_type = "any")]
#[serde(default)]
pub request: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "model_alias")]
pub struct ModelAliasConfig {
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub provider_plugin_id: Option<String>,
pub model_id: String,
#[config_doc(map_key = "<request-key>", value_type = "any")]
#[serde(default)]
pub request: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "model_metadata")]
pub struct ModelMetadataConfig {
#[serde(default)]
pub provider_plugin_id: Option<String>,
#[serde(default)]
pub context_window: Option<u32>,
#[serde(default)]
pub max_output_tokens: Option<u32>,
#[config_doc(nested)]
#[serde(default)]
pub reasoning: ReasoningConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelSelectionSource {
Config,
Profile { name: String },
Alias { name: String },
AuthConfig,
Environment { variable: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedModelSelection {
pub provider_plugin_id: Option<String>,
pub provider_source: Option<ModelSelectionSource>,
pub model_id: Option<String>,
pub model_source: Option<ModelSelectionSource>,
pub selected_model_id: Option<String>,
pub model_profile: Option<String>,
pub auth_profile: Option<String>,
pub auth_pool: Option<String>,
pub settings: BTreeMap<String, String>,
pub request: BTreeMap<String, bcode_model::ProviderRequestValue>,
pub reasoning: ReasoningConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ModelSelectionIncomplete {
#[error("Connect a provider, then choose a model in Models before launching.")]
ProviderAndModel,
#[error("Select a provider for the chosen model before launching.")]
Provider,
#[error(
"Choose a model in Models before launching. Connecting an account alone does not select a model."
)]
Model,
}
impl ResolvedModelSelection {
pub fn validate_selection(&self) -> Result<(), ModelSelectionIncomplete> {
let provider = self
.provider_plugin_id
.as_deref()
.is_some_and(|id| !id.trim().is_empty());
let model = self
.model_id
.as_deref()
.is_some_and(|id| !id.trim().is_empty());
match (provider, model) {
(true, true) => Ok(()),
(false, false) => Err(ModelSelectionIncomplete::ProviderAndModel),
(false, true) => Err(ModelSelectionIncomplete::Provider),
(true, false) => Err(ModelSelectionIncomplete::Model),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ConfigDocEnum)]
#[serde(rename_all = "kebab-case")]
pub enum PluginDefaultMode {
#[default]
Bundled,
None,
All,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ConfigDoc)]
#[config_doc(section = "plugins")]
pub struct PluginConfig {
#[serde(default)]
pub default: PluginDefaultMode,
#[serde(default)]
pub enabled: BTreeSet<String>,
#[serde(default)]
pub disabled: BTreeSet<String>,
#[config_doc(
map_key = "<plugin-id>.<setting>",
value_type = "any",
value_description = "Plugin-specific setting owned by the target plugin."
)]
#[serde(default)]
pub config: BTreeMap<String, toml::Value>,
}
impl Default for PluginConfig {
fn default() -> Self {
Self {
default: PluginDefaultMode::Bundled,
enabled: BTreeSet::new(),
disabled: BTreeSet::new(),
config: BTreeMap::new(),
}
}
}
fn merge_reasoning_config(base: &ReasoningConfig, overlay: &ReasoningConfig) -> ReasoningConfig {
ReasoningConfig {
effort: overlay.effort.clone().or_else(|| base.effort.clone()),
summary: overlay.summary.clone().or_else(|| base.summary.clone()),
effort_values: if overlay.effort_values.is_empty() {
base.effort_values.clone()
} else {
overlay.effort_values.clone()
},
summary_values: if overlay.summary_values.is_empty() {
base.summary_values.clone()
} else {
overlay.summary_values.clone()
},
default_effort: overlay
.default_effort
.clone()
.or_else(|| base.default_effort.clone()),
default_summary: overlay
.default_summary
.clone()
.or_else(|| base.default_summary.clone()),
visible_summary_supported: overlay
.visible_summary_supported
.or(base.visible_summary_supported),
raw_reasoning_supported: overlay
.raw_reasoning_supported
.or(base.raw_reasoning_supported),
}
}
fn reasoning_capabilities_from_config(
reasoning: &ReasoningConfig,
) -> Option<bcode_model::ModelReasoningInfo> {
(!reasoning.effort_values.is_empty()
|| !reasoning.summary_values.is_empty()
|| reasoning.default_effort.is_some()
|| reasoning.default_summary.is_some()
|| reasoning.visible_summary_supported.is_some()
|| reasoning.raw_reasoning_supported.is_some())
.then(|| bcode_model::ModelReasoningInfo {
control: None,
effort_values: reasoning.effort_values.clone(),
default_effort: reasoning.default_effort.clone(),
visible_summary_supported: reasoning.visible_summary_supported.unwrap_or_default(),
summary_values: reasoning.summary_values.clone(),
default_summary: reasoning.default_summary.clone(),
raw_reasoning_supported: reasoning.raw_reasoning_supported.unwrap_or_default(),
source: bcode_model::ModelReasoningCapabilitySource::ConfigOverride,
})
}
fn insert_model_reasoning_settings(
settings: &mut BTreeMap<String, String>,
model_id: &str,
reasoning: &bcode_model::ModelReasoningInfo,
) {
if !reasoning.effort_values.is_empty() {
settings.insert(
format!("model_metadata.{model_id}.reasoning.effort_values"),
reasoning.effort_values.join(","),
);
}
if let Some(default_effort) = &reasoning.default_effort {
settings.insert(
format!("model_metadata.{model_id}.reasoning.default_effort"),
default_effort.clone(),
);
}
settings.insert(
format!("model_metadata.{model_id}.reasoning.visible_summary_supported"),
reasoning.visible_summary_supported.to_string(),
);
if !reasoning.summary_values.is_empty() {
settings.insert(
format!("model_metadata.{model_id}.reasoning.summary_values"),
reasoning.summary_values.join(","),
);
}
if let Some(default_summary) = &reasoning.default_summary {
settings.insert(
format!("model_metadata.{model_id}.reasoning.default_summary"),
default_summary.clone(),
);
}
settings.insert(
format!("model_metadata.{model_id}.reasoning.raw_reasoning_supported"),
reasoning.raw_reasoning_supported.to_string(),
);
}
impl From<&PluginConfig> for PluginSelection {
fn from(value: &PluginConfig) -> Self {
Self {
mode: match value.default {
PluginDefaultMode::All => bcode_plugin_models::PluginSelectionMode::All,
PluginDefaultMode::Bundled | PluginDefaultMode::None => {
bcode_plugin_models::PluginSelectionMode::Explicit
}
},
enabled: value.enabled.clone(),
disabled: value.disabled.clone(),
}
}
}
impl From<&BcodeConfig> for PluginSelection {
fn from(value: &BcodeConfig) -> Self {
plugin_selection_with_default_plugin_ids(value, std::iter::empty::<&str>())
}
}
#[must_use]
pub fn plugin_selection_with_default_plugin_ids<I, S>(
value: &BcodeConfig,
default_plugin_ids: I,
) -> PluginSelection
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut selection = PluginSelection::from(&value.plugins);
let had_explicit_enabled_plugins = !selection.enabled.is_empty();
let env_provider = provider_plugin_id_from_environment();
let resolved_provider = value.resolved_model_selection().provider_plugin_id;
let provider = env_provider
.clone()
.or_else(|| resolved_provider.clone())
.unwrap_or_else(|| DEFAULT_MODEL_PROVIDER_PLUGIN_ID.to_string());
if value.plugins.default == PluginDefaultMode::Bundled {
for plugin_id in default_plugin_ids {
enable_plugin_unless_disabled(&mut selection, plugin_id.as_ref());
}
}
enable_default_model_provider_plugins(&mut selection);
if !had_explicit_enabled_plugins {
enable_plugin_unless_disabled(&mut selection, &provider);
} else if let Some(env_provider) = env_provider {
enable_plugin_unless_disabled(&mut selection, &env_provider);
} else if let Some(resolved_provider) = resolved_provider {
enable_plugin_unless_disabled(&mut selection, &resolved_provider);
}
selection
}
fn enable_default_model_provider_plugins(selection: &mut PluginSelection) {
for plugin_id in DEFAULT_MODEL_PROVIDER_PLUGIN_IDS {
enable_plugin_unless_disabled(selection, plugin_id);
}
}
fn enable_plugin_unless_disabled(selection: &mut PluginSelection, plugin_id: &str) {
if !selection.disabled.contains(plugin_id) {
selection.enabled.insert(plugin_id.to_string());
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error while reading {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("composition error: {message}")]
Composition { message: String },
#[error("unknown permission category: {0}")]
UnknownPermissionCategory(String),
#[error("unknown permission action: {0}")]
UnknownPermissionAction(String),
#[error(
"removed shorthand tool ID at agent.{agent_id}.tools.{tool_id}; use exact tool ID {replacement} instead"
)]
RemovedShorthandToolId {
agent_id: String,
tool_id: String,
replacement: &'static str,
},
#[error(
"removed permission category at agent.{agent_id}.permission.{category}; use permission.{replacement} instead"
)]
RemovedPermissionCategory {
agent_id: String,
category: String,
replacement: &'static str,
},
}
fn validate_config(config: &BcodeConfig) -> Result<(), ConfigError> {
if config.session_storage.maintenance_interval_secs == 0
|| config.session_storage.artifact_timeout_secs == 0
{
return Err(ConfigError::Composition { message: "session_storage maintenance_interval_secs and artifact_timeout_secs must be positive".to_owned() });
}
if config.session_storage.light_after_days == 0
|| config.session_storage.deep_after_days <= config.session_storage.light_after_days
{
return Err(ConfigError::Composition {
message: "session_storage requires 0 < light_after_days < deep_after_days".to_owned(),
});
}
if config.client.request_timeout_secs == 0 {
return Err(ConfigError::Composition {
message: "client.request_timeout_secs must be greater than zero".to_owned(),
});
}
if config.model.compaction.proactive_threshold_percent == 0
|| config.model.compaction.proactive_threshold_percent > 100
{
return Err(ConfigError::Composition {
message: "model.compaction.proactive_threshold_percent must be between 1 and 100"
.to_owned(),
});
}
for (model_id, policy) in &config.model.compaction.models {
if policy
.proactive_threshold_percent
.is_some_and(|value| value == 0 || value > 100)
{
return Err(ConfigError::Composition {
message: format!(
"model.compaction.models.{model_id}.proactive_threshold_percent must be between 1 and 100"
),
});
}
if policy.proactive_threshold_tokens == Some(0) {
return Err(ConfigError::Composition {
message: format!(
"model.compaction.models.{model_id}.proactive_threshold_tokens must be greater than zero"
),
});
}
if policy.keep_recent_tokens == Some(0) {
return Err(ConfigError::Composition {
message: format!(
"model.compaction.models.{model_id}.keep_recent_tokens must be greater than zero"
),
});
}
}
let mut visual_adapters = BTreeSet::new();
for adapter in config
.tui
.visual_adapters
.preferred
.iter()
.chain(&config.tui.visual_adapters.disabled)
{
let valid = adapter
.split_once('/')
.is_some_and(|(plugin_id, adapter_id)| {
!plugin_id.is_empty()
&& !adapter_id.is_empty()
&& adapter.len() <= 512
&& !adapter.chars().any(char::is_whitespace)
});
if !valid || !visual_adapters.insert(adapter) {
return Err(ConfigError::Composition {
message: format!(
"TUI visual adapter references must be unique `<plugin-id>/<adapter-id>` values: {adapter}"
),
});
}
}
for (agent_id, agent) in &config.agent {
for tool_id in agent.tools.keys() {
if let Some(replacement) = removed_shorthand_tool_replacement(tool_id) {
return Err(ConfigError::RemovedShorthandToolId {
agent_id: agent_id.clone(),
tool_id: tool_id.clone(),
replacement,
});
}
}
}
Ok(())
}
fn removed_shorthand_tool_replacement(tool_id: &str) -> Option<&'static str> {
match tool_id {
"bash" | "command" => Some("shell.run"),
"read" => Some("filesystem.read"),
"grep" => Some("filesystem.grep"),
"find" => Some("filesystem.find"),
"ls" => Some("filesystem.list"),
"stat" => Some("filesystem.stat"),
"write" => Some("filesystem.write"),
"edit" => Some("filesystem.edit"),
"worktree.read" => Some("worktree.list"),
_ => None,
}
}
fn validate_removed_permission_categories(value: &toml::Value) -> Result<(), ConfigError> {
let Some(agents) = value.get("agent").and_then(toml::Value::as_table) else {
return Ok(());
};
for (agent_id, agent) in agents {
let Some(permission) = agent.get("permission").and_then(toml::Value::as_table) else {
continue;
};
if permission.contains_key("bash") {
return Err(ConfigError::RemovedPermissionCategory {
agent_id: agent_id.clone(),
category: "bash".to_string(),
replacement: "command",
});
}
}
Ok(())
}
fn validate_config_value(value: toml::Value, context: &str) -> Result<BcodeConfig, ConfigError> {
if value.get("auth_pool_config").is_some() {
return Err(ConfigError::Composition { message: "auth_pool_config requires a file-backed configuration layer; unresolved references are not accepted in inline or transported configuration".to_owned() });
}
validate_removed_permission_categories(&value)?;
let config = value
.try_into()
.map_err(|source| ConfigError::Composition {
message: format!("failed to deserialize {context}: {source}"),
})?;
validate_config(&config)?;
Ok(config)
}
pub fn upsert_agent_permission_rule(
agent_id: &str,
category: &str,
pattern: String,
action: &str,
) -> Result<PathBuf, ConfigError> {
let action = parse_action(action)?;
update_permissions_state(|agents| {
insert_agent_permission_rule(agents, agent_id, category, pattern, action)
})
}
pub fn set_openai_sshenv_auth(
profile: String,
vault: PathBuf,
model_id: Option<String>,
) -> Result<PathBuf, ConfigError> {
set_openai_sshenv_auth_mode(profile, vault, model_id, AuthMode::ApiKey)
}
pub fn set_openai_sshenv_auth_mode(
profile: String,
vault: PathBuf,
model_id: Option<String>,
mode: AuthMode,
) -> Result<PathBuf, ConfigError> {
set_openai_compatible_sshenv_auth_mode("openai", profile, vault, model_id, mode, None)
}
pub fn set_openai_compatible_sshenv_auth_mode(
provider: &str,
profile: String,
vault: PathBuf,
model_id: Option<String>,
mode: AuthMode,
base_url: Option<&str>,
) -> Result<PathBuf, ConfigError> {
let method = auth_mode_setting(&mode);
set_openai_compatible_sshenv_auth_method(OpenAiCompatibleAuthConfigUpdate {
provider,
profile,
vault,
model_id,
mode,
method,
base_url,
})
}
pub struct OpenAiCompatibleAuthConfigUpdate<'a> {
pub provider: &'a str,
pub profile: String,
pub vault: PathBuf,
pub model_id: Option<String>,
pub mode: AuthMode,
pub method: &'a str,
pub base_url: Option<&'a str>,
}
pub fn set_openai_compatible_sshenv_auth_method(
options: OpenAiCompatibleAuthConfigUpdate<'_>,
) -> Result<PathBuf, ConfigError> {
let OpenAiCompatibleAuthConfigUpdate {
provider,
profile,
vault,
model_id,
mode,
method,
base_url,
} = options;
update_writable_config(|config| {
let vault_setting = vault.display().to_string();
let mode_setting = auth_mode_setting(&mode);
config
.plugins
.enabled
.insert("bcode.openai-compatible".to_string());
config.model.provider_plugin_id = Some("bcode.openai-compatible".to_string());
if let Some(model_id) = model_id {
config.model.model_id = Some(model_id);
}
let auth_map = openai_compatible_auth_map(provider, &mode);
if provider == "openai" {
config.auth.openai = Some(AuthProviderConfig {
backend: "sshenv".to_string(),
mode,
profile: profile.clone(),
vault: Some(vault),
});
}
let mut settings = BTreeMap::new();
settings.insert("provider".to_string(), provider.to_string());
settings.insert("profile".to_string(), profile.clone());
settings.insert("vault".to_string(), vault_setting);
settings.insert("mode".to_string(), mode_setting.to_string());
if let Some(base_url) = base_url {
settings.insert("base_url".to_string(), base_url.to_string());
}
config.auth.profiles.insert(
profile.clone(),
AuthProfileConfig {
backend: "sshenv".to_string(),
provider_id: Some(provider.to_string()),
owner_plugin_id: Some("bcode.openai-compatible".to_string()),
scheme: Some(method.to_string()),
map: auth_map,
settings,
},
);
if let Some(model_id) = config.model.model_id.clone() {
config
.model
.profiles
.entry(profile.clone())
.or_insert_with(|| ModelProfileConfig {
display_name: None,
provider_plugin_id: "bcode.openai-compatible".to_string(),
model_id: Some(model_id),
auth_profile: Some(profile),
auth_pool: None,
settings: BTreeMap::new(),
reasoning: ReasoningConfig::default(),
request: BTreeMap::new(),
});
}
Ok(())
})
}
pub fn add_openai_chatgpt_subscription_auth(
pool: &str,
profile: &str,
vault: &Path,
model_id: Option<String>,
) -> Result<PathBuf, ConfigError> {
update_writable_config(|config| {
let vault_setting = vault.display().to_string();
config
.plugins
.enabled
.insert("bcode.openai-compatible".to_string());
config.model.provider_plugin_id = Some("bcode.openai-compatible".to_string());
if let Some(model_id) = model_id {
config.model.model_id = Some(model_id);
}
let mut settings = BTreeMap::new();
settings.insert("provider".to_string(), "openai".to_string());
settings.insert("profile".to_string(), profile.to_string());
settings.insert("vault".to_string(), vault_setting);
settings.insert("mode".to_string(), "chatgpt".to_string());
config.auth.profiles.insert(
profile.to_string(),
AuthProfileConfig {
backend: "sshenv".to_string(),
provider_id: Some("openai".to_owned()),
owner_plugin_id: Some("bcode.openai-compatible".to_owned()),
scheme: Some("chatgpt".to_string()),
map: openai_compatible_auth_map("openai", &AuthMode::ChatGpt),
settings,
},
);
let auth_pool = config
.auth
.pools
.entry(pool.to_string())
.or_insert_with(|| AuthPoolConfig {
automatic_provider: None,
automatic_group: None,
provider_plugin_id: Some("bcode.openai-compatible".to_string()),
strategy: AuthPoolStrategy::Failover,
preferred_profile: None,
profiles: Vec::new(),
priming: AuthPoolPrimingConfig::default(),
quota: AuthPoolQuotaConfig::default(),
});
auth_pool.provider_plugin_id = Some("bcode.openai-compatible".to_string());
auth_pool.strategy = AuthPoolStrategy::Failover;
if !auth_pool
.profiles
.iter()
.any(|candidate| candidate == profile)
{
auth_pool.profiles.push(profile.to_string());
}
if let Some(model_id) = config.model.model_id.clone() {
config
.model
.profiles
.entry(pool.to_string())
.and_modify(|model_profile| {
model_profile.auth_profile = None;
model_profile.auth_pool = Some(pool.to_string());
})
.or_insert_with(|| ModelProfileConfig {
display_name: None,
provider_plugin_id: "bcode.openai-compatible".to_string(),
model_id: Some(model_id),
auth_profile: None,
auth_pool: Some(pool.to_string()),
settings: BTreeMap::new(),
reasoning: ReasoningConfig::default(),
request: BTreeMap::new(),
});
}
Ok(())
})
}
fn openai_compatible_auth_map(
provider: &str,
mode: &AuthMode,
) -> BTreeMap<String, AuthCredentialMapping> {
match mode {
AuthMode::ApiKey => BTreeMap::from([(
"api_key".to_string(),
AuthCredentialMapping {
env: Some(match provider {
"xai" | "grok" => "BCODE_XAI_API_KEY".to_string(),
_ => "BCODE_OPENAI_API_KEY".to_string(),
}),
key: None,
},
)]),
AuthMode::ChatGpt => BTreeMap::from([
(
"access_token".to_string(),
AuthCredentialMapping {
env: Some("BCODE_OPENAI_CODEX_ACCESS_TOKEN".to_string()),
key: None,
},
),
(
"refresh_token".to_string(),
AuthCredentialMapping {
env: Some("BCODE_OPENAI_CODEX_REFRESH_TOKEN".to_string()),
key: None,
},
),
(
"id_token".to_string(),
AuthCredentialMapping {
env: Some("BCODE_OPENAI_CODEX_ID_TOKEN".to_string()),
key: None,
},
),
(
"expires_at".to_string(),
AuthCredentialMapping {
env: Some("BCODE_OPENAI_CODEX_EXPIRES_AT".to_string()),
key: None,
},
),
(
"account_id".to_string(),
AuthCredentialMapping {
env: Some("BCODE_OPENAI_CODEX_ACCOUNT_ID".to_string()),
key: None,
},
),
]),
}
}
const fn auth_mode_setting(mode: &AuthMode) -> &'static str {
match mode {
AuthMode::ApiKey => "api_key",
AuthMode::ChatGpt => "chatgpt",
}
}
pub fn set_bedrock_model_profile(
profile: &str,
model_id: String,
aws_profile: Option<String>,
region: Option<String>,
endpoint_url: Option<&str>,
model_ids: &[String],
) -> Result<PathBuf, ConfigError> {
update_writable_config(|config| {
config.plugins.enabled.insert("bcode.bedrock".to_string());
config.model.profile = Some(profile.to_string());
config.model.provider_plugin_id = Some("bcode.bedrock".to_string());
config.model.model_id = Some(model_id.clone());
let auth_profile = format!("{profile}-aws");
let mut settings = BTreeMap::new();
if let Some(region) = region.clone() {
settings.insert("region".to_string(), region);
}
if let Some(endpoint_url) = endpoint_url {
settings.insert("endpoint_url".to_string(), endpoint_url.to_string());
}
if !model_ids.is_empty() {
settings.insert("models".to_string(), model_ids.join(","));
}
config.model.profiles.insert(
profile.to_string(),
ModelProfileConfig {
display_name: None,
provider_plugin_id: "bcode.bedrock".to_string(),
model_id: Some(model_id),
auth_profile: Some(auth_profile.clone()),
auth_pool: None,
settings,
reasoning: ReasoningConfig::default(),
request: BTreeMap::new(),
},
);
let mut auth_settings = BTreeMap::new();
if let Some(aws_profile) = aws_profile {
auth_settings.insert("profile".to_string(), aws_profile);
}
if let Some(region) = region {
auth_settings.insert("region".to_string(), region);
}
config.auth.profiles.insert(
auth_profile,
AuthProfileConfig {
backend: "aws_default_chain".to_string(),
provider_id: None,
owner_plugin_id: None,
scheme: Some("aws_default_chain".to_string()),
settings: auth_settings,
..AuthProfileConfig::default()
},
);
Ok(())
})
}
fn update_writable_config(
update: impl FnOnce(&mut BcodeConfig) -> Result<(), ConfigError>,
) -> Result<PathBuf, ConfigError> {
let path = writable_config_path();
let mut config = if path.exists() {
read_config(&path)?
} else {
BcodeConfig::default()
};
update(&mut config)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(&path, config_to_toml(&config)).map_err(|source| ConfigError::Io {
path: path.clone(),
source,
})?;
Ok(path)
}
mod auth_enrollment;
pub use auth_enrollment::publish_auth_enrollment;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthSubscriptions {
#[serde(default)]
pub pools: BTreeMap<String, RuntimeAuthSubscriptionPool>,
#[serde(default)]
pub bindings: BTreeMap<String, RuntimeAuthBinding>,
#[serde(default)]
pub profiles: BTreeMap<String, RuntimeAuthProfile>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthBinding {
pub profile: String,
pub owner_plugin_id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthProfile {
pub provider_id: String,
pub owner_plugin_id: String,
pub backend: String,
pub scheme: String,
pub storage_profile: String,
pub vault: PathBuf,
#[serde(default)]
pub map: BTreeMap<String, AuthCredentialMapping>,
#[serde(default)]
pub device_seal: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthSubscriptionPool {
#[serde(default)]
pub automatic_group: Option<String>,
#[serde(default)]
pub provider_plugin_id: Option<String>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub owner_plugin_id: Option<String>,
#[serde(default)]
pub preferred_profile: Option<String>,
#[serde(default)]
pub profiles: Vec<RuntimeAuthSubscriptionProfile>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthSubscriptionProfile {
pub auth_profile: String,
pub storage_profile: String,
pub vault: PathBuf,
pub provider: String,
pub scheme: String,
#[serde(default)]
pub owner_plugin_id: Option<String>,
#[serde(default)]
pub map: BTreeMap<String, AuthCredentialMapping>,
#[serde(default)]
pub device_seal: Option<String>,
}
#[must_use]
pub fn runtime_auth_subscriptions_path() -> PathBuf {
if let Ok(path) = env::var("BCODE_AUTH_SUBSCRIPTIONS") {
return PathBuf::from(path);
}
default_state_dir().join("auth").join("subscriptions.json")
}
fn lock_auth_subscriptions(path: &Path) -> Result<fs::File, ConfigError> {
let parent = path.parent().ok_or_else(|| ConfigError::Composition {
message: "auth registry requires a parent directory".to_owned(),
})?;
fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
path: parent.to_path_buf(),
source,
})?;
let lock_path = path.with_extension("lock");
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|source| ConfigError::Io {
path: lock_path.clone(),
source,
})?;
file.try_lock().map_err(|error| ConfigError::Composition {
message: format!("auth registry is busy or cannot be locked: {error}"),
})?;
Ok(file)
}
fn auth_registry_representation_preserved(
original: &serde_json::Value,
supported: &serde_json::Value,
) -> bool {
match (original, supported) {
(serde_json::Value::Object(original), serde_json::Value::Object(supported)) => {
original.iter().all(|(key, value)| {
supported
.get(key)
.is_some_and(|known| auth_registry_representation_preserved(value, known))
})
}
(serde_json::Value::Array(original), serde_json::Value::Array(supported)) => {
original.len() == supported.len()
&& original
.iter()
.zip(supported)
.all(|(value, known)| auth_registry_representation_preserved(value, known))
}
_ => original == supported,
}
}
fn read_auth_subscriptions_for_update(
path: &Path,
) -> Result<RuntimeAuthSubscriptions, ConfigError> {
use std::io::Read as _;
const MAX_REGISTRY_BYTES: u64 = 8 * 1024 * 1024;
match fs::File::open(path) {
Ok(file) => {
let invalid = || ConfigError::Composition {
message: "auth registry is invalid or unsupported; existing state preserved"
.to_owned(),
};
let mut bytes = Vec::new();
file.take(MAX_REGISTRY_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
if bytes.len() as u64 > MAX_REGISTRY_BYTES {
return Err(ConfigError::Composition {
message:
"auth registry exceeds the supported read bound; existing state preserved"
.to_owned(),
});
}
let original: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|_| invalid())?;
let registry: RuntimeAuthSubscriptions =
serde_json::from_value(original.clone()).map_err(|_| invalid())?;
let supported = serde_json::to_value(®istry).map_err(|_| invalid())?;
if !auth_registry_representation_preserved(&original, &supported) {
return Err(invalid());
}
Ok(registry)
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
match fs::symlink_metadata(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(RuntimeAuthSubscriptions::default())
}
_ => Err(ConfigError::Io {
path: path.to_path_buf(),
source,
}),
}
}
Err(source) => Err(ConfigError::Io {
path: path.to_path_buf(),
source,
}),
}
}
pub fn try_load_runtime_auth_subscriptions() -> Result<RuntimeAuthSubscriptions, ConfigError> {
read_auth_subscriptions_for_update(&runtime_auth_subscriptions_path())
}
#[must_use]
pub fn load_runtime_auth_subscriptions() -> RuntimeAuthSubscriptions {
let path = runtime_auth_subscriptions_path();
let Ok(contents) = fs::read_to_string(path) else {
return RuntimeAuthSubscriptions::default();
};
serde_json::from_str(&contents).unwrap_or_default()
}
pub fn register_runtime_auth_subscription(
pool: &str,
profile: RuntimeAuthSubscriptionProfile,
) -> Result<PathBuf, ConfigError> {
let owner_plugin_id =
profile
.owner_plugin_id
.clone()
.ok_or_else(|| ConfigError::Composition {
message: "runtime auth subscription requires plugin ownership".to_owned(),
})?;
if profile.auth_profile.trim().is_empty()
|| profile.storage_profile.trim().is_empty()
|| profile.provider.trim().is_empty()
|| profile.scheme.trim().is_empty()
{
return Err(ConfigError::Composition {
message: "runtime auth subscription requires non-empty profile, storage profile, provider, and scheme"
.to_owned(),
});
}
let runtime_profile = RuntimeAuthProfile {
provider_id: profile.provider.clone(),
owner_plugin_id,
backend: "sshenv".to_owned(),
scheme: profile.scheme.clone(),
storage_profile: profile.storage_profile.clone(),
vault: profile.vault.clone(),
map: profile.map.clone(),
device_seal: profile.device_seal.clone(),
};
let path = runtime_auth_subscriptions_path();
let _lock = lock_auth_subscriptions(&path)?;
let mut registry = read_auth_subscriptions_for_update(&path)?;
if let Some(existing) = registry.profiles.get(&profile.auth_profile)
&& existing != &runtime_profile
{
return Err(ConfigError::Composition {
message: format!(
"runtime auth profile '{}' ownership conflicts with existing metadata",
profile.auth_profile
),
});
}
registry
.profiles
.insert(profile.auth_profile.clone(), runtime_profile);
let pool_entry =
registry
.pools
.entry(pool.to_string())
.or_insert_with(|| RuntimeAuthSubscriptionPool {
automatic_group: None,
provider_plugin_id: profile.owner_plugin_id.clone(),
provider_id: Some(profile.provider.clone()),
owner_plugin_id: profile.owner_plugin_id.clone(),
preferred_profile: None,
profiles: Vec::new(),
});
if [
pool_entry.provider_plugin_id.as_ref(),
pool_entry.owner_plugin_id.as_ref(),
]
.into_iter()
.flatten()
.any(|owner| Some(owner) != profile.owner_plugin_id.as_ref())
|| pool_entry
.provider_id
.as_ref()
.is_some_and(|provider| provider != &profile.provider)
{
return Err(ConfigError::Composition {
message: "runtime authentication pool ownership conflicts with enrollment; existing metadata preserved".to_owned(),
});
}
pool_entry
.provider_plugin_id
.clone_from(&profile.owner_plugin_id);
pool_entry.provider_id = Some(profile.provider.clone());
pool_entry
.owner_plugin_id
.clone_from(&profile.owner_plugin_id);
if let Some(existing) = pool_entry
.profiles
.iter_mut()
.find(|existing| existing.auth_profile == profile.auth_profile)
{
if existing != &profile {
return Err(ConfigError::Composition {
message: "runtime authentication pool member conflicts with enrollment; existing metadata preserved".to_owned(),
});
}
} else {
pool_entry.profiles.push(profile);
}
write_runtime_auth_subscriptions(&path, ®istry)?;
Ok(path)
}
pub fn register_runtime_auth_profile(
profile_name: &str,
profile: RuntimeAuthProfile,
) -> Result<PathBuf, ConfigError> {
if profile_name.trim().is_empty()
|| profile.provider_id.trim().is_empty()
|| profile.owner_plugin_id.trim().is_empty()
{
return Err(ConfigError::Composition {
message: "runtime auth profile requires non-empty profile, provider, and plugin IDs"
.to_string(),
});
}
let path = runtime_auth_subscriptions_path();
let _lock = lock_auth_subscriptions(&path)?;
let mut registry = read_auth_subscriptions_for_update(&path)?;
if let Some(existing) = registry.profiles.get(profile_name)
&& existing != &profile
{
return Err(ConfigError::Composition {
message: format!(
"runtime auth profile '{profile_name}' ownership conflicts with existing metadata"
),
});
}
if let Some(existing) = registry.bindings.get(&profile.provider_id)
&& existing.owner_plugin_id != profile.owner_plugin_id
{
return Err(ConfigError::Composition {
message: format!(
"runtime auth binding '{}' conflicts with existing metadata",
profile.provider_id
),
});
}
registry
.bindings
.entry(profile.provider_id.clone())
.or_insert_with(|| RuntimeAuthBinding {
profile: profile_name.to_string(),
owner_plugin_id: profile.owner_plugin_id.clone(),
});
registry.profiles.insert(profile_name.to_string(), profile);
write_runtime_auth_subscriptions(&path, ®istry)?;
Ok(path)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeAuthProfileRemoval {
pub removed_profile: bool,
pub removed_bindings: Vec<String>,
pub removed_from_pools: Vec<String>,
}
impl RuntimeAuthProfileRemoval {
#[must_use]
pub const fn changed(&self) -> bool {
self.removed_profile
|| !self.removed_bindings.is_empty()
|| !self.removed_from_pools.is_empty()
}
}
pub fn remove_runtime_auth_profile(
profile_name: &str,
owner_plugin_id: &str,
) -> Result<RuntimeAuthProfileRemoval, ConfigError> {
let path = runtime_auth_subscriptions_path();
let _lock = lock_auth_subscriptions(&path)?;
let mut registry = read_auth_subscriptions_for_update(&path)?;
let removal = remove_runtime_auth_profile_from(&mut registry, profile_name, owner_plugin_id);
if removal.changed() {
write_runtime_auth_subscriptions(&path, ®istry)?;
}
Ok(removal)
}
fn remove_runtime_auth_profile_from(
registry: &mut RuntimeAuthSubscriptions,
profile_name: &str,
owner_plugin_id: &str,
) -> RuntimeAuthProfileRemoval {
let mut removal = RuntimeAuthProfileRemoval::default();
if registry
.profiles
.get(profile_name)
.is_some_and(|profile| profile.owner_plugin_id == owner_plugin_id)
{
registry.profiles.remove(profile_name);
removal.removed_profile = true;
}
let bound_providers = registry
.bindings
.iter()
.filter(|(_, binding)| {
binding.profile == profile_name && binding.owner_plugin_id == owner_plugin_id
})
.map(|(provider_id, _)| provider_id.clone())
.collect::<Vec<_>>();
for provider_id in &bound_providers {
registry.bindings.remove(provider_id);
}
removal.removed_bindings = bound_providers;
let mut emptied_pools = Vec::new();
for (pool_name, pool) in &mut registry.pools {
let owned = |member: &RuntimeAuthSubscriptionProfile| {
member
.owner_plugin_id
.as_deref()
.or(pool.owner_plugin_id.as_deref())
.or(pool.provider_plugin_id.as_deref())
== Some(owner_plugin_id)
};
let before = pool.profiles.len();
pool.profiles
.retain(|member| member.auth_profile != profile_name || !owned(member));
if pool.profiles.len() != before {
removal.removed_from_pools.push(pool_name.clone());
if pool.preferred_profile.as_deref() == Some(profile_name) {
pool.preferred_profile = None;
}
if pool.profiles.is_empty() {
emptied_pools.push(pool_name.clone());
}
}
}
for pool_name in emptied_pools {
registry.pools.remove(&pool_name);
}
removal
}
pub fn set_runtime_auth_pool_preference(
pool: &str,
profile: Option<&str>,
) -> Result<PathBuf, ConfigError> {
if pool.trim().is_empty() {
return Err(ConfigError::Composition {
message: "auth pool preference requires a non-empty pool ID".to_owned(),
});
}
let config = load_config()?;
let path = runtime_auth_subscriptions_path();
let _lock = lock_auth_subscriptions(&path)?;
let mut registry = read_auth_subscriptions_for_update(&path)?;
update_runtime_auth_pool_preference(&config, &mut registry, pool, profile)?;
write_runtime_auth_subscriptions(&path, ®istry)?;
Ok(path)
}
pub fn update_runtime_auth_pool_preference(
config: &BcodeConfig,
registry: &mut RuntimeAuthSubscriptions,
pool: &str,
profile: Option<&str>,
) -> Result<(), ConfigError> {
if pool.trim().is_empty() {
return Err(ConfigError::Composition {
message: "auth pool preference requires a non-empty pool ID".to_owned(),
});
}
let mut known_profiles = config
.auth
.pools
.get(pool)
.map(|entry| entry.profiles.clone())
.unwrap_or_default();
if let Some(runtime_pool) = registry.pools.get(pool) {
known_profiles.extend(
runtime_pool
.profiles
.iter()
.map(|entry| entry.auth_profile.clone()),
);
}
known_profiles.sort();
known_profiles.dedup();
if known_profiles.is_empty() {
return Err(ConfigError::Composition {
message: format!("auth pool '{pool}' is not declared or registered"),
});
}
if let Some(profile) = profile
&& !known_profiles.iter().any(|known| known == profile)
{
return Err(ConfigError::Composition {
message: format!("auth profile '{profile}' is not a member of pool '{pool}'"),
});
}
let entry = registry.pools.entry(pool.to_owned()).or_default();
entry.preferred_profile = profile.map(str::to_owned);
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EffectiveAuthPoolOrder {
pub profiles: Vec<String>,
pub preferred_profile: Option<String>,
pub preference_source: Option<String>,
pub degraded_reason: Option<String>,
}
#[must_use]
pub fn effective_auth_pool_order(
config: &BcodeConfig,
registry: &RuntimeAuthSubscriptions,
pool: &str,
selected_profile: Option<&str>,
) -> EffectiveAuthPoolOrder {
let declared = config.auth.pools.get(pool);
let runtime = registry.pools.get(pool);
let mut profiles = Vec::new();
let mut seen = BTreeSet::new();
for profile in selected_profile
.into_iter()
.chain(
declared
.into_iter()
.flat_map(|entry| entry.profiles.iter().map(String::as_str)),
)
.chain(runtime.into_iter().flat_map(|entry| {
entry
.profiles
.iter()
.map(|profile| profile.auth_profile.as_str())
}))
{
if seen.insert(profile.to_owned()) {
profiles.push(profile.to_owned());
}
}
let preferences = [
(
runtime.and_then(|entry| entry.preferred_profile.as_deref()),
"interactive_state",
),
(
declared.and_then(|entry| entry.preferred_profile.as_deref()),
"declarative",
),
(selected_profile, "selected_profile"),
(profiles.first().map(String::as_str), "pool_order"),
];
let mut degraded_reason = None;
let mut effective = None;
for (preference, source) in preferences {
let Some(preference) = preference else {
continue;
};
if profiles.iter().any(|profile| profile == preference) {
effective = Some((preference.to_owned(), source.to_owned()));
break;
}
if matches!(source, "interactive_state" | "declarative") && degraded_reason.is_none() {
degraded_reason = Some(format!(
"preferred auth profile '{preference}' is not a member of pool '{pool}'"
));
}
}
if let Some((preferred, _)) = &effective
&& let Some(position) = profiles.iter().position(|profile| profile == preferred)
{
let profile = profiles.remove(position);
profiles.insert(0, profile);
}
EffectiveAuthPoolOrder {
profiles,
preferred_profile: effective.as_ref().map(|(profile, _)| profile.clone()),
preference_source: effective.map(|(_, source)| source),
degraded_reason,
}
}
fn write_runtime_auth_subscriptions(
path: &Path,
registry: &RuntimeAuthSubscriptions,
) -> Result<(), ConfigError> {
use std::io::Write as _;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let contents =
serde_json::to_string_pretty(registry).map_err(|source| ConfigError::Composition {
message: format!("failed to serialize runtime auth metadata: {source}"),
})?;
let parent = path.parent().ok_or_else(|| ConfigError::Composition {
message: "auth registry requires a parent directory".to_owned(),
})?;
let io_error = |source| ConfigError::Io {
path: path.to_path_buf(),
source,
};
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(io_error)?;
temporary.write_all(contents.as_bytes()).map_err(io_error)?;
temporary.as_file().sync_all().map_err(io_error)?;
temporary
.persist(path)
.map_err(|error| io_error(error.error))?;
#[cfg(unix)]
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(io_error)?;
Ok(())
}
#[allow(clippy::must_use_candidate)] pub fn explicit_state_dir() -> Option<PathBuf> {
explicit_state_dir_with_environment(&ProcessConfigEnvironment)
}
pub fn explicit_state_dir_with_environment(
environment: &impl ConfigEnvironment,
) -> Option<PathBuf> {
process_state_location_root().or_else(|| {
environment
.var(BCODE_STATE_DIR_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
})
}
#[must_use]
pub fn default_state_dir() -> PathBuf {
default_state_dir_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn default_session_store_dir() -> PathBuf {
default_session_store_dir_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn default_session_store_dir_with_environment(environment: &impl ConfigEnvironment) -> PathBuf {
if let Some(sessions_root) = process_state_sessions_root() {
return sessions_root;
}
if let Some(path) = environment.var(BCODE_SESSION_STORE_DIR_ENV) {
return PathBuf::from(path);
}
default_state_dir_with_environment(environment).join("sessions")
}
#[must_use]
pub fn default_state_dir_with_environment(environment: &impl ConfigEnvironment) -> PathBuf {
if let Some(root) = process_state_location_root() {
return root;
}
if let Some(path) = environment.var(BCODE_STATE_DIR_ENV) {
return PathBuf::from(path);
}
if let Some(state_home) = environment.var("XDG_STATE_HOME") {
return PathBuf::from(state_home).join("bcode");
}
if let Some(home) = environment.var("HOME") {
return PathBuf::from(home)
.join(".local")
.join("state")
.join("bcode");
}
env::temp_dir().join("bcode")
}
#[cfg(test)]
mod explicit_state_tests {
use super::*;
#[test]
fn explicit_state_lookup_does_not_enable_default_persistence() {
let environment = ConfigEnvironmentSnapshot::new(
BTreeMap::from([("HOME".to_owned(), OsString::from("/home/test"))]),
PathBuf::from("/"),
);
assert_eq!(
explicit_state_dir_with_environment(&environment),
process_state_location_root()
);
for (value, expected) in [("", None), ("/selected", Some(PathBuf::from("/selected")))] {
let environment = ConfigEnvironmentSnapshot::new(
BTreeMap::from([(BCODE_STATE_DIR_ENV.to_owned(), OsString::from(value))]),
PathBuf::from("/"),
);
assert_eq!(
explicit_state_dir_with_environment(&environment),
process_state_location_root().or(expected)
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProcessStateSelection {
root: PathBuf,
sessions_root: Option<PathBuf>,
}
fn process_state_selection() -> &'static std::sync::RwLock<Option<ProcessStateSelection>> {
static SELECTION: std::sync::OnceLock<std::sync::RwLock<Option<ProcessStateSelection>>> =
std::sync::OnceLock::new();
SELECTION.get_or_init(|| std::sync::RwLock::new(None))
}
fn process_state_location_root() -> Option<PathBuf> {
process_state_selection()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|selection| selection.root.clone())
}
fn process_state_sessions_root() -> Option<PathBuf> {
process_state_selection()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(|selection| selection.sessions_root.clone())
}
#[derive(Debug)]
pub struct StateLocationGuard {
previous: Option<ProcessStateSelection>,
}
impl Drop for StateLocationGuard {
fn drop(&mut self) {
let mut guard = process_state_selection()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(*guard).clone_from(&self.previous);
}
}
#[must_use]
pub fn push_process_state_location(location: &StateLocation) -> StateLocationGuard {
let sessions_root = Some(location.sessions_root().to_path_buf());
let mut guard = process_state_selection()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let previous = guard.clone();
*guard = Some(ProcessStateSelection {
root: location.root().to_path_buf(),
sessions_root,
});
drop(guard);
StateLocationGuard { previous }
}
pub const BCODE_STATE_DIR_ENV: &str = "BCODE_STATE_DIR";
pub const BCODE_SESSION_STORE_DIR_ENV: &str = "BCODE_SESSION_STORE_DIR";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StateLocationProvenance {
Cli,
Env,
ConfigProfile,
Config,
XdgDefault,
}
impl StateLocationProvenance {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cli => "cli",
Self::Env => "env",
Self::ConfigProfile => "config_profile",
Self::Config => "config",
Self::XdgDefault => "xdg_default",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct StateLocationId(String);
impl StateLocationId {
#[must_use]
pub fn from_canonical_root(root: &Path) -> Self {
use sha2::{Digest as _, Sha256};
let mut hasher = Sha256::new();
hasher.update(root.as_os_str().as_encoded_bytes());
let digest = hex::encode(hasher.finalize());
Self(digest[..32].to_owned())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for StateLocationId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[must_use]
pub fn runtime_scope_id(state_root: &Path, config_dir: &Path) -> String {
use sha2::{Digest as _, Sha256};
let state_root = fs::canonicalize(state_root).unwrap_or_else(|_| state_root.to_path_buf());
let config_dir = fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
let mut hasher = Sha256::new();
hasher.update(state_root.as_os_str().as_encoded_bytes());
hasher.update([0]);
hasher.update(config_dir.as_os_str().as_encoded_bytes());
let digest = hex::encode(hasher.finalize());
digest[..32].to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateLocation {
root: PathBuf,
sessions_root: PathBuf,
id: StateLocationId,
provenance: StateLocationProvenance,
profile: Option<String>,
}
impl StateLocation {
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn sessions_root(&self) -> &Path {
&self.sessions_root
}
#[must_use]
pub const fn id(&self) -> &StateLocationId {
&self.id
}
#[must_use]
pub const fn provenance(&self) -> StateLocationProvenance {
self.provenance
}
#[must_use]
pub fn profile(&self) -> Option<&str> {
self.profile.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateLocationSet {
primary: StateLocation,
readable: Vec<StateLocation>,
}
impl StateLocationSet {
#[must_use]
pub const fn primary(&self) -> &StateLocation {
&self.primary
}
#[must_use]
pub fn readable(&self) -> &[StateLocation] {
&self.readable
}
}
#[derive(Debug, Error)]
pub enum StateLocationError {
#[error("state location {source_label} must be an absolute path, got {path}")]
NotAbsolute {
source_label: String,
path: PathBuf,
},
#[error("state profile {profile} is not declared in [state.profile]")]
UnknownProfile {
profile: String,
},
#[error("state profile {profile} declares no root")]
ProfileMissingRoot {
profile: String,
},
#[error("state location {source_label} at {path} is unavailable: {message}")]
Unavailable {
source_label: String,
path: PathBuf,
message: String,
},
#[error("state location {source_label} at {path} is not a directory")]
NotADirectory {
source_label: String,
path: PathBuf,
},
#[error("state location {source_label} at {path} is not writable: {message}")]
NotWritable {
source_label: String,
path: PathBuf,
message: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StateLocationSelection {
pub root: Option<PathBuf>,
pub profile: Option<String>,
}
impl StateLocationSelection {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.root.is_none() && self.profile.is_none()
}
}
pub fn resolve_state_location_set(
config: &StateConfig,
selection: &StateLocationSelection,
) -> Result<StateLocationSet, StateLocationError> {
resolve_state_location_set_with_environment(config, selection, &ProcessConfigEnvironment)
}
pub fn resolve_state_location_set_with_environment(
config: &StateConfig,
selection: &StateLocationSelection,
environment: &impl ConfigEnvironment,
) -> Result<StateLocationSet, StateLocationError> {
let primary = resolve_primary_state_location(config, selection, environment)?;
let mut readable = vec![primary.clone()];
for profile_name in &config.readable_profiles {
if Some(profile_name.as_str()) == primary.profile() {
continue;
}
let location = resolve_profile_state_location(config, profile_name, environment)?;
if location.id() == primary.id()
|| readable
.iter()
.any(|existing| existing.id() == location.id())
{
continue;
}
readable.push(location);
}
Ok(StateLocationSet { primary, readable })
}
fn resolve_primary_state_location(
config: &StateConfig,
selection: &StateLocationSelection,
environment: &impl ConfigEnvironment,
) -> Result<StateLocation, StateLocationError> {
let sessions_override = environment
.var(BCODE_SESSION_STORE_DIR_ENV)
.map(PathBuf::from);
if let Some(root) = &selection.root {
return build_state_location(
root,
sessions_override,
None,
StateLocationProvenance::Cli,
None,
"--state-root",
);
}
if let Some(profile) = &selection.profile {
return resolve_named_profile_location(
config,
profile,
sessions_override,
StateLocationProvenance::Cli,
&format!("--state-profile {profile}"),
);
}
if let Some(root) = environment.var(BCODE_STATE_DIR_ENV) {
return build_state_location(
Path::new(&root),
sessions_override,
None,
StateLocationProvenance::Env,
None,
BCODE_STATE_DIR_ENV,
);
}
if let Some(profile) = &config.default_profile {
return resolve_named_profile_location(
config,
profile,
sessions_override,
StateLocationProvenance::ConfigProfile,
&format!("[state] default_profile = {profile}"),
);
}
if let Some(root) = &config.root {
return build_state_location(
root,
sessions_override,
config.sessions_root.clone(),
StateLocationProvenance::Config,
None,
"[state] root",
);
}
build_state_location(
&default_state_dir_with_environment(environment),
sessions_override,
config.sessions_root.clone(),
StateLocationProvenance::XdgDefault,
None,
"default state directory",
)
}
fn resolve_profile_state_location(
config: &StateConfig,
profile: &str,
environment: &impl ConfigEnvironment,
) -> Result<StateLocation, StateLocationError> {
let _ = environment;
resolve_named_profile_location(
config,
profile,
None,
StateLocationProvenance::ConfigProfile,
&format!("[state.profile.{profile}]"),
)
}
fn resolve_named_profile_location(
config: &StateConfig,
profile: &str,
sessions_override: Option<PathBuf>,
provenance: StateLocationProvenance,
source_label: &str,
) -> Result<StateLocation, StateLocationError> {
let declared =
config
.profile
.get(profile)
.ok_or_else(|| StateLocationError::UnknownProfile {
profile: profile.to_owned(),
})?;
let root = declared
.root
.clone()
.ok_or_else(|| StateLocationError::ProfileMissingRoot {
profile: profile.to_owned(),
})?;
build_state_location(
&root,
sessions_override,
declared.sessions_root.clone(),
provenance,
Some(profile.to_owned()),
source_label,
)
}
fn build_state_location(
root: &Path,
sessions_override: Option<PathBuf>,
configured_sessions_root: Option<PathBuf>,
provenance: StateLocationProvenance,
profile: Option<String>,
source_label: &str,
) -> Result<StateLocation, StateLocationError> {
let canonical_root = prepare_state_directory(root, source_label)?;
let sessions_root = match sessions_override.or(configured_sessions_root) {
Some(explicit) => {
let label = format!("{source_label} sessions root");
prepare_state_directory(&explicit, &label)?
}
None => canonical_root.join("sessions"),
};
let id = StateLocationId::from_canonical_root(&canonical_root);
Ok(StateLocation {
root: canonical_root,
sessions_root,
id,
provenance,
profile,
})
}
fn prepare_state_directory(path: &Path, source_label: &str) -> Result<PathBuf, StateLocationError> {
if !path.is_absolute() {
return Err(StateLocationError::NotAbsolute {
source_label: source_label.to_owned(),
path: path.to_path_buf(),
});
}
if let Err(source) = fs::create_dir_all(path) {
return Err(StateLocationError::Unavailable {
source_label: source_label.to_owned(),
path: path.to_path_buf(),
message: source.to_string(),
});
}
let canonical = fs::canonicalize(path).map_err(|source| StateLocationError::Unavailable {
source_label: source_label.to_owned(),
path: path.to_path_buf(),
message: source.to_string(),
})?;
let metadata = fs::metadata(&canonical).map_err(|source| StateLocationError::Unavailable {
source_label: source_label.to_owned(),
path: canonical.clone(),
message: source.to_string(),
})?;
if !metadata.is_dir() {
return Err(StateLocationError::NotADirectory {
source_label: source_label.to_owned(),
path: canonical,
});
}
verify_state_directory_writable(&canonical, source_label)?;
Ok(canonical)
}
fn verify_state_directory_writable(
path: &Path,
source_label: &str,
) -> Result<(), StateLocationError> {
let probe = path.join(".bcode-write-probe");
match fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&probe)
{
Ok(file) => {
drop(file);
let _ = fs::remove_file(&probe);
Ok(())
}
Err(source) => Err(StateLocationError::NotWritable {
source_label: source_label.to_owned(),
path: path.to_path_buf(),
message: source.to_string(),
}),
}
}
#[must_use]
pub fn default_auth_vault_path() -> PathBuf {
default_auth_vault_path_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn default_auth_vault_path_with_environment(environment: &impl ConfigEnvironment) -> PathBuf {
if let Some(path) = environment.var("BCODE_AUTH_VAULT") {
return PathBuf::from(path);
}
default_state_dir_with_environment(environment)
.join("auth")
.join("vault")
}
#[must_use]
pub fn default_permissions_state_path() -> PathBuf {
default_permissions_state_path_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn default_permissions_state_path_with_environment(
environment: &impl ConfigEnvironment,
) -> PathBuf {
if let Some(path) = environment.var("BCODE_PERMISSIONS_STATE") {
return PathBuf::from(path);
}
default_state_dir_with_environment(environment).join("permissions.toml")
}
#[must_use]
pub fn default_tui_state_path() -> PathBuf {
default_tui_state_path_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn default_tui_state_path_with_environment(environment: &impl ConfigEnvironment) -> PathBuf {
if let Some(path) = environment.var("BCODE_TUI_STATE") {
return PathBuf::from(path);
}
default_state_dir_with_environment(environment).join("tui.toml")
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
struct TuiState {
#[serde(default)]
theme: TuiThemeState,
#[serde(default)]
presentation: TuiPresentationState,
#[serde(flatten)]
extra: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
struct TuiThemeState {
#[serde(default)]
name: Option<String>,
#[serde(flatten)]
extra: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
struct TuiPresentationState {
#[serde(default)]
streaming: Option<TuiStreamingState>,
#[serde(flatten)]
extra: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct TuiStreamingState {
enabled: bool,
curve: bcode_session_view_models::StreamingInterpolationCurve,
graphemes_per_second: u32,
max_lag_ms: u64,
#[serde(flatten)]
extra: BTreeMap<String, toml::Value>,
}
impl TuiStreamingState {
const fn policy(&self) -> bcode_session_view_models::StreamingPresentationPolicy {
bcode_session_view_models::StreamingPresentationPolicy {
enabled: self.enabled,
curve: self.curve,
graphemes_per_second: self.graphemes_per_second,
max_lag_ms: self.max_lag_ms,
}
.normalized()
}
}
impl From<bcode_session_view_models::StreamingPresentationPolicy> for TuiStreamingState {
fn from(policy: bcode_session_view_models::StreamingPresentationPolicy) -> Self {
let policy = policy.normalized();
Self {
enabled: policy.enabled,
curve: policy.curve,
graphemes_per_second: policy.graphemes_per_second,
max_lag_ms: policy.max_lag_ms,
extra: BTreeMap::new(),
}
}
}
fn load_tui_state_from(path: &Path) -> Result<TuiState, ConfigError> {
if !path.exists() {
return Ok(TuiState::default());
}
let raw = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
toml::from_str::<TuiState>(&raw).map_err(|source| ConfigError::Composition {
message: format!(
"failed to parse TUI state {}: {source}",
display_from_current_dir(path)
),
})
}
pub fn load_tui_theme_selection() -> Result<Option<String>, ConfigError> {
load_tui_theme_selection_from(&default_tui_state_path())
}
pub fn load_tui_theme_selection_from(path: &Path) -> Result<Option<String>, ConfigError> {
Ok(load_tui_state_from(path)?.theme.name)
}
pub fn set_tui_theme_selection(name: &str) -> Result<PathBuf, ConfigError> {
if name.trim().is_empty() {
return Err(ConfigError::Composition {
message: "theme name must not be empty".to_owned(),
});
}
let path = default_tui_state_path();
let mut state = load_tui_state_from(&path)?;
state.theme.name = Some(name.to_owned());
write_tui_state(&path, &state)
}
pub fn clear_tui_theme_selection() -> Result<PathBuf, ConfigError> {
let path = default_tui_state_path();
let mut state = load_tui_state_from(&path)?;
state.theme.name = None;
write_tui_state(&path, &state)
}
pub fn load_tui_streaming_presentation_override()
-> Result<Option<bcode_session_view_models::StreamingPresentationPolicy>, ConfigError> {
load_tui_streaming_presentation_override_from(&default_tui_state_path())
}
pub fn load_tui_streaming_presentation_override_from(
path: &Path,
) -> Result<Option<bcode_session_view_models::StreamingPresentationPolicy>, ConfigError> {
Ok(load_tui_state_from(path)?
.presentation
.streaming
.as_ref()
.map(TuiStreamingState::policy))
}
pub fn set_tui_streaming_presentation_override(
policy: bcode_session_view_models::StreamingPresentationPolicy,
) -> Result<PathBuf, ConfigError> {
let path = default_tui_state_path();
let mut state = load_tui_state_from(&path)?;
state.presentation.streaming = Some(policy.into());
write_tui_state(&path, &state)
}
pub fn clear_tui_streaming_presentation_override() -> Result<PathBuf, ConfigError> {
let path = default_tui_state_path();
let mut state = load_tui_state_from(&path)?;
state.presentation.streaming = None;
write_tui_state(&path, &state)
}
fn write_tui_state(path: &Path, state: &TuiState) -> Result<PathBuf, ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let contents = toml::to_string(state).map_err(|source| ConfigError::Composition {
message: format!("failed to serialize TUI state: {source}"),
})?;
fs::write(path, contents).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(path.to_path_buf())
}
#[must_use]
pub fn default_model_ignores_state_path() -> PathBuf {
if let Ok(path) = env::var("BCODE_MODEL_IGNORES_STATE") {
return PathBuf::from(path);
}
default_state_dir().join("model-ignores.toml")
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct ModelIgnoresState {
#[serde(default)]
providers: BTreeMap<String, ModelIgnoreConfig>,
}
pub fn load_model_ignores_state() -> Result<BTreeMap<String, ModelIgnoreConfig>, ConfigError> {
load_model_ignores_state_from(&default_model_ignores_state_path())
}
pub fn load_model_ignores_state_from(
path: &Path,
) -> Result<BTreeMap<String, ModelIgnoreConfig>, ConfigError> {
match std::fs::symlink_metadata(path) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(BTreeMap::new());
}
Err(source) => {
return Err(ConfigError::Io {
path: path.to_path_buf(),
source,
});
}
}
let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
let state =
toml::from_str::<ModelIgnoresState>(&raw).map_err(|_| ConfigError::Composition {
message: format!(
"failed to parse model ignore state {}: invalid TOML or ignore-rule schema",
display_from_current_dir(path)
),
})?;
Ok(state.providers)
}
pub fn effective_model_ignore_rules(
provider_plugin_id: &str,
) -> Result<EffectiveModelIgnoreRules, ConfigError> {
let config = load_config()?;
let state = load_model_ignores_state()?;
Ok(EffectiveModelIgnoreRules {
config: config
.model
.ignored
.get(provider_plugin_id)
.cloned()
.unwrap_or_default(),
state: state.get(provider_plugin_id).cloned().unwrap_or_default(),
})
}
pub fn ignore_model_in_state(
provider_plugin_id: &str,
model_id: String,
) -> Result<PathBuf, ConfigError> {
update_model_ignores_state(|providers| {
providers
.entry(provider_plugin_id.to_string())
.or_default()
.models
.insert(model_id);
Ok(())
})
}
pub fn unignore_model_in_state(
provider_plugin_id: &str,
model_id: &str,
) -> Result<PathBuf, ConfigError> {
update_model_ignores_state(|providers| {
if let Some(rules) = providers.get_mut(provider_plugin_id) {
rules.models.remove(model_id);
}
Ok(())
})
}
fn update_model_ignores_state(
update: impl FnOnce(&mut BTreeMap<String, ModelIgnoreConfig>) -> Result<(), ConfigError>,
) -> Result<PathBuf, ConfigError> {
let path = default_model_ignores_state_path();
let mut providers = load_model_ignores_state_from(&path)?;
update(&mut providers)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(&path, model_ignores_state_to_toml(&providers)).map_err(|source| {
ConfigError::Io {
path: path.clone(),
source,
}
})?;
Ok(path)
}
fn model_ignores_state_to_toml(providers: &BTreeMap<String, ModelIgnoreConfig>) -> String {
let mut output = String::new();
output.push_str(
"# Bcode runtime model ignore state. Managed automatically by the TUI and CLI.\n",
);
output.push_str(
"# Declarative [model.ignored] rules in bcode.toml are unioned with this file.\n\n",
);
for (provider, rules) in providers {
let mut provider_key = String::from("\"");
for character in provider.chars() {
match character {
'"' => provider_key.push_str("\\\""),
'\\' => provider_key.push_str("\\\\"),
character if character.is_control() => {
let _ = write!(provider_key, "\\u{:04X}", u32::from(character));
}
character => provider_key.push(character),
}
}
provider_key.push('"');
let _ = writeln!(output, "[providers.{provider_key}]");
write_model_ignore_string_set(&mut output, "models", &rules.models);
write_string_slice(&mut output, "patterns", &rules.patterns);
output.push('\n');
}
output
}
fn write_model_ignore_string_set(output: &mut String, key: &str, values: &BTreeSet<String>) {
let values = values.iter().cloned().collect::<Vec<_>>();
write_string_slice(output, key, &values);
}
fn write_string_slice(output: &mut String, key: &str, values: &[String]) {
if values.is_empty() {
return;
}
let escaped = values
.iter()
.map(|value| toml::Value::String(value.clone()).to_string())
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(output, "{key} = [{escaped}]");
}
pub fn load_permissions_state()
-> Result<BTreeMap<String, bcode_agent_policy_models::AgentConfig>, ConfigError> {
load_permissions_state_from(&default_permissions_state_path())
}
pub fn load_permissions_state_value() -> Result<Option<toml::Value>, ConfigError> {
load_permissions_state_value_from(&default_permissions_state_path())
}
pub fn load_permissions_state_value_from(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
if !path.exists() {
return Ok(None);
}
let value = load_toml_file(path)?;
validate_removed_permission_categories(&value)?;
Ok(Some(value))
}
pub fn load_permissions_state_from(
path: &Path,
) -> Result<BTreeMap<String, bcode_agent_policy_models::AgentConfig>, ConfigError> {
if !path.exists() {
return Ok(BTreeMap::new());
}
let value = load_toml_file(path)?;
let context = format!("permissions state {}", display_from_current_dir(path));
let config = validate_config_value(value, &context)?;
Ok(config.agent)
}
fn update_permissions_state(
update: impl FnOnce(
&mut BTreeMap<String, bcode_agent_policy_models::AgentConfig>,
) -> Result<(), ConfigError>,
) -> Result<PathBuf, ConfigError> {
let path = default_permissions_state_path();
let mut agents = if path.exists() {
load_permissions_state_from(&path)?
} else {
BTreeMap::new()
};
update(&mut agents)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(&path, permissions_state_to_toml(&agents)).map_err(|source| {
ConfigError::Io {
path: path.clone(),
source,
}
})?;
Ok(path)
}
fn permissions_state_to_toml(
agents: &BTreeMap<String, bcode_agent_policy_models::AgentConfig>,
) -> String {
let mut output = String::new();
output.push_str("# Bcode runtime permissions state. Managed automatically by\n");
output.push_str("# `bcode permission add` and the TUI always-allow prompts.\n");
output.push_str("# Entries here win over same-pattern rules in bcode.toml.\n\n");
write_agents_toml(&mut output, agents);
output
}
fn insert_agent_permission_rule(
agent: &mut BTreeMap<String, bcode_agent_policy_models::AgentConfig>,
agent_id: &str,
category: &str,
pattern: String,
action: bcode_agent_policy_models::Action,
) -> Result<(), ConfigError> {
let entry = agent.entry(agent_id.to_string()).or_default();
let permission = &mut entry.permission;
let map = match category {
"command" => &mut permission.command,
"read" => &mut permission.read,
"write" => &mut permission.write,
"edit" => &mut permission.edit,
"web" => &mut permission.web,
_ => return Err(ConfigError::UnknownPermissionCategory(category.to_string())),
};
map.insert(pattern, action);
Ok(())
}
fn parse_action(action: &str) -> Result<bcode_agent_policy_models::Action, ConfigError> {
match action {
"allow" => Ok(bcode_agent_policy_models::Action::Allow),
"ask" => Ok(bcode_agent_policy_models::Action::Ask),
"deny" => Ok(bcode_agent_policy_models::Action::Deny),
_ => Err(ConfigError::UnknownPermissionAction(action.to_string())),
}
}
#[must_use]
pub fn default_config_dir() -> PathBuf {
default_config_dir_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn default_config_dir_with_environment(environment: &impl ConfigEnvironment) -> PathBuf {
if let Some(config_home) = environment.var("XDG_CONFIG_HOME") {
return PathBuf::from(config_home).join("bcode");
}
if let Some(home) = environment.var("HOME") {
return PathBuf::from(home).join(".config").join("bcode");
}
env::temp_dir().join("bcode")
}
fn writable_config_path() -> PathBuf {
if let Ok(path) = env::var("BCODE_CONFIG") {
return PathBuf::from(path);
}
default_config_dir().join(DEFAULT_CONFIG_FILE_NAME)
}
fn config_to_toml(config: &BcodeConfig) -> String {
if config.contexts.is_some() {
return toml::to_string_pretty(config).expect("serializable context configuration");
}
let mut output = String::new();
write_plugins_toml(&mut output, &config.plugins);
write_tools_toml(&mut output, &config.tools);
write_model_toml(&mut output, &config.model);
write_agents_toml(&mut output, &config.agent);
write_auth_toml(&mut output, &config.auth);
if config.onboarding != OnboardingConfig::default() {
output.push_str("[onboarding]\n");
writeln!(
output,
"credential_discovery = {}",
config.onboarding.credential_discovery
)
.expect("write string");
writeln!(
output,
"open_browser = {}\n",
config.onboarding.open_browser
)
.expect("write string");
}
write_observability_toml(&mut output, &config.observability);
write_skills_toml(&mut output, &config.skills);
write_workflows_toml(&mut output, &config.workflows);
write_invariants_toml(&mut output, &config.invariants);
write_system_prompt_toml(&mut output, &config.system_prompt);
write_prompt_profile_toml(&mut output, &config.prompt_profile);
write_presentation_toml(&mut output, config.presentation);
write_tui_toml(&mut output, &config.tui);
write_client_toml(&mut output, &config.client);
write_state_toml(&mut output, &config.state);
write_domain_toml(&mut output, "web_search", &config.web_search);
output
}
fn write_state_toml(output: &mut String, state: &StateConfig) {
if state == &StateConfig::default() {
return;
}
output.push_str("[state]\n");
if let Some(root) = &state.root {
writeln!(
output,
"root = {}",
toml_string(&root.display().to_string())
)
.expect("writing to string should not fail");
}
if let Some(sessions_root) = &state.sessions_root {
writeln!(
output,
"sessions_root = {}",
toml_string(&sessions_root.display().to_string())
)
.expect("writing to string should not fail");
}
if let Some(default_profile) = &state.default_profile {
writeln!(output, "default_profile = {}", toml_string(default_profile))
.expect("writing to string should not fail");
}
if !state.readable_profiles.is_empty() {
let entries = state
.readable_profiles
.iter()
.map(|profile| toml_string(profile))
.collect::<Vec<_>>()
.join(", ");
writeln!(output, "readable_profiles = [{entries}]")
.expect("writing to string should not fail");
}
output.push('\n');
for (name, profile) in &state.profile {
writeln!(output, "[state.profile.{}]", toml_table_key(name))
.expect("writing to string should not fail");
if let Some(root) = &profile.root {
writeln!(
output,
"root = {}",
toml_string(&root.display().to_string())
)
.expect("writing to string should not fail");
}
if let Some(sessions_root) = &profile.sessions_root {
writeln!(
output,
"sessions_root = {}",
toml_string(&sessions_root.display().to_string())
)
.expect("writing to string should not fail");
}
output.push('\n');
}
}
fn write_client_toml(output: &mut String, client: &ClientConfig) {
if client == &ClientConfig::default() {
return;
}
output.push_str("[client]\n");
writeln!(
output,
"request_timeout_secs = {}",
client.request_timeout_secs
)
.expect("writing to string should not fail");
output.push('\n');
}
fn write_domain_toml(output: &mut String, section: &str, value: &toml::Value) {
let Some(table) = value.as_table() else {
return;
};
if table.is_empty() {
return;
}
writeln!(output, "[{}]", toml_table_key(section)).expect("writing to string should not fail");
for (key, value) in table {
write_toml_value(output, key, value);
}
output.push('\n');
}
fn write_model_compaction_toml(output: &mut String, compaction: &CompactionConfig) {
if compaction == &CompactionConfig::default() {
return;
}
output.push_str("[model.compaction]\n");
writeln!(
output,
"mode = {}",
toml_string(compaction_mode_name(compaction.mode))
)
.expect("writing to string should not fail");
writeln!(
output,
"backend = {}",
toml_string(compaction_backend_name(compaction.backend))
)
.expect("writing to string should not fail");
writeln!(
output,
"proactive_threshold_percent = {}",
compaction.proactive_threshold_percent
)
.expect("writing to string should not fail");
writeln!(
output,
"keep_recent_tokens = {}",
compaction.keep_recent_tokens
)
.expect("writing to string should not fail");
if compaction.context_chars > 0 {
writeln!(output, "context_chars = {}", compaction.context_chars)
.expect("writing to string should not fail");
}
output.push('\n');
for (model_id, policy) in &compaction.models {
writeln!(output, "[model.compaction.models.{}]", toml_key(model_id))
.expect("writing to string should not fail");
if let Some(provider_plugin_id) = &policy.provider_plugin_id {
writeln!(
output,
"provider_plugin_id = {}",
toml_string(provider_plugin_id)
)
.expect("writing to string should not fail");
}
if let Some(mode) = policy.mode {
writeln!(output, "mode = {}", toml_string(compaction_mode_name(mode)))
.expect("writing to string should not fail");
}
if let Some(backend) = policy.backend {
writeln!(
output,
"backend = {}",
toml_string(compaction_backend_name(backend))
)
.expect("writing to string should not fail");
}
if let Some(tokens) = policy.proactive_threshold_tokens {
writeln!(output, "proactive_threshold_tokens = {tokens}")
.expect("writing to string should not fail");
}
if let Some(percent) = policy.proactive_threshold_percent {
writeln!(output, "proactive_threshold_percent = {percent}")
.expect("writing to string should not fail");
}
if let Some(tokens) = policy.keep_recent_tokens {
writeln!(output, "keep_recent_tokens = {tokens}")
.expect("writing to string should not fail");
}
output.push('\n');
}
}
fn write_model_tool_output_toml(output: &mut String, tool_output: &ToolOutputConfig) {
if tool_output == &ToolOutputConfig::default() {
return;
}
output.push_str("[model.tool_output]\n");
writeln!(output, "context_chars = {}", tool_output.context_chars)
.expect("writing to string should not fail");
writeln!(
output,
"fallback_argument_chars = {}",
tool_output.fallback_argument_chars
)
.expect("writing to string should not fail");
output.push('\n');
}
fn write_model_retry_toml(output: &mut String, retry: &ModelRetryConfig) {
if retry == &ModelRetryConfig::default() {
return;
}
output.push_str("[model.retry]\n");
if retry.enabled != default_model_retry_enabled() {
writeln!(output, "enabled = {}", retry.enabled).expect("writing to string should not fail");
}
if retry.overload_enabled != default_overload_retry_enabled() {
writeln!(output, "overload_enabled = {}", retry.overload_enabled)
.expect("writing to string should not fail");
}
if let Some(retry_forever) = retry.overload_retry_forever {
writeln!(output, "overload_retry_forever = {retry_forever}")
.expect("writing to string should not fail");
}
if let Some(max_retries) = retry.max_overload_retries {
writeln!(output, "max_overload_retries = {max_retries}")
.expect("writing to string should not fail");
}
writeln!(
output,
"overload_initial_delay_ms = {}",
retry.overload_initial_delay_ms
)
.expect("writing to string should not fail");
writeln!(
output,
"overload_max_delay_ms = {}",
retry.overload_max_delay_ms
)
.expect("writing to string should not fail");
if retry.no_progress_timeout_enabled != default_no_progress_timeout_retry_enabled() {
writeln!(
output,
"no_progress_timeout_enabled = {}",
retry.no_progress_timeout_enabled
)
.expect("writing to string should not fail");
}
if let Some(retry_forever) = retry.no_progress_timeout_retry_forever {
writeln!(
output,
"no_progress_timeout_retry_forever = {retry_forever}"
)
.expect("writing to string should not fail");
}
if let Some(max_retries) = retry.max_no_progress_timeout_retries {
writeln!(output, "max_no_progress_timeout_retries = {max_retries}")
.expect("writing to string should not fail");
}
writeln!(
output,
"no_progress_timeout_initial_delay_ms = {}",
retry.no_progress_timeout_initial_delay_ms
)
.expect("writing to string should not fail");
writeln!(
output,
"no_progress_timeout_max_delay_ms = {}",
retry.no_progress_timeout_max_delay_ms
)
.expect("writing to string should not fail");
if retry.remote_catalog_rules_enabled != default_remote_catalog_retry_rules_enabled() {
writeln!(
output,
"remote_catalog_rules_enabled = {}",
retry.remote_catalog_rules_enabled
)
.expect("writing to string should not fail");
}
output.push('\n');
for rule in &retry.rules {
write_model_retry_rule_toml(output, rule);
}
}
fn write_model_retry_rule_toml(output: &mut String, rule: &ModelRetryRuleConfig) {
output.push_str("[[model.retry.rules]]\n");
writeln!(output, "id = {}", toml_string(&rule.id)).expect("writing to string should not fail");
if let Some(enabled) = rule.enabled {
writeln!(output, "enabled = {enabled}").expect("writing to string should not fail");
}
write_optional_string(
output,
"provider_plugin_id",
rule.provider_plugin_id.as_ref(),
);
write_optional_string(
output,
"provider_plugin_id_contains",
rule.provider_plugin_id_contains.as_ref(),
);
write_optional_string(output, "model_id", rule.model_id.as_ref());
write_optional_string(output, "model_id_contains", rule.model_id_contains.as_ref());
if let Some(retry_forever) = rule.retry_forever {
writeln!(output, "retry_forever = {retry_forever}")
.expect("writing to string should not fail");
}
if let Some(max_retries) = rule.max_retries {
writeln!(output, "max_retries = {max_retries}").expect("writing to string should not fail");
}
if let Some(initial_delay_ms) = rule.initial_delay_ms {
writeln!(output, "initial_delay_ms = {initial_delay_ms}")
.expect("writing to string should not fail");
}
if let Some(max_delay_ms) = rule.max_delay_ms {
writeln!(output, "max_delay_ms = {max_delay_ms}")
.expect("writing to string should not fail");
}
if let Some(use_provider_retry_hint) = rule.use_provider_retry_hint {
writeln!(
output,
"use_provider_retry_hint = {use_provider_retry_hint}"
)
.expect("writing to string should not fail");
}
output.push('\n');
output.push_str("[model.retry.rules.match]\n");
write_model_retry_rule_match_toml(output, &rule.r#match);
output.push('\n');
}
fn write_model_retry_rule_match_toml(output: &mut String, matcher: &ModelRetryRuleMatchConfig) {
if let Some(category) = matcher.category {
writeln!(
output,
"category = {}",
toml_string(provider_error_category_name(category))
)
.expect("writing to string should not fail");
}
write_optional_string(output, "code", matcher.code.as_ref());
write_optional_string(output, "message_equals", matcher.message_equals.as_ref());
write_optional_string(
output,
"message_contains",
matcher.message_contains.as_ref(),
);
write_optional_string(
output,
"provider_message_equals",
matcher.provider_message_equals.as_ref(),
);
write_optional_string(
output,
"provider_message_contains",
matcher.provider_message_contains.as_ref(),
);
}
const fn provider_error_category_name(
category: bcode_model::ProviderErrorCategory,
) -> &'static str {
match category {
bcode_model::ProviderErrorCategory::Auth => "auth",
bcode_model::ProviderErrorCategory::Config => "config",
bcode_model::ProviderErrorCategory::InvalidRequest => "invalid_request",
bcode_model::ProviderErrorCategory::ModelNotFound => "model_not_found",
bcode_model::ProviderErrorCategory::ContextLength => "context_length",
bcode_model::ProviderErrorCategory::Network => "network",
bcode_model::ProviderErrorCategory::Timeout => "timeout",
bcode_model::ProviderErrorCategory::RateLimit => "rate_limit",
bcode_model::ProviderErrorCategory::UnsupportedFeature => "unsupported_feature",
bcode_model::ProviderErrorCategory::ProviderInternal => "provider_internal",
bcode_model::ProviderErrorCategory::Overloaded => "overloaded",
bcode_model::ProviderErrorCategory::Cancelled => "cancelled",
}
}
fn write_optional_string(output: &mut String, key: &str, value: Option<&String>) {
if let Some(value) = value {
writeln!(output, "{key} = {}", toml_string(value))
.expect("writing to string should not fail");
}
}
fn write_model_streaming_toml(output: &mut String, streaming: &StreamingConfig) {
if streaming == &StreamingConfig::default() {
return;
}
output.push_str("[model.streaming]\n");
writeln!(
output,
"no_progress_warning_secs = {}",
streaming.no_progress_warning_secs
)
.expect("writing to string should not fail");
writeln!(
output,
"no_progress_timeout_secs = {}",
streaming.no_progress_timeout_secs
)
.expect("writing to string should not fail");
output.push('\n');
}
fn write_model_toml(output: &mut String, model: &ModelConfig) {
if model.provider_plugin_id.is_some()
|| model.model_id.is_some()
|| model.default_thinking_level.is_some()
|| model.reasoning != ReasoningConfig::default()
|| model.max_tool_rounds.is_some()
|| model.profile.is_some()
|| !model.aliases.is_empty()
|| !model.metadata.is_empty()
|| model.context_strategy != ContextStrategyConfig::default()
|| model.prompt_cache != PromptCacheConfig::default()
|| model.conversation_reuse != ConversationReuseConfig::default()
|| model.tool_output != ToolOutputConfig::default()
|| model.streaming != StreamingConfig::default()
|| model.retry != ModelRetryConfig::default()
|| model.compaction != CompactionConfig::default()
{
output.push_str("[model]\n");
if let Some(profile) = &model.profile {
writeln!(output, "profile = {}", toml_string(profile))
.expect("writing to string should not fail");
}
if let Some(provider_plugin_id) = &model.provider_plugin_id {
writeln!(
output,
"provider_plugin_id = {}",
toml_string(provider_plugin_id)
)
.expect("writing to string should not fail");
}
if let Some(model_id) = &model.model_id {
writeln!(output, "model_id = {}", toml_string(model_id))
.expect("writing to string should not fail");
}
if let Some(level) = &model.default_thinking_level {
writeln!(output, "default_thinking_level = \"{level:?}\"")
.expect("writing to string should not fail");
}
write_reasoning_inline_toml(output, &model.reasoning);
if let Some(max_tool_rounds) = model.max_tool_rounds {
writeln!(output, "max_tool_rounds = {max_tool_rounds}")
.expect("writing to string should not fail");
}
output.push('\n');
}
if model.context_strategy != ContextStrategyConfig::default() {
output.push_str("[model.context_strategy]\n");
writeln!(
output,
"mode = {}",
toml_string(context_strategy_mode_name(model.context_strategy.mode))
)
.expect("writing to string should not fail");
output.push('\n');
}
if model.prompt_cache != PromptCacheConfig::default() {
output.push_str("[model.prompt_cache]\n");
writeln!(
output,
"mode = {}",
toml_string(prompt_cache_mode_name(model.prompt_cache.mode))
)
.expect("writing to string should not fail");
output.push('\n');
}
if model.conversation_reuse != ConversationReuseConfig::default() {
output.push_str("[model.conversation_reuse]\n");
writeln!(
output,
"mode = {}",
toml_string(conversation_reuse_mode_name(model.conversation_reuse.mode))
)
.expect("writing to string should not fail");
output.push('\n');
}
write_model_tool_output_toml(output, &model.tool_output);
write_model_streaming_toml(output, &model.streaming);
write_model_retry_toml(output, &model.retry);
if model.compaction != CompactionConfig::default() {
write_model_compaction_toml(output, &model.compaction);
}
write_model_profiles_toml(output, &model.profiles);
write_model_aliases_toml(output, &model.aliases);
write_model_metadata_toml(output, &model.metadata);
}
fn write_reasoning_inline_toml(output: &mut String, reasoning: &ReasoningConfig) {
if let Some(effort) = &reasoning.effort {
writeln!(output, "reasoning_effort = {}", toml_string(effort))
.expect("writing to string should not fail");
}
if let Some(summary) = &reasoning.summary {
writeln!(output, "reasoning_summary = {}", toml_string(summary))
.expect("writing to string should not fail");
}
if !reasoning.effort_values.is_empty() {
write_string_array(output, "reasoning_effort_values", &reasoning.effort_values);
}
if !reasoning.summary_values.is_empty() {
write_string_array(
output,
"reasoning_summary_values",
&reasoning.summary_values,
);
}
if let Some(default_effort) = &reasoning.default_effort {
writeln!(
output,
"reasoning_default_effort = {}",
toml_string(default_effort)
)
.expect("writing to string should not fail");
}
if let Some(default_summary) = &reasoning.default_summary {
writeln!(
output,
"reasoning_default_summary = {}",
toml_string(default_summary)
)
.expect("writing to string should not fail");
}
if let Some(supported) = reasoning.visible_summary_supported {
writeln!(output, "reasoning_visible_summary_supported = {supported}")
.expect("writing to string should not fail");
}
if let Some(supported) = reasoning.raw_reasoning_supported {
writeln!(output, "reasoning_raw_supported = {supported}")
.expect("writing to string should not fail");
}
}
fn write_string_array(output: &mut String, key: &str, values: &[String]) {
let rendered = values
.iter()
.map(|value| toml_string(value))
.collect::<Vec<_>>()
.join(", ");
writeln!(output, "{key} = [{rendered}]").expect("writing to string should not fail");
}
fn write_model_profiles_toml(output: &mut String, profiles: &BTreeMap<String, ModelProfileConfig>) {
for (profile_name, profile) in profiles {
writeln!(output, "[model.profiles.{}]", toml_key(profile_name))
.expect("writing to string should not fail");
writeln!(
output,
"provider_plugin_id = {}",
toml_string(&profile.provider_plugin_id)
)
.expect("writing to string should not fail");
if let Some(model_id) = &profile.model_id {
writeln!(output, "model_id = {}", toml_string(model_id))
.expect("writing to string should not fail");
}
if let Some(auth_profile) = &profile.auth_profile {
writeln!(output, "auth_profile = {}", toml_string(auth_profile))
.expect("writing to string should not fail");
}
write_reasoning_inline_toml(output, &profile.reasoning);
output.push('\n');
write_string_map_table(
output,
&format!("model.profiles.{}.settings", toml_key(profile_name)),
&profile.settings,
);
write_json_map_table(
output,
&format!("model.profiles.{}.request", toml_key(profile_name)),
&profile.request,
);
}
}
fn write_model_aliases_toml(output: &mut String, aliases: &BTreeMap<String, ModelAliasConfig>) {
for (alias_name, alias) in aliases {
writeln!(output, "[model.aliases.{}]", toml_key(alias_name))
.expect("writing to string should not fail");
if let Some(provider_plugin_id) = &alias.provider_plugin_id {
writeln!(
output,
"provider_plugin_id = {}",
toml_string(provider_plugin_id)
)
.expect("writing to string should not fail");
}
writeln!(output, "model_id = {}", toml_string(&alias.model_id))
.expect("writing to string should not fail");
output.push('\n');
write_json_map_table(
output,
&format!("model.aliases.{}.request", toml_key(alias_name)),
&alias.request,
);
}
}
fn write_model_metadata_toml(
output: &mut String,
metadata: &BTreeMap<String, ModelMetadataConfig>,
) {
for (model_id, metadata) in metadata {
writeln!(output, "[model.metadata.{}]", toml_key(model_id))
.expect("writing to string should not fail");
if let Some(provider_plugin_id) = &metadata.provider_plugin_id {
writeln!(
output,
"provider_plugin_id = {}",
toml_string(provider_plugin_id)
)
.expect("writing to string should not fail");
}
if let Some(context_window) = metadata.context_window {
writeln!(output, "context_window = {context_window}")
.expect("writing to string should not fail");
}
if let Some(max_output_tokens) = metadata.max_output_tokens {
writeln!(output, "max_output_tokens = {max_output_tokens}")
.expect("writing to string should not fail");
}
output.push('\n');
}
}
fn write_invariants_toml(output: &mut String, invariants: &InvariantsConfig) {
if invariants == &InvariantsConfig::default() {
return;
}
output.push_str("[invariants]\n");
if !invariants.enabled {
output.push_str("enabled = false\n");
}
if invariants.mode != InvariantGuidanceMode::Full {
output.push_str("mode = \"relevant\"\n");
}
if invariants.max_selected != default_invariant_max_selected() {
writeln!(output, "max_selected = {}", invariants.max_selected).expect("write to string");
}
if !invariants.include_task_summary {
output.push_str("include_task_summary = false\n");
}
output.push('\n');
if invariants.selector != InvariantSelectorConfig::default() {
output.push_str("[invariants.selector]\n");
if let Some(profile) = &invariants.selector.model_profile {
writeln!(output, "model_profile = {}", toml_string(profile)).expect("write to string");
}
if let Some(timeout) = invariants.selector.initial_timeout_ms {
writeln!(output, "initial_timeout_ms = {timeout}").expect("write to string");
}
if let Some(timeout) = invariants.selector.background_timeout_ms {
writeln!(output, "background_timeout_ms = {timeout}").expect("write to string");
}
if invariants.selector.wait_for_background_on_next_prompt {
output.push_str("wait_for_background_on_next_prompt = true\n");
}
if invariants.selector.cancel_on_timeout {
output.push_str("cancel_on_timeout = true\n");
}
if invariants.selector.cancel_stale {
output.push_str("cancel_stale = true\n");
}
if invariants.selector.on_timeout != InvariantSelectorTimeoutPolicy::Full {
writeln!(
output,
"on_timeout = {}",
toml_string(match invariants.selector.on_timeout {
InvariantSelectorTimeoutPolicy::Full => "full",
InvariantSelectorTimeoutPolicy::Previous => "previous",
InvariantSelectorTimeoutPolicy::Deterministic => "deterministic",
InvariantSelectorTimeoutPolicy::None => "none",
})
)
.expect("write to string");
}
output.push('\n');
}
}
fn write_system_prompt_toml(output: &mut String, system_prompt: &SystemPromptConfig) {
if system_prompt == &SystemPromptConfig::default() {
return;
}
output.push_str("[system_prompt]\n");
if system_prompt.mode != SystemPromptMode::Default {
writeln!(
output,
"mode = {}",
toml_string(system_prompt_mode_name(system_prompt.mode))
)
.expect("write to string");
}
if let Some(text) = &system_prompt.text {
writeln!(output, "text = {}", toml_string(text)).expect("write to string");
}
if let Some(max_chars) = system_prompt.repository_instructions_max_chars {
writeln!(output, "repository_instructions_max_chars = {max_chars}")
.expect("write to string");
}
if let Some(max_chars) = system_prompt.repository_invariants_max_chars {
writeln!(output, "repository_invariants_max_chars = {max_chars}").expect("write to string");
}
if system_prompt.git_status_max_chars != default_git_status_max_chars() {
writeln!(
output,
"git_status_max_chars = {}",
system_prompt.git_status_max_chars
)
.expect("write to string");
}
output.push('\n');
if system_prompt.sections != SystemPromptSectionsConfig::default() {
output.push_str("[system_prompt.sections]\n");
if !system_prompt.sections.current_datetime {
output.push_str("current_datetime = false\n");
}
if !system_prompt.sections.execution_environment {
output.push_str("execution_environment = false\n");
}
if !system_prompt.sections.hosting_environment {
output.push_str("hosting_environment = false\n");
}
if !system_prompt.sections.locale {
output.push_str("locale = false\n");
}
if !system_prompt.sections.git_revision {
output.push_str("git_revision = false\n");
}
if !system_prompt.sections.runtime_mode {
output.push_str("runtime_mode = false\n");
}
if system_prompt.sections.toolchain_context {
output.push_str("toolchain_context = true\n");
}
if !system_prompt.sections.worktree_context {
output.push_str("worktree_context = false\n");
}
if !system_prompt.sections.config_selection {
output.push_str("config_selection = false\n");
}
if !system_prompt.sections.config_profile {
output.push_str("config_profile = false\n");
}
if !system_prompt.sections.repository_invariants {
output.push_str("repository_invariants = false\n");
}
if !system_prompt.sections.repository_context {
output.push_str("repository_context = false\n");
}
if !system_prompt.sections.dynamic_repository_context {
output.push_str("dynamic_repository_context = false\n");
}
if !system_prompt.sections.agent_suffix {
output.push_str("agent_suffix = false\n");
}
if !system_prompt.sections.user_authority {
output.push_str("user_authority = false\n");
}
if !system_prompt.sections.skill_catalog {
output.push_str("skill_catalog = false\n");
}
if !system_prompt.sections.model_profile {
output.push_str("model_profile = false\n");
}
output.push('\n');
}
}
fn write_prompt_profile_toml(output: &mut String, profile: &PromptProfileConfig) {
if profile == &PromptProfileConfig::default() {
return;
}
let encoded = toml::to_string(profile).expect("prompt profile encodes as TOML");
for line in encoded.lines() {
if let Some(table) = line
.strip_prefix('[')
.and_then(|line| line.strip_suffix(']'))
{
writeln!(output, "[prompt_profile.{table}]").expect("write to string");
} else if !line.is_empty() {
output.push_str(line);
output.push('\n');
}
}
output.push('\n');
}
const fn system_prompt_mode_name(mode: SystemPromptMode) -> &'static str {
match mode {
SystemPromptMode::Default => "default",
SystemPromptMode::Replace => "replace",
}
}
fn write_presentation_toml(output: &mut String, presentation: PresentationConfig) {
if presentation == PresentationConfig::default() {
return;
}
if presentation.completion != CompletionConfig::default() {
let completion = presentation.completion;
writeln!(output, "[presentation.completion]").expect("writing to string should not fail");
let match_mode = match completion.match_mode {
CompletionMatchMode::Fuzzy => "fuzzy",
CompletionMatchMode::Prefix => "prefix",
CompletionMatchMode::Substring => "substring",
};
writeln!(output, "match_mode = {}", toml_string(match_mode))
.expect("writing to string should not fail");
writeln!(output, "sort_order = {}", toml_string("alphabetical"))
.expect("writing to string should not fail");
let filtered_sort_order = match completion.filtered_sort_order {
CompletionFilteredSortOrder::Relevance => "relevance",
CompletionFilteredSortOrder::Alphabetical => "alphabetical",
CompletionFilteredSortOrder::Inherit => "inherit",
};
writeln!(
output,
"filtered_sort_order = {}",
toml_string(filtered_sort_order)
)
.expect("writing to string should not fail");
output.push('\n');
}
if presentation.streaming == PresentationStreamingConfig::default() {
return;
}
let streaming = presentation.streaming;
writeln!(output, "[presentation.streaming]").expect("writing to string should not fail");
writeln!(output, "enabled = {}", streaming.enabled).expect("writing to string should not fail");
writeln!(
output,
"curve = {}",
toml_string(streaming_curve_name(streaming.curve))
)
.expect("writing to string should not fail");
writeln!(
output,
"graphemes_per_second = {}",
streaming.graphemes_per_second
)
.expect("writing to string should not fail");
writeln!(output, "max_lag_ms = {}", streaming.max_lag_ms)
.expect("writing to string should not fail");
output.push('\n');
}
const fn streaming_curve_name(curve: StreamingInterpolationCurveConfig) -> &'static str {
match curve {
StreamingInterpolationCurveConfig::Linear => "linear",
StreamingInterpolationCurveConfig::EaseIn => "ease_in",
StreamingInterpolationCurveConfig::EaseOut => "ease_out",
StreamingInterpolationCurveConfig::EaseInOut => "ease_in_out",
}
}
fn write_tui_toml(output: &mut String, tui: &TuiConfig) {
write_tui_theme_toml(output, &tui.theme);
if !tui.keybindings.is_empty() {
write_tui_keybinding_section(output, "chat", &tui.keybindings.chat);
write_tui_keybinding_section(output, "permission", &tui.keybindings.permission);
write_tui_keybinding_section(output, "session_picker", &tui.keybindings.session_picker);
write_tui_keybinding_section(output, "skill_picker", &tui.keybindings.skill_picker);
for (scope, bindings) in &tui.keybindings.scopes {
write_tui_keybinding_section(output, &format!("scopes.{}", toml_key(scope)), bindings);
}
}
write_tui_mouse_toml(output, &tui.mouse);
write_tui_selection_toml(output, tui.selection);
write_tui_interactions_toml(output, tui.interactions);
write_tui_markdown_toml(output, tui.markdown);
writeln!(output, "[tui.thinking]").expect("writing to string should not fail");
writeln!(output, "show = {}", tui.thinking.show).expect("writing to string should not fail");
writeln!(
output,
"mode = \"{}\"",
tui_thinking_mode_name(tui.thinking.mode)
)
.expect("writing to string should not fail");
}
fn write_tui_selection_toml(output: &mut String, selection: TuiSelectionConfig) {
if selection == TuiSelectionConfig::default() {
return;
}
writeln!(output, "[tui.selection]").expect("writing to string should not fail");
writeln!(
output,
"auto_copy_on_mouse_release = {}",
selection.auto_copy_on_mouse_release
)
.expect("writing to string should not fail");
writeln!(output, "clear_after_copy = {}", selection.clear_after_copy)
.expect("writing to string should not fail");
writeln!(output, "clear_delay_ms = {}", selection.clear_delay_ms)
.expect("writing to string should not fail");
}
fn write_tui_theme_toml(output: &mut String, theme: &TuiThemeConfig) {
if theme == &TuiThemeConfig::default() {
return;
}
writeln!(output, "[tui.theme]").expect("writing to string should not fail");
writeln!(output, "name = {}", toml_string(&theme.name))
.expect("writing to string should not fail");
if !theme.overlays.is_empty() {
write!(output, "overlays = [").expect("writing to string should not fail");
for (index, overlay) in theme.overlays.iter().enumerate() {
if index > 0 {
output.push_str(", ");
}
output.push_str(&toml_string(overlay));
}
output.push_str("]\n");
}
writeln!(
output,
"variant = {}",
toml_string(match theme.variant {
TuiThemeVariant::Auto => "auto",
TuiThemeVariant::Dark => "dark",
TuiThemeVariant::Light => "light",
})
)
.expect("writing to string should not fail");
if !theme.paths.is_empty() {
write!(output, "paths = [").expect("writing to string should not fail");
for (index, path) in theme.paths.iter().enumerate() {
if index > 0 {
output.push_str(", ");
}
output.push_str(&toml_string(&path.to_string_lossy()));
}
output.push_str("]\n");
}
writeln!(
output,
"agent_accent = {}",
toml_string(match theme.agent_accent {
TuiAgentAccentPolicy::ThemeOnly => "theme_only",
TuiAgentAccentPolicy::AgentWithThemeFallback => "agent_with_theme_fallback",
})
)
.expect("writing to string should not fail");
writeln!(
output,
"accent_transition = {}",
toml_string(match theme.accent_transition {
TuiAccentTransitionMode::Immediate => "immediate",
TuiAccentTransitionMode::Transition => "transition",
})
)
.expect("writing to string should not fail");
writeln!(
output,
"accent_transition_ms = {}",
theme.accent_transition_ms
)
.expect("writing to string should not fail");
writeln!(
output,
"accent_transition_curve = {}",
toml_string(match theme.accent_transition_curve {
TuiAccentTransitionCurve::Linear => "linear",
TuiAccentTransitionCurve::EaseIn => "ease_in",
TuiAccentTransitionCurve::EaseOut => "ease_out",
TuiAccentTransitionCurve::EaseInOut => "ease_in_out",
})
)
.expect("writing to string should not fail");
output.push('\n');
}
const fn tui_thinking_mode_name(mode: TuiThinkingMode) -> &'static str {
match mode {
TuiThinkingMode::All => "all",
TuiThinkingMode::Summary => "summary",
TuiThinkingMode::Raw => "raw",
}
}
fn write_tui_interactions_toml(output: &mut String, interactions: TuiInteractionConfig) {
if interactions == TuiInteractionConfig::default() {
return;
}
writeln!(output, "[tui.interactions]").expect("writing to string should not fail");
writeln!(
output,
"placement = {}",
toml_string(match interactions.placement {
TuiInteractionPlacement::Transcript => "transcript",
TuiInteractionPlacement::Pinned => "pinned",
})
)
.expect("writing to string should not fail");
writeln!(
output,
"offscreen_focus = {}",
toml_string(match interactions.offscreen_focus {
TuiInteractionOffscreenFocus::Retain => "retain",
TuiInteractionOffscreenFocus::Suspend => "suspend",
})
)
.expect("writing to string should not fail");
output.push('\n');
}
fn write_tui_markdown_toml(output: &mut String, markdown: TuiMarkdownConfig) {
if markdown == TuiMarkdownConfig::default() {
return;
}
writeln!(output, "[tui.markdown]").expect("writing to string should not fail");
writeln!(output, "network_images = {}", markdown.network_images)
.expect("writing to string should not fail");
writeln!(output, "mermaid = {}", markdown.mermaid).expect("writing to string should not fail");
}
fn write_tui_mouse_toml(output: &mut String, mouse: &TuiMouseConfig) {
if mouse == &TuiMouseConfig::default() {
return;
}
writeln!(output, "[tui.mouse]").expect("writing to string should not fail");
writeln!(
output,
"selection_autoscroll = {}",
mouse.selection_autoscroll
)
.expect("writing to string should not fail");
writeln!(output, "scroll_rows = {}", mouse.scroll_rows)
.expect("writing to string should not fail");
writeln!(output, "multi_click_ms = {}", mouse.multi_click_ms)
.expect("writing to string should not fail");
writeln!(
output,
"multi_click_max_distance = {}",
mouse.multi_click_max_distance
)
.expect("writing to string should not fail");
writeln!(
output,
"double_click_select = {}",
toml_string(tui_mouse_click_selection_name(mouse.double_click_select))
)
.expect("writing to string should not fail");
writeln!(
output,
"triple_click_select = {}",
toml_string(tui_mouse_click_selection_name(mouse.triple_click_select))
)
.expect("writing to string should not fail");
output.push('\n');
}
const fn tui_mouse_click_selection_name(selection: TuiMouseClickSelection) -> &'static str {
match selection {
TuiMouseClickSelection::Disabled => "disabled",
TuiMouseClickSelection::Word => "word",
TuiMouseClickSelection::Line => "line",
TuiMouseClickSelection::All => "all",
}
}
fn write_tui_keybinding_section(
output: &mut String,
section: &str,
bindings: &BTreeMap<String, String>,
) {
if bindings.is_empty() {
return;
}
writeln!(output, "[tui.keybindings.{section}]").expect("writing to string should not fail");
for (key, action) in bindings {
writeln!(output, "{} = {}", toml_string(key), toml_string(action))
.expect("writing to string should not fail");
}
output.push('\n');
}
fn write_agents_toml(
output: &mut String,
agents: &BTreeMap<String, bcode_agent_policy_models::AgentConfig>,
) {
for (agent_id, agent) in agents {
if !agent.tools.is_empty() {
writeln!(output, "[agent.{}.tools]", toml_table_key(agent_id))
.expect("writing to string should not fail");
for (tool, enabled) in &agent.tools {
writeln!(output, "{} = {}", toml_string(tool), enabled)
.expect("writing to string should not fail");
}
output.push('\n');
}
let permission = &agent.permission;
let has_permission = !permission.command.is_empty()
|| !permission.command_patterns.is_empty()
|| permission.command_policy.is_some()
|| !permission.tools.is_empty()
|| !permission.read.is_empty()
|| !permission.write.is_empty()
|| !permission.edit.is_empty()
|| !permission.web.is_empty()
|| permission.external_directory
!= bcode_agent_policy_models::default_external_directory_action();
if !has_permission {
continue;
}
writeln!(output, "[agent.{}.permission]", toml_table_key(agent_id))
.expect("writing to string should not fail");
if permission.external_directory
!= bcode_agent_policy_models::default_external_directory_action()
{
writeln!(
output,
"external_directory = {}",
toml_string(action_name(permission.external_directory))
)
.expect("writing to string should not fail");
}
write_action_map(output, "command", &permission.command);
write_action_map(output, "command_patterns", &permission.command_patterns);
write_action_map(output, "tools", &permission.tools);
if let Some(policy) = &permission.command_policy {
let value = toml::Value::try_from(policy).expect("command policy is TOML serializable");
writeln!(output, "command_policy = {value}")
.expect("writing to string should not fail");
}
write_action_map(output, "read", &permission.read);
write_action_map(output, "write", &permission.write);
write_action_map(output, "edit", &permission.edit);
write_action_map(output, "web", &permission.web);
output.push('\n');
}
}
fn write_action_map(
output: &mut String,
name: &str,
map: &BTreeMap<String, bcode_agent_policy_models::Action>,
) {
if map.is_empty() {
return;
}
output.push_str(name);
output.push_str(" = { ");
let mut first = true;
for (pattern, action) in map {
if !first {
output.push_str(", ");
}
first = false;
write!(
output,
"{} = {}",
toml_string(pattern),
toml_string(action_name(*action))
)
.expect("writing to string should not fail");
}
output.push_str(" }\n");
}
const fn context_strategy_mode_name(mode: ContextStrategyMode) -> &'static str {
match mode {
ContextStrategyMode::ProviderReuse => "provider_reuse",
ContextStrategyMode::ExplicitCachedTranscript => "explicit_cached_transcript",
}
}
const fn prompt_cache_mode_name(mode: bcode_model::PromptCacheMode) -> &'static str {
match mode {
bcode_model::PromptCacheMode::Off => "off",
bcode_model::PromptCacheMode::Auto => "auto",
bcode_model::PromptCacheMode::Aggressive => "aggressive",
}
}
const fn conversation_reuse_mode_name(mode: bcode_model::ConversationReuseMode) -> &'static str {
match mode {
bcode_model::ConversationReuseMode::Off => "off",
bcode_model::ConversationReuseMode::Auto => "auto",
}
}
const fn compaction_backend_name(backend: CompactionBackend) -> &'static str {
match backend {
CompactionBackend::Auto => "auto",
CompactionBackend::ProviderNative => "provider_native",
CompactionBackend::Local => "local",
}
}
const fn compaction_mode_name(mode: CompactionMode) -> &'static str {
match mode {
CompactionMode::Off => "off",
CompactionMode::OnOverflow => "on_overflow",
CompactionMode::Proactive => "proactive",
CompactionMode::ProactiveAndOverflow => "proactive_and_overflow",
CompactionMode::Auto => "auto",
}
}
const fn action_name(action: bcode_agent_policy_models::Action) -> &'static str {
match action {
bcode_agent_policy_models::Action::Allow => "allow",
bcode_agent_policy_models::Action::Ask => "ask",
bcode_agent_policy_models::Action::Deny => "deny",
}
}
fn toml_table_key(key: &str) -> String {
let needs_quoting = key.is_empty()
|| !key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if needs_quoting {
toml_string(key)
} else {
key.to_string()
}
}
fn write_auth_toml(output: &mut String, auth: &AuthConfig) {
if let Some(openai) = &auth.openai {
output.push_str("[auth.openai]\n");
writeln!(output, "backend = {}", toml_string(&openai.backend))
.expect("writing to string should not fail");
writeln!(
output,
"mode = {}",
toml_string(auth_mode_name(&openai.mode))
)
.expect("writing to string should not fail");
writeln!(output, "profile = {}", toml_string(&openai.profile))
.expect("writing to string should not fail");
if let Some(vault) = &openai.vault {
writeln!(
output,
"vault = {}",
toml_string(&vault.display().to_string())
)
.expect("writing to string should not fail");
}
output.push('\n');
}
for (profile_name, profile) in &auth.profiles {
writeln!(output, "[auth.profiles.{}]", toml_key(profile_name))
.expect("writing to string should not fail");
writeln!(output, "backend = {}", toml_string(&profile.backend))
.expect("writing to string should not fail");
if let Some(provider_id) = &profile.provider_id {
writeln!(output, "provider_id = {}", toml_string(provider_id))
.expect("writing to string should not fail");
}
if let Some(owner_plugin_id) = &profile.owner_plugin_id {
writeln!(output, "owner_plugin_id = {}", toml_string(owner_plugin_id))
.expect("writing to string should not fail");
}
if let Some(scheme) = &profile.scheme {
writeln!(output, "scheme = {}", toml_string(scheme))
.expect("writing to string should not fail");
}
output.push('\n');
write_auth_mapping_tables(output, profile_name, &profile.map);
write_string_map_table(
output,
&format!("auth.profiles.{}.settings", toml_key(profile_name)),
&profile.settings,
);
}
}
const fn auth_mode_name(mode: &AuthMode) -> &'static str {
match mode {
AuthMode::ApiKey => "api_key",
AuthMode::ChatGpt => "chatgpt",
}
}
fn write_skills_toml(output: &mut String, skills: &SkillsConfig) {
if skills == &SkillsConfig::default() {
return;
}
output.push_str("[skills]\n");
if !skills.enabled {
output.push_str("enabled = false\n");
}
if skills.auto_activate != SkillAutoActivateMode::Suggest {
writeln!(
output,
"auto_activate = {}",
toml_string(skill_auto_activate_mode_name(skills.auto_activate))
)
.expect("write to string");
}
if !skills.include_repo_skills {
output.push_str("include_repo_skills = false\n");
}
if !skills.include_generic_repo_skills {
output.push_str("include_generic_repo_skills = false\n");
}
if !skills.include_user_skills {
output.push_str("include_user_skills = false\n");
}
if !skills.include_compat_claude_skills {
output.push_str("include_compat_claude_skills = false\n");
}
if let Some(max_context_bytes) = skills.max_context_bytes {
writeln!(output, "max_context_bytes = {max_context_bytes}").expect("write to string");
}
if skills.max_skill_file_bytes != default_skill_file_bytes() {
writeln!(
output,
"max_skill_file_bytes = {}",
skills.max_skill_file_bytes
)
.expect("write to string");
}
if skills.preview_max_chars != default_skill_preview_chars() {
writeln!(output, "preview_max_chars = {}", skills.preview_max_chars)
.expect("write to string");
}
if !skills.follow_symlinks {
output.push_str("follow_symlinks = false\n");
}
output.push('\n');
if skills.prompt != SkillPromptConfig::default() {
output.push_str("[skills.prompt]\n");
if skills.prompt.catalog != SkillPromptCatalogMode::Summary {
writeln!(
output,
"catalog = {}",
toml_string(skill_prompt_catalog_mode_name(skills.prompt.catalog))
)
.expect("write to string");
}
if let Some(max_bytes) = skills.prompt.max_bytes {
writeln!(output, "max_bytes = {max_bytes}").expect("write to string");
}
if let Some(max_description_chars) = skills.prompt.max_description_chars {
writeln!(output, "max_description_chars = {max_description_chars}")
.expect("write to string");
}
if !skills.prompt.include_sources {
output.push_str("include_sources = false\n");
}
if skills.prompt.include_keywords {
output.push_str("include_keywords = true\n");
}
output.push('\n');
}
if !skills.sources.paths.is_empty() {
output.push_str("[skills.sources]\npaths = [");
for (index, path) in skills.sources.paths.iter().enumerate() {
if index > 0 {
output.push_str(", ");
}
output.push_str(&toml_string(&path.to_string_lossy()));
}
output.push_str("]\n\n");
}
if !skills.disabled.ids.is_empty() {
output.push_str("[skills.disabled]\n");
write_string_set(output, "ids", &skills.disabled.ids);
output.push('\n');
}
}
const fn skill_auto_activate_mode_name(mode: SkillAutoActivateMode) -> &'static str {
match mode {
SkillAutoActivateMode::Off => "off",
SkillAutoActivateMode::Suggest => "suggest",
SkillAutoActivateMode::On => "on",
}
}
const fn skill_prompt_catalog_mode_name(mode: SkillPromptCatalogMode) -> &'static str {
match mode {
SkillPromptCatalogMode::Off => "off",
SkillPromptCatalogMode::NamesOnly => "names_only",
SkillPromptCatalogMode::Summary => "summary",
}
}
fn write_workflows_toml(output: &mut String, workflows: &WorkflowsConfig) {
if workflows == &WorkflowsConfig::default() {
return;
}
output.push_str("[workflows]\n");
if workflows.admission_timeout_ms != default_workflow_admission_timeout_ms() {
writeln!(
output,
"admission_timeout_ms = {}",
workflows.admission_timeout_ms
)
.expect("write to string");
}
if workflows.continuation_workers != default_workflow_continuation_workers() {
writeln!(
output,
"continuation_workers = {}",
workflows.continuation_workers
)
.expect("write to string");
}
if !workflows.include_repo_workflows {
output.push_str("include_repo_workflows = false\n");
}
if !workflows.include_user_workflows {
output.push_str("include_user_workflows = false\n");
}
if !workflows.run_edit_plugins.is_empty() {
output.push_str("run_edit_plugins = [");
for (index, plugin) in workflows.run_edit_plugins.iter().enumerate() {
if index > 0 {
output.push_str(", ");
}
output.push_str(&toml_string(plugin));
}
output.push_str("]\n");
}
if workflows.run_publication_local_clients {
output.push_str("run_publication_local_clients = true\n");
}
if !workflows.run_publication_plugins.is_empty() {
output.push_str("run_publication_plugins = [");
for (index, plugin) in workflows.run_publication_plugins.iter().enumerate() {
if index > 0 {
output.push_str(", ");
}
output.push_str(&toml_string(plugin));
}
output.push_str("]\n");
}
if !workflows.paths.is_empty() {
output.push_str("paths = [");
for (index, path) in workflows.paths.iter().enumerate() {
if index > 0 {
output.push_str(", ");
}
output.push_str(&toml_string(&path.to_string_lossy()));
}
output.push_str("]\n");
}
output.push('\n');
}
fn write_observability_toml(output: &mut String, observability: &ObservabilityConfig) {
if observability == &ObservabilityConfig::default() {
return;
}
output.push_str("[observability]\n");
writeln!(
output,
"level = {}",
toml_string(observability_level_name(observability.level))
)
.expect("writing to string should not fail");
if observability.persist_model_requests {
output.push_str("persist_model_requests = true\n");
}
if !observability.persist_tool_io {
output.push_str("persist_tool_io = false\n");
}
if observability.max_blob_bytes != default_max_trace_blob_bytes() {
writeln!(output, "max_blob_bytes = {}", observability.max_blob_bytes)
.expect("writing to string should not fail");
}
output.push('\n');
}
const fn observability_level_name(level: ObservabilityLevel) -> &'static str {
match level {
ObservabilityLevel::Off => "off",
ObservabilityLevel::Standard => "standard",
ObservabilityLevel::Debug => "debug",
ObservabilityLevel::Raw => "raw",
}
}
fn write_plugins_toml(output: &mut String, plugins: &PluginConfig) {
if plugins == &PluginConfig::default() {
return;
}
output.push_str("[plugins]\n");
if plugins.default != PluginDefaultMode::Bundled {
writeln!(
output,
"default = {}",
toml_string(plugin_default_mode_name(plugins.default))
)
.expect("writing to string should not fail");
}
write_string_set(output, "enabled", &plugins.enabled);
write_string_set(output, "disabled", &plugins.disabled);
output.push('\n');
for (plugin_id, value) in &plugins.config {
if let Some(table) = value.as_table() {
writeln!(output, "[plugins.config.{}]", toml_table_key(plugin_id))
.expect("writing to string should not fail");
for (key, value) in table {
write_toml_value(output, key, value);
}
output.push('\n');
}
}
}
fn write_tools_toml(output: &mut String, tools: &ToolsConfig) {
if tools == &ToolsConfig::default() {
return;
}
output.push_str("[tools]\n");
if tools.default != ToolDefaultMode::Agent {
writeln!(
output,
"default = {}",
toml_string(tool_default_mode_name(tools.default))
)
.expect("writing to string should not fail");
}
write_string_set(output, "enabled", &tools.enabled);
write_string_set(output, "disabled", &tools.disabled);
output.push('\n');
}
const fn plugin_default_mode_name(mode: PluginDefaultMode) -> &'static str {
match mode {
PluginDefaultMode::Bundled => "bundled",
PluginDefaultMode::None => "none",
PluginDefaultMode::All => "all",
}
}
const fn tool_default_mode_name(mode: ToolDefaultMode) -> &'static str {
match mode {
ToolDefaultMode::Agent => "agent",
ToolDefaultMode::None => "none",
ToolDefaultMode::All => "all",
}
}
fn write_toml_value(output: &mut String, key: &str, value: &toml::Value) {
let encoded = toml_value_literal(value);
writeln!(output, "{} = {}", toml_key(key), encoded.trim())
.expect("writing to string should not fail");
}
fn toml_value_literal(value: &toml::Value) -> String {
match value {
toml::Value::String(value) => toml_string(value),
toml::Value::Integer(value) => value.to_string(),
toml::Value::Float(value) => value.to_string(),
toml::Value::Boolean(value) => value.to_string(),
toml::Value::Datetime(value) => value.to_string(),
toml::Value::Array(values) => {
let values = values
.iter()
.map(toml_value_literal)
.collect::<Vec<_>>()
.join(", ");
format!("[{values}]")
}
toml::Value::Table(_) => "{}".to_string(),
}
}
fn write_string_set(output: &mut String, key: &str, values: &BTreeSet<String>) {
if values.is_empty() {
return;
}
let values = values
.iter()
.map(|value| toml_string(value))
.collect::<Vec<_>>()
.join(", ");
writeln!(output, "{key} = [{values}]").expect("writing to string should not fail");
}
fn write_auth_mapping_tables(
output: &mut String,
profile_name: &str,
values: &BTreeMap<String, AuthCredentialMapping>,
) {
if values.is_empty() {
return;
}
for (credential, mapping) in values {
writeln!(
output,
"[auth.profiles.{}.map.{}]",
toml_key(profile_name),
toml_key(credential)
)
.expect("writing to string should not fail");
if let Some(env) = &mapping.env {
writeln!(output, "env = {}", toml_string(env))
.expect("writing to string should not fail");
}
if let Some(key) = &mapping.key {
writeln!(output, "key = {}", toml_string(key))
.expect("writing to string should not fail");
}
output.push('\n');
}
}
fn write_string_map_table(output: &mut String, table: &str, values: &BTreeMap<String, String>) {
if values.is_empty() {
return;
}
writeln!(output, "[{table}]").expect("writing to string should not fail");
for (key, value) in values {
writeln!(output, "{} = {}", toml_key(key), toml_string(value))
.expect("writing to string should not fail");
}
output.push('\n');
}
fn write_json_map_table(
output: &mut String,
table: &str,
values: &BTreeMap<String, serde_json::Value>,
) {
if values.is_empty() {
return;
}
writeln!(output, "[{table}]").expect("writing to string should not fail");
for (key, value) in values {
write_json_toml_value(output, table, key, value);
}
output.push('\n');
}
fn write_json_toml_value(output: &mut String, table: &str, key: &str, value: &serde_json::Value) {
match value {
serde_json::Value::Object(values) => {
output.push('\n');
writeln!(output, "[{table}.{}]", toml_key(key))
.expect("writing to string should not fail");
for (child_key, child_value) in values {
write_json_toml_value(
output,
&format!("{table}.{}", toml_key(key)),
child_key,
child_value,
);
}
}
serde_json::Value::Array(values) => {
let values = values
.iter()
.map(json_toml_inline_value)
.collect::<Vec<_>>()
.join(", ");
writeln!(output, "{} = [{values}]", toml_key(key))
.expect("writing to string should not fail");
}
_ => writeln!(
output,
"{} = {}",
toml_key(key),
json_toml_inline_value(value)
)
.expect("writing to string should not fail"),
}
}
fn json_toml_inline_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "\"\"".to_string(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::String(value) => toml_string(value),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(json_toml_inline_value)
.collect::<Vec<_>>()
.join(", ")
),
serde_json::Value::Object(_) => toml_string(&value.to_string()),
}
}
fn toml_key(value: &str) -> String {
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
value.to_string()
} else {
toml_string(value)
}
}
fn toml_string(value: &str) -> String {
let escaped = value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n");
format!("\"{escaped}\"")
}
#[must_use]
pub fn default_config_paths() -> Vec<PathBuf> {
default_config_paths_with_environment(&ProcessConfigEnvironment)
}
#[must_use]
pub fn default_config_paths_with_environment(environment: &impl ConfigEnvironment) -> Vec<PathBuf> {
default_config_paths_from_with_environment(&environment.current_dir(), environment)
}
#[must_use]
pub fn default_config_paths_from(start: &Path) -> Vec<PathBuf> {
default_config_paths_from_with_environment(start, &ProcessConfigEnvironment)
}
#[must_use]
pub fn default_config_paths_from_with_environment(
start: &Path,
environment: &impl ConfigEnvironment,
) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(config_home) = environment.var("XDG_CONFIG_HOME") {
paths.push(
PathBuf::from(config_home)
.join("bcode")
.join(DEFAULT_CONFIG_FILE_NAME),
);
} else if let Some(home) = environment.var("HOME") {
paths.push(
PathBuf::from(home)
.join(".config")
.join("bcode")
.join(DEFAULT_CONFIG_FILE_NAME),
);
}
let root = discover_config_root(start).unwrap_or_else(|| start.to_path_buf());
paths.push(root.join(DEFAULT_CONFIG_FILE_NAME));
paths.push(root.join(".bcode").join(DEFAULT_CONFIG_FILE_NAME));
paths
}
fn discover_config_root(start: &Path) -> Option<PathBuf> {
let mut current = if start.is_file() {
start.parent()?.to_path_buf()
} else {
start.to_path_buf()
};
loop {
if current.join(".git").exists() {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
pub fn load_config() -> Result<BcodeConfig, ConfigError> {
load_config_with_environment(&ProcessConfigEnvironment)
}
pub fn load_config_with_environment(
environment: &impl ConfigEnvironment,
) -> Result<BcodeConfig, ConfigError> {
let overrides = effective_config_overrides(environment);
load_config_from_paths_with_overrides_and_environment(
&default_config_paths_with_environment(environment),
&overrides,
environment,
)
}
pub fn load_config_with_overrides(
overrides: &ConfigLoadOverrides,
) -> Result<BcodeConfig, ConfigError> {
load_config_with_environment_and_overrides(&ProcessConfigEnvironment, overrides)
}
pub fn load_config_with_environment_and_overrides(
environment: &impl ConfigEnvironment,
overrides: &ConfigLoadOverrides,
) -> Result<BcodeConfig, ConfigError> {
load_config_from_paths_with_overrides_and_environment(
&default_config_paths_with_environment(environment),
overrides,
environment,
)
}
pub fn load_config_from_paths(paths: &[PathBuf]) -> Result<BcodeConfig, ConfigError> {
load_config_from_paths_with_environment(paths, &ProcessConfigEnvironment)
}
pub fn load_config_from_paths_with_environment(
paths: &[PathBuf],
environment: &impl ConfigEnvironment,
) -> Result<BcodeConfig, ConfigError> {
let process_overrides = process_config_overrides()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
process_overrides.as_ref().map_or_else(
|| {
load_config_from_paths_with_overrides_and_environment(
paths,
&ConfigLoadOverrides::default(),
environment,
)
},
|overrides| {
load_config_from_paths_with_overrides_and_environment(paths, overrides, environment)
},
)
}
fn effective_config_overrides(environment: &impl ConfigEnvironment) -> ConfigLoadOverrides {
process_config_overrides()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.unwrap_or_else(|| {
ConfigLoadOverrides::from_config_environment_with_cli(environment, None, None)
})
}
pub fn load_composed_config_value() -> Result<toml::Value, ConfigError> {
let overrides = effective_config_overrides(&ProcessConfigEnvironment);
load_composed_config_value_with_overrides(&overrides)
}
pub fn load_composed_config_value_with_overrides(
overrides: &ConfigLoadOverrides,
) -> Result<toml::Value, ConfigError> {
let raw = merged_raw_config_value_with_overrides(&default_config_paths(), overrides)?;
let (mut resolved, _resolution) = resolve_composed_config_value(&raw)?;
contexts::apply_profile_override(&mut resolved, overrides)?;
Ok(resolved)
}
pub fn encode_effective_config(config: &BcodeConfig) -> Result<String, ConfigError> {
toml::to_string(config).map_err(|source| ConfigError::Composition {
message: format!("failed to encode effective config: {source}"),
})
}
pub fn decode_effective_config(contents: &str) -> Result<BcodeConfig, ConfigError> {
let value = parse_raw_toml_config(contents, "client effective config")?;
let config = validate_config_value(value, "client effective config")?;
contexts::validate_effective(&config)?;
Ok(config)
}
pub fn load_config_from_paths_with_overrides(
paths: &[PathBuf],
overrides: &ConfigLoadOverrides,
) -> Result<BcodeConfig, ConfigError> {
load_config_from_paths_with_overrides_and_environment(
paths,
overrides,
&ProcessConfigEnvironment,
)
}
fn load_config_from_paths_with_overrides_and_environment(
paths: &[PathBuf],
overrides: &ConfigLoadOverrides,
environment: &impl ConfigEnvironment,
) -> Result<BcodeConfig, ConfigError> {
let result = (|| {
let raw = merged_raw_config_value_with_overrides(paths, overrides)?;
let (mut resolved, _resolution) = resolve_composed_config_value(&raw)?;
contexts::apply_profile_override(&mut resolved, overrides)?;
let mut config = validate_config_value(resolved, "composed config")?;
if let Ok(Some(selection)) =
startup::load_from(&startup::path_with_environment(environment))
{
let _ = startup::apply_fallback(&mut config, &selection, environment);
}
Ok(config)
})();
if overrides.exclusive_config_path.is_some() {
result.map_err(|_| ConfigError::Composition {
message: "invalid exclusive configuration: supply a readable regular file with valid configuration".to_owned(),
})
} else {
result
}
}
fn merged_raw_config_value_with_overrides(
paths: &[PathBuf],
overrides: &ConfigLoadOverrides,
) -> Result<toml::Value, ConfigError> {
if let Some(path) = &overrides.exclusive_config_path {
let metadata = std::fs::metadata(path).map_err(|source| ConfigError::Io {
path: path.clone(),
source,
})?;
if !metadata.is_file() {
return Err(ConfigError::Composition {
message: "exclusive configuration must be a regular file".to_owned(),
});
}
let mut merged = load_toml_file(path)?;
if let Some(raw) = overrides.cli_config_toml.as_deref() {
merge_toml_value(&mut merged, parse_raw_toml_config(raw, "cli")?);
}
return Ok(merged);
}
let mut merged = toml::Value::Table(toml::Table::new());
if let Some(path) = overrides.base_config_path.as_ref()
&& path.exists()
{
merge_toml_value(&mut merged, load_toml_file(path)?);
}
for path in paths {
if path.exists() {
merge_toml_value(&mut merged, load_toml_file(path)?);
}
}
if let Some(path) = overrides.env_config_path.as_ref() {
let path = resolve_config_override_path(path);
if !path.exists() {
return Err(ConfigError::Io {
path,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"environment config path not found",
),
});
}
merge_toml_value(&mut merged, load_toml_file(&path)?);
}
if let Some(raw) = overrides.env_config_toml.as_deref() {
merge_toml_value(&mut merged, parse_raw_toml_config(raw, "env")?);
}
if let Some(path) = overrides.cli_config_path.as_ref() {
let path = resolve_config_override_path(path);
if !path.exists() {
return Err(ConfigError::Io {
path,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"CLI config path not found",
),
});
}
merge_toml_value(&mut merged, load_toml_file(&path)?);
}
if let Some(raw) = overrides.cli_config_toml.as_deref() {
merge_toml_value(&mut merged, parse_raw_toml_config(raw, "cli")?);
}
Ok(merged)
}
fn resolve_config_override_path(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
}
fn parse_raw_toml_config(raw: &str, source_name: &str) -> Result<toml::Value, ConfigError> {
toml::from_str(raw).map_err(|source| ConfigError::Composition {
message: format!("failed to parse {source_name} raw config TOML: {source}"),
})
}
fn load_toml_file(path: &Path) -> Result<toml::Value, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
let mut raw = toml::from_str(&contents).map_err(|source| ConfigError::Composition {
message: format!(
"failed to parse config {}: {source}",
display_from_current_dir(path)
),
})?;
shared_pools::expand(&mut raw, path)?;
Ok(raw)
}
fn read_config(path: &Path) -> Result<BcodeConfig, ConfigError> {
let raw = load_toml_file(path)?;
let (resolved, _resolution) = resolve_composed_config_value(&raw)?;
let context = format!("config {}", display_from_current_dir(path));
validate_config_value(resolved, &context)
}
#[cfg(test)]
mod tests {
#[test]
fn ordered_policy_settings_roundtrip_and_identity_preserve_order() {
let raw = r#"
[agent.build.permission]
command_patterns = { "git ..." = "ask" }
command_policy = { version = 1, layers = [ { command = { "git *" = "deny" } }, { command_patterns = { "git status" = "allow" } } ] }
"#;
let config: super::BcodeConfig = toml::from_str(raw).unwrap();
let encoded = super::config_to_toml(&config);
let decoded: super::BcodeConfig = toml::from_str(&encoded).unwrap();
assert_eq!(decoded.agent, config.agent);
let mut state = String::new();
super::write_agents_toml(&mut state, &config.agent);
let decoded: bcode_agent_policy_models::AgentPermissionConfig =
toml::from_str(&state).unwrap();
assert_eq!(decoded.agent, config.agent);
let mut reversed = config.agent["build"].clone();
reversed
.permission
.command_policy
.as_mut()
.unwrap()
.layers
.reverse();
assert_ne!(
serde_json::to_vec(&reversed).unwrap(),
serde_json::to_vec(&config.agent["build"]).unwrap()
);
let legacy = bcode_agent_policy_models::PermissionConfig::default();
let serialized = serde_json::to_value(&legacy).unwrap();
assert!(serialized.get("command_policy").is_none());
assert_eq!(serialized.as_object().unwrap().len(), 8);
assert_eq!(
serde_json::from_value::<bcode_agent_policy_models::PermissionConfig>(serialized)
.unwrap(),
legacy
);
}
#[test]
fn ordered_policy_composition_replaces_layer_vector_without_reordering() {
let mut base: toml::Value = toml::from_str(
r#"
[agent.build.permission]
command_policy = { version = 1, layers = [{ command = { base = "deny" } }] }
"#,
)
.unwrap();
let overlay: toml::Value = toml::from_str(r#"
[agent.build.permission]
command_policy = { version = 1, layers = [{ command = { z = "deny" } }, { command = { a = "allow" } }] }
"#).unwrap();
super::merge_config_values(&mut base, overlay);
let config: super::BcodeConfig = base.try_into().unwrap();
let policy = config.agent["build"]
.permission
.command_policy
.as_ref()
.unwrap();
assert_eq!(policy.layers.len(), 2);
assert!(policy.layers[0].command.contains_key("z"));
assert!(policy.layers[1].command.contains_key("a"));
}
#[test]
fn ordered_policy_rejects_unknown_version_fields_and_missing_shape() {
for policy in [
"{ version = 2, layers = [] }",
"{ version = 1 }",
"{ layers = [] }",
"{ version = 1, layers = [], future = true }",
"{ version = 1, layers = [{ commands = {} }] }",
"{ version = 1, layers = [{ command = { x = 'permit' } }] }",
] {
let raw = format!("[agent.build.permission]\ncommand_policy = {policy}\n");
assert!(
toml::from_str::<super::BcodeConfig>(&raw).is_err(),
"{policy}"
);
}
}
#[test]
fn config_serialization_preserves_credential_discovery_opt_out() {
let config = super::BcodeConfig {
onboarding: super::OnboardingConfig {
credential_discovery: false,
..super::OnboardingConfig::default()
},
..super::BcodeConfig::default()
};
let encoded = super::config_to_toml(&config);
let decoded: super::BcodeConfig = toml::from_str(&encoded).expect("config parses");
assert!(!decoded.onboarding.credential_discovery);
}
#[test]
fn browser_opt_out_survives_config_serialization() {
let mut config = super::BcodeConfig::default();
let mut environment = super::ConfigEnvironmentSnapshot::isolated("/tmp/browser-policy");
assert!(config.onboarding.browser_opening_enabled(&environment));
environment.set_var("BCODE_NO_BROWSER", "1");
assert!(!config.onboarding.browser_opening_enabled(&environment));
environment.set_var("BCODE_NO_BROWSER", "0");
config.onboarding.open_browser = false;
assert!(!config.onboarding.browser_opening_enabled(&environment));
let decoded: super::BcodeConfig =
toml::from_str(&super::config_to_toml(&config)).expect("config");
assert!(!decoded.onboarding.open_browser);
}
#[test]
fn credential_discovery_opt_out_is_disable_wins() {
let mut environment =
super::ConfigEnvironmentSnapshot::isolated("/tmp/bcode-discovery-test");
let mut config = super::OnboardingConfig::default();
assert!(config.credential_discovery_enabled(false, &environment));
assert!(!config.credential_discovery_enabled(true, &environment));
for value in ["1", "true", "", "unknown"] {
environment.set_var("BCODE_NO_CREDENTIAL_DISCOVERY", value);
assert!(!config.credential_discovery_enabled(false, &environment));
}
for value in ["0", "false"] {
environment.set_var("BCODE_NO_CREDENTIAL_DISCOVERY", value);
assert!(config.credential_discovery_enabled(false, &environment));
assert!(!config.credential_discovery_enabled(true, &environment));
}
config.credential_discovery = false;
assert!(!config.credential_discovery_enabled(false, &environment));
environment.remove_var("BCODE_NO_CREDENTIAL_DISCOVERY");
assert!(!config.credential_discovery_enabled(false, &environment));
let parsed: super::BcodeConfig =
toml::from_str("[onboarding]\ncredential_discovery = false\n")
.expect("onboarding config parses");
assert!(!parsed.onboarding.credential_discovery);
let defaults: super::BcodeConfig = toml::from_str("").expect("empty config parses");
assert!(defaults.onboarding.credential_discovery);
}
#[test]
fn auth_registry_updates_reject_unknown_fields_without_rewriting() {
let temp = tempfile::tempdir().expect("temporary directory");
let path = temp.path().join("subscriptions.json");
for bytes in [
r#"{"version":999,"pools":{}}"#,
r#"{"pools":{"pool":{"future_policy":true}}}"#,
] {
std::fs::write(&path, bytes).expect("future fixture");
assert!(super::read_auth_subscriptions_for_update(&path).is_err());
assert_eq!(std::fs::read_to_string(&path).expect("preserved"), bytes);
}
std::fs::write(&path, "{}").expect("defaulted current registry");
assert!(super::read_auth_subscriptions_for_update(&path).is_ok());
}
#[test]
fn auth_registry_reads_are_bounded_non_mutating_and_secret_safe() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("subscriptions.json");
assert_eq!(
super::read_auth_subscriptions_for_update(&path).unwrap(),
super::RuntimeAuthSubscriptions::default()
);
assert!(!path.exists());
assert!(!path.with_extension("lock").exists());
for bytes in [
b"{secret-token-123".to_vec(),
br#"{"future_version":42}"#.to_vec(),
vec![b' '; 8 * 1024 * 1024 + 1],
] {
std::fs::write(&path, &bytes).unwrap();
let error = super::read_auth_subscriptions_for_update(&path).unwrap_err();
assert!(!error.to_string().contains("secret-token-123"));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert!(!path.with_extension("lock").exists());
}
assert!(super::read_auth_subscriptions_for_update(temp.path()).is_err());
}
#[cfg(unix)]
#[test]
fn auth_registry_dangling_link_is_not_an_unenrolled_installation() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("subscriptions.json");
let target = temp.path().join("missing.json");
std::os::unix::fs::symlink(&target, &path).unwrap();
assert!(super::read_auth_subscriptions_for_update(&path).is_err());
assert_eq!(std::fs::read_link(path).unwrap(), target);
assert!(!target.exists());
}
#[test]
fn auth_registry_mutation_lock_and_damage_are_fail_closed() {
let temp = tempfile::tempdir().expect("temporary directory");
let path = temp.path().join("subscriptions.json");
let lock = super::lock_auth_subscriptions(&path).expect("first owner");
assert!(super::lock_auth_subscriptions(&path).is_err());
std::fs::write(&path, b"invalid registry").expect("damage fixture");
assert!(super::read_auth_subscriptions_for_update(&path).is_err());
assert_eq!(
std::fs::read(&path).expect("preserved bytes"),
b"invalid registry"
);
drop(lock);
let next = super::lock_auth_subscriptions(&path).expect("released owner");
drop(next);
}
#[test]
fn model_ignore_parse_errors_do_not_echo_file_contents() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("ignores.toml");
for contents in [
"secret-token-123 = [",
"[providers.first]\nmodels = [\"secret-token-123\", 42]",
] {
std::fs::write(&path, contents).unwrap();
let error = super::load_model_ignores_state_from(&path).unwrap_err();
assert!(!error.to_string().contains("secret-token-123"));
assert!(!format!("{error:?}").contains("secret-token-123"));
assert!(
error
.to_string()
.contains("invalid TOML or ignore-rule schema")
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), contents);
}
}
#[test]
fn model_ignore_state_only_defaults_when_missing() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("ignores.toml");
assert!(
super::load_model_ignores_state_from(&path)
.unwrap()
.is_empty()
);
std::fs::write(&path, "invalid [toml").unwrap();
assert!(super::load_model_ignores_state_from(&path).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "invalid [toml");
assert!(super::load_model_ignores_state_from(root.path()).is_err());
}
#[cfg(unix)]
#[test]
fn model_ignore_state_rejects_dangling_symlinks() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("missing.toml");
let link = root.path().join("ignores.toml");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(super::load_model_ignores_state_from(&link).is_err());
assert!(!target.exists());
assert_eq!(std::fs::read_link(link).unwrap(), target);
}
#[test]
fn model_ignore_state_round_trips_toml_special_characters() {
for value in [
"plain",
"back\\slash",
"quoted\"name",
"new\nline",
"tab\tname",
"unicode-λ",
"control\u{0001}",
] {
let providers = std::collections::BTreeMap::from([(
value.to_owned(),
super::ModelIgnoreConfig {
models: std::collections::BTreeSet::from([value.to_owned()]),
patterns: vec![format!("{value}*")],
},
)]);
let encoded = super::model_ignores_state_to_toml(&providers);
let decoded: super::ModelIgnoresState = toml::from_str(&encoded).expect("valid TOML");
assert_eq!(decoded.providers, providers);
}
}
use super::{
AuthConfig, AuthPoolConfig, BCODE_CONFIG_ENV, BcodeConfig, CompactionBackend,
CompactionMode, ConfigDocSchema, ConfigEnvironmentSnapshot, ConfigError,
ConfigLoadOverrides, ContextStrategyMode, FieldDoc, InvariantGuidanceMode,
InvariantSelectorTimeoutPolicy, InvariantsConfig, NestedFieldDoc,
ProactiveCompactionThreshold, PromptProfileLayerConfig, PromptProfileTextMode,
RuntimeAuthSubscriptionPool, RuntimeAuthSubscriptions, ToolDescriptionOverrideConfig,
TuiAccentTransitionCurve, TuiAgentAccentPolicy, TuiInteractionOffscreenFocus,
TuiInteractionPlacement, TuiMouseConfig, TuiRenderConfig, TuiThemeVariant,
TuiVisualAdapterConfig, clear_tui_streaming_presentation_override,
clear_tui_theme_selection, default_config_paths_from, default_permissions_state_path,
load_config_from_paths, load_config_from_paths_with_overrides, load_permissions_state_from,
load_runtime_auth_subscriptions, load_tui_streaming_presentation_override_from,
load_tui_theme_selection_from, merge_config_values,
plugin_selection_with_default_plugin_ids, push_process_config_overrides, recovery,
register_runtime_auth_profile, register_runtime_auth_subscription,
set_openai_compatible_sshenv_auth_method, set_tui_streaming_presentation_override,
set_tui_theme_selection, upsert_agent_permission_rule, validate_config,
};
use bcode_agent_policy_models::Action;
use bcode_plugin_models::{PluginSelection, PluginSelectionMode};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn contributor_keymap_scopes_round_trip() {
let config: BcodeConfig = toml::from_str(
r#"
[tui.keybindings.skill_picker]
"ctrl+i" = "tui.skill.invoke"
[tui.keybindings.scopes."metrics-dashboard"]
"ctrl+x" = "bcode.metrics.close"
"q" = ""
"f10" = "external.plugin.action"
"#,
)
.expect("contributor keybindings parse");
let serialized = toml::to_string(&config).expect("serialize config");
let restored: BcodeConfig = toml::from_str(&serialized).expect("parse serialized config");
assert_eq!(restored.tui.keybindings, config.tui.keybindings);
let mut rendered = String::new();
super::write_tui_toml(&mut rendered, &config.tui);
let restored: BcodeConfig = toml::from_str(&rendered).expect("parse rendered config");
assert_eq!(restored.tui.keybindings, config.tui.keybindings);
}
#[test]
fn interaction_presentation_defaults_parse_and_serialize() {
let default = BcodeConfig::default().tui.interactions;
assert_eq!(default.placement, TuiInteractionPlacement::Transcript);
assert_eq!(
default.offscreen_focus,
TuiInteractionOffscreenFocus::Retain
);
let configured: BcodeConfig = toml::from_str(
"[tui.interactions]\nplacement = \"pinned\"\noffscreen_focus = \"suspend\"\n",
)
.expect("interaction presentation config parses");
assert_eq!(
configured.tui.interactions.placement,
TuiInteractionPlacement::Pinned
);
assert_eq!(
configured.tui.interactions.offscreen_focus,
TuiInteractionOffscreenFocus::Suspend
);
let rendered = super::config_to_toml(&configured);
assert!(rendered.contains("[tui.interactions]"), "{rendered}");
assert!(rendered.contains("placement = \"pinned\""), "{rendered}");
assert!(
rendered.contains("offscreen_focus = \"suspend\""),
"{rendered}"
);
let round_trip: BcodeConfig = toml::from_str(&rendered).expect("rendered config parses");
assert_eq!(round_trip.tui.interactions, configured.tui.interactions);
assert!(
toml::from_str::<BcodeConfig>("[tui.interactions]\nplacement = \"floating\"\n")
.is_err()
);
assert!(
toml::from_str::<BcodeConfig>("[tui.interactions]\noffscreen_focus = \"guess\"\n")
.is_err()
);
}
#[test]
fn compatible_provider_configuration_matrix_preserves_provider_settings() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("bcode.toml");
let previous = std::env::var_os("BCODE_CONFIG");
unsafe {
std::env::set_var("BCODE_CONFIG", &config_path);
}
set_openai_compatible_sshenv_auth_method(super::OpenAiCompatibleAuthConfigUpdate {
provider: "openai",
profile: "openai".to_owned(),
vault: temp.path().join("vault"),
model_id: Some("gpt-5".to_owned()),
mode: super::AuthMode::ApiKey,
method: "api_key",
base_url: Some("https://openai.example/v1"),
})
.expect("write OpenAI compatibility configuration");
let openai = super::read_config(&config_path).expect("read OpenAI configuration");
let profile = &openai.auth.profiles["openai"];
assert_eq!(profile.provider_id.as_deref(), Some("openai"));
assert_eq!(
profile.owner_plugin_id.as_deref(),
Some("bcode.openai-compatible")
);
assert_eq!(profile.scheme.as_deref(), Some("api_key"));
assert_eq!(
profile.settings.get("base_url").map(String::as_str),
Some("https://openai.example/v1")
);
assert_eq!(openai.model.model_id.as_deref(), Some("gpt-5"));
assert_eq!(
openai
.auth
.openai
.as_ref()
.map(|auth| auth.profile.as_str()),
Some("openai")
);
set_openai_compatible_sshenv_auth_method(super::OpenAiCompatibleAuthConfigUpdate {
provider: "xai",
profile: "xai-work".to_owned(),
vault: temp.path().join("vault"),
model_id: Some("grok-4".to_owned()),
mode: super::AuthMode::ApiKey,
method: "api_key",
base_url: Some("https://api.x.ai/v1"),
})
.expect("write xAI compatibility configuration");
let xai = super::read_config(&config_path).expect("read xAI configuration");
let profile = &xai.auth.profiles["xai-work"];
assert_eq!(profile.provider_id.as_deref(), Some("xai"));
assert_eq!(
profile.owner_plugin_id.as_deref(),
Some("bcode.openai-compatible")
);
assert_eq!(profile.scheme.as_deref(), Some("api_key"));
assert_eq!(
profile.settings.get("base_url").map(String::as_str),
Some("https://api.x.ai/v1")
);
assert_eq!(xai.model.model_id.as_deref(), Some("grok-4"));
assert_eq!(
xai.auth.openai.as_ref().map(|auth| auth.profile.as_str()),
Some("openai")
);
restore_env("BCODE_CONFIG", previous);
}
#[test]
fn compatible_device_login_persists_registered_method_scheme() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("bcode.toml");
let previous = std::env::var_os("BCODE_CONFIG");
unsafe {
std::env::set_var("BCODE_CONFIG", &config_path);
}
set_openai_compatible_sshenv_auth_method(super::OpenAiCompatibleAuthConfigUpdate {
provider: "openai",
profile: "openai".to_owned(),
vault: temp.path().join("vault"),
model_id: Some("gpt-5".to_owned()),
mode: super::AuthMode::ChatGpt,
method: "device",
base_url: None,
})
.expect("write compatibility configuration");
let config = super::read_config(&config_path).expect("read compatibility configuration");
let profile = &config.auth.profiles["openai"];
assert_eq!(profile.scheme.as_deref(), Some("device"));
assert_eq!(
profile.settings.get("mode").map(String::as_str),
Some("chatgpt")
);
assert_eq!(config.model.model_id.as_deref(), Some("gpt-5"));
restore_env("BCODE_CONFIG", previous);
}
#[test]
fn interactive_auth_pool_preference_wins_and_preserves_remaining_order() {
let config = BcodeConfig {
auth: AuthConfig {
pools: BTreeMap::from([(
"provider".to_owned(),
AuthPoolConfig {
profiles: vec!["one".to_owned(), "two".to_owned(), "three".to_owned()],
preferred_profile: Some("two".to_owned()),
..AuthPoolConfig::default()
},
)]),
..AuthConfig::default()
},
..BcodeConfig::default()
};
let runtime = RuntimeAuthSubscriptions {
pools: BTreeMap::from([(
"provider".to_owned(),
RuntimeAuthSubscriptionPool {
preferred_profile: Some("three".to_owned()),
..RuntimeAuthSubscriptionPool::default()
},
)]),
..RuntimeAuthSubscriptions::default()
};
let order = super::effective_auth_pool_order(&config, &runtime, "provider", Some("one"));
assert_eq!(order.profiles, vec!["three", "one", "two"]);
assert_eq!(order.preferred_profile.as_deref(), Some("three"));
assert_eq!(
order.preference_source.as_deref(),
Some("interactive_state")
);
}
#[test]
fn remove_runtime_auth_profile_forgets_owned_metadata_only() {
let member = |name: &str, owner: Option<&str>| super::RuntimeAuthSubscriptionProfile {
auth_profile: name.to_owned(),
storage_profile: name.to_owned(),
vault: PathBuf::from("/tmp/vault"),
provider: "openai".to_owned(),
scheme: "chatgpt".to_owned(),
owner_plugin_id: owner.map(str::to_owned),
map: BTreeMap::new(),
device_seal: None,
};
let mut registry = RuntimeAuthSubscriptions {
pools: BTreeMap::from([
(
"openai".to_owned(),
RuntimeAuthSubscriptionPool {
provider_plugin_id: Some("bcode.openai-compatible".to_owned()),
preferred_profile: Some("openai-2".to_owned()),
profiles: vec![member("openai-2", None), member("openai-3", None)],
..RuntimeAuthSubscriptionPool::default()
},
),
(
"foreign".to_owned(),
RuntimeAuthSubscriptionPool {
provider_plugin_id: Some("bcode.other".to_owned()),
profiles: vec![member("openai-2", None)],
..RuntimeAuthSubscriptionPool::default()
},
),
]),
bindings: BTreeMap::from([(
"openai".to_owned(),
super::RuntimeAuthBinding {
profile: "openai-2".to_owned(),
owner_plugin_id: "bcode.openai-compatible".to_owned(),
},
)]),
profiles: BTreeMap::from([(
"openai-2".to_owned(),
super::RuntimeAuthProfile {
provider_id: "openai".to_owned(),
owner_plugin_id: "bcode.openai-compatible".to_owned(),
backend: "sshenv".to_owned(),
scheme: "chatgpt".to_owned(),
storage_profile: "openai-2".to_owned(),
vault: PathBuf::from("/tmp/vault"),
map: BTreeMap::new(),
device_seal: None,
},
)]),
};
let removal = super::remove_runtime_auth_profile_from(
&mut registry,
"openai-2",
"bcode.openai-compatible",
);
assert!(removal.removed_profile);
assert_eq!(removal.removed_bindings, vec!["openai"]);
assert_eq!(removal.removed_from_pools, vec!["openai"]);
assert!(!registry.profiles.contains_key("openai-2"));
assert!(!registry.bindings.contains_key("openai"));
let pool = ®istry.pools["openai"];
assert_eq!(pool.preferred_profile, None);
assert_eq!(pool.profiles.len(), 1);
assert_eq!(pool.profiles[0].auth_profile, "openai-3");
assert_eq!(registry.pools["foreign"].profiles.len(), 1);
let removal = super::remove_runtime_auth_profile_from(
&mut registry,
"openai-3",
"bcode.openai-compatible",
);
assert_eq!(removal.removed_from_pools, vec!["openai"]);
assert!(!registry.pools.contains_key("openai"));
let removal =
super::remove_runtime_auth_profile_from(&mut registry, "openai-2", "bcode.nobody");
assert!(!removal.changed());
}
#[test]
fn runtime_subscription_persists_owned_profile_without_rebinding_primary() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("tempdir");
let runtime_path = temp.path().join("auth-runtime.json");
let previous = std::env::var_os("BCODE_AUTH_SUBSCRIPTIONS");
unsafe {
std::env::set_var("BCODE_AUTH_SUBSCRIPTIONS", &runtime_path);
}
register_runtime_auth_profile(
"openai",
super::RuntimeAuthProfile {
provider_id: "openai".to_owned(),
owner_plugin_id: "bcode.openai-compatible".to_owned(),
backend: "sshenv".to_owned(),
scheme: "chatgpt".to_owned(),
storage_profile: "openai".to_owned(),
vault: temp.path().join("vault"),
map: BTreeMap::new(),
device_seal: None,
},
)
.expect("primary profile");
let map = BTreeMap::from([(
"access_token".to_owned(),
super::AuthCredentialMapping {
env: None,
key: Some("BCODE_OPENAI_CODEX_ACCESS_TOKEN".to_owned()),
},
)]);
register_runtime_auth_subscription(
"openai",
super::RuntimeAuthSubscriptionProfile {
auth_profile: "openai-2".to_owned(),
storage_profile: "openai-2".to_owned(),
vault: temp.path().join("vault"),
provider: "openai".to_owned(),
scheme: "chatgpt".to_owned(),
owner_plugin_id: Some("bcode.openai-compatible".to_owned()),
map: map.clone(),
device_seal: Some("off".to_owned()),
},
)
.expect("secondary subscription");
let registry = load_runtime_auth_subscriptions();
assert_eq!(registry.bindings["openai"].profile, "openai");
assert_eq!(registry.profiles["openai-2"].map, map);
assert_eq!(
registry.profiles["openai-2"].device_seal.as_deref(),
Some("off")
);
assert_eq!(registry.pools["openai"].profiles.len(), 1);
restore_env("BCODE_AUTH_SUBSCRIPTIONS", previous);
}
#[test]
fn runtime_auth_profile_persistence_is_non_secret_and_does_not_mutate_declarative_config() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("tempdir");
let runtime_path = temp.path().join("auth-runtime.json");
let declarative_path = temp.path().join("bcode.toml");
let declarative = r#"
[auth.profiles.exa]
backend = "sshenv"
provider_id = "exa"
owner_plugin_id = "bcode.web-search"
scheme = "api_key"
"#;
std::fs::write(&declarative_path, declarative).expect("write declarative config");
unsafe {
std::env::set_var("BCODE_AUTH_SUBSCRIPTIONS", &runtime_path);
}
let profile = super::RuntimeAuthProfile {
provider_id: "exa".to_owned(),
owner_plugin_id: "bcode.web-search".to_owned(),
backend: "sshenv".to_owned(),
scheme: "api_key".to_owned(),
storage_profile: "exa".to_owned(),
vault: temp.path().join("vault"),
map: BTreeMap::from([(
"api_key".to_owned(),
super::AuthCredentialMapping {
env: None,
key: Some("TEST_PROVIDER_API_KEY".to_owned()),
},
)]),
device_seal: None,
};
register_runtime_auth_profile("exa", profile).expect("persist runtime profile");
let loaded = load_runtime_auth_subscriptions();
unsafe {
std::env::remove_var("BCODE_AUTH_SUBSCRIPTIONS");
}
assert_eq!(loaded.bindings["exa"].profile, "exa");
assert_eq!(loaded.profiles["exa"].owner_plugin_id, "bcode.web-search");
let runtime_json = std::fs::read_to_string(&runtime_path).expect("runtime JSON");
assert!(!runtime_json.contains("secret-value"));
assert_eq!(
std::fs::read_to_string(&declarative_path).expect("declarative config"),
declarative
);
}
#[test]
fn additional_runtime_account_preserves_primary_binding_and_other_credentials() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("tempdir");
let runtime_path = temp.path().join("auth-runtime.json");
unsafe {
std::env::set_var("BCODE_AUTH_SUBSCRIPTIONS", &runtime_path);
}
let first = super::RuntimeAuthProfile {
provider_id: "example".to_owned(),
owner_plugin_id: "bcode.example".to_owned(),
backend: "sshenv".to_owned(),
scheme: "oauth".to_owned(),
storage_profile: "first-account".to_owned(),
vault: temp.path().join("vault"),
map: BTreeMap::new(),
device_seal: None,
};
register_runtime_auth_profile("example", first.clone()).expect("first account");
let mut second = first.clone();
second.storage_profile = "second-account".to_owned();
register_runtime_auth_profile("example-2", second.clone()).expect("additional account");
register_runtime_auth_profile("example-2", second.clone()).expect("idempotent publication");
let loaded = load_runtime_auth_subscriptions();
let before_conflict = std::fs::read(&runtime_path).expect("registered metadata");
let mut redirected = second.clone();
redirected.storage_profile = "redirected-account".to_owned();
assert!(register_runtime_auth_profile("example-2", redirected).is_err());
assert_eq!(std::fs::read(&runtime_path).unwrap(), before_conflict);
let mut foreign = second.clone();
foreign.owner_plugin_id = "bcode.foreign".to_owned();
let conflict = register_runtime_auth_profile("example-3", foreign);
unsafe {
std::env::remove_var("BCODE_AUTH_SUBSCRIPTIONS");
}
assert_eq!(loaded.bindings["example"].profile, "example");
assert_eq!(loaded.profiles["example"], first);
assert_eq!(loaded.profiles["example-2"], second);
assert!(conflict.is_err());
assert_eq!(std::fs::read(&runtime_path).unwrap(), before_conflict);
}
#[test]
fn runtime_auth_profile_persistence_rejects_owner_conflicts_without_mutation() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("tempdir");
let runtime_path = temp.path().join("auth-runtime.json");
unsafe {
std::env::set_var("BCODE_AUTH_SUBSCRIPTIONS", &runtime_path);
}
let profile = super::RuntimeAuthProfile {
provider_id: "exa".to_owned(),
owner_plugin_id: "bcode.web-search".to_owned(),
backend: "sshenv".to_owned(),
scheme: "api_key".to_owned(),
storage_profile: "exa".to_owned(),
vault: temp.path().join("vault"),
map: BTreeMap::new(),
device_seal: None,
};
register_runtime_auth_profile("exa", profile.clone()).expect("initial persistence");
let before = std::fs::read(&runtime_path).expect("runtime before");
let mut conflicting = profile;
conflicting.owner_plugin_id = "bcode.other".to_owned();
assert!(register_runtime_auth_profile("exa", conflicting).is_err());
unsafe {
std::env::remove_var("BCODE_AUTH_SUBSCRIPTIONS");
}
assert_eq!(std::fs::read(&runtime_path).expect("runtime after"), before);
}
#[test]
fn thinking_defaults_to_all_and_preserves_explicit_legacy_modes() {
let default = BcodeConfig::default();
assert_eq!(default.tui.thinking.mode, super::TuiThinkingMode::All);
let rendered = super::config_to_toml(&default);
assert!(rendered.contains("[tui.thinking]\nshow = true\nmode = \"all\""));
for (name, expected) in [
("summary", super::TuiThinkingMode::Summary),
("raw", super::TuiThinkingMode::Raw),
] {
let value: toml::Value =
toml::from_str(&format!("[tui.thinking]\nshow = true\nmode = \"{name}\"\n"))
.expect("thinking config should parse");
let config = super::validate_config_value(value, "thinking config")
.expect("thinking config should validate");
assert_eq!(config.tui.thinking.mode, expected);
}
}
#[test]
fn tool_execution_concurrency_is_unlimited_by_default_and_optionally_bounded() {
let default = super::ToolExecutionConfig::default();
assert_eq!(default.max_concurrency, None);
assert_eq!(default.runtime_options().max_concurrency, None);
let omitted: super::ToolExecutionConfig =
toml::from_str("parallel = true\npreparation_timeout_ms = 30000\n")
.expect("omitted concurrency should parse as unlimited");
assert_eq!(omitted.max_concurrency, None);
let encoded = toml::to_string(&omitted).expect("unlimited config should encode");
assert!(!encoded.contains("max_concurrency"));
let configured: super::ToolExecutionConfig = toml::from_str(
"parallel = true\nmax_concurrency = 8\npreparation_timeout_ms = 30000\n",
)
.expect("positive concurrency limit should parse");
assert_eq!(configured.max_concurrency, std::num::NonZeroUsize::new(8));
assert_eq!(
configured.runtime_options().max_concurrency,
std::num::NonZeroUsize::new(8)
);
assert!(
toml::from_str::<super::ToolExecutionConfig>(
"parallel = true\nmax_concurrency = 0\npreparation_timeout_ms = 30000\n"
)
.is_err()
);
}
#[test]
fn every_configured_stream_curve_maps_through_normal_config() {
for (name, expected) in [
(
"linear",
bcode_session_view_models::StreamingInterpolationCurve::Linear,
),
(
"ease_in",
bcode_session_view_models::StreamingInterpolationCurve::EaseIn,
),
(
"ease_out",
bcode_session_view_models::StreamingInterpolationCurve::EaseOut,
),
(
"ease_in_out",
bcode_session_view_models::StreamingInterpolationCurve::EaseInOut,
),
] {
let config: BcodeConfig =
toml::from_str(&format!("[presentation.streaming]\ncurve = \"{name}\"\n"))
.expect("configured stream curve parses");
assert_eq!(config.presentation.streaming.policy().curve, expected);
}
let zero: BcodeConfig =
toml::from_str("[presentation.streaming]\nenabled = true\nmax_lag_ms = 0\n")
.expect("zero lag parses");
assert!(zero.presentation.streaming.policy().is_immediate());
}
#[test]
fn streaming_presentation_defaults_and_configuration_round_trip() {
let default = BcodeConfig::default().presentation.streaming;
assert!(default.enabled);
assert_eq!(
default.curve,
super::StreamingInterpolationCurveConfig::Linear
);
assert_eq!(default.graphemes_per_second, 300);
assert_eq!(default.max_lag_ms, 40);
assert_eq!(
default.policy(),
bcode_session_view_models::StreamingPresentationPolicy::default()
);
let config: BcodeConfig = toml::from_str(
r#"
[presentation.streaming]
enabled = false
curve = "ease_in_out"
graphemes_per_second = 50000
max_lag_ms = 5000
"#,
)
.expect("stream presentation config parses");
let policy = config.presentation.streaming.policy();
assert!(!policy.enabled);
assert_eq!(
policy.curve,
bcode_session_view_models::StreamingInterpolationCurve::EaseInOut
);
assert_eq!(
policy.graphemes_per_second,
bcode_session_view_models::StreamingPresentationPolicy::MAX_GRAPHEMES_PER_SECOND
);
assert_eq!(
policy.max_lag_ms,
bcode_session_view_models::StreamingPresentationPolicy::MAX_LAG_MS
);
let rendered = super::config_to_toml(&config);
assert!(rendered.contains("[presentation.streaming]"), "{rendered}");
assert!(rendered.contains("enabled = false"), "{rendered}");
assert!(rendered.contains("curve = \"ease_in_out\""), "{rendered}");
assert!(
rendered.contains("graphemes_per_second = 50000"),
"{rendered}"
);
assert!(rendered.contains("max_lag_ms = 5000"), "{rendered}");
let reparsed: BcodeConfig = toml::from_str(&rendered).expect("rendered config parses");
assert_eq!(reparsed.presentation, config.presentation);
}
#[test]
fn completion_configuration_defaults_and_round_trips() {
assert_eq!(
BcodeConfig::default().presentation.completion,
super::CompletionConfig::default()
);
for mode in ["fuzzy", "prefix", "substring"] {
for order in ["relevance", "alphabetical", "inherit"] {
let input = format!(
"[presentation.completion]\nmatch_mode = {mode:?}\nsort_order = \"alphabetical\"\nfiltered_sort_order = {order:?}\n"
);
let config: BcodeConfig = toml::from_str(&input).expect("completion config parses");
let rendered = super::config_to_toml(&config);
let reparsed: BcodeConfig = toml::from_str(&rendered).expect("config round trips");
assert_eq!(reparsed.presentation, config.presentation);
}
}
for input in [
"[presentation.completion]\nmatch_mode = 'unknown'",
"[presentation.completion]\nsort_order = 'unknown'",
"[presentation.completion]\nfiltered_sort_order = 'unknown'",
] {
assert!(toml::from_str::<BcodeConfig>(input).is_err());
}
}
#[test]
fn streaming_presentation_rejects_unknown_curve() {
assert!(
toml::from_str::<BcodeConfig>(
r#"
[presentation.streaming]
curve = "bounce"
"#
)
.is_err()
);
}
#[test]
fn session_storage_resource_limits_load_and_reject_zero_timers() {
let value = toml::from_str("[session_storage]\nmaintenance_interval_secs = 120\nartifact_timeout_secs = 7\nminimum_saved_bytes = 8192").expect("toml");
let config = super::validate_config_value(value, "test").expect("validated");
assert_eq!(config.session_storage.maintenance_interval_secs, 120);
assert_eq!(config.session_storage.artifact_timeout_secs, 7);
assert_eq!(config.session_storage.minimum_saved_bytes, 8192);
for field in ["maintenance_interval_secs", "artifact_timeout_secs"] {
let value = toml::from_str(&format!("[session_storage]\n{field} = 0")).expect("toml");
assert!(super::validate_config_value(value, "test").is_err());
}
}
#[test]
fn session_storage_defaults_and_overrides_are_validated() {
let defaults = BcodeConfig::default().session_storage;
assert!(defaults.enabled);
assert_eq!(
(defaults.light_after_days, defaults.deep_after_days),
(5, 30)
);
let value = toml::from_str(
"[session_storage]\nenabled = false\nlight_after_days = 2\ndeep_after_days = 60",
)
.expect("toml");
let config = super::validate_config_value(value, "test").expect("valid");
assert!(!config.session_storage.enabled);
assert_eq!(
(
config.session_storage.light_after_days,
config.session_storage.deep_after_days
),
(2, 60)
);
for fields in [
"light_after_days = 0",
"deep_after_days = 5",
"deep_after_days = 2",
"light_after_days = -1",
"deep_after_days = 4294967296",
"deep_after_day = 30",
] {
let value = toml::from_str(&format!("[session_storage]\n{fields}")).expect("toml");
assert!(
super::validate_config_value(value, "test").is_err(),
"{fields}"
);
}
}
#[test]
fn session_search_global_enablement_defaults_on_and_loads_from_toml() {
assert!(BcodeConfig::default().session_search.enabled);
let config: BcodeConfig = toml::from_str(
r"
[session_search]
enabled = false
",
)
.expect("session search config should parse");
assert!(!config.session_search.enabled);
}
#[test]
fn client_request_timeout_defaults_to_fifteen_seconds() {
assert_eq!(BcodeConfig::default().client.request_timeout_secs, 15);
}
#[test]
fn client_request_timeout_can_be_configured() {
let value: toml::Value =
toml::from_str("[client]\nrequest_timeout_secs = 60\n").expect("config should parse");
let config =
super::validate_config_value(value, "test config").expect("config should validate");
assert_eq!(config.client.request_timeout_secs, 60);
}
#[test]
fn cli_overlay_overrides_client_request_timeout() {
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("temp root should be created");
let config_path = root.join("bcode.toml");
std::fs::write(&config_path, "[client]\nrequest_timeout_secs = 30\n")
.expect("config should be written");
let overrides = ConfigLoadOverrides::default()
.with_cli_config_toml(Some("[client]\nrequest_timeout_secs = 60\n".to_owned()));
let config = load_config_from_paths_with_overrides(&[config_path], &overrides)
.expect("overridden config should load");
assert_eq!(config.client.request_timeout_secs, 60);
}
#[test]
fn tui_theme_selection_uses_state_without_mutating_config() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("temp dir");
let config_path = temp.path().join("bcode.toml");
let state_path = temp.path().join("tui.toml");
std::fs::write(&config_path, "[tui.theme]\nname = \"bcode-dark\"\n")
.expect("config should be written");
let original_config = std::fs::read_to_string(&config_path).expect("config should read");
let previous_config = std::env::var_os(BCODE_CONFIG_ENV);
let previous_state = std::env::var_os("BCODE_TUI_STATE");
unsafe {
std::env::set_var(BCODE_CONFIG_ENV, &config_path);
std::env::set_var("BCODE_TUI_STATE", &state_path);
}
let written = set_tui_theme_selection("bcode-light").expect("theme state should write");
assert_eq!(written, state_path);
assert_eq!(
load_tui_theme_selection_from(&written).expect("theme state should load"),
Some("bcode-light".to_owned())
);
assert_eq!(
std::fs::read_to_string(&config_path).expect("config should remain readable"),
original_config
);
clear_tui_theme_selection().expect("theme state should clear");
assert_eq!(
load_tui_theme_selection_from(&written).expect("cleared state should load"),
None
);
restore_env(BCODE_CONFIG_ENV, previous_config);
restore_env("BCODE_TUI_STATE", previous_state);
}
#[test]
fn tui_state_updates_preserve_sibling_and_unknown_fields() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("temp dir");
let state_path = temp.path().join("tui.toml");
std::fs::write(
&state_path,
"future = 'kept'\n[theme]\nname = 'bcode-dark'\naccent = 'kept'\n[presentation]\nfuture = 7\n",
)
.expect("state should be written");
let previous_state = std::env::var_os("BCODE_TUI_STATE");
unsafe { std::env::set_var("BCODE_TUI_STATE", &state_path) };
let policy = bcode_session_view_models::StreamingPresentationPolicy {
enabled: true,
curve: bcode_session_view_models::StreamingInterpolationCurve::EaseInOut,
graphemes_per_second: 20_000,
max_lag_ms: 2_000,
};
set_tui_streaming_presentation_override(policy).expect("streaming state should write");
assert_eq!(
load_tui_streaming_presentation_override_from(&state_path)
.expect("streaming state should load"),
Some(policy.normalized())
);
set_tui_theme_selection("bcode-light").expect("theme should update");
let contents = std::fs::read_to_string(&state_path).expect("state should read");
assert!(contents.contains("future = \"kept\""), "{contents}");
assert!(contents.contains("accent = \"kept\""), "{contents}");
assert!(contents.contains("future = 7"), "{contents}");
assert_eq!(
load_tui_streaming_presentation_override_from(&state_path)
.expect("streaming state should survive theme update"),
Some(policy.normalized())
);
clear_tui_theme_selection().expect("theme should clear");
assert_eq!(
load_tui_streaming_presentation_override_from(&state_path)
.expect("streaming state should survive theme clear"),
Some(policy.normalized())
);
set_tui_theme_selection("bcode-light").expect("theme should update again");
clear_tui_streaming_presentation_override().expect("streaming state should clear");
assert_eq!(
load_tui_theme_selection_from(&state_path).expect("theme should survive clear"),
Some("bcode-light".to_owned())
);
assert_eq!(
load_tui_streaming_presentation_override_from(&state_path)
.expect("streaming state should load"),
None
);
restore_env("BCODE_TUI_STATE", previous_state);
}
#[test]
fn streaming_state_updates_never_touch_declarative_config() {
let _guard = ENV_LOCK.lock().expect("environment lock");
let temp = tempfile::tempdir().expect("temp dir");
let config_path = temp.path().join("bcode.toml");
let state_path = temp.path().join("tui.toml");
std::fs::write(
&config_path,
"[presentation.streaming]\ngraphemes_per_second = 175\n",
)
.expect("config should be written");
let original = std::fs::read_to_string(&config_path).expect("config should read");
let previous_config = std::env::var_os(BCODE_CONFIG_ENV);
let previous_state = std::env::var_os("BCODE_TUI_STATE");
unsafe {
std::env::set_var(BCODE_CONFIG_ENV, &config_path);
std::env::set_var("BCODE_TUI_STATE", &state_path);
}
set_tui_streaming_presentation_override(
bcode_session_view_models::StreamingPresentationPolicy::default(),
)
.expect("override should write");
assert_eq!(
std::fs::read_to_string(&config_path).expect("config should remain readable"),
original
);
clear_tui_streaming_presentation_override().expect("override should clear");
assert_eq!(
std::fs::read_to_string(&config_path).expect("config should remain readable"),
original
);
restore_env(BCODE_CONFIG_ENV, previous_config);
restore_env("BCODE_TUI_STATE", previous_state);
}
#[test]
fn malformed_tui_state_fails_closed() {
let temp = tempfile::tempdir().expect("temp dir");
let state_path = temp.path().join("tui.toml");
std::fs::write(
&state_path,
"[presentation.streaming]\ncurve = 'future_curve'\n",
)
.expect("state should be written");
assert!(load_tui_streaming_presentation_override_from(&state_path).is_err());
}
#[test]
fn zero_client_request_timeout_is_rejected() {
let value: toml::Value =
toml::from_str("[client]\nrequest_timeout_secs = 0\n").expect("config should parse");
let error = super::validate_config_value(value, "test config")
.expect_err("zero timeout should be rejected");
assert!(error.to_string().contains("must be greater than zero"));
}
const TEST_CODE_REVIEW_PLUGIN_ID: &str = "bcode.code_review";
const TEST_AGENT_PROFILE_PLUGIN_ID: &str = "bcode.default-agents";
const TEST_PI_SESSION_IMPORT_PLUGIN_ID: &str = "bcode.pi-session-import";
const TEST_DOCUMENT_PLUGIN_ID: &str = "bcode.example-document";
const TEST_FILESYSTEM_PLUGIN_ID: &str = "bcode.example-filesystem";
const TEST_GIT_PLUGIN_ID: &str = "bcode.example-git";
const TEST_SHELL_PLUGIN_ID: &str = "bcode.example-shell";
const TEST_WEB_SEARCH_PLUGIN_ID: &str = "bcode.example-web-search";
const TEST_WORKTREE_PLUGIN_ID: &str = "bcode.example-worktree";
const TEST_DEFAULT_CORE_PLUGIN_IDS: &[&str] = &[
TEST_CODE_REVIEW_PLUGIN_ID,
TEST_DOCUMENT_PLUGIN_ID,
TEST_FILESYSTEM_PLUGIN_ID,
TEST_GIT_PLUGIN_ID,
TEST_SHELL_PLUGIN_ID,
TEST_WEB_SEARCH_PLUGIN_ID,
TEST_WORKTREE_PLUGIN_ID,
TEST_AGENT_PROFILE_PLUGIN_ID,
TEST_PI_SESSION_IMPORT_PLUGIN_ID,
];
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn recovery_current_load_retains_effective_exclusive_context_and_profile() {
let _lock = ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("exclusive.toml");
std::fs::write(&path, "[contexts.entries.work.model.profiles.fast]\nprovider_plugin_id='owned.provider'\nmodel_id='fast-model'\n").unwrap();
let overrides = ConfigLoadOverrides {
exclusive_config_path: Some(path.clone()),
cli_config_toml: Some(
"[contexts]\nactive='work'\n[model]\nprofile='fast'\n".to_owned(),
),
..Default::default()
};
let _override = push_process_config_overrides(overrides.clone());
let (config, launch) = recovery::load_current_config_with_recovery_context().unwrap();
assert_eq!(config.active_context.as_deref(), Some("work"));
assert_eq!(config.model.profile.as_deref(), Some("fast"));
let sources = launch.recovery_sources.as_ref().unwrap();
assert_eq!(sources.exclusive_config_path.as_ref(), Some(&path));
assert_eq!(sources.active_context.as_deref(), Some("work"));
assert_eq!(sources.model_profile.as_deref(), Some("fast"));
assert!(!sources.inline_overrides);
let environment = ConfigEnvironmentSnapshot::isolated(dir.path());
let (deterministic, retained) =
recovery::load_config_with_recovery_context(&environment, &overrides).unwrap();
assert_eq!(
recovery::execution_fingerprint(&config).unwrap(),
recovery::execution_fingerprint(&deterministic).unwrap()
);
assert_eq!(retained.recovery_sources.unwrap().cwd, dir.path());
recovery::capture_with_sources(&config, sources, &[dir.path().to_owned()]).unwrap();
}
#[test]
fn request_overlay_preserves_launch_layers_and_restores_them() {
let _lock = ENV_LOCK.lock().unwrap();
let original = ConfigLoadOverrides {
cli_config_path: Some(PathBuf::from("selected.toml")),
cli_config_toml: Some(
"[observability]\npersist_model_requests = false\n[model]\nprofile = 'sol'".into(),
),
..Default::default()
};
let _selection = crate::push_process_config_overrides(original.clone());
let overlay = crate::push_process_inline_config_overlay(
"[observability]\npersist_model_requests = true",
)
.unwrap();
let current = crate::effective_config_overrides(&crate::ProcessConfigEnvironment);
assert_eq!(current.cli_config_path, original.cli_config_path);
let value: toml::Value = toml::from_str(¤t.cli_config_toml.unwrap()).unwrap();
assert_eq!(value["model"]["profile"].as_str(), Some("sol"));
assert_eq!(
value["observability"]["persist_model_requests"].as_bool(),
Some(true)
);
drop(overlay);
assert_eq!(
crate::effective_config_overrides(&crate::ProcessConfigEnvironment),
original
);
}
#[test]
fn launch_provenance_and_selection_toggle_are_secret_safe() {
let _lock = ENV_LOCK.lock().unwrap();
let _guard = crate::push_process_config_overrides(ConfigLoadOverrides {
env_config_path: Some(PathBuf::from("providers/sol.toml")),
env_config_toml: Some("secret-inline-value".into()),
..Default::default()
});
let launch = crate::config_launch_context(std::path::Path::new("/client"));
assert_eq!(
launch.config_files,
vec![PathBuf::from("/client/providers/sol.toml")]
);
assert!(launch.inline_overrides);
assert!(
!serde_json::to_string(&launch)
.unwrap()
.contains("secret-inline-value")
);
let mut config = BcodeConfig::default();
config.system_prompt.sections.config_selection = false;
let encoded = crate::encode_effective_config(&config).unwrap();
let decoded: BcodeConfig = toml::from_str(&encoded).unwrap();
assert!(!decoded.system_prompt.sections.config_selection);
assert!(
BcodeConfig::default()
.system_prompt
.sections
.config_selection
);
}
#[test]
fn stable_config_doc_sections_are_derive_backed() {
for (section, keys) in [
("system_prompt", &["mode", "text", "sections"][..]),
("skills", &["enabled", "auto_activate", "prompt"]),
(
"observability",
&["level", "persist_tool_io", "max_blob_bytes"],
),
("client", &["request_timeout_secs"]),
("daemon", &["idle_shutdown", "idle_shutdown_after_secs"]),
("worktree", &["root", "branch_prefix", "setup"]),
("tools", &["shell"]),
(
"session_import",
&["enabled", "auto_discover_on_startup", "pi", "opencode"],
),
] {
let root_fields = BcodeConfig::field_docs();
let field = root_fields
.iter()
.find(|field| field.toml_key == section)
.unwrap_or_else(|| panic!("missing root config doc section: {section}"));
let Some(NestedFieldDoc::Inline { fields, .. }) = &field.nested else {
panic!("root config doc section {section} is not inline nested");
};
for key in keys {
assert!(
fields.iter().any(|field| field.toml_key == *key),
"missing derived key {section}.{key}"
);
}
}
assert_eq!(
super::SystemPromptMode::config_doc_values(),
&["default", "replace"]
);
assert_eq!(
super::SkillAutoActivateMode::config_doc_values(),
&["off", "suggest", "on"]
);
assert_eq!(
super::ShellToolEnvMode::config_doc_values(),
&["auto", "inherit", "direnv"]
);
}
#[test]
fn config_doc_sections_include_real_defaults() {
let fields = BcodeConfig::field_docs();
assert_section_defaults(
&fields,
"observability",
&[("level", "standard"), ("persist_tool_io", "true")],
);
assert_section_defaults(
&fields,
"skills",
&[("enabled", "true"), ("auto_activate", "suggest")],
);
assert_section_defaults(
&fields,
"daemon",
&[
("idle_shutdown", "true"),
("idle_shutdown_after_secs", "900"),
],
);
assert_section_defaults(&fields, "model", &[]);
assert_section_defaults(
&fields,
"plugins",
&[
("default", "bundled"),
("enabled", "[]"),
("disabled", "[]"),
],
);
}
#[test]
fn config_doc_nested_defaults_cover_manual_dynamic_sections() {
let fields = BcodeConfig::field_docs();
assert_nested_defaults(
&fields,
"model",
"retry",
&[
("enabled", "true"),
("overload_retry_forever", "—"),
("max_overload_retries", "—"),
("overload_max_delay_ms", "600000"),
("no_progress_timeout_retry_forever", "—"),
("max_no_progress_timeout_retries", "—"),
("no_progress_timeout_max_delay_ms", "600000"),
],
);
assert_nested_defaults(
&fields,
"model",
"compaction",
&[
("mode", "auto"),
("backend", "auto"),
("proactive_threshold_percent", "90"),
("keep_recent_tokens", "20000"),
("context_chars", "0"),
],
);
assert_nested_defaults(
&fields,
"tui",
"mouse",
&[("scroll_rows", "3"), ("double_click_select", "word")],
);
assert_map_value_defaults(
&fields,
"auth",
"pools",
&[("strategy", "failover"), ("profiles", "[]")],
);
assert_map_value_defaults(&fields, "agent", "", &[("tools", "{}")]);
}
fn assert_section_defaults(fields: &[FieldDoc], section: &str, expected: &[(&str, &str)]) {
let section = fields
.iter()
.find(|field| field.toml_key == section)
.unwrap_or_else(|| panic!("missing section {section}"));
let Some(NestedFieldDoc::Inline { defaults, .. }) = §ion.nested else {
panic!("section {} is not inline nested", section.toml_key);
};
assert_defaults(defaults, expected);
}
fn assert_nested_defaults(
fields: &[FieldDoc],
section: &str,
nested: &str,
expected: &[(&str, &str)],
) {
let section = fields
.iter()
.find(|field| field.toml_key == section)
.unwrap_or_else(|| panic!("missing section {section}"));
let Some(NestedFieldDoc::Inline { fields, .. }) = §ion.nested else {
panic!("section {} is not inline nested", section.toml_key);
};
let nested = fields
.iter()
.find(|field| field.toml_key == nested)
.unwrap_or_else(|| panic!("missing nested field {nested}"));
let Some(NestedFieldDoc::Inline { defaults, .. }) = &nested.nested else {
panic!("nested field {} is not inline nested", nested.toml_key);
};
assert_defaults(defaults, expected);
}
fn assert_map_value_defaults(
fields: &[FieldDoc],
section: &str,
map_key: &str,
expected: &[(&str, &str)],
) {
let section = fields
.iter()
.find(|field| field.toml_key == section)
.unwrap_or_else(|| panic!("missing section {section}"));
let Some(NestedFieldDoc::Inline { fields, .. }) = §ion.nested else {
panic!("section {} is not inline nested", section.toml_key);
};
let map = fields
.iter()
.find(|field| field.toml_key == map_key)
.unwrap_or_else(|| panic!("missing map field {map_key}"));
let Some(NestedFieldDoc::Map { value_defaults, .. }) = &map.nested else {
panic!("field {} is not a map", map.toml_key);
};
assert_defaults(value_defaults, expected);
}
fn assert_dynamic_map_value(
fields: &[FieldDoc],
section: &str,
map_field: &str,
key_placeholder: &str,
value_type: &str,
) {
let section_fields = section_fields(fields, section);
let field = find_field_path(section_fields, map_field)
.unwrap_or_else(|| panic!("missing dynamic map field {section}.{map_field}"));
let Some(NestedFieldDoc::MapValue {
key_placeholder: actual_key,
value_type_display,
..
}) = &field.nested
else {
panic!("field {section}.{map_field} is not a dynamic map value");
};
assert_eq!(
(*actual_key, *value_type_display),
(key_placeholder, value_type)
);
}
fn assert_nested_dynamic_map_value(
fields: &[FieldDoc],
section: &str,
map_field: &str,
nested_field: &str,
key_placeholder: &str,
value_type: &str,
) {
let fields = nested_map_fields(fields, section, map_field);
let field = find_field_path(fields, nested_field).unwrap_or_else(|| {
panic!("missing nested dynamic map field {section}.{map_field}.{nested_field}")
});
let Some(NestedFieldDoc::MapValue {
key_placeholder: actual_key,
value_type_display,
..
}) = &field.nested
else {
panic!("field {section}.{map_field}.{nested_field} is not a dynamic map value");
};
assert_eq!(
(*actual_key, *value_type_display),
(key_placeholder, value_type)
);
}
fn assert_nested_dynamic_list_value(
fields: &[FieldDoc],
section: &str,
map_field: &str,
nested_field: &str,
index_placeholder: &str,
) {
let fields = nested_map_fields(fields, section, map_field);
let field = find_field_path(fields, nested_field).unwrap_or_else(|| {
panic!("missing nested dynamic list field {section}.{map_field}.{nested_field}")
});
let Some(NestedFieldDoc::ListValue {
index_placeholder: actual_index,
..
}) = &field.nested
else {
panic!("field {section}.{map_field}.{nested_field} is not a dynamic list value");
};
assert_eq!(*actual_index, index_placeholder);
}
fn nested_map_fields<'a>(
fields: &'a [FieldDoc],
section: &str,
map_field: &str,
) -> &'a [FieldDoc] {
let fields = section_fields(fields, section);
let map = find_field_path(fields, map_field)
.unwrap_or_else(|| panic!("missing map field {section}.{map_field}"));
let Some(NestedFieldDoc::Map { value_fields, .. }) = &map.nested else {
panic!("field {section}.{map_field} is not a nested map");
};
value_fields
}
fn section_fields<'a>(fields: &'a [FieldDoc], section: &str) -> &'a [FieldDoc] {
let section = fields
.iter()
.find(|field| field.toml_key == section)
.unwrap_or_else(|| panic!("missing section {section}"));
let Some(NestedFieldDoc::Inline { fields, .. }) = §ion.nested else {
panic!("section {} is not inline nested", section.toml_key);
};
fields
}
fn find_field_path<'a>(fields: &'a [FieldDoc], path: &str) -> Option<&'a FieldDoc> {
let (head, tail) = path.split_once('.').unwrap_or((path, ""));
let field = fields.iter().find(|field| field.toml_key == head)?;
if tail.is_empty() {
return Some(field);
}
let NestedFieldDoc::Inline { fields, .. } = field.nested.as_ref()? else {
return None;
};
find_field_path(fields, tail)
}
fn assert_defaults(defaults: &BTreeMap<String, String>, expected: &[(&str, &str)]) {
for (key, value) in expected {
assert_eq!(
defaults.get(*key).map(String::as_str),
Some(*value),
"unexpected default for {key}"
);
}
}
#[test]
fn tui_selection_defaults_and_partial_tables() {
let default = super::TuiSelectionConfig::default();
assert!(default.auto_copy_on_mouse_release);
assert!(default.clear_after_copy);
assert_eq!(default.clear_delay_ms, 1000);
for input in ["", "[tui]", "[tui.selection]"] {
let config: BcodeConfig = toml::from_str(input).expect("config parses");
assert_eq!(config.tui.selection, default);
}
for (input, expected) in [
(
"auto_copy_on_mouse_release = false",
super::TuiSelectionConfig {
auto_copy_on_mouse_release: false,
..default
},
),
(
"clear_after_copy = false",
super::TuiSelectionConfig {
clear_after_copy: false,
..default
},
),
(
"clear_delay_ms = 0",
super::TuiSelectionConfig {
clear_delay_ms: 0,
..default
},
),
(
"clear_delay_ms = 2750",
super::TuiSelectionConfig {
clear_delay_ms: 2750,
..default
},
),
] {
let config: BcodeConfig = toml::from_str(&format!("[tui.selection]\n{input}\n"))
.expect("partial selection config parses");
assert_eq!(config.tui.selection, expected);
}
assert_nested_defaults(
&BcodeConfig::field_docs(),
"tui",
"selection",
&[
("auto_copy_on_mouse_release", "true"),
("clear_after_copy", "true"),
("clear_delay_ms", "1000"),
],
);
}
#[test]
fn tui_selection_writing_and_transport_preserve_settings() {
for clear_delay_ms in [0, 1000, 2750, i64::MAX.cast_unsigned()] {
for (auto_copy_on_mouse_release, clear_after_copy) in
[(true, true), (false, true), (true, false), (false, false)]
{
let mut config = BcodeConfig::default();
config.tui.selection = super::TuiSelectionConfig {
auto_copy_on_mouse_release,
clear_after_copy,
clear_delay_ms,
};
for encoded in [
super::config_to_toml(&config),
super::encode_effective_config(&config).expect("effective config encodes"),
] {
let decoded: BcodeConfig =
toml::from_str(&encoded).expect("written config parses");
assert_eq!(decoded.tui.selection, config.tui.selection);
}
}
}
let extreme = super::TuiSelectionConfig {
clear_delay_ms: u64::MAX,
..Default::default()
};
let encoded = serde_json::to_string(&extreme).expect("selection serializes");
let decoded: super::TuiSelectionConfig =
serde_json::from_str(&encoded).expect("selection parses");
assert_eq!(decoded, extreme);
assert!(toml::from_str::<super::TuiSelectionConfig>("clear_delay_ms = -1").is_err());
}
#[test]
fn tui_selection_layers_merge_individual_fields() {
let temp = tempfile::tempdir().expect("temp dir");
let user = temp.path().join("user.toml");
let project = temp.path().join("project.toml");
std::fs::write(&user, "[tui.selection]\nauto_copy_on_mouse_release = false\nclear_after_copy = false\nclear_delay_ms = 2750\n").expect("user config writes");
std::fs::write(&project, "[tui.selection]\nclear_delay_ms = 0\n")
.expect("project config writes");
let paths = [user, project];
let config = load_config_from_paths_with_overrides(&paths, &ConfigLoadOverrides::default())
.expect("layers load");
assert_eq!(
config.tui.selection,
super::TuiSelectionConfig {
auto_copy_on_mouse_release: false,
clear_after_copy: false,
clear_delay_ms: 0,
}
);
let overrides = ConfigLoadOverrides::default().with_cli_config_toml(Some(
"[tui.selection]\nclear_after_copy = true\n".to_owned(),
));
let overridden =
load_config_from_paths_with_overrides(&paths, &overrides).expect("override loads");
assert_eq!(
overridden.tui.selection,
super::TuiSelectionConfig {
clear_after_copy: true,
..config.tui.selection
}
);
}
#[test]
fn tui_render_config_defaults_bounds_zero_and_round_trips() {
let default = TuiRenderConfig::default();
assert_eq!(default.max_fps, 60);
assert_eq!(
default.frame_interval(),
Some(std::time::Duration::from_secs_f64(1.0 / 60.0))
);
assert_eq!(TuiRenderConfig { max_fps: 0 }.frame_interval(), None);
assert_eq!(
TuiRenderConfig { max_fps: 500 }.frame_interval(),
Some(std::time::Duration::from_secs_f64(1.0 / 240.0))
);
let encoded = toml::to_string(&default).expect("render config serializes");
let decoded: TuiRenderConfig = toml::from_str(&encoded).expect("render config parses");
assert_eq!(decoded, default);
}
#[test]
fn config_doc_schema_documents_dynamic_map_and_list_entries() {
let fields = BcodeConfig::field_docs();
assert_dynamic_map_value(&fields, "plugins", "config", "<plugin-id>.<setting>", "any");
assert_nested_dynamic_map_value(
&fields,
"model",
"profiles",
"request",
"<request-key>",
"any",
);
assert_nested_dynamic_list_value(&fields, "auth", "pools", "profiles", "<index>");
assert_nested_dynamic_map_value(&fields, "agent", "", "tools", "<tool-id>", "bool");
assert_nested_dynamic_map_value(
&fields,
"agent",
"",
"permission.command",
"<pattern>",
"string",
);
}
#[test]
fn root_config_doc_schema_documents_major_nested_sections() {
let fields = BcodeConfig::field_docs();
for section in ["model", "auth", "agent", "skills", "system_prompt", "tools"] {
assert!(
fields.iter().any(|field| field.toml_key == section),
"missing root config doc section: {section}"
);
}
}
#[test]
fn removed_shorthand_agent_tool_ids_are_rejected() {
for (tool_id, replacement) in [
("bash", "shell.run"),
("command", "shell.run"),
("read", "filesystem.read"),
("grep", "filesystem.grep"),
("find", "filesystem.find"),
("ls", "filesystem.list"),
("stat", "filesystem.stat"),
("write", "filesystem.write"),
("edit", "filesystem.edit"),
("worktree.read", "worktree.list"),
] {
let result = load_config_from_paths_with_overrides(
&[],
&ConfigLoadOverrides::from_env_with_cli(
None,
Some(format!("[agent.plan.tools]\n\"{tool_id}\" = true\n")),
),
);
assert!(
matches!(
result,
Err(ConfigError::RemovedShorthandToolId {
agent_id,
tool_id: actual_tool_id,
replacement: actual_replacement,
}) if agent_id == "plan" && actual_tool_id == tool_id && actual_replacement == replacement
),
"{tool_id} should be rejected with replacement {replacement}"
);
}
}
#[test]
fn removed_permission_bash_category_is_rejected() {
let result = load_config_from_paths_with_overrides(
&[],
&ConfigLoadOverrides::from_env_with_cli(
None,
Some("[agent.plan.permission]\nbash = { \"*\" = \"deny\" }\n".to_string()),
),
);
assert!(matches!(
result,
Err(ConfigError::RemovedPermissionCategory {
agent_id,
category,
replacement: "command"
}) if agent_id == "plan" && category == "bash"
));
}
#[test]
fn auth_pool_config_loads_from_toml_and_resolves_model_profile() {
let config: BcodeConfig = toml::from_str(
r#"
[auth.profiles.openai]
backend = "sshenv"
scheme = "chatgpt"
[auth.profiles.openai-2]
backend = "sshenv"
scheme = "chatgpt"
[auth.pools.openai]
provider_plugin_id = "bcode.openai-compatible"
strategy = "failover"
profiles = ["openai", "openai-2"]
[model.profiles.openai]
provider_plugin_id = "bcode.openai-compatible"
model_id = "gpt-5.5"
auth_pool = "openai"
"#,
)
.expect("config should parse");
let pool = config
.auth
.pools
.get("openai")
.expect("auth pool should parse");
assert_eq!(pool.profiles, vec!["openai", "openai-2"]);
assert_eq!(
pool.provider_plugin_id.as_deref(),
Some("bcode.openai-compatible")
);
let mut config = config;
config.model.profile = Some("openai".to_string());
let environment = ConfigEnvironmentSnapshot::isolated(unique_temp_dir());
let selection = config.resolved_model_selection_with_environment(&environment);
assert_eq!(selection.auth_pool.as_deref(), Some("openai"));
assert_eq!(selection.auth_profile, None);
assert_eq!(
selection.provider_source,
Some(crate::ModelSelectionSource::Profile {
name: "openai".to_string()
})
);
assert_eq!(
selection.model_source,
Some(crate::ModelSelectionSource::Profile {
name: "openai".to_string()
})
);
}
#[test]
fn model_retry_config_loads_from_toml() {
let config: BcodeConfig = toml::from_str(
r#"
[model.retry]
overload_retry_forever = false
max_overload_retries = 3
overload_initial_delay_ms = 1000
overload_max_delay_ms = 10000
no_progress_timeout_enabled = false
no_progress_timeout_retry_forever = false
max_no_progress_timeout_retries = 4
no_progress_timeout_initial_delay_ms = 1500
no_progress_timeout_max_delay_ms = 12000
remote_catalog_rules_enabled = false
[[model.retry.rules]]
id = "unsupported-content-type"
provider_plugin_id = "bcode.openai-compatible"
model_id_contains = "claude"
max_retries = 2
retry_forever = false
initial_delay_ms = 500
max_delay_ms = 4000
use_provider_retry_hint = false
[model.retry.rules.match]
code = "http_400"
message_contains = "Unsupported content type"
"#,
)
.expect("config should parse");
assert_eq!(config.model.retry.overload_retry_forever, Some(false));
assert_eq!(config.model.retry.max_overload_retries, Some(3));
assert_eq!(config.model.retry.overload_initial_delay_ms, 1_000);
assert_eq!(config.model.retry.overload_max_delay_ms, 10_000);
assert!(!config.model.retry.no_progress_timeout_enabled);
assert_eq!(
config.model.retry.no_progress_timeout_retry_forever,
Some(false)
);
assert_eq!(config.model.retry.max_no_progress_timeout_retries, Some(4));
assert_eq!(
config.model.retry.no_progress_timeout_initial_delay_ms,
1_500
);
assert_eq!(config.model.retry.no_progress_timeout_max_delay_ms, 12_000);
assert!(!config.model.retry.remote_catalog_rules_enabled);
let rule = config
.model
.retry
.rules
.first()
.expect("custom retry rule should parse");
assert_eq!(rule.id, "unsupported-content-type");
assert_eq!(
rule.provider_plugin_id.as_deref(),
Some("bcode.openai-compatible")
);
assert_eq!(rule.model_id_contains.as_deref(), Some("claude"));
assert_eq!(rule.max_retries, Some(2));
assert_eq!(rule.retry_forever, Some(false));
assert_eq!(rule.initial_delay_ms, Some(500));
assert_eq!(rule.max_delay_ms, Some(4_000));
assert_eq!(rule.use_provider_retry_hint, Some(false));
assert_eq!(rule.r#match.code.as_deref(), Some("http_400"));
assert_eq!(
rule.r#match.message_contains.as_deref(),
Some("Unsupported content type")
);
}
#[test]
fn tui_markdown_capabilities_are_explicit_and_secure_by_default() {
let defaults = BcodeConfig::default().tui.markdown;
assert!(!defaults.network_images);
assert!(defaults.mermaid);
let config: BcodeConfig = toml::from_str(
r"
[tui.markdown]
network_images = true
mermaid = false
",
)
.expect("config should parse");
assert!(config.tui.markdown.network_images);
assert!(!config.tui.markdown.mermaid);
}
#[test]
fn tui_mouse_config_loads_from_toml() {
let config: BcodeConfig = toml::from_str(
r#"
[tui.mouse]
selection_autoscroll = false
scroll_rows = 4
multi_click_ms = 300
multi_click_max_distance = 1
double_click_select = "word"
triple_click_select = "all"
"#,
)
.expect("config should parse");
assert!(!config.tui.mouse.selection_autoscroll);
assert_eq!(config.tui.mouse.scroll_rows, 4);
assert_eq!(config.tui.mouse.multi_click_ms, 300);
assert_eq!(config.tui.mouse.multi_click_max_distance, 1);
assert_eq!(
config.tui.mouse.double_click_select,
super::TuiMouseClickSelection::Word
);
assert_eq!(
config.tui.mouse.triple_click_select,
super::TuiMouseClickSelection::All
);
}
#[test]
fn tui_theme_transition_curve_loads_from_toml() {
let config: BcodeConfig = toml::from_str(
r#"
[tui.theme]
name = "terminal-native-structured"
overlays = ["high-contrast-status"]
variant = "dark"
paths = ["themes/custom.toml"]
agent_accent = "theme_only"
accent_transition = "transition"
accent_transition_ms = 180
accent_transition_curve = "ease_in_out"
"#,
)
.expect("config should parse");
assert_eq!(config.tui.theme.name, "terminal-native-structured");
assert_eq!(config.tui.theme.overlays, ["high-contrast-status"]);
assert_eq!(config.tui.theme.variant, TuiThemeVariant::Dark);
assert_eq!(
config.tui.theme.paths,
[PathBuf::from("themes/custom.toml")]
);
assert_eq!(
config.tui.theme.agent_accent,
TuiAgentAccentPolicy::ThemeOnly
);
assert_eq!(config.tui.theme.accent_transition_ms, 180);
assert_eq!(
config.tui.theme.accent_transition_curve,
TuiAccentTransitionCurve::EaseInOut
);
let rendered = super::config_to_toml(&config);
let round_trip: BcodeConfig = toml::from_str(&rendered).expect("rendered config parses");
assert_eq!(round_trip.tui.theme, config.tui.theme);
}
#[test]
fn tui_theme_transition_curve_defaults_to_ease_out() {
assert_eq!(
BcodeConfig::default().tui.theme.accent_transition_curve,
TuiAccentTransitionCurve::EaseOut
);
}
#[test]
fn tui_mouse_scroll_rows_defaults_and_clamps_zero() {
assert!(TuiMouseConfig::default().selection_autoscroll);
assert_eq!(TuiMouseConfig::default().scroll_rows, 3);
assert_eq!(TuiMouseConfig::default().effective_scroll_rows(), 3);
assert_eq!(
TuiMouseConfig {
scroll_rows: 0,
..TuiMouseConfig::default()
}
.effective_scroll_rows(),
1
);
}
fn assert_default_core_plugins_enabled(plugin_selection: &PluginSelection) {
assert!(
plugin_selection
.enabled
.contains(TEST_CODE_REVIEW_PLUGIN_ID)
);
assert!(plugin_selection.enabled.contains(TEST_DOCUMENT_PLUGIN_ID));
assert!(plugin_selection.enabled.contains(TEST_FILESYSTEM_PLUGIN_ID));
assert!(plugin_selection.enabled.contains(TEST_GIT_PLUGIN_ID));
assert!(plugin_selection.enabled.contains(TEST_SHELL_PLUGIN_ID));
assert!(plugin_selection.enabled.contains(TEST_WEB_SEARCH_PLUGIN_ID));
assert!(
plugin_selection
.enabled
.contains(TEST_AGENT_PROFILE_PLUGIN_ID)
);
assert!(
plugin_selection
.enabled
.contains(TEST_PI_SESSION_IMPORT_PLUGIN_ID)
);
}
#[test]
fn merges_plugin_selection_from_existing_files() {
let _lock = ENV_LOCK.lock().unwrap();
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("temp root should be created");
let user = root.join("user.toml");
let project = root.join("project.toml");
std::fs::write(
&user,
r#"
[plugins]
enabled = ["example.a"]
disabled = ["example.b"]
"#,
)
.expect("user config should be written");
std::fs::write(
&project,
r#"
[plugins]
enabled = ["example.c"]
disabled = ["example.d"]
[agent.build.tools]
"example.tool" = true
[agent.build.permission]
external_directory = "ask"
command = { "cargo *" = "allow", "git push *" = "deny" }
read = { "**" = "allow" }
write = { "target/**" = "allow" }
edit = { "src/**" = "ask" }
[tui.keybindings.chat]
"ctrl+x" = "app.exit"
[tui.keybindings.permission]
"y" = "app.permission.approve"
"escape" = "tui.select.cancel"
[tui.keybindings]
"app.exit" = ["ctrl+d"]
"app.permission.approve" = []
"#,
)
.expect("project config should be written");
let config = load_config_from_paths(&[user, project]).expect("config should load");
assert!(!config.plugins.enabled.contains("example.a"));
assert!(config.plugins.enabled.contains("example.c"));
assert!(!config.plugins.disabled.contains("example.b"));
assert!(config.plugins.disabled.contains("example.d"));
let build = config
.agent
.get("build")
.expect("build agent config should be loaded");
assert_eq!(
build.tools.get("example.tool").copied(),
Some(true),
"build agent should enable example.tool"
);
assert_eq!(
build.permission.external_directory,
bcode_agent_policy_models::Action::Ask
);
assert_eq!(
build.permission.command.get("cargo *").copied(),
Some(bcode_agent_policy_models::Action::Allow)
);
assert_eq!(
build.permission.command.get("git push *").copied(),
Some(bcode_agent_policy_models::Action::Deny)
);
assert_eq!(
build.permission.read.get("**").copied(),
Some(bcode_agent_policy_models::Action::Allow)
);
assert_eq!(
build.permission.write.get("target/**").copied(),
Some(bcode_agent_policy_models::Action::Allow)
);
assert_eq!(
build.permission.edit.get("src/**").copied(),
Some(bcode_agent_policy_models::Action::Ask)
);
assert_eq!(
config.tui.keybindings.chat.get("ctrl+x"),
Some(&"app.exit".to_string())
);
assert_eq!(
config.tui.keybindings.permission.get("y"),
Some(&"app.permission.approve".to_string())
);
assert_eq!(
config.tui.keybindings.legacy_actions.get("app.exit"),
Some(&vec!["ctrl+d".to_string()])
);
assert_eq!(
config
.tui
.keybindings
.legacy_actions
.get("app.permission.approve"),
Some(&Vec::new())
);
std::fs::remove_dir_all(root).expect("temp root should clean up");
}
#[test]
fn plugin_default_none_only_enables_explicit_and_provider_plugins() {
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
default = "none"
enabled = ["bcode.filesystem"]
"#,
)
.expect("config parses");
let selection = plugin_selection_with_default_plugin_ids(
&config,
["bcode.default-agents", "bcode.vim-edit"],
);
assert_eq!(selection.mode, PluginSelectionMode::Explicit);
assert!(selection.enabled.contains("bcode.filesystem"));
assert!(!selection.enabled.contains("bcode.default-agents"));
assert!(!selection.enabled.contains("bcode.vim-edit"));
assert!(selection.enabled.contains("bcode.openai-compatible"));
}
#[test]
fn plugin_default_bundled_enables_only_supplied_defaults_and_explicit_plugins() {
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
enabled = ["bcode.vim-edit"]
"#,
)
.expect("config parses");
let selection = plugin_selection_with_default_plugin_ids(&config, ["bcode.default-agents"]);
assert_eq!(selection.mode, PluginSelectionMode::Explicit);
assert!(selection.is_enabled("bcode.default-agents"));
assert!(selection.is_enabled("bcode.vim-edit"));
assert!(!selection.is_enabled("bcode.other-available"));
}
#[test]
fn plugin_default_bundled_respects_explicit_disable() {
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
disabled = ["bcode.vim-edit"]
"#,
)
.expect("config parses");
let selection = plugin_selection_with_default_plugin_ids(
&config,
["bcode.default-agents", "bcode.vim-edit"],
);
assert_eq!(selection.mode, PluginSelectionMode::Explicit);
assert!(selection.is_enabled("bcode.default-agents"));
assert!(!selection.is_enabled("bcode.vim-edit"));
}
#[test]
fn plugin_default_all_enables_available_plugins() {
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
default = "all"
"#,
)
.expect("config parses");
let selection = plugin_selection_with_default_plugin_ids(&config, ["bcode.default-agents"]);
assert_eq!(selection.mode, PluginSelectionMode::All);
assert!(selection.is_enabled("bcode.vim-edit"));
assert!(selection.is_enabled("bcode.any-discovered-plugin"));
}
#[test]
fn plugin_default_all_enables_discovered_unless_disabled() {
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
default = "all"
disabled = ["bcode.vim-edit"]
"#,
)
.expect("config parses");
let selection = plugin_selection_with_default_plugin_ids(&config, ["bcode.default-agents"]);
assert_eq!(selection.mode, PluginSelectionMode::All);
assert!(selection.is_enabled("bcode.any-discovered-plugin"));
assert!(!selection.is_enabled("bcode.vim-edit"));
}
#[test]
fn tool_config_parses_default_enabled_and_disabled() {
let config: BcodeConfig = toml::from_str(
r#"
[tools]
default = "none"
enabled = ["filesystem.read", "vim_edit.preview"]
disabled = ["shell.run"]
"#,
)
.expect("config parses");
assert_eq!(config.tools.default, super::ToolDefaultMode::None);
assert!(config.tools.enabled.contains("filesystem.read"));
assert!(config.tools.enabled.contains("vim_edit.preview"));
assert!(config.tools.disabled.contains("shell.run"));
}
#[test]
fn config_to_toml_preserves_auth_profile_ownership() {
let mut config = BcodeConfig::default();
config.auth.profiles.insert(
"openai".to_owned(),
super::AuthProfileConfig {
backend: "sshenv".to_owned(),
provider_id: Some("openai".to_owned()),
owner_plugin_id: Some("bcode.openai-compatible".to_owned()),
scheme: Some("api_key".to_owned()),
map: BTreeMap::new(),
settings: BTreeMap::new(),
},
);
let rendered = super::config_to_toml(&config);
let decoded: BcodeConfig = toml::from_str(&rendered).expect("rendered config parses");
assert_eq!(
decoded.auth.profiles["openai"],
config.auth.profiles["openai"]
);
assert!(rendered.contains("provider_id = \"openai\""), "{rendered}");
assert!(
rendered.contains("owner_plugin_id = \"bcode.openai-compatible\""),
"{rendered}"
);
}
#[test]
fn config_to_toml_writes_plugin_and_tool_selection_modes() {
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
default = "none"
enabled = ["bcode.vim-edit"]
[tools]
default = "none"
enabled = ["vim_edit.preview"]
disabled = ["vim_edit.apply"]
"#,
)
.expect("config parses");
let rendered = super::config_to_toml(&config);
assert!(rendered.contains("[plugins]"), "{rendered}");
assert!(rendered.contains("default = \"none\""), "{rendered}");
assert!(
rendered.contains("enabled = [\"bcode.vim-edit\"]"),
"{rendered}"
);
assert!(rendered.contains("[tools]"), "{rendered}");
assert!(
rendered.contains("enabled = [\"vim_edit.preview\"]"),
"{rendered}"
);
assert!(
rendered.contains("disabled = [\"vim_edit.apply\"]"),
"{rendered}"
);
}
#[test]
fn config_to_toml_writes_repository_invariant_prompt_settings() {
let mut config = BcodeConfig::default();
config.system_prompt.repository_instructions_max_chars = std::num::NonZeroUsize::new(6_000);
config.system_prompt.repository_invariants_max_chars = std::num::NonZeroUsize::new(8_000);
config.system_prompt.git_status_max_chars = std::num::NonZeroUsize::new(12_000).unwrap();
config.system_prompt.sections.current_datetime = false;
config.system_prompt.sections.execution_environment = false;
config.system_prompt.sections.hosting_environment = false;
config.system_prompt.sections.locale = false;
config.system_prompt.sections.git_revision = false;
config.system_prompt.sections.runtime_mode = false;
config.system_prompt.sections.toolchain_context = true;
config.system_prompt.sections.worktree_context = false;
config.system_prompt.sections.config_profile = false;
config.system_prompt.sections.repository_invariants = false;
config.skills.max_skill_file_bytes = std::num::NonZeroU64::new(512_000).unwrap();
config.skills.preview_max_chars = std::num::NonZeroUsize::new(4_000).unwrap();
config.model.tool_output.fallback_argument_chars =
std::num::NonZeroUsize::new(9_000).unwrap();
let rendered = super::config_to_toml(&config);
assert!(
rendered.contains("repository_instructions_max_chars = 6000"),
"{rendered}"
);
assert!(
rendered.contains("repository_invariants_max_chars = 8000"),
"{rendered}"
);
assert!(
rendered.contains("git_status_max_chars = 12000"),
"{rendered}"
);
assert!(
rendered.contains("max_skill_file_bytes = 512000"),
"{rendered}"
);
assert!(rendered.contains("preview_max_chars = 4000"), "{rendered}");
assert!(
rendered.contains("fallback_argument_chars = 9000"),
"{rendered}"
);
assert!(
rendered.contains("repository_invariants = false"),
"{rendered}"
);
assert!(rendered.contains("current_datetime = false"), "{rendered}");
assert!(
rendered.contains("execution_environment = false"),
"{rendered}"
);
assert!(
rendered.contains("hosting_environment = false"),
"{rendered}"
);
assert!(rendered.contains("locale = false"), "{rendered}");
assert!(rendered.contains("git_revision = false"), "{rendered}");
assert!(rendered.contains("runtime_mode = false"), "{rendered}");
assert!(rendered.contains("toolchain_context = true"), "{rendered}");
assert!(rendered.contains("worktree_context = false"), "{rendered}");
assert!(rendered.contains("config_profile = false"), "{rendered}");
let parsed: BcodeConfig = toml::from_str(&rendered).expect("rendered config parses");
assert_eq!(
parsed.system_prompt.repository_instructions_max_chars,
std::num::NonZeroUsize::new(6_000)
);
assert_eq!(
parsed.system_prompt.repository_invariants_max_chars,
std::num::NonZeroUsize::new(8_000)
);
assert_eq!(parsed.system_prompt.git_status_max_chars.get(), 12_000);
assert_eq!(parsed.skills.max_skill_file_bytes.get(), 512_000);
assert_eq!(parsed.skills.preview_max_chars.get(), 4_000);
assert_eq!(
parsed.model.tool_output.fallback_argument_chars.get(),
9_000
);
assert!(!parsed.system_prompt.sections.repository_invariants);
assert!(!parsed.system_prompt.sections.current_datetime);
assert!(!parsed.system_prompt.sections.execution_environment);
assert!(!parsed.system_prompt.sections.hosting_environment);
assert!(!parsed.system_prompt.sections.locale);
assert!(!parsed.system_prompt.sections.git_revision);
assert!(!parsed.system_prompt.sections.runtime_mode);
assert!(parsed.system_prompt.sections.toolchain_context);
assert!(!parsed.system_prompt.sections.worktree_context);
assert!(!parsed.system_prompt.sections.config_profile);
}
#[test]
fn current_datetime_prompt_section_defaults_to_enabled_when_omitted() {
let default = BcodeConfig::default();
assert!(default.system_prompt.sections.current_datetime);
assert!(default.system_prompt.sections.execution_environment);
assert!(default.system_prompt.sections.hosting_environment);
assert!(default.system_prompt.sections.locale);
assert!(default.system_prompt.sections.git_revision);
assert!(default.system_prompt.sections.runtime_mode);
assert!(!default.system_prompt.sections.toolchain_context);
assert!(default.system_prompt.sections.worktree_context);
assert!(default.system_prompt.sections.config_profile);
let parsed: BcodeConfig = toml::from_str("[system_prompt.sections]\n").expect("config");
assert!(parsed.system_prompt.sections.current_datetime);
assert!(parsed.system_prompt.sections.execution_environment);
assert!(parsed.system_prompt.sections.hosting_environment);
assert!(parsed.system_prompt.sections.locale);
assert!(parsed.system_prompt.sections.git_revision);
assert!(parsed.system_prompt.sections.runtime_mode);
assert!(!parsed.system_prompt.sections.toolchain_context);
assert!(parsed.system_prompt.sections.worktree_context);
assert!(parsed.system_prompt.sections.config_profile);
}
#[test]
fn resolves_named_model_profile_without_active_profile_environment() {
let config: BcodeConfig = toml::from_str(
r#"
[model.profiles.fast]
provider_plugin_id = "bcode.fast"
model_id = "fast-model"
auth_profile = "fast-auth"
"#,
)
.expect("profile config");
let selection = config.resolved_model_profile("fast").expect("selection");
assert_eq!(selection.provider_plugin_id.as_deref(), Some("bcode.fast"));
assert_eq!(selection.model_id.as_deref(), Some("fast-model"));
assert_eq!(selection.auth_profile.as_deref(), Some("fast-auth"));
assert_eq!(selection.model_profile.as_deref(), Some("fast"));
assert!(config.resolved_model_profile("missing").is_none());
}
#[test]
fn invariant_guidance_defaults_to_enabled_full_mode() {
let config = InvariantsConfig::default();
assert!(config.enabled);
assert_eq!(config.mode, InvariantGuidanceMode::Full);
assert_eq!(config.max_selected.get(), 6);
assert!(config.selector.initial_timeout_ms.is_none());
assert!(config.selector.background_timeout_ms.is_none());
assert!(!config.selector.cancel_on_timeout);
assert!(!config.selector.cancel_stale);
}
#[test]
fn invariant_kill_switch_serializes_without_selector_settings() {
let mut config = BcodeConfig::default();
config.invariants.enabled = false;
let rendered = super::config_to_toml(&config);
assert!(
rendered.contains("[invariants]\nenabled = false"),
"{rendered}"
);
assert!(!rendered.contains("[invariants.selector]"), "{rendered}");
}
#[test]
fn invariant_guidance_config_round_trips() {
let config: BcodeConfig = toml::from_str(
r#"
[invariants]
enabled = true
mode = "relevant"
max_selected = 4
include_task_summary = false
[invariants.selector]
model_profile = "fast"
initial_timeout_ms = 2000
background_timeout_ms = 10000
cancel_on_timeout = true
cancel_stale = true
wait_for_background_on_next_prompt = true
on_timeout = "deterministic"
"#,
)
.expect("invariant config parses");
let rendered = super::config_to_toml(&config);
assert!(rendered.contains("[invariants]"), "{rendered}");
assert!(rendered.contains("mode = \"relevant\""), "{rendered}");
assert!(rendered.contains("[invariants.selector]"), "{rendered}");
let parsed: BcodeConfig = toml::from_str(&rendered).expect("rendered config parses");
assert_eq!(parsed.invariants.mode, InvariantGuidanceMode::Relevant);
assert_eq!(parsed.invariants.max_selected.get(), 4);
assert_eq!(
parsed.invariants.selector.model_profile.as_deref(),
Some("fast")
);
assert!(parsed.invariants.selector.cancel_on_timeout);
assert!(parsed.invariants.selector.cancel_stale);
assert!(
parsed
.invariants
.selector
.wait_for_background_on_next_prompt
);
assert_eq!(
parsed.invariants.selector.on_timeout,
InvariantSelectorTimeoutPolicy::Deterministic
);
}
#[test]
fn zero_optional_prompt_limits_are_rejected() {
assert!(
toml::from_str::<BcodeConfig>(
"[system_prompt]\nrepository_instructions_max_chars = 0\n"
)
.is_err()
);
assert!(toml::from_str::<BcodeConfig>("[skills]\nmax_context_bytes = 0\n").is_err());
assert!(toml::from_str::<BcodeConfig>("[skills.prompt]\nmax_bytes = 0\n").is_err());
assert!(
toml::from_str::<BcodeConfig>("[system_prompt]\ngit_status_max_chars = 0\n").is_err()
);
assert!(toml::from_str::<BcodeConfig>("[skills]\nmax_skill_file_bytes = 0\n").is_err());
assert!(toml::from_str::<BcodeConfig>("[skills]\npreview_max_chars = 0\n").is_err());
assert!(
toml::from_str::<BcodeConfig>("[model.tool_output]\nfallback_argument_chars = 0\n")
.is_err()
);
}
#[test]
fn workflow_admission_policy_round_trips_and_rejects_zero() {
let mut config = BcodeConfig::default();
config.workflows.admission_timeout_ms = std::num::NonZeroU64::new(1234).unwrap();
config.workflows.continuation_workers = std::num::NonZeroUsize::new(3).unwrap();
let decoded: BcodeConfig = toml::from_str(&super::config_to_toml(&config)).unwrap();
assert_eq!(decoded.workflows, config.workflows);
for field in ["admission_timeout_ms", "continuation_workers"] {
assert!(toml::from_str::<BcodeConfig>(&format!("[workflows]\n{field} = 0")).is_err());
}
}
#[test]
fn config_to_toml_writes_client_request_timeout() {
let mut config = BcodeConfig::default();
config.client.request_timeout_secs = 60;
let rendered = super::config_to_toml(&config);
assert!(rendered.contains("[client]"), "{rendered}");
assert!(rendered.contains("request_timeout_secs = 60"), "{rendered}");
}
#[test]
fn state_config_round_trips_and_is_documented() {
let mut config = BcodeConfig::default();
config.state.root = Some(PathBuf::from("/volumes/big/bcode-state"));
config.state.sessions_root = Some(PathBuf::from("/volumes/big/bcode-sessions"));
config.state.default_profile = Some("big".to_owned());
config.state.readable_profiles = vec!["big".to_owned(), "local".to_owned()];
config.state.profile.insert(
"big".to_owned(),
super::StateProfileConfig {
root: Some(PathBuf::from("/volumes/big/bcode-state")),
sessions_root: None,
},
);
let rendered = super::config_to_toml(&config);
let decoded: BcodeConfig = toml::from_str(&rendered).expect("decode rendered config");
assert_eq!(decoded.state, config.state, "{rendered}");
assert!(rendered.contains("[state]"), "{rendered}");
assert!(rendered.contains("[state.profile.big]"), "{rendered}");
assert!(
rendered.contains("readable_profiles = [\"big\", \"local\"]"),
"{rendered}"
);
assert!(
BcodeConfig::field_docs()
.iter()
.any(|field| field.toml_key == "state"),
"state section must be documented"
);
}
fn state_environment(root: &std::path::Path) -> ConfigEnvironmentSnapshot {
let mut environment = ConfigEnvironmentSnapshot::isolated(root);
environment.set_var("HOME", root.join("home").into_os_string());
environment
}
#[test]
fn cli_root_outranks_env_and_config_selection() {
let temp = tempfile::tempdir().expect("temporary directory");
let cli_root = temp.path().join("cli");
let env_root = temp.path().join("env");
let config_root = temp.path().join("config-root");
std::fs::create_dir_all(&cli_root).expect("cli root");
std::fs::create_dir_all(&env_root).expect("env root");
std::fs::create_dir_all(&config_root).expect("config root");
let mut environment = state_environment(temp.path());
environment.set_var(super::BCODE_STATE_DIR_ENV, env_root.as_os_str());
let config = super::StateConfig {
root: Some(config_root),
..super::StateConfig::default()
};
let selection = super::StateLocationSelection {
root: Some(cli_root.clone()),
profile: None,
};
let resolved =
super::resolve_state_location_set_with_environment(&config, &selection, &environment)
.expect("cli selection resolves");
assert_eq!(
resolved.primary().root(),
std::fs::canonicalize(&cli_root)
.expect("canonical cli root")
.as_path()
);
assert_eq!(
resolved.primary().provenance(),
super::StateLocationProvenance::Cli
);
assert_eq!(
resolved.primary().sessions_root(),
std::fs::canonicalize(&cli_root)
.expect("canonical cli root")
.join("sessions")
);
}
#[test]
fn env_state_dir_outranks_config_root() {
let temp = tempfile::tempdir().expect("temporary directory");
let env_root = temp.path().join("env");
let config_root = temp.path().join("config-root");
std::fs::create_dir_all(&env_root).expect("env root");
std::fs::create_dir_all(&config_root).expect("config root");
let mut environment = state_environment(temp.path());
environment.set_var(super::BCODE_STATE_DIR_ENV, env_root.as_os_str());
let config = super::StateConfig {
root: Some(config_root),
..super::StateConfig::default()
};
let resolved = super::resolve_state_location_set_with_environment(
&config,
&super::StateLocationSelection::default(),
&environment,
)
.expect("env selection resolves");
assert_eq!(
resolved.primary().root(),
std::fs::canonicalize(&env_root)
.expect("canonical env root")
.as_path()
);
assert_eq!(
resolved.primary().provenance(),
super::StateLocationProvenance::Env
);
}
#[test]
fn session_store_env_overrides_sessions_root() {
let temp = tempfile::tempdir().expect("temporary directory");
let root = temp.path().join("state");
let sessions = temp.path().join("external-sessions");
std::fs::create_dir_all(&root).expect("state root");
std::fs::create_dir_all(&sessions).expect("sessions root");
let mut environment = state_environment(temp.path());
environment.set_var(super::BCODE_STATE_DIR_ENV, root.as_os_str());
environment.set_var(super::BCODE_SESSION_STORE_DIR_ENV, sessions.clone());
let resolved = super::resolve_state_location_set_with_environment(
&super::StateConfig::default(),
&super::StateLocationSelection::default(),
&environment,
)
.expect("resolution succeeds");
assert_eq!(
resolved.primary().sessions_root(),
std::fs::canonicalize(&sessions)
.expect("canonical sessions root")
.as_path()
);
assert_eq!(
super::default_session_store_dir_with_environment(&environment),
sessions
);
}
#[test]
fn named_profile_selection_resolves_declared_root() {
let temp = tempfile::tempdir().expect("temporary directory");
let big_root = temp.path().join("big");
std::fs::create_dir_all(&big_root).expect("profile root");
let environment = state_environment(temp.path());
let mut config = super::StateConfig::default();
config.profile.insert(
"big".to_owned(),
super::StateProfileConfig {
root: Some(big_root.clone()),
sessions_root: None,
},
);
let selection = super::StateLocationSelection {
root: None,
profile: Some("big".to_owned()),
};
let resolved =
super::resolve_state_location_set_with_environment(&config, &selection, &environment)
.expect("profile resolves");
assert_eq!(resolved.primary().profile(), Some("big"));
assert_eq!(
resolved.primary().root(),
std::fs::canonicalize(&big_root)
.expect("canonical profile root")
.as_path()
);
}
#[test]
fn readable_profiles_aggregate_without_duplicating_primary() {
let temp = tempfile::tempdir().expect("temporary directory");
let primary_root = temp.path().join("primary");
let other_root = temp.path().join("other");
std::fs::create_dir_all(&primary_root).expect("primary root");
std::fs::create_dir_all(&other_root).expect("other root");
let environment = state_environment(temp.path());
let mut config = super::StateConfig {
default_profile: Some("primary".to_owned()),
readable_profiles: vec!["primary".to_owned(), "other".to_owned()],
..super::StateConfig::default()
};
config.profile.insert(
"primary".to_owned(),
super::StateProfileConfig {
root: Some(primary_root),
sessions_root: None,
},
);
config.profile.insert(
"other".to_owned(),
super::StateProfileConfig {
root: Some(other_root),
sessions_root: None,
},
);
let resolved = super::resolve_state_location_set_with_environment(
&config,
&super::StateLocationSelection::default(),
&environment,
)
.expect("aggregate resolves");
assert_eq!(resolved.readable().len(), 2);
assert_eq!(resolved.readable()[0].id(), resolved.primary().id());
assert_ne!(resolved.readable()[1].id(), resolved.primary().id());
}
#[test]
fn runtime_scope_identity_separates_state_roots_and_config_directories() {
let temp = tempfile::tempdir().expect("temporary directory");
let state_a = temp.path().join("state-a");
let state_b = temp.path().join("state-b");
let config_a = temp.path().join("config-a");
let config_b = temp.path().join("config-b");
for path in [&state_a, &state_b, &config_a, &config_b] {
std::fs::create_dir_all(path).expect("scope directory");
}
let baseline = super::runtime_scope_id(&state_a, &config_a);
assert_eq!(
baseline,
super::runtime_scope_id(&state_a, &config_a),
"identity must be deterministic for one scope"
);
assert_ne!(
baseline,
super::runtime_scope_id(&state_b, &config_a),
"a different state root must produce a different daemon scope"
);
assert_ne!(
baseline,
super::runtime_scope_id(&state_a, &config_b),
"a different config directory must produce a different daemon scope, \
because config selects plugins and permission policy"
);
assert_ne!(
super::runtime_scope_id(&state_a, &config_b),
super::runtime_scope_id(&state_b, &config_a),
"the two scope dimensions must not collide with each other"
);
}
#[test]
fn runtime_scope_identity_is_stable_across_equivalent_paths() {
let temp = tempfile::tempdir().expect("temporary directory");
let state = temp.path().join("state");
let config = temp.path().join("config");
std::fs::create_dir_all(&state).expect("state root");
std::fs::create_dir_all(&config).expect("config dir");
let direct = super::runtime_scope_id(&state, &config);
let indirect = super::runtime_scope_id(&temp.path().join("state").join("."), &config);
assert_eq!(
direct, indirect,
"two clients naming the same directory must agree on the scope identity"
);
}
#[test]
fn runtime_scope_identity_is_infallible_for_absent_paths() {
let temp = tempfile::tempdir().expect("temporary directory");
let absent_state = temp.path().join("unmounted").join("state");
let absent_config = temp.path().join("unmounted").join("config");
let first = super::runtime_scope_id(&absent_state, &absent_config);
let second = super::runtime_scope_id(&absent_state, &absent_config);
assert_eq!(first, second);
assert_eq!(first.len(), 32);
assert!(
!absent_state.exists(),
"identity must not create directories"
);
}
#[test]
fn explicit_location_preserves_default_session_root() {
const CHILD: &str = "BCODE_TEST_EXPLICIT_SESSION_ROOT_CHILD";
if std::env::var_os(CHILD).is_none() {
let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
.args([
"--exact",
"tests::explicit_location_preserves_default_session_root",
])
.env(CHILD, "1")
.status()
.expect("isolated test process");
assert!(status.success());
return;
}
let temp = tempfile::tempdir().expect("temporary directory");
let mut environment = state_environment(temp.path());
let locations = super::resolve_state_location_set_with_environment(
&super::StateConfig::default(),
&super::StateLocationSelection {
root: Some(temp.path().join("selected")),
profile: None,
},
&environment,
)
.expect("resolved location");
let selected = locations.primary().sessions_root().to_path_buf();
environment.set_var(
super::BCODE_SESSION_STORE_DIR_ENV,
temp.path().join("foreign"),
);
let guard = super::push_process_state_location(locations.primary());
assert_eq!(
super::default_session_store_dir_with_environment(&environment),
selected
);
drop(guard);
assert_eq!(
super::default_session_store_dir_with_environment(&environment),
temp.path().join("foreign")
);
}
#[test]
fn location_identity_derives_only_from_canonical_root() {
let temp = tempfile::tempdir().expect("temporary directory");
let root = temp.path().join("shared");
std::fs::create_dir_all(&root).expect("shared root");
let canonical = std::fs::canonicalize(&root).expect("canonical root");
let environment = state_environment(temp.path());
let via_cli = super::resolve_state_location_set_with_environment(
&super::StateConfig::default(),
&super::StateLocationSelection {
root: Some(root.clone()),
profile: None,
},
&environment,
)
.expect("cli resolves");
let mut env_environment = state_environment(temp.path());
env_environment.set_var(super::BCODE_STATE_DIR_ENV, root);
let via_env = super::resolve_state_location_set_with_environment(
&super::StateConfig::default(),
&super::StateLocationSelection::default(),
&env_environment,
)
.expect("env resolves");
assert_eq!(via_cli.primary().id(), via_env.primary().id());
assert_eq!(
via_cli.primary().id(),
&super::StateLocationId::from_canonical_root(&canonical)
);
assert_ne!(
via_cli.primary().provenance(),
via_env.primary().provenance()
);
}
#[test]
fn relative_root_is_rejected_without_fallback() {
let temp = tempfile::tempdir().expect("temporary directory");
let environment = state_environment(temp.path());
let error = super::resolve_state_location_set_with_environment(
&super::StateConfig::default(),
&super::StateLocationSelection {
root: Some(PathBuf::from("relative/state")),
profile: None,
},
&environment,
)
.expect_err("relative root must be rejected");
assert!(
matches!(error, super::StateLocationError::NotAbsolute { .. }),
"{error}"
);
}
#[test]
fn unknown_profile_is_rejected_without_fallback() {
let temp = tempfile::tempdir().expect("temporary directory");
let environment = state_environment(temp.path());
let error = super::resolve_state_location_set_with_environment(
&super::StateConfig::default(),
&super::StateLocationSelection {
root: None,
profile: Some("missing".to_owned()),
},
&environment,
)
.expect_err("unknown profile must be rejected");
assert!(
matches!(error, super::StateLocationError::UnknownProfile { .. }),
"{error}"
);
}
#[test]
fn file_at_state_root_is_rejected_as_not_a_directory() {
let temp = tempfile::tempdir().expect("temporary directory");
let file = temp.path().join("not-a-dir");
std::fs::write(&file, b"content").expect("write file");
let environment = state_environment(temp.path());
let error = super::resolve_state_location_set_with_environment(
&super::StateConfig::default(),
&super::StateLocationSelection {
root: Some(file),
profile: None,
},
&environment,
)
.expect_err("file root must be rejected");
assert!(
matches!(
error,
super::StateLocationError::Unavailable { .. }
| super::StateLocationError::NotADirectory { .. }
),
"{error}"
);
}
#[test]
fn declared_profile_without_root_is_rejected() {
let temp = tempfile::tempdir().expect("temporary directory");
let environment = state_environment(temp.path());
let mut config = super::StateConfig::default();
config
.profile
.insert("empty".to_owned(), super::StateProfileConfig::default());
let error = super::resolve_state_location_set_with_environment(
&config,
&super::StateLocationSelection {
root: None,
profile: Some("empty".to_owned()),
},
&environment,
)
.expect_err("profile without root must be rejected");
assert!(
matches!(error, super::StateLocationError::ProfileMissingRoot { .. }),
"{error}"
);
}
#[test]
fn resolves_active_model_profile() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[model]
profile = "bedrock-work"
[model.profiles.bedrock-work]
provider_plugin_id = "bcode.bedrock"
model_id = "anthropic.claude-3-5-sonnet-20241022-v2:0"
auth_profile = "aws-work"
[model.profiles.bedrock-work.settings]
region = "us-east-1"
[auth.profiles.aws-work]
backend = "aws_default_chain"
[auth.profiles.aws-work.settings]
profile = "work"
"#,
)
.expect("profile config should parse");
let selection = config.resolved_model_selection();
assert_eq!(
selection.provider_plugin_id,
Some("bcode.bedrock".to_string())
);
assert_eq!(
selection.model_id,
Some("anthropic.claude-3-5-sonnet-20241022-v2:0".to_string())
);
assert_eq!(selection.auth_profile, Some("aws-work".to_string()));
assert_eq!(
selection.settings.get("region"),
Some(&"us-east-1".to_string())
);
restore_provider_env(previous_env);
}
#[test]
fn resolves_top_level_model_auth_and_settings_without_a_profile() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[model]
provider_plugin_id = "bcode.openai-compatible"
model_id = "gpt-5.6-sol"
auth_profile = "openai"
auth_pool = "openai"
[model.settings]
dialect = "chatgpt_codex"
[auth.profiles.openai]
backend = "sshenv"
scheme = "chatgpt"
"#,
)
.expect("top-level model auth config should parse");
let selection = config.resolved_model_selection();
assert_eq!(selection.auth_profile.as_deref(), Some("openai"));
assert_eq!(selection.auth_pool.as_deref(), Some("openai"));
assert_eq!(
selection.settings.get("dialect").map(String::as_str),
Some("chatgpt_codex")
);
restore_provider_env(previous_env);
}
#[test]
fn active_model_profile_overrides_top_level_model_auth_and_merges_settings() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[model]
profile = "work"
auth_profile = "personal"
auth_pool = "personal-pool"
[model.settings]
dialect = "responses_api"
request_timeout_secs = "30"
[model.profiles.work]
provider_plugin_id = "bcode.openai-compatible"
auth_profile = "work"
[model.profiles.work.settings]
dialect = "chatgpt_codex"
"#,
)
.expect("layered model auth config should parse");
let selection = config.resolved_model_selection();
assert_eq!(selection.auth_profile.as_deref(), Some("work"));
assert_eq!(selection.auth_pool.as_deref(), Some("personal-pool"));
assert_eq!(
selection.settings.get("dialect").map(String::as_str),
Some("chatgpt_codex"),
"profile settings win over top-level settings"
);
assert_eq!(
selection
.settings
.get("request_timeout_secs")
.map(String::as_str),
Some("30"),
"top-level settings not shadowed by the profile are retained"
);
restore_provider_env(previous_env);
}
#[test]
fn unknown_model_keys_are_rejected_instead_of_silently_dropped() {
let error = toml::from_str::<BcodeConfig>(
r#"
[model]
provider_plugin_id = "bcode.openai-compatible"
auth_profil = "openai"
"#,
)
.expect_err("misspelled [model] key must not be accepted");
let message = error.to_string();
assert!(
message.contains("auth_profil"),
"error must name the offending key: {message}"
);
}
#[test]
fn resolves_model_alias_request_options() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[model]
profile = "openai-fast"
[model.profiles.openai-fast]
provider_plugin_id = "bcode.openai-compatible"
model_id = "gpt-5.5-fast"
[model.profiles.openai-fast.request]
custom_boolean = true
[model.aliases."gpt-5.5-fast"]
provider_plugin_id = "bcode.openai-compatible"
model_id = "gpt-5.5"
[model.aliases."gpt-5.5-fast".request]
service_tier = "priority"
custom_boolean = false
"#,
)
.expect("alias config should parse");
let selection = config.resolved_model_selection();
assert_eq!(selection.selected_model_id.as_deref(), Some("gpt-5.5-fast"));
assert_eq!(selection.model_id.as_deref(), Some("gpt-5.5"));
assert_eq!(
selection.provider_source,
Some(crate::ModelSelectionSource::Alias {
name: "gpt-5.5-fast".to_string()
})
);
assert_eq!(
selection.model_source,
Some(crate::ModelSelectionSource::Alias {
name: "gpt-5.5-fast".to_string()
})
);
assert_eq!(
selection.request.get("service_tier"),
Some(&bcode_model::ProviderRequestValue::from(serde_json::json!(
"priority"
)))
);
assert_eq!(
selection.request.get("custom_boolean"),
Some(&bcode_model::ProviderRequestValue::from(serde_json::json!(
true
))),
"profile request options override alias defaults"
);
restore_provider_env(previous_env);
}
#[test]
fn parses_context_strategy_mode() {
let config: BcodeConfig = toml::from_str(
r#"
[model.context_strategy]
mode = "explicit_cached_transcript"
"#,
)
.expect("config should parse");
assert_eq!(
config.model.context_strategy.mode,
ContextStrategyMode::ExplicitCachedTranscript
);
assert_eq!(
config.model.effective_prompt_cache_mode(),
bcode_model::PromptCacheMode::Aggressive
);
assert_eq!(
config.model.effective_conversation_reuse_mode(),
bcode_model::ConversationReuseMode::Off
);
}
#[test]
fn parses_prompt_cache_mode() {
let config: BcodeConfig = toml::from_str(
r#"
[model.prompt_cache]
mode = "off"
"#,
)
.expect("config should parse");
assert_eq!(
config.model.prompt_cache.mode,
bcode_model::PromptCacheMode::Off
);
}
#[test]
fn defaults_conversation_reuse_to_auto() {
let config = BcodeConfig::default();
assert_eq!(
config.model.conversation_reuse.mode,
bcode_model::ConversationReuseMode::Auto
);
}
#[test]
fn parses_conversation_reuse_mode() {
let config: BcodeConfig = toml::from_str(
r#"
[model.conversation_reuse]
mode = "off"
"#,
)
.expect("config should parse");
assert_eq!(
config.model.conversation_reuse.mode,
bcode_model::ConversationReuseMode::Off
);
}
#[test]
fn max_tool_rounds_zero_means_unlimited() {
let config: BcodeConfig = toml::from_str(
r"
[model]
max_tool_rounds = 0
",
)
.expect("config should parse");
assert_eq!(config.model.effective_max_tool_rounds(), None);
}
#[test]
fn positive_max_tool_rounds_is_effective() {
let config: BcodeConfig = toml::from_str(
r"
[model]
max_tool_rounds = 3
",
)
.expect("config should parse");
assert_eq!(config.model.effective_max_tool_rounds(), Some(3));
}
#[test]
fn default_model_limits_are_unlimited_and_context_policies_enabled() {
let config = BcodeConfig::default();
assert_eq!(config.model.effective_max_tool_rounds(), None);
assert_eq!(config.model.tool_output.context_chars, 4_000);
assert_eq!(config.model.compaction.mode, CompactionMode::Auto);
assert_eq!(config.model.compaction.backend, CompactionBackend::Auto);
assert_eq!(config.model.compaction.proactive_threshold_percent, 90);
assert_eq!(config.model.compaction.keep_recent_tokens, 20_000);
assert_eq!(config.model.compaction.context_chars, 0);
}
#[test]
fn resolves_model_specific_compaction_policy() {
let config: BcodeConfig = toml::from_str(
r#"
[model.compaction]
mode = "proactive"
proactive_threshold_percent = 90
[model.compaction.models."gpt-5.6-sol"]
provider_plugin_id = "bcode.amazon-bedrock"
mode = "proactive_and_overflow"
proactive_threshold_tokens = 272000
keep_recent_tokens = 16000
"#,
)
.expect("config should parse");
let matched = config
.model
.compaction
.effective_for(Some("bcode.amazon-bedrock"), Some("gpt-5.6-sol"));
assert_eq!(matched.mode, CompactionMode::ProactiveAndOverflow);
assert_eq!(
matched.proactive_threshold,
ProactiveCompactionThreshold::Tokens(272_000)
);
assert_eq!(matched.keep_recent_tokens, 16_000);
let provider_mismatch = config
.model
.compaction
.effective_for(Some("other-provider"), Some("gpt-5.6-sol"));
assert_eq!(provider_mismatch.mode, CompactionMode::Proactive);
assert_eq!(
provider_mismatch.proactive_threshold,
ProactiveCompactionThreshold::Percent(90)
);
}
#[test]
fn rejects_invalid_model_compaction_threshold() {
let value: toml::Value = toml::from_str(
r"
[model.compaction.models.test]
proactive_threshold_tokens = 0
",
)
.expect("TOML should parse");
let error = super::validate_config_value(value, "test config")
.expect_err("zero threshold should be rejected");
assert!(error.to_string().contains("must be greater than zero"));
}
#[test]
fn auto_compaction_mode_is_overflow_only_for_host_policy() {
assert!(!CompactionMode::Auto.is_proactive_enabled());
assert!(CompactionMode::Auto.is_overflow_recovery_enabled());
assert!(CompactionMode::Proactive.is_proactive_enabled());
assert!(!CompactionMode::Proactive.is_overflow_recovery_enabled());
assert!(CompactionMode::ProactiveAndOverflow.is_proactive_enabled());
assert!(CompactionMode::ProactiveAndOverflow.is_overflow_recovery_enabled());
}
#[test]
fn parses_tool_output_context_limit() {
let config: BcodeConfig = toml::from_str(
r"
[model.tool_output]
context_chars = 1200
",
)
.expect("config should parse");
assert_eq!(config.model.tool_output.context_chars, 1_200);
}
#[test]
fn parses_auto_compaction_config() {
let config: BcodeConfig = toml::from_str(
r#"
[model.compaction]
mode = "off"
backend = "local"
proactive_threshold_percent = 85
keep_recent_tokens = 24000
context_chars = 90000
"#,
)
.expect("config should parse");
assert_eq!(config.model.compaction.mode, CompactionMode::Off);
assert_eq!(config.model.compaction.backend, CompactionBackend::Local);
assert_eq!(config.model.compaction.proactive_threshold_percent, 85);
assert_eq!(config.model.compaction.keep_recent_tokens, 24_000);
assert_eq!(config.model.compaction.context_chars, 90_000);
}
#[test]
fn parses_new_compaction_modes() {
for (mode, expected) in [
("on_overflow", CompactionMode::OnOverflow),
("proactive", CompactionMode::Proactive),
(
"proactive_and_overflow",
CompactionMode::ProactiveAndOverflow,
),
("auto", CompactionMode::Auto),
] {
let config: BcodeConfig = toml::from_str(&format!(
r#"
[model.compaction]
mode = "{mode}"
"#
))
.expect("config should parse");
assert_eq!(config.model.compaction.mode, expected);
}
}
#[test]
fn default_plugin_selection_includes_default_core_plugins() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config = BcodeConfig::default();
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert_default_core_plugins_enabled(&plugin_selection);
restore_provider_env(previous_env);
}
#[test]
fn explicit_plugin_selection_still_includes_default_core_plugins() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
enabled = ["bcode.openai-compatible"]
"#,
)
.expect("config should parse");
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert!(plugin_selection.enabled.contains("bcode.openai-compatible"));
assert_default_core_plugins_enabled(&plugin_selection);
restore_provider_env(previous_env);
}
#[test]
fn default_agent_profiles_can_be_disabled() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
disabled = ["bcode.default-agents"]
"#,
)
.expect("config should parse");
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert!(
!plugin_selection
.enabled
.contains(TEST_AGENT_PROFILE_PLUGIN_ID)
);
assert!(plugin_selection.enabled.contains(TEST_FILESYSTEM_PLUGIN_ID));
assert!(plugin_selection.enabled.contains(TEST_SHELL_PLUGIN_ID));
restore_provider_env(previous_env);
}
#[test]
fn default_code_review_can_be_disabled() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
disabled = ["bcode.code_review"]
"#,
)
.expect("config should parse");
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert!(
!plugin_selection
.enabled
.contains(TEST_CODE_REVIEW_PLUGIN_ID)
);
assert!(plugin_selection.enabled.contains(TEST_FILESYSTEM_PLUGIN_ID));
restore_provider_env(previous_env);
}
#[test]
fn default_pi_session_import_can_be_disabled() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
disabled = ["bcode.pi-session-import"]
"#,
)
.expect("config should parse");
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert!(
!plugin_selection
.enabled
.contains(TEST_PI_SESSION_IMPORT_PLUGIN_ID)
);
assert!(plugin_selection.enabled.contains(TEST_FILESYSTEM_PLUGIN_ID));
restore_provider_env(previous_env);
}
#[test]
fn default_tool_plugins_can_be_disabled() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
enabled = ["bcode.openai-compatible"]
disabled = ["bcode.example-shell"]
"#,
)
.expect("config should parse");
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert!(plugin_selection.enabled.contains(TEST_FILESYSTEM_PLUGIN_ID));
assert!(
plugin_selection
.enabled
.contains(TEST_AGENT_PROFILE_PLUGIN_ID)
);
assert!(!plugin_selection.enabled.contains(TEST_SHELL_PLUGIN_ID));
restore_provider_env(previous_env);
}
#[test]
fn bedrock_env_overrides_persisted_openai_provider() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
unsafe {
std::env::set_var("AWS_BEARER_TOKEN_BEDROCK", "test-token");
}
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
enabled = ["bcode.openai-compatible"]
[model]
provider_plugin_id = "bcode.openai-compatible"
model_id = "gpt-4.1-mini"
"#,
)
.expect("config should parse");
let selection = config.resolved_model_selection();
assert_eq!(
selection.provider_plugin_id,
Some("bcode.bedrock".to_string())
);
assert_eq!(selection.model_id, None);
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert!(plugin_selection.enabled.contains("bcode.bedrock"));
assert!(plugin_selection.enabled.contains("bcode.openai-compatible"));
assert_default_core_plugins_enabled(&plugin_selection);
restore_provider_env(previous_env);
}
#[test]
fn openai_env_overrides_persisted_bedrock_provider() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
unsafe {
std::env::set_var("OPENAI_API_KEY", "test-key");
}
let config: BcodeConfig = toml::from_str(
r#"
[plugins]
enabled = ["bcode.bedrock"]
[model]
provider_plugin_id = "bcode.bedrock"
model_id = "anthropic.claude-test"
"#,
)
.expect("config should parse");
let selection = config.resolved_model_selection();
assert_eq!(
selection.provider_plugin_id,
Some("bcode.openai-compatible".to_string())
);
assert_eq!(selection.model_id, None);
let plugin_selection =
plugin_selection_with_default_plugin_ids(&config, TEST_DEFAULT_CORE_PLUGIN_IDS);
assert!(plugin_selection.enabled.contains("bcode.openai-compatible"));
assert!(plugin_selection.enabled.contains("bcode.bedrock"));
assert_default_core_plugins_enabled(&plugin_selection);
restore_provider_env(previous_env);
}
#[test]
fn explicit_provider_env_wins_over_provider_specific_env() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
unsafe {
std::env::set_var("BCODE_PROVIDER", "bedrock");
std::env::set_var("OPENAI_API_KEY", "test-key");
}
let config = BcodeConfig::default();
let selection = config.resolved_model_selection();
assert_eq!(
selection.provider_plugin_id,
Some("bcode.bedrock".to_string())
);
assert_eq!(selection.model_id, None);
restore_provider_env(previous_env);
}
#[test]
fn provider_env_model_overrides_same_provider_config_model() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
unsafe {
std::env::set_var("BCODE_OPENAI_MODEL", "env-model");
}
let config: BcodeConfig = toml::from_str(
r#"
[model]
provider_plugin_id = "bcode.openai-compatible"
model_id = "config-model"
"#,
)
.expect("config should parse");
let selection = config.resolved_model_selection();
assert_eq!(
selection.provider_plugin_id,
Some("bcode.openai-compatible".to_string())
);
assert_eq!(selection.model_id, Some("env-model".to_string()));
restore_provider_env(previous_env);
}
#[test]
fn same_provider_config_model_survives_without_env_model() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
unsafe {
std::env::set_var("OPENAI_API_KEY", "test-key");
}
let config: BcodeConfig = toml::from_str(
r#"
[model]
provider_plugin_id = "bcode.openai-compatible"
model_id = "config-model"
"#,
)
.expect("config should parse");
let selection = config.resolved_model_selection();
assert_eq!(
selection.provider_plugin_id,
Some("bcode.openai-compatible".to_string())
);
assert_eq!(selection.model_id, Some("config-model".to_string()));
restore_provider_env(previous_env);
}
#[test]
fn config_auth_selects_openai_provider_when_model_provider_is_omitted() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let previous_env = clear_provider_env();
let config: BcodeConfig = toml::from_str(
r#"
[auth.openai]
backend = "sshenv"
profile = "openai-max"
mode = "chatgpt"
"#,
)
.expect("config should parse");
let selection = config.resolved_model_selection();
assert_eq!(
selection.provider_plugin_id,
Some("bcode.openai-compatible".to_string())
);
assert_eq!(
selection.provider_source,
Some(crate::ModelSelectionSource::AuthConfig)
);
restore_provider_env(previous_env);
}
#[test]
fn upsert_permission_rule_writes_state_file_only() {
let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("temp root should be created");
let state_path = root.join("permissions.toml");
let config_path = root.join("bcode.toml");
std::fs::write(
&config_path,
r#"[agent.build.permission]
command = { "cargo *" = "allow" }
"#,
)
.expect("declarative config should be written");
let previous_state = std::env::var_os("BCODE_PERMISSIONS_STATE");
let previous_config = std::env::var_os("BCODE_CONFIG");
unsafe {
std::env::set_var("BCODE_PERMISSIONS_STATE", &state_path);
std::env::set_var("BCODE_CONFIG", &config_path);
}
let written =
upsert_agent_permission_rule("build", "command", "echo hello".to_string(), "allow")
.expect("state write should succeed");
assert_eq!(written, state_path);
assert_eq!(default_permissions_state_path(), state_path);
let declarative_after = std::fs::read_to_string(&config_path)
.expect("declarative config should still be readable");
assert!(
declarative_after.contains("cargo *"),
"declarative bcode.toml must not be rewritten by runtime rule upsert"
);
assert!(
!declarative_after.contains("echo hello"),
"runtime rule must not leak into declarative bcode.toml"
);
let state_after =
std::fs::read_to_string(&state_path).expect("state file should be written");
assert!(state_after.contains("echo hello"));
let loaded = load_permissions_state_from(&state_path).expect("state file should load");
assert_eq!(
loaded.get("build").and_then(|agent| agent
.permission
.command
.get("echo hello")
.copied()),
Some(Action::Allow)
);
restore_env("BCODE_PERMISSIONS_STATE", previous_state);
restore_env("BCODE_CONFIG", previous_config);
}
#[test]
fn raw_agent_config_merge_recurses_key_by_key() {
let mut base: toml::Value = toml::from_str(
r##"
[agent.build]
accent = "#22d3ee"
[agent.build.tools]
"example.read" = true
"example.write" = true
[agent.build.permission.command]
"cargo *" = "allow"
"git push *" = "deny"
"##,
)
.expect("base TOML should parse");
let overlay: toml::Value = toml::from_str(
r##"
[agent.build]
accent = "#6b7280"
[agent.build.tools]
"example.write" = false
"example.run" = true
[agent.build.permission.command]
"cargo *" = "deny"
"echo *" = "allow"
"##,
)
.expect("overlay TOML should parse");
merge_config_values(&mut base, overlay);
let build = base
.get("agent")
.and_then(toml::Value::as_table)
.and_then(|agents| agents.get("build"))
.and_then(toml::Value::as_table)
.expect("build agent table should exist");
let tools = build
.get("tools")
.and_then(toml::Value::as_table)
.expect("tools table should exist");
let command_rules = build
.get("permission")
.and_then(toml::Value::as_table)
.and_then(|permission| permission.get("command"))
.and_then(toml::Value::as_table)
.expect("command permission table should exist");
assert_eq!(
build.get("accent").and_then(toml::Value::as_str),
Some("#6b7280")
);
assert_eq!(
tools.get("example.read").and_then(toml::Value::as_bool),
Some(true)
);
assert_eq!(
tools.get("example.write").and_then(toml::Value::as_bool),
Some(false)
);
assert_eq!(
tools.get("example.run").and_then(toml::Value::as_bool),
Some(true)
);
assert_eq!(
command_rules.get("cargo *").and_then(toml::Value::as_str),
Some("deny")
);
assert_eq!(
command_rules
.get("git push *")
.and_then(toml::Value::as_str),
Some("deny")
);
assert_eq!(
command_rules.get("echo *").and_then(toml::Value::as_str),
Some("allow")
);
}
#[test]
fn raw_agent_config_merge_adds_state_only_agent() {
let mut base = toml::Value::Table(toml::Table::new());
let overlay: toml::Value = toml::from_str(
r##"
[agent.scratch]
accent = "#abcdef"
[agent.scratch.tools]
"example.run" = true
[agent.scratch.permission.command]
"*" = "ask"
"##,
)
.expect("overlay TOML should parse");
merge_config_values(&mut base, overlay);
let scratch = base
.get("agent")
.and_then(toml::Value::as_table)
.and_then(|agents| agents.get("scratch"))
.and_then(toml::Value::as_table)
.expect("scratch agent should be added");
assert_eq!(
scratch.get("accent").and_then(toml::Value::as_str),
Some("#abcdef")
);
assert_eq!(
scratch
.get("tools")
.and_then(toml::Value::as_table)
.and_then(|tools| tools.get("example.run"))
.and_then(toml::Value::as_bool),
Some(true)
);
assert_eq!(
scratch
.get("permission")
.and_then(toml::Value::as_table)
.and_then(|permission| permission.get("command"))
.and_then(toml::Value::as_table)
.and_then(|command| command.get("*"))
.and_then(toml::Value::as_str),
Some("ask")
);
}
#[test]
fn load_permissions_state_missing_file_returns_empty() {
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("temp root should be created");
let missing = root.join("nonexistent.toml");
let loaded = load_permissions_state_from(&missing).expect("missing file should be ok");
assert!(loaded.is_empty());
}
const TEST_PROVIDER_ENV_NAMES: &[&str] = &[
"BCODE_MODEL_PROVIDER",
"BCODE_PROVIDER",
"AWS_BEARER_TOKEN_BEDROCK",
"BCODE_BEDROCK_MODEL",
"BCODE_BEDROCK_MODELS",
"BCODE_BEDROCK_REGION",
"BCODE_BEDROCK_AWS_PROFILE",
"BCODE_BEDROCK_ENDPOINT_URL",
"BEDROCK_MODEL",
"BEDROCK_MODELS",
"BEDROCK_ENDPOINT_URL",
"BCODE_XAI_API_KEY",
"XAI_API_KEY",
"BCODE_XAI_MODEL",
"XAI_MODEL",
"BCODE_XAI_MODELS",
"XAI_MODELS",
"BCODE_XAI_BASE_URL",
"XAI_BASE_URL",
"BCODE_OPENAI_API_KEY",
"OPENAI_API_KEY",
"BCODE_OPENAI_MODEL",
"OPENAI_MODEL",
"BCODE_OPENAI_MODELS",
"OPENAI_MODELS",
"BCODE_OPENAI_BASE_URL",
"OPENAI_BASE_URL",
"BCODE_OPENAI_CODEX_ACCESS_TOKEN",
"BCODE_OPENAI_CODEX_REFRESH_TOKEN",
"BCODE_OPENAI_CODEX_ID_TOKEN",
];
fn clear_provider_env() -> Vec<(&'static str, Option<std::ffi::OsString>)> {
let previous = TEST_PROVIDER_ENV_NAMES
.iter()
.map(|name| (*name, std::env::var_os(name)))
.collect::<Vec<_>>();
unsafe {
for name in TEST_PROVIDER_ENV_NAMES {
std::env::remove_var(name);
}
}
previous
}
fn restore_provider_env(previous: Vec<(&'static str, Option<std::ffi::OsString>)>) {
for (name, value) in previous {
restore_env(name, value);
}
}
fn restore_env(name: &str, value: Option<std::ffi::OsString>) {
unsafe {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
}
#[test]
fn composition_profile_deep_merges_and_arrays_replace() {
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("temp root should be created");
let config_path = root.join("bcode.toml");
std::fs::write(
&config_path,
r#"
[composition]
active_profile = "dev"
[composition.profiles.base.patch.plugins]
enabled = ["base"]
[composition.profiles.base.patch.agent.build.tools]
"example.run" = false
"example.read" = true
[composition.profiles.dev]
extends = ["base"]
[composition.profiles.dev.patch.plugins]
enabled = ["dev"]
[composition.profiles.dev.patch.agent.build.tools]
"example.run" = true
[agent.build.permission]
read = { "**" = "allow" }
"#,
)
.expect("config should be written");
let config = load_config_from_paths(&[config_path]).expect("config should load");
assert_eq!(
config.plugins.enabled.iter().cloned().collect::<Vec<_>>(),
vec!["dev".to_string()],
"arrays replace rather than concatenate"
);
let build = config.agent.get("build").expect("build agent should exist");
assert_eq!(build.tools.get("example.run"), Some(&true));
assert_eq!(build.tools.get("example.read"), Some(&true));
assert_eq!(
build.permission.read.get("**"),
Some(&bcode_agent_policy_models::Action::Allow)
);
}
#[test]
fn composition_layer_order_can_make_profile_override_config() {
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("temp root should be created");
let config_path = root.join("bcode.toml");
std::fs::write(
&config_path,
r#"
[composition]
active_profile = "override"
layer_order = ["defaults", "config", "profile:active"]
[composition.profiles.override.patch.model]
max_tool_rounds = 9
[model]
max_tool_rounds = 3
"#,
)
.expect("config should be written");
let config = load_config_from_paths(&[config_path]).expect("config should load");
assert_eq!(config.model.max_tool_rounds, Some(9));
}
#[test]
fn composition_rejects_colliding_normalized_profile_names() {
let raw = toml::from_str(
r#"
[composition.profiles.alpha.patch.model]
model_id = "first"
[composition.profiles." alpha ".patch.model]
model_id = "second"
"#,
)
.expect("valid TOML");
assert!(super::resolve_composed_config_value(&raw).is_err());
}
#[test]
fn auth_environment_override_is_shared_and_empty_values_do_not_clear_selection() {
let mut config = BcodeConfig::default();
config.model.auth_profile = Some("configured-account".to_owned());
for (value, expected) in [
("override-account", "override-account"),
("", "configured-account"),
(" ", "configured-account"),
] {
let environment = super::ConfigEnvironmentSnapshot::new(
BTreeMap::from([(super::BCODE_AUTH_PROFILE_ENV.to_owned(), value.into())]),
PathBuf::from("."),
);
let selection = config.resolved_model_selection_with_environment(&environment);
assert_eq!(selection.auth_profile.as_deref(), Some(expected));
}
}
#[test]
fn missing_explicit_model_profile_never_uses_other_selection_credentials() {
let mut config = BcodeConfig::default();
config.model.profile = Some("requested-profile".to_owned());
config.model.provider_plugin_id = Some("other-provider".to_owned());
config.model.model_id = Some("other-model".to_owned());
config.model.auth_profile = Some("other-account".to_owned());
config.model.auth_pool = Some("other-pool".to_owned());
let environment = super::ConfigEnvironmentSnapshot::new(
BTreeMap::from([
(
"BCODE_MODEL_PROVIDER".to_owned(),
"environment-provider".into(),
),
("BCODE_MODEL".to_owned(), "environment-model".into()),
]),
PathBuf::from("."),
);
let selection = config.resolved_model_selection_with_environment(&environment);
assert_eq!(
selection.model_profile.as_deref(),
Some("requested-profile")
);
assert!(selection.provider_plugin_id.is_none());
assert!(selection.model_id.is_none());
assert!(selection.auth_profile.is_none());
assert!(selection.auth_pool.is_none());
assert!(selection.validate_selection().is_err());
}
#[test]
fn model_selection_requires_nonempty_provider_and_model() {
for provider in [None, Some(""), Some(" "), Some("provider")] {
for model in [None, Some(""), Some(" "), Some("model")] {
let selection = super::ResolvedModelSelection {
provider_plugin_id: provider.map(str::to_owned),
model_id: model.map(str::to_owned),
..super::ResolvedModelSelection::default()
};
assert_eq!(
selection.validate_selection().is_ok(),
provider == Some("provider") && model == Some("model")
);
}
}
}
#[test]
fn composition_rejects_unknown_profile_and_cycles() {
let unknown = toml::from_str(
r#"
[composition]
active_profile = "missing"
"#,
)
.expect("raw toml should parse");
assert!(super::resolve_composed_config_value(&unknown).is_err());
let cycle = toml::from_str(
r#"
[composition]
active_profile = "a"
[composition.profiles.a]
extends = ["b"]
[composition.profiles.b]
extends = ["a"]
"#,
)
.expect("raw toml should parse");
assert!(super::resolve_composed_config_value(&cycle).is_err());
}
#[test]
fn explicit_override_layers_apply_after_paths() {
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("temp root should be created");
let base = root.join("base.toml");
let user = root.join("user.toml");
let env_path = root.join("env.toml");
let cli_path = root.join("cli.toml");
std::fs::write(&base, "[model]\nmax_tool_rounds = 1\n").expect("base should be written");
std::fs::write(&user, "[model]\nmax_tool_rounds = 2\n").expect("user should be written");
std::fs::write(&env_path, "[model]\nmax_tool_rounds = 3\n").expect("env should be written");
std::fs::write(&cli_path, "[model]\nmax_tool_rounds = 5\n").expect("cli should be written");
let config = load_config_from_paths_with_overrides(
&[user],
&ConfigLoadOverrides {
exclusive_config_path: None,
base_config_path: Some(base),
env_config_path: Some(env_path),
env_config_toml: Some("[model]\nmax_tool_rounds = 4\n".to_string()),
cli_config_path: Some(cli_path),
cli_config_toml: Some("[model]\nmax_tool_rounds = 6\n".to_string()),
},
)
.expect("config should load");
assert_eq!(config.model.max_tool_rounds, Some(6));
}
#[test]
fn exclusive_config_ignores_untrusted_layers_and_requires_file() {
let root = unique_temp_dir();
std::fs::create_dir_all(&root).expect("directory");
let trusted = root.join("trusted.toml");
let untrusted = root.join("untrusted.toml");
std::fs::write(&trusted, "[model]\nmax_tool_rounds = 3\n").expect("trusted config");
std::fs::write(&untrusted, "invalid TOML !!!").expect("untrusted config");
let mut overrides = ConfigLoadOverrides {
exclusive_config_path: Some(trusted.clone()),
base_config_path: Some(untrusted.clone()),
cli_config_path: Some(untrusted.clone()),
env_config_path: Some(untrusted.clone()),
env_config_toml: Some("invalid TOML !!!".to_owned()),
..ConfigLoadOverrides::default()
};
let config = load_config_from_paths_with_overrides(&[untrusted], &overrides)
.expect("untrusted layers must not be parsed");
assert_eq!(config.model.max_tool_rounds, Some(3));
overrides.cli_config_toml = Some("[model]\nmax_tool_rounds = 4\n".to_owned());
assert_eq!(
load_config_from_paths_with_overrides(&[], &overrides)
.expect("CLI overlay")
.model
.max_tool_rounds,
Some(4)
);
std::fs::write(&trusted, "invalid TOML !!!").expect("corrupt trusted config");
assert!(load_config_from_paths_with_overrides(&[], &overrides).is_err());
std::fs::remove_file(trusted).expect("remove trusted config");
assert!(load_config_from_paths_with_overrides(&[], &overrides).is_err());
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn config_override_debug_reports_presence_without_values() {
let secret = "private-override-canary";
let overrides = ConfigLoadOverrides {
exclusive_config_path: Some(secret.into()),
base_config_path: Some(secret.into()),
env_config_path: Some(secret.into()),
env_config_toml: Some(secret.to_owned()),
cli_config_path: Some(secret.into()),
cli_config_toml: Some(secret.to_owned()),
};
for rendered in [format!("{overrides:?}"), format!("{overrides:#?}")] {
assert!(!rendered.contains(secret));
assert!(rendered.contains("true"));
}
assert!(!format!("{:?}", ConfigLoadOverrides::default()).contains("true"));
let guard = super::ConfigOverrideGuard {
previous: Some(overrides),
};
let guard = std::mem::ManuallyDrop::new(guard);
assert!(!format!("{guard:?}").contains(secret));
}
#[test]
fn exclusive_config_errors_do_not_retain_secret_values() {
let root = unique_temp_dir();
std::fs::create_dir_all(&root).unwrap();
let trusted = root.join("private-config-canary.toml");
for (contents, overlay) in [
("credential = \"private-config-canary", None),
("[model]\nmax_tool_rounds = \"private-config-canary\"", None),
("", Some("credential = \"private-config-canary")),
(
"",
Some("[model]\nmax_tool_rounds = \"private-config-canary\""),
),
(
"[composition]\nactive_profile = \"private-config-canary\"",
None,
),
] {
std::fs::write(&trusted, contents).unwrap();
let overrides = ConfigLoadOverrides {
exclusive_config_path: Some(trusted.clone()),
cli_config_toml: overlay.map(str::to_owned),
..ConfigLoadOverrides::default()
};
let error = load_config_from_paths_with_overrides(&[], &overrides)
.expect_err("invalid exclusive configuration");
assert!(
error
.to_string()
.contains("invalid exclusive configuration")
);
assert!(!error.to_string().contains("private-config-canary"));
assert!(!format!("{error:?}").contains("private-config-canary"));
assert!(std::error::Error::source(&error).is_none());
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn default_config_paths_include_repo_local_layers() {
let root = unique_temp_dir();
let nested = root.join("src").join("bin");
std::fs::create_dir_all(root.join(".git")).expect("git dir should be created");
std::fs::create_dir_all(&nested).expect("nested dir should be created");
let paths = default_config_paths_from(&nested);
assert!(paths.contains(&root.join("bcode.toml")));
assert!(paths.contains(&root.join(".bcode").join("bcode.toml")));
}
#[test]
fn user_authority_config_defaults_and_round_trips() {
let omitted: BcodeConfig = toml::from_str("").expect("empty config");
assert!(omitted.system_prompt.sections.user_authority);
assert!(super::SystemPromptSectionsConfig::default().user_authority);
for enabled in [true, false] {
let input = format!("[system_prompt.sections]\nuser_authority = {enabled}\n");
let config: BcodeConfig = toml::from_str(&input).expect("explicit setting");
assert_eq!(config.system_prompt.sections.user_authority, enabled);
let rendered = super::config_to_toml(&config);
let decoded: BcodeConfig = toml::from_str(&rendered).expect("round trip");
assert_eq!(
decoded.system_prompt.sections,
config.system_prompt.sections
);
}
assert!(
super::SystemPromptSectionsConfig::field_docs()
.iter()
.any(|field| field.toml_key == "user_authority")
);
}
#[test]
fn prompt_profile_config_round_trips_and_is_documented() {
let mut config = BcodeConfig::default();
config.prompt_profile.catalog_entry.insert(
"anthropic.claude-opus-5".to_string(),
PromptProfileLayerConfig {
system_prompt_mode: PromptProfileTextMode::Append,
system_prompt: Some("Use complete output.".to_string()),
tool_description: BTreeMap::from([(
"shell.run".to_string(),
ToolDescriptionOverrideConfig {
mode: PromptProfileTextMode::Prepend,
text: "Preserve output.".to_string(),
},
)]),
},
);
config.system_prompt.sections.model_profile = false;
let rendered = super::config_to_toml(&config);
let decoded: BcodeConfig = toml::from_str(&rendered).expect("decode rendered config");
assert_eq!(decoded.prompt_profile, config.prompt_profile);
assert!(!decoded.system_prompt.sections.model_profile);
assert!(rendered.contains("[prompt_profile.catalog_entry."));
assert!(rendered.contains("anthropic.claude-opus-5"));
assert!(
BcodeConfig::field_docs()
.iter()
.any(|field| field.toml_key == "prompt_profile")
);
}
#[test]
fn default_config_does_not_render_prompt_profile_tables() {
let rendered = super::config_to_toml(&BcodeConfig::default());
assert!(!rendered.contains("prompt_profile"));
assert!(!rendered.contains("model_profile"));
}
#[test]
fn visual_adapter_preferences_override_defaults_and_disabled_routes_are_removed() {
let route =
|plugin_id: &str, adapter_id: &str| bcode_plugin_models::PluginVisualAdapterRoute {
plugin_id: plugin_id.to_owned(),
adapter_id: adapter_id.to_owned(),
schema: "test.visual".to_owned(),
service_interface_id: bcode_tool::TOOL_SERVICE_INTERFACE_ID.to_owned(),
surfaces: vec!["tui".to_owned()],
priority: 0,
producer_default: false,
render_mode: bcode_plugin_models::PluginVisualAdapterRenderMode::TranscriptBlock,
};
let config = TuiVisualAdapterConfig {
preferred: vec!["user/custom".to_owned()],
disabled: BTreeSet::from(["bcode/disabled".to_owned()]),
};
let routes = config.order_routes(vec![
route("bcode", "default"),
route("user", "custom"),
route("bcode", "disabled"),
]);
assert_eq!(routes[0].adapter_reference(), "user/custom");
assert_eq!(routes[1].adapter_reference(), "bcode/default");
assert_eq!(routes.len(), 2);
}
#[test]
fn config_rejects_duplicate_or_malformed_visual_adapter_references() {
for preferred in [
vec!["missing-separator".to_owned()],
vec!["plugin/adapter".to_owned(), "plugin/adapter".to_owned()],
] {
let mut config = BcodeConfig::default();
config.tui.visual_adapters.preferred = preferred;
assert!(validate_config(&config).is_err());
}
}
fn unique_temp_dir() -> std::path::PathBuf {
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("bcode-config-test-{nanos}-{id}"))
}
}