use std::collections::VecDeque;
use std::sync::{Arc, Mutex, MutexGuard};
use crate::settings::Settings;
use crate::tui::app::AppMode;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StartupDefaults {
mode: Option<&'static str>,
reasoning_effort: Option<String>,
default_model: Option<String>,
}
impl StartupDefaults {
#[must_use]
pub fn mode(mode: AppMode) -> Self {
Self {
mode: Some(mode.as_setting()),
..Self::default()
}
}
#[must_use]
pub fn reasoning_effort(setting: impl Into<String>) -> Self {
Self {
reasoning_effort: Some(setting.into()),
..Self::default()
}
}
#[cfg(test)]
#[must_use]
pub fn with_default_model(mut self, model: &str) -> Self {
self.default_model = Some(model.to_string());
self
}
#[cfg(test)]
#[must_use]
pub fn with_reasoning_effort(mut self, effort: &str) -> Self {
self.reasoning_effort = Some(effort.to_string());
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.mode.is_none() && self.reasoning_effort.is_none() && self.default_model.is_none()
}
#[must_use]
fn subjects(&self) -> Vec<StartupDefaultSubject> {
let mut subjects = Vec::new();
if self.mode.is_some() {
subjects.push(StartupDefaultSubject::Mode);
}
if self.reasoning_effort.is_some() {
subjects.push(StartupDefaultSubject::Thinking);
}
if self.default_model.is_some() {
subjects.push(StartupDefaultSubject::Model);
}
subjects
}
fn apply(&self) -> anyhow::Result<()> {
if self.is_empty() {
return Ok(());
}
Settings::transact(|settings| {
if let Some(mode) = self.mode {
settings.set("default_mode", mode)?;
}
if let Some(model) = self.default_model.as_deref() {
settings.set("default_model", model)?;
}
if let Some(effort) = self.reasoning_effort.as_deref() {
settings.set("reasoning_effort", effort)?;
}
Ok(())
})
}
fn apply_reporting(&self, failures: &StartupDefaultFailures) {
if let Err(err) = self.apply() {
let subjects = self.subjects();
tracing::warn!(
target: "settings",
subjects = ?subjects,
error = ?err,
"startup default was not persisted"
);
failures.record(StartupDefaultFailure {
subjects,
detail: safe_error_detail(&err),
});
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartupDefaultSubject {
Mode,
Thinking,
Model,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartupDefaultFailure {
pub subjects: Vec<StartupDefaultSubject>,
pub detail: String,
}
fn safe_error_detail(err: &anyhow::Error) -> String {
const MAX: usize = 160;
let raw = err.root_cause().to_string();
let scrubbed = raw
.split_whitespace()
.map(|token| {
let looks_like_path = token.contains('/')
|| token.contains('\\')
|| (token.len() > 2 && token.as_bytes()[1] == b':');
if looks_like_path { "<path>" } else { token }
})
.collect::<Vec<_>>()
.join(" ");
if scrubbed.chars().count() > MAX {
let truncated: String = scrubbed.chars().take(MAX).collect();
format!("{truncated}…")
} else {
scrubbed
}
}
#[derive(Debug, Clone, Default)]
pub struct StartupDefaultsWriter {
inner: Arc<WriterInner>,
}
#[derive(Debug, Default)]
struct WriterInner {
queue: Mutex<VecDeque<StartupDefaults>>,
write: Mutex<()>,
failures: StartupDefaultFailures,
}
impl StartupDefaultsWriter {
pub fn spawn(&self, update: StartupDefaults) {
if update.is_empty() {
return;
}
if !spawn_writes_permitted() {
return;
}
if tokio::runtime::Handle::try_current().is_err() {
self.lock_queue().push_back(update);
self.drain_pending();
return;
}
#[cfg(test)]
let ticket = match TestDrainTicket::capture() {
Some(ticket) => ticket,
None => {
self.lock_queue().push_back(update);
self.drain_pending();
return;
}
};
self.lock_queue().push_back(update);
let writer = self.clone();
crate::utils::spawn_blocking_supervised("startup-defaults-persist", move || {
#[cfg(test)]
let _scope = ticket.enter();
writer.drain_pending();
});
}
pub fn apply_blocking(&self, update: StartupDefaults) -> anyhow::Result<()> {
let _write = self.lock_write();
self.drain_locked();
if update.is_empty() {
return Ok(());
}
update.apply()
}
pub fn flush(&self) {
let _write = self.lock_write();
self.drain_locked();
}
#[must_use]
pub fn shutdown(&self) -> Vec<StartupDefaultFailure> {
self.flush();
self.drain_failures()
}
#[cfg(test)]
pub(crate) fn pending_len(&self) -> usize {
self.lock_queue().len()
}
pub fn drain_failures(&self) -> Vec<StartupDefaultFailure> {
self.inner.failures.drain()
}
fn drain_pending(&self) {
let _write = self.lock_write();
self.drain_locked();
}
fn drain_locked(&self) {
loop {
let Some(update) = self.lock_queue().pop_front() else {
return;
};
if !spawn_writes_permitted() {
continue;
}
update.apply_reporting(&self.inner.failures);
}
}
#[cfg(not(test))]
fn lock_write(&self) -> MutexGuard<'_, ()> {
self.inner
.write
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
fn lock_write(&self) -> MutexGuard<'_, ()> {
use std::sync::TryLockError;
let deadline = std::time::Instant::now() + WRITE_LOCK_TEST_DEADLINE;
loop {
match self.inner.write.try_lock() {
Ok(guard) => return guard,
Err(TryLockError::Poisoned(poisoned)) => return poisoned.into_inner(),
Err(TryLockError::WouldBlock) => {}
}
assert!(
std::time::Instant::now() < deadline,
"startup-defaults write lock was not released within {WRITE_LOCK_TEST_DEADLINE:?}. \
Some thread is holding it across a settings transaction that cannot finish — \
usually because it is blocked on a lock this test already holds (see the \
no-deadlock contract in tui::startup_defaults)."
);
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
fn lock_queue(&self) -> MutexGuard<'_, VecDeque<StartupDefaults>> {
self.inner
.queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn spawn_writes_permitted() -> bool {
#[cfg(test)]
{
authorized_test_write_generation().is_some()
}
#[cfg(not(test))]
{
true
}
}
#[cfg(test)]
fn authorized_test_write_generation() -> Option<u64> {
let generation = crate::test_support::current_env_scope_generation()?;
let scopes = lock_test_write_scopes();
scopes
.authorized
.contains(&generation)
.then_some(generation)
}
#[cfg(test)]
const WRITE_LOCK_TEST_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15);
#[cfg(test)]
#[derive(Default)]
struct TestWriteScopes {
authorized: Vec<u64>,
outstanding: Vec<(u64, usize)>,
}
#[cfg(test)]
impl TestWriteScopes {
fn outstanding_for(&self, generation: u64) -> usize {
self.outstanding
.iter()
.find(|(scope, _)| *scope == generation)
.map_or(0, |(_, count)| *count)
}
fn adjust(&mut self, generation: u64, delta: isize) {
if let Some(entry) = self
.outstanding
.iter_mut()
.find(|(scope, _)| *scope == generation)
{
entry.1 = entry.1.saturating_add_signed(delta);
if entry.1 == 0 {
self.outstanding.retain(|(scope, _)| *scope != generation);
}
} else if delta > 0 {
self.outstanding.push((generation, delta as usize));
}
}
}
#[cfg(test)]
fn test_write_scopes() -> &'static (Mutex<TestWriteScopes>, std::sync::Condvar) {
static SCOPES: std::sync::OnceLock<(Mutex<TestWriteScopes>, std::sync::Condvar)> =
std::sync::OnceLock::new();
SCOPES.get_or_init(|| {
(
Mutex::new(TestWriteScopes::default()),
std::sync::Condvar::new(),
)
})
}
#[cfg(test)]
fn lock_test_write_scopes() -> MutexGuard<'static, TestWriteScopes> {
test_write_scopes()
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
pub(crate) fn allow_writes_in_tests() -> TestWriteGuard {
let generation = crate::test_support::current_env_scope_generation().expect(
"allow_writes_in_tests() requires the calling thread to hold \
test_support::lock_test_env() with a sealed HOME",
);
let mut scopes = lock_test_write_scopes();
if !scopes.authorized.contains(&generation) {
scopes.authorized.push(generation);
}
drop(scopes);
TestWriteGuard { generation }
}
#[cfg(test)]
pub(crate) struct TestWriteGuard {
generation: u64,
}
#[cfg(test)]
impl Drop for TestWriteGuard {
fn drop(&mut self) {
let drained = wait_for_outstanding_test_drains(self.generation);
let mut scopes = lock_test_write_scopes();
scopes.authorized.retain(|scope| *scope != self.generation);
drop(scopes);
assert!(
drained || std::thread::panicking(),
"background startup-default drain(s) for env scope {} did not finish within \
{WRITE_LOCK_TEST_DEADLINE:?}; see the no-deadlock contract in \
tui::startup_defaults",
self.generation
);
}
}
#[cfg(test)]
fn wait_for_outstanding_test_drains(generation: u64) -> bool {
let (scopes, done) = test_write_scopes();
let mut guard = scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let deadline = std::time::Instant::now() + WRITE_LOCK_TEST_DEADLINE;
while guard.outstanding_for(generation) > 0 {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return false;
}
let (next, _timeout) = done
.wait_timeout(guard, remaining)
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard = next;
}
true
}
#[cfg(test)]
struct TestDrainTicket {
env: crate::test_support::EnvScopeTicket,
}
#[cfg(test)]
impl TestDrainTicket {
fn capture() -> Option<Self> {
let generation = authorized_test_write_generation()?;
let env = crate::test_support::env_scope_ticket()?;
if env.generation() != generation {
return None;
}
lock_test_write_scopes().adjust(generation, 1);
Some(Self { env })
}
fn enter(&self) -> Option<crate::test_support::EnvScopeMembership> {
crate::test_support::join_env_scope(Some(self.env))
}
}
#[cfg(test)]
impl Drop for TestDrainTicket {
fn drop(&mut self) {
let (scopes, done) = test_write_scopes();
let mut guard = scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.adjust(self.env.generation(), -1);
drop(guard);
done.notify_all();
}
}
#[derive(Debug, Clone, Default)]
pub struct StartupDefaultFailures(Arc<Mutex<Vec<StartupDefaultFailure>>>);
impl StartupDefaultFailures {
fn record(&self, failure: StartupDefaultFailure) {
if let Ok(mut guard) = self.0.lock() {
guard.push(failure);
}
}
pub fn drain(&self) -> Vec<StartupDefaultFailure> {
self.0
.lock()
.map(|mut guard| std::mem::take(&mut *guard))
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_update_only_targets_default_mode() {
let update = StartupDefaults::mode(AppMode::Operate);
assert_eq!(update.mode, Some("operate"));
assert!(update.reasoning_effort.is_none());
assert!(update.default_model.is_none());
assert_eq!(update.subjects(), vec![StartupDefaultSubject::Mode]);
}
#[test]
fn subjects_stay_typed_for_a_combined_model_and_thinking_update() {
let update = StartupDefaults::default()
.with_default_model("deepseek-chat")
.with_reasoning_effort("high");
assert_eq!(
update.subjects(),
vec![
StartupDefaultSubject::Thinking,
StartupDefaultSubject::Model
]
);
}
#[test]
fn safe_error_detail_keeps_the_cause_and_drops_the_path() {
let err = anyhow::anyhow!("Permission denied (os error 13)")
.context("Failed to write settings to /Users/real-name/.codewhale/settings.toml");
let detail = safe_error_detail(&err);
assert_eq!(detail, "Permission denied (os error 13)");
assert!(!detail.contains("real-name"));
assert!(!detail.contains(".codewhale"));
let rooted = anyhow::anyhow!("cannot open /Users/real-name/.codewhale/settings.toml");
let scrubbed = safe_error_detail(&rooted);
assert_eq!(scrubbed, "cannot open <path>");
}
#[test]
fn legacy_yolo_selection_persists_the_mode_it_actually_installs() {
assert_eq!(StartupDefaults::mode(AppMode::Yolo).mode, Some("agent"));
}
#[test]
fn empty_update_is_a_no_op() {
assert!(StartupDefaults::default().is_empty());
StartupDefaults::default()
.apply()
.expect("empty update must not touch disk");
}
#[test]
fn failure_mailbox_drains_once() {
let failures = StartupDefaultFailures::default();
let failure = StartupDefaultFailure {
subjects: vec![StartupDefaultSubject::Mode],
detail: "boom".to_string(),
};
failures.record(failure.clone());
assert_eq!(failures.drain(), vec![failure]);
assert!(failures.drain().is_empty());
}
#[test]
fn a_worker_enrolled_in_the_test_env_scope_completes_a_transaction() {
use std::sync::mpsc;
use std::time::Duration;
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
let _deepseek_config = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _codewhale_config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _writes = allow_writes_in_tests();
let writer = StartupDefaultsWriter::default();
let ticket = crate::test_support::env_scope_ticket();
assert!(
ticket.is_some(),
"the thread holding lock_test_env must be able to mint a scope ticket"
);
let (done_tx, done_rx) = mpsc::channel();
let worker = writer.clone();
let handle = std::thread::spawn(move || {
let _membership = crate::test_support::join_env_scope(ticket);
let result = worker.apply_blocking(StartupDefaults::mode(AppMode::Operate));
done_tx.send(result).ok();
});
let result = done_rx
.recv_timeout(Duration::from_secs(10))
.expect("an enrolled worker must not block on the env lock its own test holds");
result.expect("the transaction must land");
handle.join().expect("worker thread");
writer.flush();
assert_eq!(
Settings::load_persisted()
.expect("reload settings")
.default_mode,
"operate"
);
assert!(tmp.path().join(".codewhale/settings.toml").exists());
}
fn seal_home(tmp: &std::path::Path) -> Vec<crate::test_support::EnvVarGuard> {
use crate::test_support::EnvVarGuard;
vec![
EnvVarGuard::set("HOME", tmp),
EnvVarGuard::set("USERPROFILE", tmp),
EnvVarGuard::set("CODEWHALE_HOME", tmp.join(".codewhale")),
EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
]
}
#[test]
fn an_unauthorized_thread_neither_writes_nor_waits_for_a_sealed_scope() {
use std::sync::mpsc;
use std::time::Duration;
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = seal_home(tmp.path());
let _writes = allow_writes_in_tests();
let writer = StartupDefaultsWriter::default();
let foreign = writer.clone();
let (done_tx, done_rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
foreign.spawn(StartupDefaults::mode(AppMode::Plan));
done_tx.send(()).ok();
});
done_rx
.recv_timeout(Duration::from_secs(5))
.expect("an unauthorized writer must return immediately, never block on the env lock");
handle.join().expect("foreign thread");
assert_eq!(
writer.pending_len(),
0,
"an unauthorized caller must not enqueue work an authorized drain could inherit"
);
assert!(
!tmp.path().join(".codewhale/settings.toml").exists(),
"an unauthorized caller must not write any settings file"
);
writer
.apply_blocking(StartupDefaults::mode(AppMode::Operate))
.expect("the sealed scope's own write must land");
assert_eq!(
Settings::load_persisted()
.expect("reload settings")
.default_mode,
"operate"
);
}
#[test]
fn two_sealed_scopes_share_neither_write_authorization_nor_drain_accounting() {
let first_generation;
let stale_ticket;
{
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = seal_home(tmp.path());
let _writes = allow_writes_in_tests();
first_generation = crate::test_support::current_env_scope_generation()
.expect("a sealed scope must have a generation");
stale_ticket = crate::test_support::env_scope_ticket();
assert_eq!(
authorized_test_write_generation(),
Some(first_generation),
"the scope that opted in must be the one authorized"
);
let writer = StartupDefaultsWriter::default();
writer
.apply_blocking(StartupDefaults::mode(AppMode::Plan))
.expect("first scope's write must land");
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"plan"
);
assert_eq!(
lock_test_write_scopes().outstanding_for(first_generation),
0,
"the first scope must have no outstanding drain left to wait on"
);
}
{
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = seal_home(tmp.path());
let second_generation = crate::test_support::current_env_scope_generation()
.expect("a sealed scope must have a generation");
assert_ne!(
second_generation, first_generation,
"each acquisition must open a fresh generation"
);
assert_eq!(
authorized_test_write_generation(),
None,
"a new scope must not inherit the previous scope's opt-in"
);
assert!(
crate::test_support::join_env_scope(stale_ticket).is_none(),
"a ticket from a closed scope must not enroll a thread in the current one"
);
let writer = StartupDefaultsWriter::default();
writer.spawn(StartupDefaults::mode(AppMode::Operate));
assert_eq!(writer.pending_len(), 0);
assert!(!tmp.path().join(".codewhale/settings.toml").exists());
let _writes = allow_writes_in_tests();
assert_eq!(authorized_test_write_generation(), Some(second_generation));
writer.spawn(StartupDefaults::mode(AppMode::Operate));
writer.flush();
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate"
);
assert!(tmp.path().join(".codewhale/settings.toml").exists());
}
}
#[test]
fn a_direct_writer_holds_the_boundary_against_a_queued_startup_default() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = seal_home(tmp.path());
let _writes = allow_writes_in_tests();
let writer = StartupDefaultsWriter::default();
let ticket = crate::test_support::env_scope_ticket();
let mut handle = None;
crate::settings::with_settings_transaction(|transaction| {
let mut direct = transaction.load().expect("load inside the transaction");
direct
.set("max_history", "321")
.expect("set an unrelated field");
let queued = writer.clone();
handle = Some(std::thread::spawn(move || {
let _membership = crate::test_support::join_env_scope(ticket);
queued
.apply_blocking(StartupDefaults::mode(AppMode::Plan))
.expect("the queued write must land once the boundary is released");
}));
std::thread::sleep(std::time::Duration::from_millis(150));
assert_eq!(
transaction.load().expect("re-read").default_mode,
Settings::default().default_mode,
"no other writer may save while a transaction is open"
);
transaction.save(&direct).expect("commit the direct write");
Ok(())
})
.expect("the direct transaction must complete");
handle
.expect("worker spawned")
.join()
.expect("queued writer thread");
let settled = Settings::load_persisted().expect("reload");
assert_eq!(
settled.max_input_history, 321,
"the direct write must survive the queued startup-default write"
);
assert_eq!(
settled.default_mode, "plan",
"the queued startup-default write must survive the direct write"
);
}
}