use super::*;
pub(crate) fn complete_trust_directory_onboarding(
app: &mut App,
config: &Config,
) -> Result<(), String> {
onboarding::mark_trusted(&app.workspace).map_err(|err| err.to_string())?;
app.trust_mode = true;
app.hooks = app.hooks.rebind(
crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &app.workspace),
app.workspace.clone(),
);
app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
app.status_message = None;
advance_after_trust_directory_choice(app);
Ok(())
}
pub(crate) fn continue_without_trusting_directory(app: &mut App) {
app.trust_mode = false;
app.status_message = Some(app.tr(MessageId::OnboardTrustUntrustedNotice).to_string());
advance_after_trust_directory_choice(app);
}
pub(crate) fn advance_after_trust_directory_choice(app: &mut App) {
if app.onboarding_workspace_trust_gate {
app.onboarding_workspace_trust_gate = false;
app.onboarding = OnboardingState::None;
} else if app.onboarding_missing_key_recovery {
app.onboarding = OnboardingState::Tips;
} else {
app.onboarding = OnboardingState::MentalModels;
}
}
pub(crate) fn onboarding_key_route(
onboarding: OnboardingState,
top_kind: Option<ModalKind>,
key: &KeyEvent,
) -> OnboardingKeyRoute {
if onboarding == OnboardingState::None {
return OnboardingKeyRoute::Legacy;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return OnboardingKeyRoute::Quit;
}
if onboarding == OnboardingState::Provider && is_explore_offline_shortcut(key) {
return OnboardingKeyRoute::ExploreOffline;
}
if onboarding == OnboardingState::Provider && top_kind == Some(ModalKind::ProviderPicker) {
return OnboardingKeyRoute::ProviderPicker;
}
if onboarding == OnboardingState::Appearance && top_kind == Some(ModalKind::ThemePicker) {
return OnboardingKeyRoute::ThemePicker;
}
OnboardingKeyRoute::Legacy
}
pub(crate) fn back_from_provider_onboarding(app: &mut App) {
if app.onboarding_missing_key_recovery {
app.onboarding = OnboardingState::None;
app.status_message = None;
app.needs_redraw = true;
return;
}
app.onboarding = OnboardingState::Language;
app.status_message = None;
}
pub(crate) fn complete_provider_picker_onboarding(app: &mut App, provider: ApiProvider) {
app.onboarding_provider = provider;
app.onboarding_needs_api_key = false;
app.api_key_env_only = false;
app.offline_mode = false;
onboarding::advance_onboarding_after_provider(app);
}
pub(crate) fn complete_provider_picker_onboarding_if_switched(
app: &mut App,
provider: ApiProvider,
switched: bool,
) {
if switched && app.onboarding == OnboardingState::Provider {
complete_provider_picker_onboarding(app, provider);
}
}
pub(crate) async fn fetch_deepseek_balance(
api_key: &str,
base_url: &str,
) -> Option<crate::pricing::BalanceInfo> {
let url = format!("{}/user/balance", base_url.trim_end_matches('/'));
let client = &*BALANCE_CLIENT;
let response = client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.ok()?;
if !response.status().is_success() {
tracing::debug!(
"balance API returned {}: {}",
response.status().as_u16(),
response.text().await.unwrap_or_default()
);
return None;
}
let body: crate::pricing::BalanceResponse = response.json().await.ok()?;
body.balance_infos.into_iter().next()
}
pub(crate) fn should_fetch_deepseek_balance(app: &App) -> bool {
app.status_items.contains(&StatusItem::Balance)
&& matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
)
}
pub(crate) fn paste_text_into_provider_picker(app: &mut App, text: &str) -> bool {
if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
return false;
}
let _ = app.view_stack.handle_paste(text);
true
}
pub(crate) fn paste_provider_picker_from_clipboard(app: &mut App) -> bool {
if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
return false;
}
if app.clipboard.requires_terminal_paste() {
app.status_message = Some(app.tr(MessageId::ClipboardSshPasteHint).into_owned());
return true;
}
if let Some(ClipboardContent::Text(text)) = app.clipboard.read(app.workspace.as_path()) {
let _ = paste_text_into_provider_picker(app, &text);
}
true
}
pub(crate) async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
use crate::client::DeepSeekClient;
let client = DeepSeekClient::new(config)?;
let models = tokio::time::timeout(Duration::from_secs(20), client.list_models()).await??;
let mut ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
ids.sort();
ids.dedup();
Ok(ids)
}
pub(crate) fn resolve_cache_replay_route(
app: &App,
config: &Config,
) -> Result<crate::route_runtime::ResolvedRuntimeRoute> {
let target = app.cache_replay_target().ok_or_else(|| {
anyhow::anyhow!("Auto has no concrete route yet; send a turn before warming its cache")
})?;
let identity = config
.resolve_persisted_provider_identity(
Some(target.provider.as_str()),
target.provider_id.as_deref(),
)
.map_err(anyhow::Error::msg)?;
if identity.provider != target.provider || identity.key != target.provider_identity {
anyhow::bail!(
"saved cache route identity `{}` now resolves as {}/{} instead of {}/{}; send a new turn before warming",
target.provider_identity,
identity.provider.as_str(),
identity.key,
target.provider.as_str(),
target.provider_identity
);
}
let route = resolve_runtime_route_for_identity(config, &identity, Some(&target.model))
.map_err(anyhow::Error::msg)?;
if let Some(previous_base_url) = target.base_url.as_deref() {
let previous_endpoint = crate::route_receipt::endpoint_identity(previous_base_url);
let current_endpoint =
crate::route_receipt::endpoint_identity(&route.candidate.endpoint().base_url);
if previous_endpoint != current_endpoint {
anyhow::bail!(
"the cache route endpoint changed since the last turn; send a new turn before warming"
);
}
}
Ok(route)
}
pub(crate) fn error_health_route(
app: &App,
fallback_provider: ApiProvider,
) -> (ApiProvider, String) {
app.active_turn
.as_ref()
.and_then(|turn| turn.route.as_ref())
.map(|route| (route.provider, route.model.clone()))
.or_else(|| {
app.pending_turn_route
.as_ref()
.map(|(provider, model, _)| (*provider, model.clone()))
})
.unwrap_or_else(|| (fallback_provider, app.model.clone()))
}
pub(crate) fn rollback_provider_after_auth_failure(
app: &mut App,
config: &mut Config,
) -> Option<String> {
let pending = app.pending_provider_switch.take()?;
let PendingProviderSwitch {
previous_provider,
previous_model,
previous_model_ids_passthrough,
previous_route_limits,
previous_route_base_url,
previous_context_window_source,
previous_context_window_override,
previous_config,
previous_onboarding,
previous_onboarding_needs_api_key,
previous_api_key_env_only,
} = pending;
*config = previous_config;
if let Ok(identity) = config.active_provider_identity(previous_provider) {
app.set_provider_identity_record(identity);
} else {
app.set_provider_identity(
previous_provider,
config.provider_identity_for(previous_provider),
);
}
app.billing_presentation = crate::route_billing::for_route(config, previous_provider);
app.set_model_selection(previous_model.clone());
app.provider_models.insert(
app.provider_identity_for_persistence().to_string(),
previous_model,
);
app.pending_route_save = None;
app.model_ids_passthrough = previous_model_ids_passthrough;
app.active_context_window_override = previous_context_window_override;
app.active_route_limits = previous_route_limits;
app.active_route_base_url = previous_route_base_url;
app.active_context_window_source = previous_context_window_source;
app.update_model_compaction_budget();
app.clear_model_scoped_telemetry();
app.offline_mode = false;
app.onboarding = previous_onboarding;
app.onboarding_needs_api_key = previous_onboarding_needs_api_key;
app.api_key_env_only = previous_api_key_env_only;
let mut persistence_errors = Vec::new();
if let Err(err) = crate::tui::setup::record_provider_model_setup_state_for_app(app, config) {
persistence_errors.push(format!("setup state was not saved: {err}"));
}
let persistence_error = if persistence_errors.is_empty() {
None
} else {
Some(format!(
"provider rollback not fully persisted: {}",
persistence_errors.join("; ")
))
};
Some(match persistence_error {
Some(warning) => format!(
"Provider switch failed and has been rolled back to {}. {}",
previous_provider.as_str(),
warning
),
None => format!(
"Provider switch failed and has been rolled back to {}.",
previous_provider.as_str()
),
})
}
pub(crate) fn validated_app_runtime_route(
app: &App,
config: &Config,
) -> Result<crate::route_runtime::ValidatedRuntimeRoute, String> {
let (identity, scoped) = app_scoped_runtime_config(app, config);
resolve_runtime_route_for_identity(&scoped, &identity, Some(&app.model))?.validate()
}
pub(crate) fn compaction_for_validated_route(
app: &App,
route: &crate::route_runtime::ValidatedRuntimeRoute,
) -> crate::compaction::CompactionConfig {
let mut config = app.compaction_config_for_route(
route.identity.provider,
&route.model,
crate::route_budget::known_route_limits(route.candidate.limits()),
);
config.image_input = route.candidate.capabilities().image_input;
config
}
pub(crate) fn validated_profile_default_route(
config: &Config,
) -> Result<crate::route_runtime::ValidatedRuntimeRoute> {
let provider = config.api_provider();
let model = config.default_model();
resolve_runtime_route(config, provider, Some(&model))
.and_then(crate::route_runtime::ResolvedRuntimeRoute::validate)
.map_err(anyhow::Error::msg)
}
pub(crate) fn reasoning_effort_receipt_for_route(
tier: ReasoningEffort,
provider: ApiProvider,
endpoint_identity: &str,
model: &str,
) -> EffectiveReasoningEffort {
crate::work_graph::constrained_effective_reasoning_for_route(
tier.into(),
provider,
endpoint_identity,
model,
)
.map(Into::into)
.unwrap_or(EffectiveReasoningEffort::Tier(tier))
}
pub(crate) async fn sync_mode_update(app: &App, engine_handle: &EngineHandle) {
let _ = engine_handle
.send(Op::ChangeMode {
mode: app.mode,
allow_shell: app.allow_shell,
trust_mode: app.trust_mode,
auto_approve: app_auto_approve_enabled(app),
approval_mode: app.approval_mode,
configured_sandbox_mode: app.configured_sandbox_mode.clone(),
})
.await;
}
pub(crate) async fn switch_provider(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
target: ApiProvider,
model_override: Option<String>,
) -> bool {
let previous_provider = app.api_provider;
let previous_identity = app.provider_identity_for_persistence().to_string();
let requested_identity = config.provider_identity_for(target);
let previous_model = app.model.clone();
let previous_model_ids_passthrough = app.model_ids_passthrough;
let mut previous_config = config.clone();
previous_config.provider = Some(previous_identity.clone());
app.pending_provider_switch = Some(PendingProviderSwitch {
previous_provider,
previous_model: previous_model.clone(),
previous_model_ids_passthrough,
previous_route_limits: app.active_route_limits,
previous_route_base_url: app.active_route_base_url.clone(),
previous_context_window_source: app.active_context_window_source,
previous_context_window_override: app.active_context_window_override,
previous_config: previous_config.clone(),
previous_onboarding: app.onboarding,
previous_onboarding_needs_api_key: app.onboarding_needs_api_key,
previous_api_key_env_only: app.api_key_env_only,
});
let resolved_route = match resolve_runtime_route(config, target, model_override.as_deref()) {
Ok(route) => route,
Err(reason) => {
app.pending_provider_switch = None;
if !crate::config::has_api_key_for(config, target)
&& app.view_stack.top_kind() != Some(ModalKind::ProviderPicker)
{
let runtime_status = query_provider_runtime_status(engine_handle).await;
if let Some(picker) =
crate::tui::provider_picker::ProviderPickerView::new_for_missing_auth(
previous_provider,
target,
config,
runtime_status,
)
.map(|picker| {
picker
.with_locale(app.ui_locale)
.with_provider_health(&app.provider_health)
})
{
*config = previous_config;
app.view_stack.push(picker);
app.status_message = Some(format!(
"{} needs a key or local runtime โ enter one to switch.",
target.display_name()
));
app.needs_redraw = true;
return false;
}
}
*config = previous_config;
app.add_message(HistoryCell::System {
content: format!(
"Cannot switch to {}: {reason}\nProvider unchanged ({}).",
requested_identity, previous_identity
),
});
app.status_message = Some(format!(
"Route rejected before provider switch: {}.",
target.as_str()
));
return false;
}
};
let validated_route = match resolved_route.validate() {
Ok(route) => route,
Err(err) => {
app.pending_provider_switch = None;
*config = previous_config;
app.add_message(HistoryCell::System {
content: format!(
"Failed to switch provider to {}: {err}\nProvider unchanged ({}).",
requested_identity, previous_identity
),
});
return false;
}
};
let target_identity_record = validated_route.identity.clone();
let target_identity = target_identity_record.key.clone();
let resolved_endpoint = validated_route.candidate.endpoint().base_url.clone();
let route_limits = validated_route.candidate.limits();
let context_window_source = validated_route.context_window.source;
let new_model = validated_route.model.clone();
*config = *validated_route.config;
let new_base_url = resolved_endpoint;
let new_endpoint = display_base_url_host(&new_base_url);
let cache_scope_changed = previous_provider != target
|| previous_identity != target_identity
|| previous_model != new_model;
app.set_provider_identity_record(target_identity_record);
app.billing_presentation = crate::route_billing::for_route(config, target);
app.max_subagents = config
.max_subagents_for_provider(target)
.clamp(1, crate::config::MAX_SUBAGENTS);
app.provider_chain = target
.kind()
.map(|kind| codewhale_config::ProviderChain::new(kind, &config.fallback_providers))
.filter(|chain| chain.providers().len() > 1);
app.last_fallback_reason = None;
app.model_ids_passthrough = config.model_ids_pass_through();
app.set_model_selection(new_model.clone());
app.apply_provider_switch_reasoning_effort(target, &new_base_url, model_override.as_deref());
app.set_active_context_window_override(config.context_window_for_provider_config(target));
app.set_active_route_resolution(new_base_url.clone(), route_limits, context_window_source);
if model_override.is_some() {
app.provider_models
.insert(target_identity.clone(), new_model.clone());
app.enable_provider_model(&target_identity, &new_model);
}
app.update_model_compaction_budget();
if cache_scope_changed {
app.clear_model_scoped_telemetry();
} else {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
}
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_tui_engine(engine_config, config);
crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
system_prompt_override: false,
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
})
.await;
}
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
app.note_session_route_change(&target_identity, &new_model);
let persist_warning: Option<String> = None;
let mut switch_summary = format!(
"Provider switched: {} โ {}",
previous_identity, target_identity,
);
switch_summary.push(char::from(10));
switch_summary.push_str(&format!("Model: {previous_model} โ {new_model}"));
switch_summary.push(char::from(10));
switch_summary.push_str(&format!("Endpoint: {new_endpoint}"));
if let Some(ref warning) = persist_warning {
switch_summary.push(char::from(10));
switch_summary.push_str(warning);
}
app.add_message(HistoryCell::System {
content: switch_summary,
});
let mut status_message = format!("Provider: {target_identity} via {new_endpoint}");
let persisted = persist_warning.is_none();
if persist_warning.is_some() {
status_message.push_str(" (not fully persisted)");
}
app.status_message = Some(status_message);
onboarding::clear_offline_explore_on_route_activation(app);
if persisted {
record_provider_model_setup_progress(app, config);
}
true
}
pub(crate) fn display_base_url_host(base_url: &str) -> String {
let without_scheme = base_url
.split_once("://")
.map_or(base_url, |(_, rest)| rest);
without_scheme
.split('/')
.next()
.filter(|host| !host.is_empty())
.unwrap_or(base_url)
.to_string()
}
pub(crate) fn sync_config_provider_from_app(config: &mut Config, app: &App) {
config.provider = Some(app.provider_identity_for_persistence().to_string());
}
pub(crate) fn provider_picker_model_override(
app: &App,
config: &Config,
provider: ApiProvider,
) -> Option<String> {
(app.api_provider == provider
&& app.provider_identity_for_persistence() == config.provider_identity_for(provider))
.then(|| app.model.clone())
}
pub(crate) async fn query_provider_runtime_status(
engine_handle: &EngineHandle,
) -> Option<ProviderRuntimeStatus> {
tokio::time::timeout(
Duration::from_millis(100),
engine_handle.get_provider_runtime_status(),
)
.await
.ok()
.and_then(|result| result.ok())
}
pub(crate) fn mcp_reload_summary(snapshot: &crate::mcp::McpManagerSnapshot) -> String {
let connected = snapshot
.servers
.iter()
.filter(|server| server.connected)
.count();
let failed = snapshot
.servers
.iter()
.filter(|server| server.enabled && server.error.is_some())
.count();
let disabled = snapshot
.servers
.iter()
.filter(|server| !server.enabled)
.count();
format!(
"MCP tool pool reloaded in process: {connected} connected, {failed} failed, {disabled} disabled. The next model turn uses this catalog."
)
}
pub(crate) fn mcp_ui_action_refreshes_discovery(action: &crate::tui::app::McpUiAction) -> bool {
matches!(
action,
crate::tui::app::McpUiAction::Show
| crate::tui::app::McpUiAction::Validate
| crate::tui::app::McpUiAction::Login { .. }
| crate::tui::app::McpUiAction::Logout { .. }
| crate::tui::app::McpUiAction::ImportList
| crate::tui::app::McpUiAction::ImportApprove { .. }
)
}
pub(crate) fn mcp_import_consent_path() -> PathBuf {
codewhale_config::codewhale_home()
.unwrap_or_else(|_| PathBuf::from("."))
.join("mcp-import-consent.json")
}
pub(crate) fn mcp_external_import_status_text(workspace: &std::path::Path) -> String {
use crate::mcp::external_import::{discover_external_sources, format_candidates_for_display};
let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("."));
let market_path = codewhale_config::codewhale_home()
.ok()
.map(|h| h.join("mcp-marketplace.json"));
let markets: Vec<PathBuf> = market_path.into_iter().collect();
let all = discover_external_sources(&home, workspace, &markets);
let mut body = format_candidates_for_display(&all);
body.push_str("\n\nConfigured managed connectors stay in your mcp.json; external sources never auto-merge.");
body
}
pub(crate) fn mcp_import_apply(
workspace: &std::path::Path,
mcp_path: &std::path::Path,
name: &str,
approve: bool,
) -> anyhow::Result<String> {
use crate::mcp::external_import::{
ImportDecision, apply_approved, discover_external_sources, load_consent_store,
merge_approved_into_config, record_decisions, save_consent_store,
};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("."));
let market_path = codewhale_config::codewhale_home()
.ok()
.map(|h| h.join("mcp-marketplace.json"));
let markets: Vec<PathBuf> = market_path.into_iter().collect();
let all = discover_external_sources(&home, workspace, &markets);
let candidate = all
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.ok_or_else(|| {
anyhow::anyhow!(
"No external MCP candidate named '{name}'. Run /mcp import to list sources with provenance."
)
})?;
if approve && candidate.hard_blocked {
anyhow::bail!(
"Refusing to import '{}': {} (enabled=false is a hard block)",
candidate.name,
candidate.block_reason.as_deref().unwrap_or("hard blocked")
);
}
let mut decisions = HashMap::new();
decisions.insert(
candidate.name.clone(),
if approve {
ImportDecision::Approve
} else {
ImportDecision::Decline
},
);
let mut store = load_consent_store(&mcp_import_consent_path());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
record_decisions(&mut store, std::slice::from_ref(candidate), &decisions, now);
save_consent_store(&mcp_import_consent_path(), &store)?;
if !approve {
return Ok(format!(
"Declined external MCP '{}' from {} (hash {}). Will not re-prompt until the source content changes.",
candidate.name,
candidate.source_path.display(),
&candidate.content_hash[..12.min(candidate.content_hash.len())]
));
}
let approved = apply_approved(std::slice::from_ref(candidate), &decisions);
let mut cfg = crate::mcp::load_config(mcp_path)?;
let inserted = merge_approved_into_config(&mut cfg, &approved);
if inserted.is_empty() {
return Ok(format!(
"MCP '{}' was already present in {} or could not be merged. Provenance: {} @ {}",
candidate.name,
mcp_path.display(),
candidate.source_kind.as_str(),
candidate.source_path.display()
));
}
crate::mcp::save_config(mcp_path, &cfg)?;
Ok(format!(
"Imported managed MCP connector '{}' into {} (provenance: {} @ {}, hash {}). Run /mcp reload to connect after review.",
candidate.name,
mcp_path.display(),
candidate.source_kind.as_str(),
candidate.source_path.display(),
&candidate.content_hash[..12.min(candidate.content_hash.len())]
))
}
pub(crate) fn clear_active_provider_api_key_from_memory(app: &App, config: &mut Config) {
let active_identity = app.provider_identity_for_persistence();
let clears_legacy_root = matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
) || (app.api_provider == ApiProvider::Custom
&& active_identity == ApiProvider::Custom.as_str()
&& config.uses_legacy_literal_custom_route());
if clears_legacy_root {
config.api_key = None;
}
config.set_provider_api_key_override(app.api_provider, None);
if app.api_provider == ApiProvider::Xai {
let entry = config.provider_config_for_mut(ApiProvider::Xai);
entry.auth_mode = None;
entry.oauth_credential_generation = None;
entry.external_credentials = None;
}
}
pub(crate) fn record_provider_model_setup_progress(app: &mut App, config: &Config) {
if let Err(err) = crate::tui::setup::record_provider_model_setup_state_for_app(app, config) {
let note = format!("Setup provider/model state was not saved: {err}");
if let Some(status) = app.status_message.as_mut() {
status.push_str(" ยท ");
status.push_str(¬e);
} else {
app.status_message = Some(note.clone());
}
app.add_message(HistoryCell::System { content: note });
}
}
pub(crate) fn set_active_custom_provider_in_memory(config: &mut Config, provider_id: &str) {
let provider_id = provider_id.trim();
if provider_id.is_empty() {
return;
}
config.provider = Some(provider_id.to_string());
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.custom
.entry(provider_id.to_string())
.or_default();
}
pub(crate) fn picker_provider_identity(
config: &Config,
provider: ApiProvider,
provider_id: Option<&str>,
) -> Result<crate::config::ProviderIdentity, String> {
let identity = match provider_id {
Some(provider_id) => config
.resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider_id))?,
None if provider == ApiProvider::Custom => config.active_provider_identity(provider)?,
None => config.resolve_persisted_provider_identity(
Some(provider.as_str()),
Some(provider.as_str()),
)?,
};
if identity.provider != provider {
return Err(format!(
"provider picker identity '{}' resolved as {}, not {}",
identity.key,
identity.provider.as_str(),
provider.as_str()
));
}
Ok(identity)
}
#[cfg(test)]
pub(crate) fn provider_verification_error_category(
reason: &str,
) -> crate::error_taxonomy::ErrorCategory {
let lower = reason.to_ascii_lowercase();
if lower.contains("http 401") || lower.contains("status 401") {
crate::error_taxonomy::ErrorCategory::Authentication
} else if lower.contains("http 403") || lower.contains("status 403") {
crate::error_taxonomy::ErrorCategory::Authorization
} else if ["500", "502", "503", "504"]
.iter()
.any(|status| lower.contains(&format!("http {status}")))
{
crate::error_taxonomy::ErrorCategory::Network
} else {
crate::error_taxonomy::classify_error_message(reason)
}
}