#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use bcode_skill_models::SkillToolDecisionState;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::time::Duration;
use switchy::database::query::{FilterableQuery as _, where_eq};
use switchy::database::schema::{Column, DataType, create_table};
use switchy::database::{Database, DatabaseError, Row};
use switchy::schema::discovery::code::{CodeMigration, CodeMigrationSource};
use switchy::schema::runner::MigrationRunner;
const DATABASE_FILE_NAME: &str = "settings.db";
const SKILL_TOOL_DECISIONS_KEY: &str = "skills.tool_decisions.v1";
const MIGRATIONS_TABLE: &str = "settings_schema_migrations";
const DATABASE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const DATABASE_OPEN_INITIAL_RETRY_DELAY: Duration = Duration::from_millis(20);
const DATABASE_OPEN_MAX_RETRY_DELAY: Duration = Duration::from_millis(250);
const DATABASE_OPEN_RETRY_ATTEMPTS: u32 = 5;
const ONBOARDING_PROGRESS_ID: i64 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettingsStore {
settings_db_path: PathBuf,
}
impl Default for SettingsStore {
fn default() -> Self {
Self::from_settings_db_path(bcode_config::default_state_dir().join(DATABASE_FILE_NAME))
}
}
impl SettingsStore {
#[must_use]
pub fn from_settings_db_path(settings_db_path: impl Into<PathBuf>) -> Self {
Self {
settings_db_path: settings_db_path.into(),
}
}
#[must_use]
pub fn settings_db_path(&self) -> &Path {
&self.settings_db_path
}
#[must_use]
pub fn health(&self) -> SettingsDbHealth {
match self.with_database(|_| Box::pin(async { Ok(()) })) {
Ok(()) => SettingsDbHealth::Available,
Err(error) => SettingsDbHealth::Unavailable {
message: error.to_string(),
},
}
}
pub fn reset_database(&self) -> SettingsResult<()> {
remove_file_if_exists(&self.settings_db_path)?;
remove_file_if_exists(&sqlite_sidecar_path(&self.settings_db_path, "wal"))?;
remove_file_if_exists(&sqlite_sidecar_path(&self.settings_db_path, "shm"))?;
Ok(())
}
pub fn skill_tool_decisions(&self) -> SettingsResult<SkillToolDecisionState> {
self.control_state(SKILL_TOOL_DECISIONS_KEY)?
.map(|value| serde_json::from_value(value.value).map_err(Into::into))
.transpose()
.map(Option::unwrap_or_default)
}
pub fn save_skill_tool_decisions(
&self,
state: &SkillToolDecisionState,
updated_at_ms: u64,
) -> SettingsResult<()> {
self.put_control_state(
SKILL_TOOL_DECISIONS_KEY,
&serde_json::to_value(state)?,
updated_at_ms,
)
}
pub fn save_readiness_report(
&self,
report: &SetupReadinessReport,
updated_at_ms: u64,
) -> SettingsResult<()> {
self.put_control_state(
"setup.readiness_report",
&serde_json::to_value(report)?,
updated_at_ms,
)
}
pub fn onboarding_draft_setup(&self) -> SettingsResult<OnboardingDraftSetup> {
self.control_state("onboarding.draft_setup")?
.map(|value| serde_json::from_value(value.value).map_err(Into::into))
.transpose()
.map(Option::unwrap_or_default)
}
pub fn save_onboarding_draft_setup(
&self,
draft: &OnboardingDraftSetup,
saved_at_ms: u64,
) -> SettingsResult<()> {
self.put_control_state(
"onboarding.draft_setup",
&serde_json::to_value(draft)?,
saved_at_ms,
)
}
pub fn toggle_draft_provider(
&self,
provider: &str,
at_ms: u64,
) -> SettingsResult<OnboardingDraftSetup> {
let mut draft = self.onboarding_draft_setup()?;
draft.toggle_provider(provider);
self.save_onboarding_draft_setup(&draft, at_ms)?;
Ok(draft)
}
pub fn toggle_draft_auth_profile(
&self,
profile: &str,
at_ms: u64,
) -> SettingsResult<OnboardingDraftSetup> {
let mut draft = self.onboarding_draft_setup()?;
draft.toggle_auth_profile(profile);
self.save_onboarding_draft_setup(&draft, at_ms)?;
Ok(draft)
}
pub fn select_draft_model_profile(
&self,
profile: &str,
at_ms: u64,
) -> SettingsResult<OnboardingDraftSetup> {
let mut draft = self.onboarding_draft_setup()?;
draft.set_model_profile(profile);
self.save_onboarding_draft_setup(&draft, at_ms)?;
Ok(draft)
}
pub fn cycle_draft_permission_preset(
&self,
at_ms: u64,
) -> SettingsResult<OnboardingDraftSetup> {
let mut draft = self.onboarding_draft_setup()?;
draft.cycle_permission_preset();
self.save_onboarding_draft_setup(&draft, at_ms)?;
Ok(draft)
}
pub fn readiness_report(&self) -> SettingsResult<Option<SetupReadinessReport>> {
self.control_state("setup.readiness_report")?
.map(|value| serde_json::from_value(value.value).map_err(Into::into))
.transpose()
}
pub fn reconcile_setup_apply(
&self,
config: &bcode_config::BcodeConfig,
) -> SettingsResult<SetupApplyReconciliation> {
self.reconcile_setup_apply_with_discovery(config, false)
}
pub fn reconcile_setup_apply_with_discovery(
&self,
config: &bcode_config::BcodeConfig,
discovery_enabled: bool,
) -> SettingsResult<SetupApplyReconciliation> {
let draft = self.onboarding_draft_setup()?;
let detection_entries = if discovery_enabled {
self.detection_cache_entries()?
} else {
Vec::new()
};
let secure_import_plans = secure_import_plans_from_detection(&detection_entries);
let auth_detection = detect_auth_security_from_config(config);
Ok(SetupApplyReconciliation {
draft_present: draft != OnboardingDraftSetup::default(),
config_summary: SetupConfigSummary::from_config(config),
secure_import_reconciliation: reconcile_secure_import_plans(
&secure_import_plans,
&auth_detection,
),
})
}
pub fn apply_setup_plan(
&self,
plan: &SetupPlanReview,
applied_at_ms: u64,
) -> SettingsResult<AppliedSetupPlan> {
let mut applied_actions = Vec::new();
let mut skipped_actions = Vec::new();
for action in &plan.actions {
if action.kind == "launch" {
skipped_actions.push(action.clone());
continue;
}
if action.mutating {
self.put_control_state(
&format!("setup.applied.{}", action.kind),
&json!({
"section_id": action.section_id.as_str(),
"applied_at_ms": applied_at_ms,
}),
applied_at_ms,
)?;
applied_actions.push(action.clone());
} else {
skipped_actions.push(action.clone());
}
}
let result = AppliedSetupPlan {
applied_actions,
skipped_actions,
};
self.put_control_state(
"setup.last_applied_plan",
&serde_json::to_value(&result)?,
applied_at_ms,
)?;
Ok(result)
}
pub fn experience_mode(&self) -> SettingsResult<SettingsExperienceMode> {
let progress = self.onboarding_progress()?;
Ok(settings_experience_mode(progress.as_ref()))
}
pub fn ignore_model(
&self,
provider_plugin_id: &str,
model_id: String,
) -> SettingsResult<PathBuf> {
bcode_config::ignore_model_in_state(provider_plugin_id, model_id).map_err(Into::into)
}
pub fn unignore_model(
&self,
provider_plugin_id: &str,
model_id: &str,
) -> SettingsResult<PathBuf> {
bcode_config::unignore_model_in_state(provider_plugin_id, model_id).map_err(Into::into)
}
pub fn put_control_state(
&self,
key: &str,
value: &Value,
updated_at_ms: u64,
) -> SettingsResult<()> {
let key = key.to_owned();
let sanitized_value = redact_secret_like_json(value.clone());
let value_json = serde_json::to_string(&sanitized_value)?;
self.with_database(move |database| {
Box::pin(async move {
database
.delete("control_state_kv")
.filter(Box::new(where_eq("key", key.clone())))
.execute(database.as_ref())
.await?;
database
.insert("control_state_kv")
.value("key", key)
.value("value_json", value_json)
.value("updated_at_ms", u64_to_i64(updated_at_ms))
.execute(database.as_ref())
.await?;
Ok(())
})
})
}
pub fn control_state(&self, key: &str) -> SettingsResult<Option<ControlStateValue>> {
let key = key.to_owned();
self.with_database(move |database| {
Box::pin(async move {
let rows = database
.select("control_state_kv")
.columns(&["key", "value_json", "updated_at_ms"])
.filter(Box::new(where_eq("key", key)))
.execute(database.as_ref())
.await?;
rows.into_iter()
.next()
.map(|row| control_state_value_from_row(&row))
.transpose()
})
})
}
pub fn save_onboarding_progress(&self, progress: &OnboardingProgress) -> SettingsResult<()> {
let progress = progress.clone();
self.with_database(move |database| {
Box::pin(async move {
database
.upsert("onboarding_progress")
.unique(&["id"])
.value("id", ONBOARDING_PROGRESS_ID)
.value("mode", progress.mode)
.value(
"first_run_completed",
bool_to_i64(progress.first_run_completed),
)
.value("last_section", progress.last_section)
.value(
"last_opened_at_ms",
progress.last_opened_at_ms.map(u64_to_i64),
)
.value("completed_at_ms", progress.completed_at_ms.map(u64_to_i64))
.execute(database.as_ref())
.await?;
Ok(())
})
})
}
pub fn onboarding_progress(&self) -> SettingsResult<Option<OnboardingProgress>> {
self.with_database(move |database| {
Box::pin(async move {
let rows = database
.select("onboarding_progress")
.columns(&[
"mode",
"first_run_completed",
"last_section",
"last_opened_at_ms",
"completed_at_ms",
])
.filter(Box::new(where_eq("id", ONBOARDING_PROGRESS_ID)))
.execute(database.as_ref())
.await?;
rows.into_iter()
.next()
.map(|row| onboarding_progress_from_row(&row))
.transpose()
})
})
}
pub fn visit_onboarding_section(
&self,
section_id: SetupSectionId,
visited_at_ms: u64,
) -> SettingsResult<()> {
self.save_onboarding_progress(&OnboardingProgress {
mode: "first_run".to_owned(),
first_run_completed: false,
last_section: Some(section_id.as_str().to_owned()),
last_opened_at_ms: Some(visited_at_ms),
completed_at_ms: None,
})?;
self.save_onboarding_section(&OnboardingSection {
section_id: section_id.as_str().to_owned(),
status: SetupSectionStatus::Visited.as_str().to_owned(),
visited: true,
visited_at_ms: Some(visited_at_ms),
completed_at_ms: None,
skipped_at_ms: None,
dismissed: false,
})
}
pub fn complete_onboarding(&self, completed_at_ms: u64) -> SettingsResult<()> {
self.save_onboarding_progress(&OnboardingProgress {
mode: "first_run".to_owned(),
first_run_completed: true,
last_section: Some(SetupSectionId::Launch.as_str().to_owned()),
last_opened_at_ms: Some(completed_at_ms),
completed_at_ms: Some(completed_at_ms),
})
}
pub fn skip_onboarding_section(
&self,
section_id: SetupSectionId,
skipped_at_ms: u64,
) -> SettingsResult<()> {
self.save_onboarding_section(&OnboardingSection {
section_id: section_id.as_str().to_owned(),
status: SetupSectionStatus::Skipped.as_str().to_owned(),
visited: true,
visited_at_ms: Some(skipped_at_ms),
completed_at_ms: None,
skipped_at_ms: Some(skipped_at_ms),
dismissed: false,
})
}
pub fn complete_onboarding_section(
&self,
section_id: SetupSectionId,
completed_at_ms: u64,
) -> SettingsResult<()> {
self.save_onboarding_section(&OnboardingSection {
section_id: section_id.as_str().to_owned(),
status: SetupSectionStatus::Complete.as_str().to_owned(),
visited: true,
visited_at_ms: Some(completed_at_ms),
completed_at_ms: Some(completed_at_ms),
skipped_at_ms: None,
dismissed: false,
})
}
pub fn save_onboarding_section(&self, section: &OnboardingSection) -> SettingsResult<()> {
let section = section.clone();
self.with_database(move |database| {
Box::pin(async move {
database
.delete("onboarding_sections")
.filter(Box::new(where_eq("section_id", section.section_id.clone())))
.execute(database.as_ref())
.await?;
database
.insert("onboarding_sections")
.value("section_id", section.section_id)
.value("status", section.status)
.value("visited", bool_to_i64(section.visited))
.value("visited_at_ms", section.visited_at_ms.map(u64_to_i64))
.value("completed_at_ms", section.completed_at_ms.map(u64_to_i64))
.value("skipped_at_ms", section.skipped_at_ms.map(u64_to_i64))
.value("dismissed", bool_to_i64(section.dismissed))
.execute(database.as_ref())
.await?;
Ok(())
})
})
}
pub fn onboarding_sections(&self) -> SettingsResult<Vec<OnboardingSection>> {
self.with_database(move |database| {
Box::pin(async move {
database
.select("onboarding_sections")
.columns(&[
"section_id",
"status",
"visited",
"visited_at_ms",
"completed_at_ms",
"skipped_at_ms",
"dismissed",
])
.execute(database.as_ref())
.await?
.into_iter()
.map(|row| onboarding_section_from_row(&row))
.collect()
})
})
}
pub fn save_detection_cache_entry(&self, entry: &DetectionCacheEntry) -> SettingsResult<()> {
let entry = entry.clone();
let value_json = serde_json::to_string(&entry.value)?;
self.with_database(move |database| {
Box::pin(async move {
database
.upsert("detection_cache")
.unique(&["detection_id"])
.value("detection_id", detection_id(&entry.detector, &entry.key))
.value("detector", entry.detector)
.value("key", entry.key)
.value("value_json", value_json)
.value("confidence", entry.confidence)
.value("source", entry.source)
.value("detected_at_ms", u64_to_i64(entry.detected_at_ms))
.value("expires_at_ms", entry.expires_at_ms.map(u64_to_i64))
.execute(database.as_ref())
.await?;
Ok(())
})
})
}
pub fn detection_cache_entries(&self) -> SettingsResult<Vec<DetectionCacheEntry>> {
self.with_database(move |database| {
Box::pin(async move {
database
.select("detection_cache")
.columns(&[
"detector",
"key",
"value_json",
"confidence",
"source",
"detected_at_ms",
"expires_at_ms",
])
.execute(database.as_ref())
.await?
.into_iter()
.map(|row| detection_cache_entry_from_row(&row))
.collect()
})
})
}
pub fn save_setup_detection_snapshot(
&self,
snapshot: &SetupDetectionSnapshot,
) -> SettingsResult<()> {
for entry in &snapshot.entries {
self.save_detection_cache_entry(entry)?;
}
for recommendation in &snapshot.recommendations {
self.save_setup_recommendation(recommendation)?;
}
Ok(())
}
pub fn save_setup_recommendation(
&self,
recommendation: &SetupRecommendation,
) -> SettingsResult<()> {
let recommendation = recommendation.clone();
self.with_database(move |database| {
Box::pin(async move {
database
.delete("setup_recommendations")
.filter(Box::new(where_eq(
"recommendation_id",
recommendation.recommendation_id.clone(),
)))
.execute(database.as_ref())
.await?;
database
.insert("setup_recommendations")
.value("recommendation_id", recommendation.recommendation_id)
.value("section_id", recommendation.section_id)
.value("kind", recommendation.kind)
.value("status", recommendation.status)
.value("priority", i64::from(recommendation.priority))
.value("title", recommendation.title)
.value("body", recommendation.body)
.value("created_at_ms", u64_to_i64(recommendation.created_at_ms))
.value(
"dismissed_at_ms",
recommendation.dismissed_at_ms.map(u64_to_i64),
)
.execute(database.as_ref())
.await?;
Ok(())
})
})
}
pub fn dismiss_setup_recommendation(
&self,
recommendation_id: &str,
dismissed_at_ms: u64,
) -> SettingsResult<Option<SetupRecommendation>> {
let mut recommendations = self.setup_recommendations()?;
let Some(recommendation) = recommendations
.iter_mut()
.find(|recommendation| recommendation.recommendation_id == recommendation_id)
else {
return Ok(None);
};
"dismissed".clone_into(&mut recommendation.status);
recommendation.dismissed_at_ms = Some(dismissed_at_ms);
let updated = recommendation.clone();
self.save_setup_recommendation(&updated)?;
Ok(Some(updated))
}
pub fn setup_recommendations(&self) -> SettingsResult<Vec<SetupRecommendation>> {
self.with_database(move |database| {
Box::pin(async move {
database
.select("setup_recommendations")
.columns(&[
"recommendation_id",
"section_id",
"kind",
"status",
"priority",
"title",
"body",
"created_at_ms",
"dismissed_at_ms",
])
.execute(database.as_ref())
.await?
.into_iter()
.map(|row| setup_recommendation_from_row(&row))
.collect()
})
})
}
fn with_database<T>(
&self,
operation: impl FnOnce(
Box<dyn Database>,
)
-> Pin<Box<dyn Future<Output = SettingsResult<T>> + Send + 'static>>
+ Send
+ 'static,
) -> SettingsResult<T>
where
T: Send + 'static,
{
let database_path = self.settings_db_path.clone();
if let Some(parent) = database_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread Tokio runtime should build");
runtime.block_on(async {
let database = open_database(&database_path).await?;
run_migrations(database.as_ref()).await?;
operation(database).await
})
})
.join()
.map_err(|_| SettingsError::DatabaseWorkerPanicked)?
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretAuditReport {
pub safe: bool,
pub findings: Vec<String>,
}
#[must_use]
pub fn audit_no_secret_material(label: &str, payload: &str) -> SecretAuditReport {
let lower = payload.to_ascii_lowercase();
let mut findings = Vec::new();
for marker in [
"sk-",
"api_key=",
"apikey=",
"access_token=",
"secret_access_key=",
"password=",
"bearer ",
] {
if lower.contains(marker) {
findings.push(format!("{label} contains marker {marker}"));
}
}
SecretAuditReport {
safe: findings.is_empty(),
findings,
}
}
#[must_use]
pub fn redact_secret_like_json(value: Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.into_iter()
.map(|(key, value)| {
let redacted = if is_secret_like_key(&key) {
json!({ "redacted": true })
} else {
redact_secret_like_json(value)
};
(key, redacted)
})
.collect(),
),
Value::Array(values) => {
Value::Array(values.into_iter().map(redact_secret_like_json).collect())
}
other => other,
}
}
fn is_secret_like_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
lower.contains("secret")
|| lower.contains("token")
|| lower.contains("password")
|| lower.contains("api_key")
|| lower.contains("apikey")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettingsDbHealth {
Available,
Unavailable {
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingsDegradedPanel {
pub available: bool,
pub message: String,
pub reset_available: bool,
}
#[must_use]
pub fn settings_degraded_panel(health: &SettingsDbHealth) -> SettingsDegradedPanel {
match health {
SettingsDbHealth::Available => SettingsDegradedPanel {
available: true,
message: "Settings state is available.".to_owned(),
reset_available: false,
},
SettingsDbHealth::Unavailable { message } => SettingsDegradedPanel {
available: false,
message: format!(
"Settings state is unavailable. Bcode can keep running with degraded onboarding/control-center state. Details: {message}"
),
reset_available: true,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ControlStateValue {
pub key: String,
pub value: Value,
pub updated_at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingProgress {
pub mode: String,
pub first_run_completed: bool,
pub last_section: Option<String>,
pub last_opened_at_ms: Option<u64>,
pub completed_at_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingSection {
pub section_id: String,
pub status: String,
pub visited: bool,
pub visited_at_ms: Option<u64>,
pub completed_at_ms: Option<u64>,
pub skipped_at_ms: Option<u64>,
pub dismissed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DetectionCacheEntry {
pub detector: String,
pub key: String,
pub value: Value,
pub confidence: String,
pub source: String,
pub detected_at_ms: u64,
pub expires_at_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupRecommendation {
pub recommendation_id: String,
pub section_id: String,
pub kind: String,
pub status: String,
pub priority: i32,
pub title: String,
pub body: String,
pub created_at_ms: u64,
pub dismissed_at_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupSectionId {
Welcome,
Detection,
SecureVault,
Providers,
Models,
Permissions,
Imports,
Plugins,
Launch,
}
impl SetupSectionId {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Welcome => "welcome",
Self::Detection => "detection",
Self::SecureVault => "secure_vault",
Self::Providers => "providers",
Self::Models => "models",
Self::Permissions => "permissions",
Self::Imports => "imports",
Self::Plugins => "plugins",
Self::Launch => "launch",
}
}
#[must_use]
pub const fn all() -> [Self; 9] {
[
Self::Welcome,
Self::Detection,
Self::SecureVault,
Self::Providers,
Self::Models,
Self::Permissions,
Self::Imports,
Self::Plugins,
Self::Launch,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupSectionStatus {
Unvisited,
Visited,
Current,
Complete,
Recommended,
Optional,
Skipped,
Blocked,
Secured,
NeedsAttention,
}
impl SetupSectionStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unvisited => "unvisited",
Self::Visited => "visited",
Self::Current => "current",
Self::Complete => "complete",
Self::Recommended => "recommended",
Self::Optional => "optional",
Self::Skipped => "skipped",
Self::Blocked => "blocked",
Self::Secured => "secured",
Self::NeedsAttention => "needs_attention",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupReconciliationInput {
pub current_section: Option<SetupSectionId>,
pub configured_sections: BTreeSet<SetupSectionId>,
pub secured_sections: BTreeSet<SetupSectionId>,
pub blocked_sections: BTreeSet<SetupSectionId>,
pub recommended_sections: BTreeSet<SetupSectionId>,
pub optional_sections: BTreeSet<SetupSectionId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReconciledSetupSection {
pub section_id: SetupSectionId,
pub status: SetupSectionStatus,
pub visited: bool,
}
#[must_use]
pub fn reconcile_setup_sections(
persisted_sections: &[OnboardingSection],
input: &SetupReconciliationInput,
) -> Vec<ReconciledSetupSection> {
let visited_sections = persisted_sections
.iter()
.filter(|section| section.visited)
.map(|section| section.section_id.as_str())
.collect::<BTreeSet<_>>();
let skipped_sections = persisted_sections
.iter()
.filter(|section| section.status == SetupSectionStatus::Skipped.as_str())
.map(|section| section.section_id.as_str())
.collect::<BTreeSet<_>>();
SetupSectionId::all()
.into_iter()
.map(|section_id| {
let section_key = section_id.as_str();
let visited = visited_sections.contains(section_key);
let status = if input.current_section == Some(section_id) {
SetupSectionStatus::Current
} else if input.secured_sections.contains(§ion_id) {
SetupSectionStatus::Secured
} else if input.configured_sections.contains(§ion_id) {
SetupSectionStatus::Complete
} else if input.blocked_sections.contains(§ion_id) {
SetupSectionStatus::Blocked
} else if input.recommended_sections.contains(§ion_id) {
SetupSectionStatus::Recommended
} else if skipped_sections.contains(section_key) {
SetupSectionStatus::Skipped
} else if input.optional_sections.contains(§ion_id) {
SetupSectionStatus::Optional
} else if visited {
SetupSectionStatus::Visited
} else {
SetupSectionStatus::Unvisited
};
ReconciledSetupSection {
section_id,
status,
visited,
}
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupConfigCapability {
ProviderSelection,
ModelSelection,
AuthConfiguration,
PermissionConfiguration,
SessionImportConfiguration,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupConfigSummary {
pub capabilities: BTreeSet<SetupConfigCapability>,
pub enabled_plugins: BTreeSet<String>,
pub disabled_plugins: BTreeSet<String>,
}
impl SetupConfigSummary {
#[must_use]
pub fn from_config(config: &bcode_config::BcodeConfig) -> Self {
let selection = config.resolved_model_selection_with_environment(
&bcode_config::ConfigEnvironmentSnapshot::new(BTreeMap::new(), PathBuf::from(".")),
);
let mut capabilities = BTreeSet::new();
if selection.provider_plugin_id.is_some() {
capabilities.insert(SetupConfigCapability::ProviderSelection);
}
if selection.model_id.is_some() || selection.model_profile.is_some() {
capabilities.insert(SetupConfigCapability::ModelSelection);
}
if config.auth.openai.is_some()
|| !config.auth.profiles.is_empty()
|| !config.auth.pools.is_empty()
{
capabilities.insert(SetupConfigCapability::AuthConfiguration);
}
if !config.agent.is_empty() {
capabilities.insert(SetupConfigCapability::PermissionConfiguration);
}
if config.session_import != bcode_config::SessionImportConfig::default() {
capabilities.insert(SetupConfigCapability::SessionImportConfiguration);
}
Self {
capabilities,
enabled_plugins: config.plugins.enabled.clone(),
disabled_plugins: config.plugins.disabled.clone(),
}
}
#[must_use]
pub fn reconciliation_input(&self) -> SetupReconciliationInput {
let mut input = SetupReconciliationInput::default();
if self
.capabilities
.contains(&SetupConfigCapability::AuthConfiguration)
{
input.secured_sections.insert(SetupSectionId::SecureVault);
}
if self
.capabilities
.contains(&SetupConfigCapability::ProviderSelection)
|| self
.capabilities
.contains(&SetupConfigCapability::AuthConfiguration)
{
input.configured_sections.insert(SetupSectionId::Providers);
}
if self
.capabilities
.contains(&SetupConfigCapability::ModelSelection)
{
input.configured_sections.insert(SetupSectionId::Models);
}
if self
.capabilities
.contains(&SetupConfigCapability::PermissionConfiguration)
{
input
.configured_sections
.insert(SetupSectionId::Permissions);
}
if self
.capabilities
.contains(&SetupConfigCapability::SessionImportConfiguration)
{
input.configured_sections.insert(SetupSectionId::Imports);
} else {
input.optional_sections.insert(SetupSectionId::Imports);
}
if !self.enabled_plugins.is_empty() || !self.disabled_plugins.is_empty() {
input.configured_sections.insert(SetupSectionId::Plugins);
} else {
input.optional_sections.insert(SetupSectionId::Plugins);
}
input
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderSetupCard {
pub provider_plugin_id: String,
pub configured: bool,
pub auth_configured: bool,
pub story: String,
}
#[must_use]
pub fn provider_setup_card(
provider_plugin_id: impl Into<String>,
configured: bool,
auth_configured: bool,
) -> ProviderSetupCard {
let provider_plugin_id = provider_plugin_id.into();
let story = if configured && auth_configured {
"This provider is ready to use with configured authentication.".to_owned()
} else if configured {
"This provider is selected, but Bcode still needs a secure auth path.".to_owned()
} else {
"Choose this provider if it matches the models and credentials you want Bcode to use."
.to_owned()
};
ProviderSetupCard {
provider_plugin_id,
configured,
auth_configured,
story,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityTrustPanel {
pub secure_storage_available: bool,
pub device_seal_available: bool,
pub story: String,
}
#[must_use]
pub fn security_trust_panel(
secure_storage_available: bool,
device_seal_available: bool,
) -> SecurityTrustPanel {
let story = match (secure_storage_available, device_seal_available) {
(true, true) => "Secure storage and device sealing are available. Bcode can keep credentials out of plaintext config and bind encrypted secrets to this machine.",
(true, false) => "Secure storage is available. Bcode can keep credentials out of plaintext shell files and config, even though device sealing was not detected.",
(false, _) => "Secure storage is not ready yet. Bcode should guide you to a safer credential path before launch.",
}
.to_owned();
SecurityTrustPanel {
secure_storage_available,
device_seal_available,
story,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthSecurityDetection {
pub sshenv_available: bool,
pub device_seal_requested: bool,
pub sshenv_profiles: BTreeSet<String>,
}
#[must_use]
pub fn detect_auth_security_from_config(
config: &bcode_config::BcodeConfig,
) -> AuthSecurityDetection {
let mut detection = AuthSecurityDetection::default();
for (profile_name, profile) in &config.auth.profiles {
if profile.backend == "sshenv" {
detection.sshenv_available = true;
detection.sshenv_profiles.insert(profile_name.clone());
if profile
.settings
.get("device_seal")
.is_none_or(|value| !matches!(value.as_str(), "off" | "false" | "disabled"))
{
detection.device_seal_requested = true;
}
}
}
if config
.auth
.openai
.as_ref()
.is_some_and(|auth| auth.backend == "sshenv")
{
detection.sshenv_available = true;
detection.sshenv_profiles.insert("openai".to_owned());
detection.device_seal_requested = true;
}
detection
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecureCredentialImportPlan {
pub env_var: String,
pub auth_profile: String,
pub credential_key: String,
pub requires_secure_storage: bool,
}
#[must_use]
pub fn secure_import_plans_from_detection(
entries: &[DetectionCacheEntry],
) -> Vec<SecureCredentialImportPlan> {
entries
.iter()
.filter(|entry| {
entry.detector == "environment"
&& matches!(
entry.key.as_str(),
"OPENAI_API_KEY" | "XAI_API_KEY" | "OPENROUTER_API_KEY" | "GITHUB_TOKEN"
)
})
.map(|entry| SecureCredentialImportPlan {
env_var: entry.key.clone(),
auth_profile: auth_profile_for_env_var(&entry.key).to_owned(),
credential_key: "api_key".to_owned(),
requires_secure_storage: true,
})
.collect()
}
fn auth_profile_for_env_var(env_var: &str) -> &'static str {
match env_var {
"OPENAI_API_KEY" => "openai",
"XAI_API_KEY" => "xai",
"OPENROUTER_API_KEY" => "openrouter",
"GITHUB_TOKEN" => "github",
_ => "default",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecureCredentialStoryPanel {
pub headline: String,
pub bullets: Vec<String>,
pub detected_sources: Vec<String>,
pub device_seal_message: String,
pub recommended_actions: Vec<String>,
}
#[must_use]
pub fn secure_credential_story_panel(
plans: &[SecureCredentialImportPlan],
auth_detection: &AuthSecurityDetection,
) -> SecureCredentialStoryPanel {
let detected_sources = plans.iter().map(|plan| plan.env_var.clone()).collect();
let device_seal_message = if auth_detection.device_seal_requested {
"Device sealing is requested for configured sshenv profiles, so encrypted secrets can be bound to this machine where supported."
} else {
"Device sealing is not configured yet; onboarding can explain and enable it where supported."
}
.to_owned();
SecureCredentialStoryPanel {
headline: "Secure detected provider credentials".to_owned(),
bullets: vec![
"Bcode keeps provider secrets out of plaintext config.".to_owned(),
"Bcode avoids scattering tokens across shell startup files.".to_owned(),
"sshenv-backed storage is the guided secure path for loaded credentials.".to_owned(),
"Secret values are never displayed, logged, or persisted in the settings database."
.to_owned(),
],
detected_sources,
device_seal_message,
recommended_actions: vec![
"Import detected credentials into sshenv-backed secure storage.".to_owned(),
"Reconcile auth profiles after import before marking setup complete.".to_owned(),
"Remove plaintext environment exports after secure import if appropriate.".to_owned(),
],
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecureImportReconciliation {
pub imported_profiles: BTreeSet<String>,
pub pending_profiles: BTreeSet<String>,
}
#[must_use]
pub fn reconcile_secure_import_plans(
plans: &[SecureCredentialImportPlan],
auth_detection: &AuthSecurityDetection,
) -> SecureImportReconciliation {
let mut imported_profiles = BTreeSet::new();
let mut pending_profiles = BTreeSet::new();
for plan in plans {
if auth_detection.sshenv_profiles.contains(&plan.auth_profile) {
imported_profiles.insert(plan.auth_profile.clone());
} else {
pending_profiles.insert(plan.auth_profile.clone());
}
}
SecureImportReconciliation {
imported_profiles,
pending_profiles,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderAuthSetupCard {
pub configured_providers: BTreeSet<String>,
pub draft_providers: BTreeSet<String>,
pub auth_profiles: BTreeSet<String>,
pub provider_copy: Vec<String>,
pub can_switch_default: bool,
pub can_add_later: bool,
}
#[must_use]
pub fn provider_auth_setup_card(
config_summary: &SetupConfigSummary,
draft: &OnboardingDraftSetup,
) -> ProviderAuthSetupCard {
let mut configured_providers = BTreeSet::new();
if config_summary
.capabilities
.contains(&SetupConfigCapability::ProviderSelection)
{
configured_providers.insert("configured-default".to_owned());
}
let auth_profiles = config_summary
.capabilities
.contains(&SetupConfigCapability::AuthConfiguration)
.then(|| "configured-auth".to_owned())
.into_iter()
.chain(draft.auth_profiles.iter().cloned())
.collect();
ProviderAuthSetupCard {
configured_providers,
draft_providers: draft.providers.clone(),
auth_profiles,
provider_copy: vec![
"OpenAI-compatible providers use API-key style credentials.".to_owned(),
"OpenRouter, XAI, and OpenAI-style keys are detected by source only.".to_owned(),
"Cloud providers may add metadata such as profile or region when supported.".to_owned(),
],
can_switch_default: true,
can_add_later: true,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelSetupCard {
pub model_profile: Option<String>,
pub model_id: Option<String>,
pub configured: bool,
pub compatibility_notes: Vec<String>,
pub tradeoff_copy: Vec<String>,
pub reasoning_defaults_available: bool,
pub ignore_unignore_available: bool,
pub story: String,
}
#[must_use]
pub fn model_setup_card(model_profile: Option<String>, model_id: Option<String>) -> ModelSetupCard {
let configured = model_profile.is_some() || model_id.is_some();
let story = if configured {
"Bcode has a model path selected, so onboarding can focus on quality, cost, and context preferences."
.to_owned()
} else {
"Choose a default model so Bcode can start quickly while still letting you switch models later."
.to_owned()
};
ModelSetupCard {
model_profile,
model_id,
configured,
compatibility_notes: vec![
"Provider/model compatibility is checked against provider-owned model metadata where available."
.to_owned(),
"You can switch providers or models later without editing settings DB rows.".to_owned(),
],
tradeoff_copy: vec![
"Fast models reduce latency for routine edits.".to_owned(),
"Balanced profiles trade cost and quality for everyday coding.".to_owned(),
"Quality profiles favor deeper reasoning and larger context where supported.".to_owned(),
],
reasoning_defaults_available: true,
ignore_unignore_available: true,
story,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionSetupCard {
pub configured: bool,
pub presets: Vec<String>,
pub asks_when: Vec<String>,
pub blocked_behavior: Vec<String>,
pub control_center_customization: bool,
pub story: String,
}
#[must_use]
pub fn permission_setup_card(configured: bool) -> PermissionSetupCard {
let story = if configured {
"Permission rules are configured, so Bcode can explain and enforce your preferred safety boundaries."
.to_owned()
} else {
"Review a permission preset so Bcode knows when to ask, when to proceed, and what should stay blocked."
.to_owned()
};
PermissionSetupCard {
configured,
presets: vec![
"cautious".to_owned(),
"balanced".to_owned(),
"autonomous".to_owned(),
],
asks_when: vec![
"Cautious asks before most mutating actions.".to_owned(),
"Balanced proceeds on low-risk work and asks before sensitive changes.".to_owned(),
"Autonomous is for advanced users who still want hard safety boundaries.".to_owned(),
],
blocked_behavior: vec![
"Broad destructive filesystem operations remain blocked unless explicitly allowed."
.to_owned(),
"Secret exfiltration and raw credential display are never allowed by onboarding."
.to_owned(),
],
control_center_customization: true,
story,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportSetupCard {
pub configured: bool,
pub optional: bool,
pub safety_notes: Vec<String>,
pub repair_is_explicit: bool,
pub story: String,
}
#[must_use]
pub fn import_setup_card(configured: bool) -> ImportSetupCard {
let story = if configured {
"Import settings are configured, so Bcode can help bring useful history forward without replaying sessions during normal setup."
.to_owned()
} else {
"Session import is optional. Review it if you want Bcode to discover supported history sources later."
.to_owned()
};
ImportSetupCard {
configured,
optional: !configured,
safety_notes: vec![
"Discovery is best-effort, bounded, and non-mutating.".to_owned(),
"Normal onboarding does not full-replay event logs.".to_owned(),
"Normal onboarding does not repair, rebuild, or write partial indexes for damaged sessions."
.to_owned(),
"Degraded or repair-required state is surfaced clearly for explicit follow-up.".to_owned(),
],
repair_is_explicit: true,
story,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginSetupCard {
pub enabled_plugins: BTreeSet<String>,
pub disabled_plugins: BTreeSet<String>,
pub story: String,
}
#[must_use]
pub fn plugin_setup_card(
enabled_plugins: BTreeSet<String>,
disabled_plugins: BTreeSet<String>,
) -> PluginSetupCard {
let story = if enabled_plugins.is_empty() && disabled_plugins.is_empty() {
"Bundled plugins are ready by default, and every capability remains reviewable and disableable."
.to_owned()
} else {
"Plugin choices are customized. Bcode will respect enabled and disabled plugin state during launch."
.to_owned()
};
PluginSetupCard {
enabled_plugins,
disabled_plugins,
story,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupPlanAction {
pub section_id: SetupSectionId,
pub kind: String,
pub title: String,
pub body: String,
pub mutating: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardingStartupCommand {
NormalTui,
ExplicitOnboard,
NewSession,
Server,
SessionManagement,
Scripting,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingAutoTriggerDecision {
pub should_start: bool,
pub reason: String,
}
#[must_use]
pub fn should_auto_start_onboarding(
command: OnboardingStartupCommand,
terminal_interactive: bool,
progress: Option<&OnboardingProgress>,
config_summary: &SetupConfigSummary,
) -> OnboardingAutoTriggerDecision {
if command == OnboardingStartupCommand::ExplicitOnboard {
return OnboardingAutoTriggerDecision {
should_start: true,
reason: "explicit onboarding requested".to_owned(),
};
}
if command != OnboardingStartupCommand::NormalTui {
return OnboardingAutoTriggerDecision {
should_start: false,
reason: "command is not normal TUI startup".to_owned(),
};
}
if !terminal_interactive {
return OnboardingAutoTriggerDecision {
should_start: false,
reason: "terminal is not interactive".to_owned(),
};
}
if progress.is_some_and(|progress| progress.first_run_completed) {
return OnboardingAutoTriggerDecision {
should_start: false,
reason: "first-run onboarding already completed".to_owned(),
};
}
let has_provider = config_summary
.capabilities
.contains(&SetupConfigCapability::ProviderSelection);
let has_model = config_summary
.capabilities
.contains(&SetupConfigCapability::ModelSelection);
let has_auth = config_summary
.capabilities
.contains(&SetupConfigCapability::AuthConfiguration);
if has_provider && has_model && has_auth {
return OnboardingAutoTriggerDecision {
should_start: false,
reason: "usable provider/model/auth setup already exists".to_owned(),
};
}
OnboardingAutoTriggerDecision {
should_start: true,
reason: "normal interactive startup has incomplete setup".to_owned(),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingDraftSetup {
pub providers: BTreeSet<String>,
pub auth_profiles: BTreeSet<String>,
pub model_profile: Option<String>,
pub permission_preset: Option<String>,
pub session_import_reviewed: bool,
pub plugins_reviewed: bool,
}
impl OnboardingDraftSetup {
pub fn toggle_provider(&mut self, provider: &str) {
if !self.providers.remove(provider) {
self.providers.insert(provider.to_owned());
}
}
pub fn toggle_auth_profile(&mut self, profile: &str) {
if !self.auth_profiles.remove(profile) {
self.auth_profiles.insert(profile.to_owned());
}
}
pub fn set_model_profile(&mut self, profile: &str) {
self.model_profile = Some(profile.to_owned());
}
pub fn cycle_permission_preset(&mut self) {
let next = if self.permission_preset.is_none()
|| self.permission_preset.as_deref() == Some("autonomous")
{
"cautious"
} else if self.permission_preset.as_deref() == Some("balanced") {
"autonomous"
} else {
"balanced"
};
self.permission_preset = Some(next.to_owned());
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupApplyReconciliation {
pub draft_present: bool,
pub config_summary: SetupConfigSummary,
pub secure_import_reconciliation: SecureImportReconciliation,
}
#[must_use]
pub fn generate_setup_plan_from_draft(
draft: &OnboardingDraftSetup,
secure_import_plans: &[SecureCredentialImportPlan],
config_summary: &SetupConfigSummary,
) -> SetupPlanReview {
let mut actions = Vec::new();
for provider in &draft.providers {
actions.push(SetupPlanAction {
section_id: SetupSectionId::Providers,
kind: format!("provider:{provider}"),
title: format!("Configure provider {provider}"),
body: "Persist provider selection through config/domain services when applying setup."
.to_owned(),
mutating: true,
});
}
for profile in &draft.auth_profiles {
actions.push(SetupPlanAction {
section_id: SetupSectionId::SecureVault,
kind: format!("auth-profile:{profile}"),
title: format!("Configure auth profile {profile}"),
body: "Persist secure auth profile references without storing raw secret values."
.to_owned(),
mutating: true,
});
}
if let Some(profile) = &draft.model_profile {
actions.push(SetupPlanAction {
section_id: SetupSectionId::Models,
kind: format!("model-profile:{profile}"),
title: format!("Select model profile {profile}"),
body: "Persist model profile/default through TOML-backed config APIs.".to_owned(),
mutating: true,
});
}
if let Some(preset) = &draft.permission_preset {
actions.push(SetupPlanAction {
section_id: SetupSectionId::Permissions,
kind: format!("permission-preset:{preset}"),
title: format!("Use {preset} permission preset"),
body: "Persist the selected permission behavior through permission/config services."
.to_owned(),
mutating: true,
});
}
if draft.session_import_reviewed {
actions.push(SetupPlanAction {
section_id: SetupSectionId::Imports,
kind: "session-import-reviewed".to_owned(),
title: "Record session import review".to_owned(),
body:
"Record that import sources were reviewed without replaying or repairing sessions."
.to_owned(),
mutating: true,
});
}
if draft.plugins_reviewed {
actions.push(SetupPlanAction {
section_id: SetupSectionId::Plugins,
kind: "plugins-reviewed".to_owned(),
title: "Record plugin review".to_owned(),
body: "Record plugin review while leaving canonical plugin state in config/plugin services."
.to_owned(),
mutating: true,
});
}
for plan in secure_import_plans {
actions.push(SetupPlanAction {
section_id: SetupSectionId::SecureVault,
kind: format!("secure-import-preview:{}", plan.auth_profile),
title: format!("Securely import {}", plan.env_var),
body: "Preview secure import into sshenv; raw secret values are never stored here."
.to_owned(),
mutating: false,
});
}
let launch_ready = config_summary
.capabilities
.contains(&SetupConfigCapability::ProviderSelection)
|| !draft.providers.is_empty();
SetupPlanReview {
actions,
launch_ready,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingQuestion {
pub question_id: String,
pub section_id: SetupSectionId,
pub prompt: String,
pub options: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingQuestionnaire {
pub questions: Vec<OnboardingQuestion>,
}
#[must_use]
pub fn deterministic_onboarding_questionnaire(
draft: &OnboardingDraftSetup,
detection: &SetupDetectionSnapshot,
) -> OnboardingQuestionnaire {
let mut questions = welcome_questions(detection);
questions.extend(provider_questions(draft));
questions.extend(model_questions());
questions.extend(permission_questions());
questions.extend(import_plugin_questions());
OnboardingQuestionnaire { questions }
}
fn welcome_questions(detection: &SetupDetectionSnapshot) -> Vec<OnboardingQuestion> {
let detected_credentials = detection
.entries
.iter()
.any(|entry| entry.detector == "environment" && entry.key.ends_with("_API_KEY"));
vec![
onboarding_question(
"welcome-new-or-import",
SetupSectionId::Welcome,
"Are you new to Bcode or importing/continuing existing setup?",
&["new", "import", "continue"],
),
onboarding_question(
"welcome-depth",
SetupSectionId::Welcome,
"Do you want quick setup or full setup?",
&["quick", "full"],
),
onboarding_question(
"credentials-present",
SetupSectionId::SecureVault,
if detected_credentials {
"We found provider credentials in your environment. Move them to secure storage?"
} else {
"Do you already have provider credentials available?"
},
&["secure-import", "enter-later", "skip"],
),
onboarding_question(
"configure-providers-now",
SetupSectionId::Providers,
"Do you want to configure providers now?",
&["yes", "no", "later"],
),
onboarding_question(
"review-permissions-now",
SetupSectionId::Permissions,
"Do you want to review permissions now?",
&["cautious", "balanced", "autonomous", "later"],
),
onboarding_question(
"import-session-history",
SetupSectionId::Imports,
"Do you want to import existing session history?",
&["review", "skip", "later"],
),
]
}
fn provider_questions(draft: &OnboardingDraftSetup) -> Vec<OnboardingQuestion> {
let mut questions = Vec::new();
if draft.providers.is_empty() {
questions.push(onboarding_question(
"provider-choice",
SetupSectionId::Providers,
"Which providers do you want to use?",
&["openai-compatible", "bedrock", "xai", "openrouter", "none"],
));
} else {
questions.push(onboarding_question(
"default-provider",
SetupSectionId::Providers,
"Which provider should be default?",
&draft
.providers
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
));
}
questions.extend([
onboarding_question(
"api-key-or-subscription",
SetupSectionId::SecureVault,
"Do you have an API key/subscription already?",
&["api-key", "subscription", "not-yet"],
),
onboarding_question(
"local-open-provider",
SetupSectionId::Providers,
"Do you want to use a local/open provider?",
&["local", "open-compatible", "cloud-only", "later"],
),
onboarding_question(
"multiple-provider-profiles",
SetupSectionId::Providers,
"Do you want multiple provider profiles/subscriptions?",
&["single", "multiple", "later"],
),
]);
questions
}
fn model_questions() -> Vec<OnboardingQuestion> {
vec![
onboarding_question(
"default-model-profile",
SetupSectionId::Models,
"Pick a default model/profile.",
&["default", "fast", "balanced", "quality"],
),
onboarding_question(
"model-tradeoff",
SetupSectionId::Models,
"Pick cost/speed/quality preference.",
&["speed", "balanced", "quality", "lowest-cost"],
),
onboarding_question(
"reasoning-profile",
SetupSectionId::Models,
"Pick reasoning/default thinking profile when supported.",
&["standard", "deeper", "minimal", "provider-default"],
),
onboarding_question(
"model-aliases",
SetupSectionId::Models,
"Configure aliases if desired.",
&["configure", "skip", "later"],
),
]
}
fn permission_questions() -> Vec<OnboardingQuestion> {
vec![
onboarding_question(
"permission-mode",
SetupSectionId::Permissions,
"Choose cautious/balanced/autonomous mode.",
&["cautious", "balanced", "autonomous"],
),
onboarding_question(
"permission-ask-behavior",
SetupSectionId::Permissions,
"Bcode asks before sensitive tool/file/network operations based on your preset.",
&["review", "accept", "customize-later"],
),
onboarding_question(
"permission-boundaries",
SetupSectionId::Permissions,
"Review tool/file/network boundaries.",
&["review", "accept", "customize-later"],
),
onboarding_question(
"permission-later-customization",
SetupSectionId::Permissions,
"You can customize permissions later in Control Center.",
&["ok", "open-control-center-later"],
),
]
}
fn import_plugin_questions() -> Vec<OnboardingQuestion> {
vec![
onboarding_question(
"session-import-sources",
SetupSectionId::Imports,
"Review supported session import sources.",
&["review", "skip", "later"],
),
onboarding_question(
"session-import-safe-optional",
SetupSectionId::Imports,
"Session import is optional and safe; normal setup does not replay or repair sessions.",
&["understood", "review", "skip"],
),
onboarding_question(
"bundled-plugin-review",
SetupSectionId::Plugins,
"Review bundled plugins.",
&["review", "accept-defaults", "disable-some"],
),
onboarding_question(
"disable-plugin-capabilities",
SetupSectionId::Plugins,
"Allow disabling plugin capabilities.",
&["disable-some", "keep-defaults", "later"],
),
]
}
fn onboarding_question(
question_id: &str,
section_id: SetupSectionId,
prompt: &str,
options: &[&str],
) -> OnboardingQuestion {
OnboardingQuestion {
question_id: question_id.to_owned(),
section_id,
prompt: prompt.to_owned(),
options: options.iter().map(ToString::to_string).collect(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupReadinessSeverity {
Info,
Recommended,
Blocking,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupReadinessItem {
pub section_id: SetupSectionId,
pub severity: SetupReadinessSeverity,
pub title: String,
pub body: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupOperationalFacts {
pub provider_available: bool,
pub model_available: bool,
pub authentication_available: bool,
}
#[must_use]
pub fn operational_setup_readiness(facts: &SetupOperationalFacts) -> SetupReadinessReport {
let mut items = Vec::new();
for (available, section_id, title) in [
(
facts.provider_available,
SetupSectionId::Providers,
"Select an enabled provider",
),
(
facts.model_available,
SetupSectionId::Models,
"Select a supported model",
),
(
facts.authentication_available,
SetupSectionId::SecureVault,
"Connect an available authentication profile",
),
] {
if !available {
items.push(SetupReadinessItem {
section_id,
severity: SetupReadinessSeverity::Blocking,
title: title.to_owned(),
body: "Current setup is incomplete or could not be verified locally.".to_owned(),
});
}
}
SetupReadinessReport {
launch_ready: items.is_empty(),
items,
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupContinuation {
#[default]
Close,
Launch,
Connection,
Model,
Credentials,
Settings,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupReadinessReport {
pub items: Vec<SetupReadinessItem>,
pub launch_ready: bool,
}
#[must_use]
pub fn setup_readiness_report(
sections: &[ReconciledSetupSection],
recommendations: &[SetupRecommendation],
) -> SetupReadinessReport {
let mut items = Vec::new();
for section in sections {
match section.status {
SetupSectionStatus::Blocked => items.push(SetupReadinessItem {
section_id: section.section_id,
severity: SetupReadinessSeverity::Blocking,
title: format!("{} is blocked", section.section_id.as_str()),
body: "Resolve this setup section before launch.".to_owned(),
}),
SetupSectionStatus::Recommended | SetupSectionStatus::NeedsAttention => {
items.push(SetupReadinessItem {
section_id: section.section_id,
severity: SetupReadinessSeverity::Recommended,
title: format!("Review {}", section.section_id.as_str()),
body: "This setup section has recommended follow-up work.".to_owned(),
});
}
_ => {}
}
}
for recommendation in recommendations
.iter()
.filter(|recommendation| recommendation.dismissed_at_ms.is_none())
{
items.push(SetupReadinessItem {
section_id: setup_section_id_from_str(&recommendation.section_id)
.unwrap_or(SetupSectionId::Welcome),
severity: SetupReadinessSeverity::Recommended,
title: recommendation.title.clone(),
body: recommendation.body.clone(),
});
}
let launch_ready = !items
.iter()
.any(|item| item.severity == SetupReadinessSeverity::Blocking);
SetupReadinessReport {
items,
launch_ready,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupPlanReview {
pub actions: Vec<SetupPlanAction>,
pub launch_ready: bool,
}
impl SetupPlanReview {
#[must_use]
pub fn launch_action(&self) -> Option<SetupPlanAction> {
self.launch_ready.then(|| SetupPlanAction {
section_id: SetupSectionId::Launch,
kind: "launch".to_owned(),
title: "Launch Bcode".to_owned(),
body: "Your setup is ready enough to start using Bcode.".to_owned(),
mutating: false,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppliedSetupPlan {
pub applied_actions: Vec<SetupPlanAction>,
pub skipped_actions: Vec<SetupPlanAction>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SettingsExperienceMode {
FirstRunOnboarding,
ControlCenter,
}
#[must_use]
pub fn settings_experience_mode(progress: Option<&OnboardingProgress>) -> SettingsExperienceMode {
if progress.is_some_and(|progress| progress.first_run_completed) {
SettingsExperienceMode::ControlCenter
} else {
SettingsExperienceMode::FirstRunOnboarding
}
}
#[must_use]
pub fn generate_setup_plan(
sections: &[ReconciledSetupSection],
recommendations: &[SetupRecommendation],
) -> SetupPlanReview {
let blocked = sections
.iter()
.any(|section| section.status == SetupSectionStatus::Blocked);
let readiness = setup_readiness_report(sections, recommendations);
let mut actions = recommendations
.iter()
.filter(|recommendation| recommendation.dismissed_at_ms.is_none())
.map(|recommendation| SetupPlanAction {
section_id: setup_section_id_from_str(&recommendation.section_id)
.unwrap_or(SetupSectionId::Welcome),
kind: recommendation.kind.clone(),
title: recommendation.title.clone(),
body: recommendation.body.clone(),
mutating: true,
})
.collect::<Vec<_>>();
if !blocked && actions.is_empty() {
actions.push(SetupPlanAction {
section_id: SetupSectionId::Launch,
kind: "launch".to_owned(),
title: "Launch Bcode".to_owned(),
body: "Your setup is ready enough to start using Bcode.".to_owned(),
mutating: false,
});
}
SetupPlanReview {
actions,
launch_ready: !blocked && readiness.launch_ready,
}
}
fn setup_section_id_from_str(value: &str) -> Option<SetupSectionId> {
SetupSectionId::all()
.into_iter()
.find(|section| section.as_str() == value)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupDetectionSnapshot {
pub entries: Vec<DetectionCacheEntry>,
pub recommendations: Vec<SetupRecommendation>,
}
#[must_use]
pub fn detect_setup_environment_from_vars(
vars: &BTreeMap<String, String>,
detected_at_ms: u64,
) -> SetupDetectionSnapshot {
let secret_names = [
"OPENAI_API_KEY",
"XAI_API_KEY",
"OPENROUTER_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GITHUB_TOKEN",
];
let metadata_names = ["AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION"];
let mut entries = Vec::new();
let mut recommendations = Vec::new();
for name in secret_names {
if vars.get(name).is_some_and(|value| !value.is_empty()) {
entries.push(DetectionCacheEntry {
detector: "environment".to_owned(),
key: name.to_owned(),
value: json!({ "present": true, "secret_value_stored": false }),
confidence: "high".to_owned(),
source: "environment".to_owned(),
detected_at_ms,
expires_at_ms: None,
});
recommendations.push(SetupRecommendation {
recommendation_id: format!("secure-env-{}", name.to_ascii_lowercase()),
section_id: SetupSectionId::SecureVault.as_str().to_owned(),
kind: "secure_env_secret".to_owned(),
status: SetupSectionStatus::Recommended.as_str().to_owned(),
priority: 100,
title: format!("Secure detected {name}"),
body: "Move this detected environment credential into secure storage.".to_owned(),
created_at_ms: detected_at_ms,
dismissed_at_ms: None,
});
}
}
for name in ["SSHENV_PROFILE", "SSHENV_VAULT", "SSHENV_DEVICE_SEAL"] {
if vars.get(name).is_some_and(|value| !value.is_empty()) {
entries.push(DetectionCacheEntry {
detector: "environment".to_owned(),
key: name.to_owned(),
value: json!({ "present": true, "value_stored": false }),
confidence: "medium".to_owned(),
source: "environment".to_owned(),
detected_at_ms,
expires_at_ms: None,
});
}
}
for name in metadata_names {
if let Some(value) = vars.get(name).filter(|value| !value.is_empty()) {
entries.push(DetectionCacheEntry {
detector: "environment".to_owned(),
key: name.to_owned(),
value: json!({ "present": true, "value": value }),
confidence: "high".to_owned(),
source: "environment".to_owned(),
detected_at_ms,
expires_at_ms: None,
});
}
}
for (name, value) in vars
.iter()
.filter(|(name, value)| name.starts_with("BCODE_") && !value.is_empty())
{
let looks_secret = name.contains("KEY")
|| name.contains("TOKEN")
|| name.contains("SECRET")
|| name.contains("PASSWORD");
entries.push(DetectionCacheEntry {
detector: "environment".to_owned(),
key: name.clone(),
value: if looks_secret {
json!({ "present": true, "secret_value_stored": false })
} else {
json!({ "present": true, "value": value })
},
confidence: "high".to_owned(),
source: "environment".to_owned(),
detected_at_ms,
expires_at_ms: None,
});
}
SetupDetectionSnapshot {
entries,
recommendations,
}
}
#[must_use]
pub fn detect_setup_environment(detected_at_ms: u64) -> SetupDetectionSnapshot {
let enabled = bcode_config::load_config().is_ok_and(|config| {
config
.onboarding
.credential_discovery_enabled(false, &bcode_config::ProcessConfigEnvironment)
});
detect_setup_environment_with_policy(enabled, detected_at_ms)
}
#[must_use]
pub fn detect_setup_environment_with_policy(
enabled: bool,
detected_at_ms: u64,
) -> SetupDetectionSnapshot {
if !enabled {
return SetupDetectionSnapshot {
entries: Vec::new(),
recommendations: Vec::new(),
};
}
detect_setup_environment_from_vars(&std::env::vars().collect(), detected_at_ms)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupMapSnapshot {
pub sections: Vec<ReconciledSetupSection>,
}
impl SetupMapSnapshot {
#[must_use]
pub fn from_reconciliation(
persisted_sections: &[OnboardingSection],
input: &SetupReconciliationInput,
) -> Self {
Self {
sections: reconcile_setup_sections(persisted_sections, input),
}
}
#[must_use]
pub fn render_text_map(&self) -> String {
self.sections
.iter()
.map(|section| {
format!(
"{}:{}{}",
section.section_id.as_str(),
section.status.as_str(),
if section.visited { ":visited" } else { "" }
)
})
.collect::<Vec<_>>()
.join(" -> ")
}
}
pub type SettingsResult<T> = Result<T, SettingsError>;
#[derive(Debug, thiserror::Error)]
pub enum SettingsError {
#[error("settings I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("failed to open settings database: {0}")]
DatabaseOpen(String),
#[error("settings database operation failed: {0}")]
Database(#[from] DatabaseError),
#[error("settings database migration failed: {0}")]
Migration(String),
#[error("settings JSON operation failed: {0}")]
Json(#[from] serde_json::Error),
#[error("settings database row is missing required column {0}")]
MissingColumn(&'static str),
#[error("settings database worker panicked")]
DatabaseWorkerPanicked,
#[error("settings config operation failed: {0}")]
Config(#[from] bcode_config::ConfigError),
}
async fn open_database(path: &Path) -> SettingsResult<Box<dyn Database>> {
let mut attempt = 0_u32;
let mut delay = DATABASE_OPEN_INITIAL_RETRY_DELAY;
loop {
match switchy::database_connection::builder()
.turso()
.with_path(path)
.with_busy_timeout(DATABASE_BUSY_TIMEOUT)
.with_multiprocess_wal(false)
.build()
.await
{
Ok(database) => return Ok(database),
Err(error)
if is_database_lock_error(&error) && attempt < DATABASE_OPEN_RETRY_ATTEMPTS =>
{
attempt = attempt.saturating_add(1);
tokio::time::sleep(delay).await;
delay = delay.saturating_mul(2).min(DATABASE_OPEN_MAX_RETRY_DELAY);
}
Err(error) => return Err(SettingsError::DatabaseOpen(error.to_string())),
}
}
}
fn is_database_lock_error(error: &impl std::fmt::Display) -> bool {
let message = error.to_string().to_ascii_lowercase();
message.contains("database is locked") || message.contains("busy")
}
async fn run_migrations(database: &dyn Database) -> SettingsResult<()> {
let runner = MigrationRunner::new(Box::new(settings_migrations()))
.with_table_name(MIGRATIONS_TABLE.to_owned());
runner
.run(database)
.await
.map_err(|error| SettingsError::Migration(error.to_string()))?;
Ok(())
}
fn settings_migrations() -> CodeMigrationSource<'static> {
let mut source = CodeMigrationSource::new();
source.add_migration(control_state_kv_table_migration());
source.add_migration(onboarding_progress_table_migration());
source.add_migration(onboarding_sections_table_migration());
source.add_migration(detection_cache_table_migration());
source.add_migration(setup_recommendations_table_migration());
source
}
fn control_state_kv_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"001_control_state_kv".to_owned(),
Box::new(
create_table("control_state_kv")
.if_not_exists(true)
.column(text_column("key"))
.column(text_column("value_json"))
.column(int_column("updated_at_ms"))
.primary_key("key"),
),
None,
)
}
fn onboarding_progress_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"002_onboarding_progress".to_owned(),
Box::new(
create_table("onboarding_progress")
.if_not_exists(true)
.column(int_column("id"))
.column(text_column("mode"))
.column(int_column("first_run_completed"))
.column(nullable_text_column("last_section"))
.column(nullable_int_column("last_opened_at_ms"))
.column(nullable_int_column("completed_at_ms"))
.primary_key("id"),
),
None,
)
}
fn onboarding_sections_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"003_onboarding_sections".to_owned(),
Box::new(
create_table("onboarding_sections")
.if_not_exists(true)
.column(text_column("section_id"))
.column(text_column("status"))
.column(int_column("visited"))
.column(nullable_int_column("visited_at_ms"))
.column(nullable_int_column("completed_at_ms"))
.column(nullable_int_column("skipped_at_ms"))
.column(int_column("dismissed"))
.primary_key("section_id"),
),
None,
)
}
fn detection_cache_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"004_detection_cache".to_owned(),
Box::new(
create_table("detection_cache")
.if_not_exists(true)
.column(text_column("detection_id"))
.column(text_column("detector"))
.column(text_column("key"))
.column(text_column("value_json"))
.column(text_column("confidence"))
.column(text_column("source"))
.column(int_column("detected_at_ms"))
.column(nullable_int_column("expires_at_ms"))
.primary_key("detection_id"),
),
None,
)
}
fn setup_recommendations_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"005_setup_recommendations".to_owned(),
Box::new(
create_table("setup_recommendations")
.if_not_exists(true)
.column(text_column("recommendation_id"))
.column(text_column("section_id"))
.column(text_column("kind"))
.column(text_column("status"))
.column(int_column("priority"))
.column(text_column("title"))
.column(text_column("body"))
.column(int_column("created_at_ms"))
.column(nullable_int_column("dismissed_at_ms"))
.primary_key("recommendation_id"),
),
None,
)
}
fn text_column(name: &str) -> Column {
Column {
name: name.to_owned(),
nullable: false,
auto_increment: false,
data_type: DataType::Text,
default: None,
}
}
fn nullable_text_column(name: &str) -> Column {
Column {
name: name.to_owned(),
nullable: true,
auto_increment: false,
data_type: DataType::Text,
default: None,
}
}
fn int_column(name: &str) -> Column {
Column {
name: name.to_owned(),
nullable: false,
auto_increment: false,
data_type: DataType::BigInt,
default: None,
}
}
fn nullable_int_column(name: &str) -> Column {
Column {
name: name.to_owned(),
nullable: true,
auto_increment: false,
data_type: DataType::BigInt,
default: None,
}
}
fn control_state_value_from_row(row: &Row) -> SettingsResult<ControlStateValue> {
Ok(ControlStateValue {
key: required_text(row, "key")?,
value: serde_json::from_str(&required_text(row, "value_json")?)?,
updated_at_ms: i64_to_u64(required_i64(row, "updated_at_ms")?),
})
}
fn onboarding_progress_from_row(row: &Row) -> SettingsResult<OnboardingProgress> {
Ok(OnboardingProgress {
mode: required_text(row, "mode")?,
first_run_completed: required_bool(row, "first_run_completed")?,
last_section: optional_text(row, "last_section"),
last_opened_at_ms: optional_i64(row, "last_opened_at_ms").map(i64_to_u64),
completed_at_ms: optional_i64(row, "completed_at_ms").map(i64_to_u64),
})
}
fn onboarding_section_from_row(row: &Row) -> SettingsResult<OnboardingSection> {
Ok(OnboardingSection {
section_id: required_text(row, "section_id")?,
status: required_text(row, "status")?,
visited: required_bool(row, "visited")?,
visited_at_ms: optional_i64(row, "visited_at_ms").map(i64_to_u64),
completed_at_ms: optional_i64(row, "completed_at_ms").map(i64_to_u64),
skipped_at_ms: optional_i64(row, "skipped_at_ms").map(i64_to_u64),
dismissed: required_bool(row, "dismissed")?,
})
}
fn detection_cache_entry_from_row(row: &Row) -> SettingsResult<DetectionCacheEntry> {
Ok(DetectionCacheEntry {
detector: required_text(row, "detector")?,
key: required_text(row, "key")?,
value: serde_json::from_str(&required_text(row, "value_json")?)?,
confidence: required_text(row, "confidence")?,
source: required_text(row, "source")?,
detected_at_ms: i64_to_u64(required_i64(row, "detected_at_ms")?),
expires_at_ms: optional_i64(row, "expires_at_ms").map(i64_to_u64),
})
}
fn setup_recommendation_from_row(row: &Row) -> SettingsResult<SetupRecommendation> {
Ok(SetupRecommendation {
recommendation_id: required_text(row, "recommendation_id")?,
section_id: required_text(row, "section_id")?,
kind: required_text(row, "kind")?,
status: required_text(row, "status")?,
priority: i32::try_from(required_i64(row, "priority")?).unwrap_or_default(),
title: required_text(row, "title")?,
body: required_text(row, "body")?,
created_at_ms: i64_to_u64(required_i64(row, "created_at_ms")?),
dismissed_at_ms: optional_i64(row, "dismissed_at_ms").map(i64_to_u64),
})
}
fn required_text(row: &Row, column: &'static str) -> SettingsResult<String> {
row.get(column)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.ok_or(SettingsError::MissingColumn(column))
}
fn optional_text(row: &Row, column: &'static str) -> Option<String> {
row.get(column)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
}
fn required_i64(row: &Row, column: &'static str) -> SettingsResult<i64> {
row.get(column)
.and_then(|value| value.as_i64())
.ok_or(SettingsError::MissingColumn(column))
}
fn optional_i64(row: &Row, column: &'static str) -> Option<i64> {
row.get(column).and_then(|value| value.as_i64())
}
fn required_bool(row: &Row, column: &'static str) -> SettingsResult<bool> {
Ok(required_i64(row, column)? != 0)
}
fn bool_to_i64(value: bool) -> i64 {
i64::from(value)
}
fn u64_to_i64(value: u64) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}
fn i64_to_u64(value: i64) -> u64 {
u64::try_from(value).unwrap_or_default()
}
fn detection_id(detector: &str, key: &str) -> String {
format!("{detector}\u{1f}{key}")
}
fn remove_file_if_exists(path: &Path) -> SettingsResult<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn sqlite_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
PathBuf::from(format!("{}-{suffix}", path.display()))
}
#[cfg(test)]
mod tests {
#[test]
fn operational_readiness_requires_all_current_facts() {
for mask in 0..8 {
let facts = super::SetupOperationalFacts {
provider_available: mask & 1 != 0,
model_available: mask & 2 != 0,
authentication_available: mask & 4 != 0,
};
assert_eq!(
super::operational_setup_readiness(&facts).launch_ready,
mask == 7
);
}
}
#[test]
fn disabled_discovery_returns_no_sources_or_recommendations() {
let snapshot = super::detect_setup_environment_with_policy(false, 42);
assert!(snapshot.entries.is_empty());
assert!(snapshot.recommendations.is_empty());
}
use super::*;
use serde_json::json;
#[test]
fn health_reports_available_and_reset_removes_database() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let db_path = temp.path().join("settings.db");
let store = SettingsStore::from_settings_db_path(&db_path);
assert_eq!(store.health(), SettingsDbHealth::Available);
assert!(db_path.exists());
store
.reset_database()
.expect("database reset should succeed");
assert!(!db_path.exists());
}
#[test]
fn health_reports_unavailable_for_directory_database_path() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path());
assert!(matches!(
store.health(),
SettingsDbHealth::Unavailable { .. }
));
}
#[test]
fn control_state_redacts_secret_like_json_keys() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
store
.put_control_state(
"setup.secret-test",
&json!({
"api_key": "sk-secret-value",
"nested": { "access_token": "token-secret" },
"safe": "visible",
}),
5,
)
.expect("state should write");
let value = store
.control_state("setup.secret-test")
.expect("state should load")
.expect("state should exist");
let encoded = serde_json::to_string(&value).expect("state should encode");
assert!(encoded.contains("visible"));
assert!(!encoded.contains("sk-secret-value"));
assert!(!encoded.contains("token-secret"));
}
#[test]
fn secret_audit_flags_obvious_secret_markers() {
let safe = audit_no_secret_material("safe", "OPENAI_API_KEY present but value hidden");
let unsafe_report = audit_no_secret_material("unsafe", "api_key=sk-secret-value");
assert!(safe.safe);
assert!(!unsafe_report.safe);
assert!(!unsafe_report.findings.is_empty());
}
#[test]
fn degraded_panel_explains_health_state() {
let panel = settings_degraded_panel(&SettingsDbHealth::Unavailable {
message: "cannot open".to_owned(),
});
assert!(!panel.available);
assert!(panel.reset_available);
assert!(panel.message.contains("degraded"));
}
#[test]
fn skill_tool_decisions_round_trip_through_control_state() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let mut skill_ids = BTreeSet::new();
skill_ids.insert(bcode_skill_models::SkillId::new("code-review"));
let mut state = SkillToolDecisionState::default();
state.upsert(bcode_skill_models::SkillToolDecisionEntry {
key: bcode_skill_models::SkillToolDecisionKey {
skill_ids,
tool_name: "filesystem_read".to_string(),
scope: bcode_skill_models::SkillToolDecisionScope::Workspace {
workspace_id: "repo".to_string(),
},
},
decision: bcode_skill_models::SkillToolDecision::Allow,
remembered_at_ms: 123,
reason: Some("approved by user".to_string()),
});
store
.save_skill_tool_decisions(&state, 456)
.expect("skill decisions should save");
let loaded = store
.skill_tool_decisions()
.expect("skill decisions should load");
assert_eq!(loaded, state);
assert_eq!(
store
.control_state(SKILL_TOOL_DECISIONS_KEY)
.expect("raw state should load")
.expect("raw state should exist")
.updated_at_ms,
456
);
}
#[test]
fn control_state_kv_round_trips() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
store
.put_control_state("setup.theme", &json!({"name": "default"}), 100)
.expect("control state should be written");
let value = store
.control_state("setup.theme")
.expect("control state should be read")
.expect("control state should exist");
assert_eq!(value.key, "setup.theme");
assert_eq!(value.value, json!({"name": "default"}));
assert_eq!(value.updated_at_ms, 100);
store
.put_control_state("setup.other", &json!({"name": "other"}), 101)
.expect("second control state should be written");
assert!(
store
.control_state("setup.theme")
.expect("first control state should reload")
.is_some()
);
assert!(
store
.control_state("setup.other")
.expect("second control state should load")
.is_some()
);
}
#[test]
fn onboarding_progress_and_sections_round_trip() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let progress = OnboardingProgress {
mode: "first_run".to_owned(),
first_run_completed: false,
last_section: Some("providers".to_owned()),
last_opened_at_ms: Some(200),
completed_at_ms: None,
};
let section = OnboardingSection {
section_id: "providers".to_owned(),
status: "visited".to_owned(),
visited: true,
visited_at_ms: Some(201),
completed_at_ms: None,
skipped_at_ms: None,
dismissed: false,
};
store
.save_onboarding_progress(&progress)
.expect("progress should be saved");
store
.save_onboarding_section(§ion)
.expect("section should be saved");
assert_eq!(
store.onboarding_progress().expect("progress should load"),
Some(progress)
);
assert_eq!(
store.onboarding_sections().expect("sections should load"),
vec![section]
);
}
#[test]
fn detection_cache_and_recommendations_round_trip_without_secret_values() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let detection = DetectionCacheEntry {
detector: "environment".to_owned(),
key: "OPENAI_API_KEY".to_owned(),
value: json!({"present": true, "value_stored": false}),
confidence: "high".to_owned(),
source: "environment".to_owned(),
detected_at_ms: 300,
expires_at_ms: Some(400),
};
let recommendation = SetupRecommendation {
recommendation_id: "secure-openai-env".to_owned(),
section_id: "secure_vault".to_owned(),
kind: "secure_env_secret".to_owned(),
status: "recommended".to_owned(),
priority: 100,
title: "Secure detected OpenAI key".to_owned(),
body: "Move this detected key into secure storage.".to_owned(),
created_at_ms: 301,
dismissed_at_ms: None,
};
store
.save_detection_cache_entry(&detection)
.expect("detection should be saved");
store
.save_setup_recommendation(&recommendation)
.expect("recommendation should be saved");
assert_eq!(
store
.detection_cache_entries()
.expect("detections should load"),
vec![detection]
);
assert_eq!(
store
.setup_recommendations()
.expect("recommendations should load"),
vec![recommendation]
);
}
#[test]
fn config_summary_maps_config_to_reconciliation_input() {
let mut config = bcode_config::BcodeConfig::default();
config.model.profile = Some("fast".to_owned());
config.model.profiles.insert(
"fast".to_owned(),
bcode_config::ModelProfileConfig {
provider_plugin_id: "bcode.openai-compatible".to_owned(),
model_id: Some("gpt-test".to_owned()),
..bcode_config::ModelProfileConfig::default()
},
);
config.plugins.enabled.insert("bcode.filesystem".to_owned());
let summary = SetupConfigSummary::from_config(&config);
let input = summary.reconciliation_input();
assert!(
summary
.capabilities
.contains(&SetupConfigCapability::ProviderSelection)
);
assert!(
summary
.capabilities
.contains(&SetupConfigCapability::ModelSelection)
);
assert!(
input
.configured_sections
.contains(&SetupSectionId::Providers)
);
assert!(input.configured_sections.contains(&SetupSectionId::Models));
assert!(input.configured_sections.contains(&SetupSectionId::Plugins));
}
#[test]
fn auto_start_onboarding_only_for_interactive_incomplete_normal_tui() {
let summary = SetupConfigSummary::default();
let decision =
should_auto_start_onboarding(OnboardingStartupCommand::NormalTui, true, None, &summary);
assert!(decision.should_start);
assert!(
!should_auto_start_onboarding(OnboardingStartupCommand::Server, true, None, &summary,)
.should_start
);
assert!(
!should_auto_start_onboarding(
OnboardingStartupCommand::NormalTui,
false,
None,
&summary,
)
.should_start
);
}
#[test]
fn auto_start_onboarding_skips_completed_or_usable_setup() {
let progress = OnboardingProgress {
mode: "first_run".to_owned(),
first_run_completed: true,
last_section: None,
last_opened_at_ms: None,
completed_at_ms: Some(1),
};
let completed_decision = should_auto_start_onboarding(
OnboardingStartupCommand::NormalTui,
true,
Some(&progress),
&SetupConfigSummary::default(),
);
let usable_summary = SetupConfigSummary {
capabilities: BTreeSet::from([
SetupConfigCapability::ProviderSelection,
SetupConfigCapability::ModelSelection,
SetupConfigCapability::AuthConfiguration,
]),
enabled_plugins: BTreeSet::new(),
disabled_plugins: BTreeSet::new(),
};
let usable_decision = should_auto_start_onboarding(
OnboardingStartupCommand::NormalTui,
true,
None,
&usable_summary,
);
assert!(!completed_decision.should_start);
assert!(!usable_decision.should_start);
}
#[test]
fn auth_security_detection_finds_sshenv_and_device_seal_config() {
let mut config = bcode_config::BcodeConfig::default();
config.auth.profiles.insert(
"openai".to_owned(),
bcode_config::AuthProfileConfig {
backend: "sshenv".to_owned(),
provider_id: None,
owner_plugin_id: None,
settings: BTreeMap::from([("device_seal".to_owned(), "required".to_owned())]),
..bcode_config::AuthProfileConfig::default()
},
);
let detection = detect_auth_security_from_config(&config);
assert!(detection.sshenv_available);
assert!(detection.device_seal_requested);
assert!(detection.sshenv_profiles.contains("openai"));
}
#[test]
fn setup_cards_cover_provider_model_permissions_and_import_requirements() {
let draft = OnboardingDraftSetup {
providers: BTreeSet::from(["openai-compatible".to_owned(), "openrouter".to_owned()]),
auth_profiles: BTreeSet::from(["work".to_owned(), "personal".to_owned()]),
..OnboardingDraftSetup::default()
};
let provider_card = provider_auth_setup_card(&SetupConfigSummary::default(), &draft);
let model_card = model_setup_card(Some("balanced".to_owned()), None);
let permission_card = permission_setup_card(false);
let import_card = import_setup_card(false);
assert_eq!(provider_card.draft_providers.len(), 2);
assert_eq!(provider_card.auth_profiles.len(), 2);
assert!(provider_card.can_switch_default);
assert!(provider_card.can_add_later);
assert!(
provider_card
.provider_copy
.iter()
.any(|copy| copy.contains("OpenRouter"))
);
assert!(model_card.configured);
assert!(model_card.reasoning_defaults_available);
assert!(model_card.ignore_unignore_available);
assert!(
model_card
.tradeoff_copy
.iter()
.any(|copy| copy.contains("cost"))
);
assert!(permission_card.presets.contains(&"cautious".to_owned()));
assert!(permission_card.presets.contains(&"balanced".to_owned()));
assert!(permission_card.presets.contains(&"autonomous".to_owned()));
assert!(
permission_card
.blocked_behavior
.iter()
.any(|copy| copy.contains("Secret"))
);
assert!(import_card.optional);
assert!(import_card.repair_is_explicit);
assert!(
import_card
.safety_notes
.iter()
.any(|note| note.contains("does not full-replay"))
);
}
#[test]
fn deterministic_questionnaire_contains_core_branching_questions() {
let mut vars = BTreeMap::new();
vars.insert("OPENAI_API_KEY".to_owned(), "sk-secret".to_owned());
let detection = detect_setup_environment_from_vars(&vars, 42);
let draft = OnboardingDraftSetup {
providers: BTreeSet::from(["openai-compatible".to_owned()]),
..OnboardingDraftSetup::default()
};
let questionnaire = deterministic_onboarding_questionnaire(&draft, &detection);
let text = serde_json::to_string(&questionnaire).expect("questionnaire should encode");
assert!(questionnaire.questions.iter().any(|question| {
question.prompt.contains("quick setup") && question.options.contains(&"full".to_owned())
}));
assert!(questionnaire.questions.iter().any(|question| {
question.question_id == "credentials-present"
&& question.prompt.contains("secure storage")
}));
assert!(questionnaire.questions.iter().any(|question| {
question.question_id == "default-provider"
&& question.options.contains(&"openai-compatible".to_owned())
}));
assert!(!text.contains("sk-secret"));
}
#[test]
fn setup_plan_from_draft_applies_and_reconciles_state() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let draft = OnboardingDraftSetup {
providers: BTreeSet::from(["openai-compatible".to_owned()]),
auth_profiles: BTreeSet::from(["default".to_owned()]),
model_profile: Some("default".to_owned()),
permission_preset: Some("balanced".to_owned()),
session_import_reviewed: true,
plugins_reviewed: true,
};
let config = bcode_config::BcodeConfig::default();
let plan =
generate_setup_plan_from_draft(&draft, &[], &SetupConfigSummary::from_config(&config));
let applied = store
.apply_setup_plan(&plan, 900)
.expect("plan should apply");
let reconciliation = store
.reconcile_setup_apply(&config)
.expect("reconciliation should load");
assert!(plan.launch_ready);
assert_eq!(applied.applied_actions.len(), plan.actions.len());
assert!(!reconciliation.draft_present);
assert!(
store
.control_state("setup.last_applied_plan")
.expect("control state should load")
.is_some()
);
}
#[test]
fn onboarding_draft_setup_persists_provider_auth_model_and_permissions() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let draft = store
.toggle_draft_provider("openai-compatible", 10)
.expect("provider should toggle");
assert!(draft.providers.contains("openai-compatible"));
let draft = store
.toggle_draft_auth_profile("default", 11)
.expect("auth profile should toggle");
assert!(draft.auth_profiles.contains("default"));
let draft = store
.select_draft_model_profile("default", 12)
.expect("model profile should persist");
assert_eq!(draft.model_profile.as_deref(), Some("default"));
let draft = store
.cycle_draft_permission_preset(13)
.expect("permission preset should cycle");
assert_eq!(draft.permission_preset.as_deref(), Some("cautious"));
let reloaded = store.onboarding_draft_setup().expect("draft should reload");
assert_eq!(reloaded, draft);
}
#[test]
fn secure_credential_story_panel_never_contains_secret_values() {
let entries = vec![DetectionCacheEntry {
detector: "environment".to_owned(),
key: "OPENAI_API_KEY".to_owned(),
value: json!({ "present": true, "secret_value_stored": false }),
confidence: "high".to_owned(),
source: "environment".to_owned(),
detected_at_ms: 70,
expires_at_ms: None,
}];
let plans = secure_import_plans_from_detection(&entries);
let panel = secure_credential_story_panel(
&plans,
&AuthSecurityDetection {
sshenv_available: true,
device_seal_requested: true,
sshenv_profiles: BTreeSet::from(["openai".to_owned()]),
},
);
let encoded = serde_json::to_string(&panel).expect("panel should encode");
assert!(encoded.contains("OPENAI_API_KEY"));
assert!(encoded.contains("sshenv"));
assert!(encoded.contains("Device sealing"));
assert!(!encoded.contains("sk-secret-value"));
}
#[test]
fn secure_import_plans_never_include_secret_values_and_reconcile_post_import() {
let vars = BTreeMap::from([("OPENAI_API_KEY".to_owned(), "sk-secret-value".to_owned())]);
let snapshot = detect_setup_environment_from_vars(&vars, 950);
let plans = secure_import_plans_from_detection(&snapshot.entries);
let encoded = serde_json::to_string(&plans).expect("plans should encode");
let auth_detection = AuthSecurityDetection {
sshenv_available: true,
device_seal_requested: true,
sshenv_profiles: BTreeSet::from(["openai".to_owned()]),
};
let reconciliation = reconcile_secure_import_plans(&plans, &auth_detection);
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].env_var, "OPENAI_API_KEY");
assert!(!encoded.contains("sk-secret-value"));
assert!(reconciliation.imported_profiles.contains("openai"));
assert!(reconciliation.pending_profiles.is_empty());
}
#[test]
fn section_visit_and_completion_persist_resume_state() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
store
.visit_onboarding_section(SetupSectionId::Providers, 700)
.expect("visit should persist");
let progress = store
.onboarding_progress()
.expect("progress should load")
.expect("progress should exist");
assert_eq!(progress.last_section.as_deref(), Some("providers"));
assert!(!progress.first_run_completed);
assert_eq!(
store
.onboarding_sections()
.expect("sections should load")
.first()
.expect("section should exist")
.section_id,
"providers"
);
store
.complete_onboarding(701)
.expect("completion should persist");
let progress = store
.onboarding_progress()
.expect("progress should reload")
.expect("progress should still exist");
assert!(progress.first_run_completed);
assert_eq!(progress.completed_at_ms, Some(701));
}
#[test]
fn section_completion_skip_and_recommendation_dismissal_persist() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let recommendation = SetupRecommendation {
recommendation_id: "review-imports".to_owned(),
section_id: SetupSectionId::Imports.as_str().to_owned(),
kind: "review_optional_section".to_owned(),
status: SetupSectionStatus::Recommended.as_str().to_owned(),
priority: 10,
title: "Review imports".to_owned(),
body: "Review optional import sources.".to_owned(),
created_at_ms: 900,
dismissed_at_ms: None,
};
store
.complete_onboarding_section(SetupSectionId::Models, 901)
.expect("section completion should persist");
store
.skip_onboarding_section(SetupSectionId::Imports, 902)
.expect("section skip should persist");
store
.save_setup_recommendation(&recommendation)
.expect("recommendation should persist");
let dismissed = store
.dismiss_setup_recommendation("review-imports", 903)
.expect("dismissal should persist")
.expect("recommendation should exist");
let sections = store.onboarding_sections().expect("sections should load");
assert!(sections.iter().any(|section| {
section.section_id == "models"
&& section.status == SetupSectionStatus::Complete.as_str()
}));
assert!(sections.iter().any(|section| {
section.section_id == "imports"
&& section.status == SetupSectionStatus::Skipped.as_str()
}));
assert_eq!(dismissed.dismissed_at_ms, Some(903));
}
#[test]
fn setup_plan_apply_records_mutating_actions_and_skips_launch() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let plan = SetupPlanReview {
actions: vec![
SetupPlanAction {
section_id: SetupSectionId::SecureVault,
kind: "secure_env_secret".to_owned(),
title: "Secure detected key".to_owned(),
body: "Move it to secure storage.".to_owned(),
mutating: true,
},
SetupPlanAction {
section_id: SetupSectionId::Launch,
kind: "launch".to_owned(),
title: "Launch Bcode".to_owned(),
body: "Start using Bcode.".to_owned(),
mutating: false,
},
],
launch_ready: true,
};
let applied = store
.apply_setup_plan(&plan, 1_000)
.expect("plan should apply");
let state = store
.control_state("setup.applied.secure_env_secret")
.expect("control state should load")
.expect("control state should exist");
assert_eq!(applied.applied_actions.len(), 1);
assert_eq!(applied.skipped_actions.len(), 1);
assert_eq!(state.value["section_id"], "secure_vault");
}
#[test]
fn settings_experience_mode_switches_after_completion() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
assert_eq!(
store.experience_mode().expect("mode should load"),
SettingsExperienceMode::FirstRunOnboarding
);
store
.complete_onboarding(1_001)
.expect("onboarding should complete");
assert_eq!(
store.experience_mode().expect("mode should reload"),
SettingsExperienceMode::ControlCenter
);
}
#[test]
fn readiness_report_identifies_blocking_and_recommended_items() {
let sections = vec![
ReconciledSetupSection {
section_id: SetupSectionId::SecureVault,
status: SetupSectionStatus::Blocked,
visited: true,
},
ReconciledSetupSection {
section_id: SetupSectionId::Plugins,
status: SetupSectionStatus::Recommended,
visited: false,
},
];
let recommendation = SetupRecommendation {
recommendation_id: "secure-openai-env".to_owned(),
section_id: SetupSectionId::SecureVault.as_str().to_owned(),
kind: "secure_env_secret".to_owned(),
status: SetupSectionStatus::Recommended.as_str().to_owned(),
priority: 100,
title: "Secure detected OpenAI key".to_owned(),
body: "Move this detected key into secure storage.".to_owned(),
created_at_ms: 1,
dismissed_at_ms: None,
};
let report = setup_readiness_report(§ions, &[recommendation]);
assert!(!report.launch_ready);
assert!(
report
.items
.iter()
.any(|item| item.severity == SetupReadinessSeverity::Blocking)
);
assert!(
report
.items
.iter()
.any(|item| item.severity == SetupReadinessSeverity::Recommended)
);
}
#[test]
fn readiness_report_round_trips_through_control_state() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let report = SetupReadinessReport {
items: vec![SetupReadinessItem {
section_id: SetupSectionId::Plugins,
severity: SetupReadinessSeverity::Recommended,
title: "Review plugins".to_owned(),
body: "Plugin settings are customizable.".to_owned(),
}],
launch_ready: true,
};
store
.save_readiness_report(&report, 55)
.expect("report should save");
assert_eq!(
store
.readiness_report()
.expect("report should load")
.expect("report should exist"),
report
);
}
#[test]
fn setup_plan_review_exposes_launch_action() {
let plan = SetupPlanReview {
actions: Vec::new(),
launch_ready: true,
};
let action = plan.launch_action().expect("launch action should exist");
assert_eq!(action.kind, "launch");
assert!(!action.mutating);
}
#[test]
fn setup_plan_review_uses_recommendations_and_launch_state() {
let blocked_sections = vec![ReconciledSetupSection {
section_id: SetupSectionId::SecureVault,
status: SetupSectionStatus::Blocked,
visited: true,
}];
let recommendation = SetupRecommendation {
recommendation_id: "secure-openai-env".to_owned(),
section_id: SetupSectionId::SecureVault.as_str().to_owned(),
kind: "secure_env_secret".to_owned(),
status: SetupSectionStatus::Recommended.as_str().to_owned(),
priority: 100,
title: "Secure detected OpenAI key".to_owned(),
body: "Move this detected key into secure storage.".to_owned(),
created_at_ms: 800,
dismissed_at_ms: None,
};
let blocked_plan = generate_setup_plan(&blocked_sections, &[recommendation]);
assert!(!blocked_plan.launch_ready);
assert_eq!(blocked_plan.actions[0].kind, "secure_env_secret");
let launch_plan = generate_setup_plan(&[], &[]);
assert!(launch_plan.launch_ready);
assert_eq!(launch_plan.actions[0].kind, "launch");
}
#[test]
fn setup_view_models_include_story_copy() {
let provider = provider_setup_card("bcode.openai-compatible", true, false);
let detection = AuthSecurityDetection {
sshenv_available: true,
device_seal_requested: true,
sshenv_profiles: BTreeSet::from(["openai".to_owned()]),
};
let security =
security_trust_panel(detection.sshenv_available, detection.device_seal_requested);
let model = model_setup_card(Some("fast".to_owned()), None);
let permission = permission_setup_card(false);
let import = import_setup_card(false);
let plugin = plugin_setup_card(BTreeSet::new(), BTreeSet::new());
assert!(provider.story.contains("secure auth path"));
assert!(security.story.contains("device sealing"));
assert!(model.story.contains("model path"));
assert!(permission.story.contains("permission preset"));
assert!(import.story.contains("Session import is optional"));
assert!(plugin.story.contains("Bundled plugins"));
}
#[test]
fn environment_detection_sanitizes_secret_values() {
let vars = BTreeMap::from([
("OPENAI_API_KEY".to_owned(), "sk-secret-value".to_owned()),
("AWS_PROFILE".to_owned(), "work".to_owned()),
("BCODE_AUTH_TOKEN".to_owned(), "bcode-secret".to_owned()),
("BCODE_MODEL_PROFILE".to_owned(), "fast".to_owned()),
("SSHENV_VAULT".to_owned(), "/tmp/secret/path".to_owned()),
]);
let snapshot = detect_setup_environment_from_vars(&vars, 500);
let encoded = serde_json::to_string(&snapshot).expect("snapshot should encode");
assert!(encoded.contains("OPENAI_API_KEY"));
assert!(encoded.contains("work"));
assert!(!encoded.contains("sk-secret-value"));
assert!(!encoded.contains("bcode-secret"));
assert!(!encoded.contains("/tmp/secret/path"));
assert!(encoded.contains("fast"));
assert_eq!(snapshot.recommendations.len(), 1);
}
#[test]
fn setup_map_snapshot_renders_compact_text_map() {
let persisted = vec![OnboardingSection {
section_id: SetupSectionId::Welcome.as_str().to_owned(),
status: SetupSectionStatus::Visited.as_str().to_owned(),
visited: true,
visited_at_ms: Some(1),
completed_at_ms: None,
skipped_at_ms: None,
dismissed: false,
}];
let input = SetupReconciliationInput {
current_section: Some(SetupSectionId::Welcome),
..SetupReconciliationInput::default()
};
let snapshot = SetupMapSnapshot::from_reconciliation(&persisted, &input);
let rendered = snapshot.render_text_map();
assert!(rendered.starts_with("welcome:current:visited -> detection:unvisited"));
assert!(rendered.ends_with("launch:unvisited"));
}
#[test]
fn setup_detection_snapshot_persists_entries_and_recommendations() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let vars = BTreeMap::from([(
"OPENROUTER_API_KEY".to_owned(),
"secret-openrouter-value".to_owned(),
)]);
let snapshot = detect_setup_environment_from_vars(&vars, 600);
store
.save_setup_detection_snapshot(&snapshot)
.expect("snapshot should persist");
let persisted_entries = store
.detection_cache_entries()
.expect("entries should load");
let persisted_recommendations = store
.setup_recommendations()
.expect("recommendations should load");
let encoded_entries = serde_json::to_string(&persisted_entries).expect("entries encode");
assert_eq!(persisted_entries.len(), 1);
assert_eq!(persisted_recommendations.len(), 1);
assert!(!encoded_entries.contains("secret-openrouter-value"));
}
#[test]
fn settings_service_model_ignore_writes_toml_state() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let state_path = temp.path().join("model-ignores.toml");
unsafe {
std::env::set_var("BCODE_MODEL_IGNORES_STATE", &state_path);
}
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
store
.ignore_model("bcode.openai-compatible", "gpt-hidden".to_owned())
.expect("model ignore should be written");
let rules = bcode_config::load_model_ignores_state_from(&state_path)
.expect("model ignore state should load");
assert!(
rules
.get("bcode.openai-compatible")
.expect("provider ignore entry should exist")
.models
.contains("gpt-hidden")
);
store
.unignore_model("bcode.openai-compatible", "gpt-hidden")
.expect("model ignore should be removed");
let rules = bcode_config::load_model_ignores_state_from(&state_path)
.expect("model ignore state should reload");
assert!(
!rules
.get("bcode.openai-compatible")
.expect("provider ignore entry should still exist")
.models
.contains("gpt-hidden")
);
unsafe {
std::env::remove_var("BCODE_MODEL_IGNORES_STATE");
}
}
#[test]
fn reconciliation_prefers_real_state_over_persisted_ux_state() {
let persisted = vec![
OnboardingSection {
section_id: SetupSectionId::Providers.as_str().to_owned(),
status: SetupSectionStatus::Visited.as_str().to_owned(),
visited: true,
visited_at_ms: Some(10),
completed_at_ms: None,
skipped_at_ms: None,
dismissed: false,
},
OnboardingSection {
section_id: SetupSectionId::Imports.as_str().to_owned(),
status: SetupSectionStatus::Skipped.as_str().to_owned(),
visited: true,
visited_at_ms: Some(11),
completed_at_ms: None,
skipped_at_ms: Some(12),
dismissed: false,
},
];
let input = SetupReconciliationInput {
current_section: Some(SetupSectionId::Models),
configured_sections: BTreeSet::from([SetupSectionId::Providers]),
secured_sections: BTreeSet::from([SetupSectionId::SecureVault]),
blocked_sections: BTreeSet::from([SetupSectionId::Permissions]),
recommended_sections: BTreeSet::from([SetupSectionId::Plugins]),
optional_sections: BTreeSet::from([SetupSectionId::Imports]),
};
let sections = reconcile_setup_sections(&persisted, &input);
let status_for = |section_id| {
sections
.iter()
.find(|section| section.section_id == section_id)
.expect("section should exist")
.status
};
assert_eq!(
status_for(SetupSectionId::Models),
SetupSectionStatus::Current
);
assert_eq!(
status_for(SetupSectionId::SecureVault),
SetupSectionStatus::Secured
);
assert_eq!(
status_for(SetupSectionId::Providers),
SetupSectionStatus::Complete
);
assert_eq!(
status_for(SetupSectionId::Permissions),
SetupSectionStatus::Blocked
);
assert_eq!(
status_for(SetupSectionId::Plugins),
SetupSectionStatus::Recommended
);
assert_eq!(
status_for(SetupSectionId::Imports),
SetupSectionStatus::Skipped
);
assert_eq!(
status_for(SetupSectionId::Welcome),
SetupSectionStatus::Unvisited
);
}
}