use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserConfig {
#[serde(default = "default_true")]
pub upload_enabled: bool,
#[serde(default)]
pub pending_upload: u64,
#[serde(default)]
pub last_upload_at: i64,
#[serde(default)]
pub last_worldwide_total: u64,
#[serde(default)]
pub last_worldwide_fetch_at: i64,
#[serde(default)]
pub last_flush_attempt_at: i64,
#[serde(default)]
pub cached_latest_version: String,
#[serde(default)]
pub last_version_check_at: i64,
#[serde(default)]
pub last_version_warning_at: i64,
#[serde(default)]
pub installed_agents: Vec<String>,
#[serde(default = "default_watcher_debounce", alias = "daemon_debounce")]
pub watcher_debounce: String,
#[serde(default)]
pub cached_country_flags: Vec<String>,
#[serde(default)]
pub last_flags_fetch_at: i64,
#[serde(default)]
pub last_pricing_fetch_at: i64,
#[serde(default)]
pub last_installed_version: String,
#[serde(default)]
pub previous_version: String,
#[serde(default = "default_extraction_timeout_secs")]
pub extraction_timeout_secs: u64,
#[serde(default)]
pub wildcard_permissions: bool,
#[serde(skip)]
pub(crate) loaded_config: Mutex<Option<ConfigFile>>,
#[serde(skip)]
pub(crate) loaded_state: Mutex<Option<StateFile>>,
#[serde(skip)]
pub(crate) loaded_legacy_state: AtomicBool,
}
const _: fn() = || {
fn assert_send_sync<T: Send + Sync + ?Sized>() {}
assert_send_sync::<UserConfig>();
};
fn default_true() -> bool {
true
}
fn default_watcher_debounce() -> String {
"2s".to_string()
}
fn default_extraction_timeout_secs() -> u64 {
60
}
impl Default for UserConfig {
fn default() -> Self {
Self {
upload_enabled: true,
pending_upload: 0,
last_upload_at: 0,
last_worldwide_total: 0,
last_worldwide_fetch_at: 0,
last_flush_attempt_at: 0,
cached_latest_version: String::new(),
last_version_check_at: 0,
last_version_warning_at: 0,
installed_agents: Vec::new(),
watcher_debounce: default_watcher_debounce(),
cached_country_flags: Vec::new(),
last_flags_fetch_at: 0,
last_pricing_fetch_at: 0,
last_installed_version: String::new(),
previous_version: String::new(),
extraction_timeout_secs: default_extraction_timeout_secs(),
wildcard_permissions: false,
loaded_config: Mutex::new(None),
loaded_state: Mutex::new(None),
loaded_legacy_state: AtomicBool::new(false),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ConfigFile {
#[serde(default = "default_true")]
upload_enabled: bool,
#[serde(default = "default_watcher_debounce", alias = "daemon_debounce")]
watcher_debounce: String,
#[serde(default = "default_extraction_timeout_secs")]
extraction_timeout_secs: u64,
#[serde(default)]
wildcard_permissions: bool,
}
impl ConfigFile {
fn from_config(c: &UserConfig) -> Self {
Self {
upload_enabled: c.upload_enabled,
watcher_debounce: c.watcher_debounce.clone(),
extraction_timeout_secs: c.extraction_timeout_secs,
wildcard_permissions: c.wildcard_permissions,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
pub(crate) struct StateFile {
#[serde(default)]
pending_upload: u64,
#[serde(default)]
last_upload_at: i64,
#[serde(default)]
last_worldwide_total: u64,
#[serde(default)]
last_worldwide_fetch_at: i64,
#[serde(default)]
last_flush_attempt_at: i64,
#[serde(default)]
cached_latest_version: String,
#[serde(default)]
last_version_check_at: i64,
#[serde(default)]
last_version_warning_at: i64,
#[serde(default)]
cached_country_flags: Vec<String>,
#[serde(default)]
last_flags_fetch_at: i64,
#[serde(default)]
last_pricing_fetch_at: i64,
#[serde(default)]
last_installed_version: String,
#[serde(default)]
previous_version: String,
#[serde(default)]
installed_agents: Vec<String>,
}
impl StateFile {
fn from_config(c: &UserConfig) -> Self {
Self {
pending_upload: c.pending_upload,
last_upload_at: c.last_upload_at,
last_worldwide_total: c.last_worldwide_total,
last_worldwide_fetch_at: c.last_worldwide_fetch_at,
last_flush_attempt_at: c.last_flush_attempt_at,
cached_latest_version: c.cached_latest_version.clone(),
last_version_check_at: c.last_version_check_at,
last_version_warning_at: c.last_version_warning_at,
cached_country_flags: c.cached_country_flags.clone(),
last_flags_fetch_at: c.last_flags_fetch_at,
last_pricing_fetch_at: c.last_pricing_fetch_at,
last_installed_version: c.last_installed_version.clone(),
previous_version: c.previous_version.clone(),
installed_agents: c.installed_agents.clone(),
}
}
}
const LEGACY_STATE_KEYS: &[&str] = &[
"pending_upload",
"last_upload_at",
"last_worldwide_total",
"last_worldwide_fetch_at",
"last_flush_attempt_at",
"cached_latest_version",
"last_version_check_at",
"last_version_warning_at",
"cached_country_flags",
"last_flags_fetch_at",
"last_pricing_fetch_at",
"last_installed_version",
"previous_version",
"installed_agents",
];
fn contains_legacy_state(contents: &str) -> bool {
toml::from_str::<toml::Table>(contents)
.ok()
.is_some_and(|table| LEGACY_STATE_KEYS.iter().any(|key| table.contains_key(*key)))
}
fn apply_state(config: &mut UserConfig, state: StateFile) {
config.pending_upload = state.pending_upload;
config.last_upload_at = state.last_upload_at;
config.last_worldwide_total = state.last_worldwide_total;
config.last_worldwide_fetch_at = state.last_worldwide_fetch_at;
config.last_flush_attempt_at = state.last_flush_attempt_at;
config.cached_latest_version = state.cached_latest_version;
config.last_version_check_at = state.last_version_check_at;
config.last_version_warning_at = state.last_version_warning_at;
config.cached_country_flags = state.cached_country_flags;
config.last_flags_fetch_at = state.last_flags_fetch_at;
config.last_pricing_fetch_at = state.last_pricing_fetch_at;
config.last_installed_version = state.last_installed_version;
config.previous_version = state.previous_version;
config.installed_agents = state.installed_agents;
}
fn merge_counter_delta(base: u64, current: u64, on_disk: u64) -> u64 {
if current >= base {
on_disk.saturating_add(current - base)
} else {
on_disk.saturating_sub(base - current)
}
}
fn merge_monotonic(base: i64, current: i64, on_disk: i64) -> i64 {
if current == base {
on_disk
} else {
current.max(on_disk)
}
}
fn merge_version_marker(base: &str, current: &str, on_disk: &str) -> String {
if current == base {
on_disk.to_string()
} else if on_disk == base || !crate::cloud::is_newer_version(current, on_disk) {
current.to_string()
} else {
on_disk.to_string()
}
}
fn merge_installed_agents(base: &[String], current: &[String], on_disk: &[String]) -> Vec<String> {
let mut merged: Vec<String> = on_disk
.iter()
.filter(|agent| !base.contains(agent) || current.contains(agent))
.cloned()
.collect();
for agent in current {
if !base.contains(agent) && !merged.contains(agent) {
merged.push(agent.clone());
}
}
merged
}
fn merge_state(base: &StateFile, current: &StateFile, on_disk: &StateFile) -> StateFile {
let (last_worldwide_total, last_worldwide_fetch_at) =
if (
current.last_worldwide_total,
current.last_worldwide_fetch_at,
) == (base.last_worldwide_total, base.last_worldwide_fetch_at)
|| on_disk.last_worldwide_fetch_at > current.last_worldwide_fetch_at
{
(
on_disk.last_worldwide_total,
on_disk.last_worldwide_fetch_at,
)
} else {
(
current.last_worldwide_total,
current.last_worldwide_fetch_at,
)
};
let (cached_latest_version, last_version_check_at) =
if (
¤t.cached_latest_version,
current.last_version_check_at,
) == (&base.cached_latest_version, base.last_version_check_at)
|| on_disk.last_version_check_at > current.last_version_check_at
{
(
on_disk.cached_latest_version.clone(),
on_disk.last_version_check_at,
)
} else {
(
current.cached_latest_version.clone(),
current.last_version_check_at,
)
};
let (cached_country_flags, last_flags_fetch_at) =
if (¤t.cached_country_flags, current.last_flags_fetch_at)
== (&base.cached_country_flags, base.last_flags_fetch_at)
|| on_disk.last_flags_fetch_at > current.last_flags_fetch_at
{
(
on_disk.cached_country_flags.clone(),
on_disk.last_flags_fetch_at,
)
} else {
(
current.cached_country_flags.clone(),
current.last_flags_fetch_at,
)
};
StateFile {
pending_upload: merge_counter_delta(
base.pending_upload,
current.pending_upload,
on_disk.pending_upload,
),
last_upload_at: merge_monotonic(
base.last_upload_at,
current.last_upload_at,
on_disk.last_upload_at,
),
last_worldwide_total,
last_worldwide_fetch_at,
last_flush_attempt_at: merge_monotonic(
base.last_flush_attempt_at,
current.last_flush_attempt_at,
on_disk.last_flush_attempt_at,
),
cached_latest_version,
last_version_check_at,
last_version_warning_at: merge_monotonic(
base.last_version_warning_at,
current.last_version_warning_at,
on_disk.last_version_warning_at,
),
cached_country_flags,
last_flags_fetch_at,
last_pricing_fetch_at: merge_monotonic(
base.last_pricing_fetch_at,
current.last_pricing_fetch_at,
on_disk.last_pricing_fetch_at,
),
last_installed_version: merge_version_marker(
&base.last_installed_version,
¤t.last_installed_version,
&on_disk.last_installed_version,
),
previous_version: merge_version_marker(
&base.previous_version,
¤t.previous_version,
&on_disk.previous_version,
),
installed_agents: merge_installed_agents(
&base.installed_agents,
¤t.installed_agents,
&on_disk.installed_agents,
),
}
}
static TMP_FILE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static SAVE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
const MAX_SYMLINK_HOPS: u32 = 40;
fn resolve_write_target(path: &std::path::Path) -> Option<PathBuf> {
if let Ok(canon) = std::fs::canonicalize(path) {
return Some(canon);
}
let mut current = path.to_path_buf();
for _ in 0..MAX_SYMLINK_HOPS {
let Ok(meta) = std::fs::symlink_metadata(¤t) else {
return Some(current);
};
if !meta.file_type().is_symlink() {
return Some(current);
}
let Ok(link_target) = std::fs::read_link(¤t) else {
return Some(current);
};
current = if link_target.is_absolute() {
link_target
} else {
current
.parent()
.map(|parent| parent.join(&link_target))
.unwrap_or(link_target)
};
if let Ok(canon) = std::fs::canonicalize(¤t) {
return Some(canon);
}
}
None
}
fn replace_via_backup(tmp_path: &std::path::Path, target: &std::path::Path, unique: u64) -> bool {
let backup = target.with_extension(format!("bak.{}.{unique}", std::process::id()));
if std::fs::rename(target, &backup).is_err() {
let _ = std::fs::remove_file(tmp_path);
return false;
}
if std::fs::rename(tmp_path, target).is_ok() {
let _ = std::fs::remove_file(&backup);
true
} else {
let _ = std::fs::rename(&backup, target);
let _ = std::fs::remove_file(tmp_path);
false
}
}
#[cfg(unix)]
fn set_tmp_file_mode(tmp_path: &std::path::Path, target: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(target).map_or(0o600, |m| m.permissions().mode() & 0o777);
std::fs::set_permissions(tmp_path, std::fs::Permissions::from_mode(mode)).is_ok()
}
fn write_atomic(path: &std::path::Path, contents: &str) -> bool {
#[cfg(test)]
if TEST_WRITE_FAILURE.with(|cell| cell.borrow().as_deref() == Some(path)) {
return false;
}
let Some(target) = resolve_write_target(path) else {
return false;
};
let unique = TMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp_path = target.with_extension(format!("tmp.{}.{unique}", std::process::id()));
if std::fs::write(&tmp_path, contents).is_err() {
return false;
}
#[cfg(unix)]
if !set_tmp_file_mode(&tmp_path, &target) {
let _ = std::fs::remove_file(&tmp_path);
return false;
}
match std::fs::rename(&tmp_path, &target) {
Ok(()) => true,
Err(e) if cfg!(windows) && e.kind() == std::io::ErrorKind::AlreadyExists => {
replace_via_backup(&tmp_path, &target, unique)
}
Err(_) => {
let _ = std::fs::remove_file(&tmp_path);
false
}
}
}
#[cfg(test)]
thread_local! {
static TEST_HOME_OVERRIDE: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
static TEST_WRITE_FAILURE: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
fn set_test_home_dir(dir: Option<PathBuf>) {
TEST_HOME_OVERRIDE.with(|cell| *cell.borrow_mut() = dir);
}
#[cfg(test)]
fn set_test_write_failure(path: Option<PathBuf>) {
TEST_WRITE_FAILURE.with(|cell| *cell.borrow_mut() = path);
}
fn tokensave_dir() -> Option<PathBuf> {
#[cfg(test)]
{
if let Some(dir) = TEST_HOME_OVERRIDE.with(|cell| cell.borrow().clone()) {
return Some(dir);
}
}
dirs::home_dir().map(|h| h.join(".tokensave"))
}
pub fn config_path() -> Option<PathBuf> {
tokensave_dir().map(|d| d.join("config.toml"))
}
pub fn state_path() -> Option<PathBuf> {
tokensave_dir().map(|d| d.join("state.toml"))
}
fn config_lock_path() -> Option<PathBuf> {
tokensave_dir().map(|d| d.join("config.toml.lock"))
}
fn preserve_corrupt_file(path: &std::path::Path) {
let target = resolve_write_target(path).unwrap_or_else(|| path.to_path_buf());
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let unique = TMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let backup = target.with_extension(format!(
"toml.corrupt.{timestamp}.{}.{unique}",
std::process::id()
));
let _ = std::fs::rename(&target, backup);
}
fn lock_config_file() -> Option<std::fs::File> {
let f = config_lock_path().and_then(|p| {
std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(p)
.ok()
})?;
f.lock_exclusive().ok()?;
Some(f)
}
impl UserConfig {
pub fn load() -> Self {
let _guard = SAVE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let config_p = config_path();
let config_lock = lock_config_file();
let raw = config_p.as_ref().and_then(|p| std::fs::read(p).ok());
let config_text = raw
.as_ref()
.and_then(|bytes| std::str::from_utf8(bytes).ok());
let parsed = config_text.and_then(|contents| toml::from_str::<Self>(contents).ok());
let mut base: Self = if let Some(mut cfg) = parsed {
cfg.loaded_config = Mutex::new(Some(ConfigFile::from_config(&cfg)));
cfg.loaded_legacy_state.store(
config_text.is_some_and(contains_legacy_state),
Ordering::Relaxed,
);
cfg
} else {
if raw.is_some() {
if let Some(p) = &config_p {
preserve_corrupt_file(p);
}
}
Self::default()
};
if let Some(state_p) = state_path() {
if let Ok(bytes) = std::fs::read(&state_p) {
match std::str::from_utf8(&bytes)
.ok()
.and_then(|contents| toml::from_str::<StateFile>(contents).ok())
{
Some(state) => {
apply_state(&mut base, state.clone());
base.loaded_state = Mutex::new(Some(state));
}
None => preserve_corrupt_file(&state_p),
}
}
}
if let Some(f) = &config_lock {
let _ = f.unlock();
}
base
}
pub fn save(&self) -> bool {
let Some(config_path) = config_path() else {
return false;
};
let Some(state_path) = state_path() else {
return false;
};
if let Some(parent) = config_path.parent() {
if std::fs::create_dir_all(parent).is_err() {
return false;
}
}
let _guard = SAVE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(lock_file) = lock_config_file() else {
return false;
};
let Some(state_file) = self.merge_state_file() else {
let _ = lock_file.unlock();
return false;
};
let Ok(state_contents) = toml::to_string_pretty(&state_file) else {
let _ = lock_file.unlock();
return false;
};
let Ok(config_file) = self.merge_config_file() else {
let _ = lock_file.unlock();
return false;
};
let config_contents = if let Some(config) = config_file.as_ref() {
let Ok(contents) = toml::to_string_pretty(config) else {
let _ = lock_file.unlock();
return false;
};
Some(contents)
} else {
None
};
if !write_atomic(&state_path, &state_contents) {
let _ = lock_file.unlock();
return false;
}
*self
.loaded_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(StateFile::from_config(self));
if config_contents
.as_deref()
.is_some_and(|contents| !write_atomic(&config_path, contents))
{
let _ = lock_file.unlock();
return false;
}
let _ = lock_file.unlock();
*self
.loaded_config
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(ConfigFile::from_config(self));
self.loaded_legacy_state.store(false, Ordering::Relaxed);
true
}
fn merge_config_file(&self) -> Result<Option<ConfigFile>, ()> {
let self_file = ConfigFile::from_config(self);
let baseline = self
.loaded_config
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(base) = baseline.as_ref() else {
return Ok(Some(self_file));
};
let changed = self.upload_enabled != base.upload_enabled
|| self.watcher_debounce != base.watcher_debounce
|| self.extraction_timeout_secs != base.extraction_timeout_secs
|| self.wildcard_permissions != base.wildcard_permissions;
if !changed && !self.loaded_legacy_state.load(Ordering::Relaxed) {
return Ok(None);
}
let contents = config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.ok_or(())?;
let on_disk = toml::from_str::<ConfigFile>(&contents).map_err(|_| ())?;
Ok(Some(ConfigFile {
upload_enabled: if self.upload_enabled == base.upload_enabled {
on_disk.upload_enabled
} else {
self.upload_enabled
},
watcher_debounce: if self.watcher_debounce == base.watcher_debounce {
on_disk.watcher_debounce
} else {
self.watcher_debounce.clone()
},
extraction_timeout_secs: if self.extraction_timeout_secs == base.extraction_timeout_secs
{
on_disk.extraction_timeout_secs
} else {
self.extraction_timeout_secs
},
wildcard_permissions: if self.wildcard_permissions == base.wildcard_permissions {
on_disk.wildcard_permissions
} else {
self.wildcard_permissions
},
}))
}
fn merge_state_file(&self) -> Option<StateFile> {
let self_file = StateFile::from_config(self);
let baseline = self
.loaded_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(base) = baseline.as_ref() else {
return Some(self_file);
};
let state_path = state_path()?;
let on_disk = match std::fs::read_to_string(state_path) {
Ok(contents) => toml::from_str::<StateFile>(&contents).ok()?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StateFile::default(),
Err(_) => return None,
};
Some(merge_state(base, &self_file, &on_disk))
}
pub fn is_fresh() -> bool {
config_path().is_none_or(|p| !p.exists())
}
}
pub fn parse_duration(s: &str) -> Option<std::time::Duration> {
let s = s.trim();
if let Some(secs) = s.strip_suffix('s') {
secs.trim()
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
} else if let Some(mins) = s.strip_suffix('m') {
mins.trim()
.parse::<u64>()
.ok()
.map(|m| std::time::Duration::from_secs(m * 60))
} else {
s.parse::<u64>().ok().map(std::time::Duration::from_secs)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::duration_suboptimal_units
)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn parse_duration_seconds() {
assert_eq!(parse_duration("15s"), Some(Duration::from_secs(15)));
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
assert_eq!(parse_duration(" 5s "), Some(Duration::from_secs(5)));
}
#[test]
fn parse_duration_minutes() {
assert_eq!(parse_duration("1m"), Some(Duration::from_secs(60)));
assert_eq!(parse_duration("2m"), Some(Duration::from_secs(120)));
}
#[test]
fn parse_duration_bare_number() {
assert_eq!(parse_duration("10"), Some(Duration::from_secs(10)));
}
#[test]
fn parse_duration_invalid() {
assert_eq!(parse_duration("abc"), None);
assert_eq!(parse_duration(""), None);
assert_eq!(parse_duration("1h"), None);
}
struct TestHome {
_dir: tempfile::TempDir,
}
impl Drop for TestHome {
fn drop(&mut self) {
set_test_write_failure(None);
set_test_home_dir(None);
}
}
fn test_home() -> TestHome {
let dir = tempfile::tempdir().expect("tempdir");
set_test_home_dir(Some(dir.path().to_path_buf()));
TestHome { _dir: dir }
}
fn sample_config() -> UserConfig {
UserConfig {
upload_enabled: false,
pending_upload: 42,
last_upload_at: 100,
last_worldwide_total: 999,
last_worldwide_fetch_at: 200,
last_flush_attempt_at: 300,
cached_latest_version: "1.2.3".to_string(),
last_version_check_at: 400,
last_version_warning_at: 500,
installed_agents: vec!["claude".to_string(), "cursor".to_string()],
watcher_debounce: "5s".to_string(),
cached_country_flags: vec!["us".to_string(), "cz".to_string()],
last_flags_fetch_at: 600,
last_pricing_fetch_at: 700,
last_installed_version: "1.2.2".to_string(),
previous_version: "1.2.1".to_string(),
extraction_timeout_secs: 30,
wildcard_permissions: true,
loaded_config: Mutex::new(None),
loaded_state: Mutex::new(None),
loaded_legacy_state: AtomicBool::new(false),
}
}
#[test]
fn round_trip_save_and_load() {
let _home = test_home();
let config = sample_config();
assert!(config.save());
let loaded = UserConfig::load();
assert_eq!(loaded.upload_enabled, config.upload_enabled);
assert_eq!(loaded.pending_upload, config.pending_upload);
assert_eq!(loaded.last_upload_at, config.last_upload_at);
assert_eq!(loaded.last_worldwide_total, config.last_worldwide_total);
assert_eq!(
loaded.last_worldwide_fetch_at,
config.last_worldwide_fetch_at
);
assert_eq!(loaded.last_flush_attempt_at, config.last_flush_attempt_at);
assert_eq!(loaded.cached_latest_version, config.cached_latest_version);
assert_eq!(loaded.last_version_check_at, config.last_version_check_at);
assert_eq!(
loaded.last_version_warning_at,
config.last_version_warning_at
);
assert_eq!(loaded.installed_agents, config.installed_agents);
assert_eq!(loaded.watcher_debounce, config.watcher_debounce);
assert_eq!(loaded.cached_country_flags, config.cached_country_flags);
assert_eq!(loaded.last_flags_fetch_at, config.last_flags_fetch_at);
assert_eq!(loaded.last_pricing_fetch_at, config.last_pricing_fetch_at);
assert_eq!(loaded.last_installed_version, config.last_installed_version);
assert_eq!(loaded.previous_version, config.previous_version);
assert_eq!(
loaded.extraction_timeout_secs,
config.extraction_timeout_secs
);
assert_eq!(loaded.wildcard_permissions, config.wildcard_permissions);
}
#[test]
fn save_splits_fields_across_files() {
let _home = test_home();
sample_config().save();
let config_contents =
std::fs::read_to_string(config_path().expect("config path")).expect("read config");
assert!(config_contents.contains("upload_enabled"));
assert!(!config_contents.contains("cached_latest_version"));
assert!(!config_contents.contains("pending_upload"));
let state_contents =
std::fs::read_to_string(state_path().expect("state path")).expect("read state");
assert!(state_contents.contains("pending_upload"));
assert!(state_contents.contains("cached_latest_version"));
assert!(!state_contents.contains("wildcard_permissions"));
}
#[test]
fn save_twice_overwrites_previous_files() {
let _home = test_home();
let mut config = sample_config();
assert!(config.save());
config.pending_upload = 4321;
config.wildcard_permissions = false;
assert!(config.save());
let loaded = UserConfig::load();
assert_eq!(loaded.pending_upload, 4321);
assert!(!loaded.wildcard_permissions);
}
#[test]
fn save_does_not_clobber_field_changed_by_another_process_after_load() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let a = UserConfig::load();
assert!(!a.wildcard_permissions);
let mut other = UserConfig::load();
other.wildcard_permissions = true;
assert!(other.save());
assert!(a.save());
let reloaded = UserConfig::load();
assert!(
reloaded.wildcard_permissions,
"save() clobbered another process's concurrent change to a field this process left untouched"
);
}
#[test]
fn state_only_save_leaves_config_byte_for_byte_untouched() {
let _home = test_home();
let mut initial = sample_config();
initial.wildcard_permissions = false;
assert!(initial.save());
let mut stale = UserConfig::load();
assert!(!stale.wildcard_permissions);
let config_p = config_path().expect("config path");
let externally_edited = "upload_enabled = true\n\
watcher_debounce = \"2s\"\n\
extraction_timeout_secs = 60\n\
wildcard_permissions = true # managed in dotfiles\n";
std::fs::write(&config_p, externally_edited).expect("edit preference externally");
stale.pending_upload += 10;
assert!(stale.save());
assert_eq!(
std::fs::read_to_string(config_p).expect("read config"),
externally_edited,
"a state-only save rewrote stable preferences"
);
}
#[test]
fn failed_required_config_reread_does_not_restore_stale_snapshot() {
let _home = test_home();
assert!(sample_config().save());
let mut loaded = UserConfig::load();
loaded.watcher_debounce = "45s".to_string();
let config_p = config_path().expect("config path");
let replacement = "wildcard_permissions = [temporarily invalid";
std::fs::write(&config_p, replacement).expect("replace config during save lifecycle");
assert!(
!loaded.save(),
"save must fail when an intentional preference change cannot be merged safely"
);
assert_eq!(
std::fs::read_to_string(config_p).expect("read replacement"),
replacement,
"save replaced an unreadable current file with its stale snapshot"
);
}
#[test]
fn retry_after_state_success_and_config_failure_does_not_repeat_delta() {
let _home = test_home();
let mut initial = sample_config();
initial.pending_upload = 100;
initial.watcher_debounce = "2s".to_string();
assert!(initial.save());
let mut loaded = UserConfig::load();
loaded.pending_upload += 10;
loaded.watcher_debounce = "45s".to_string();
let config_p = config_path().expect("config path");
set_test_write_failure(Some(config_p));
assert!(
!loaded.save(),
"the injected config write failure must make save report failure"
);
let persisted_after_failure =
std::fs::read_to_string(state_path().expect("state path")).expect("read state");
let persisted_after_failure: StateFile =
toml::from_str(&persisted_after_failure).expect("parse state");
assert_eq!(
persisted_after_failure.pending_upload, 110,
"the state write before the config failure must remain durable"
);
loaded.pending_upload += 5;
set_test_write_failure(None);
assert!(loaded.save());
let reloaded = UserConfig::load();
assert_eq!(
reloaded.pending_upload, 115,
"retry applied the first +10 delta more than once"
);
assert_eq!(reloaded.watcher_debounce, "45s");
}
#[test]
fn save_writes_field_this_process_intentionally_changed() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let mut loaded = UserConfig::load();
loaded.wildcard_permissions = true;
assert!(loaded.save());
let reloaded = UserConfig::load();
assert!(
reloaded.wildcard_permissions,
"a field this process intentionally changed must still be written"
);
}
#[test]
fn stale_state_save_preserves_newer_unrelated_state() {
let _home = test_home();
let mut initial = sample_config();
initial.pending_upload = 100;
initial.last_upload_at = 10;
initial.installed_agents = vec!["claude".to_string()];
assert!(initial.save());
let mut stale = UserConfig::load();
let mut newer = UserConfig::load();
newer.last_upload_at = 500;
newer.installed_agents.push("copilot".to_string());
assert!(newer.save());
stale.pending_upload += 10;
assert!(stale.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.pending_upload, 110);
assert_eq!(reloaded.last_upload_at, 500);
assert_eq!(
reloaded.installed_agents,
vec!["claude".to_string(), "copilot".to_string()]
);
}
#[test]
fn concurrent_pending_upload_deltas_accumulate() {
let _home = test_home();
let mut initial = sample_config();
initial.pending_upload = 100;
assert!(initial.save());
let mut a = UserConfig::load();
let mut b = UserConfig::load();
a.pending_upload += 10;
b.pending_upload += 20;
assert!(a.save());
assert!(b.save());
assert_eq!(UserConfig::load().pending_upload, 130);
}
#[test]
fn upload_reset_preserves_tokens_added_concurrently() {
let _home = test_home();
let mut initial = sample_config();
initial.pending_upload = 100;
assert!(initial.save());
let mut flusher = UserConfig::load();
let mut producer = UserConfig::load();
producer.pending_upload += 20;
assert!(producer.save());
flusher.pending_upload = 0;
flusher.last_upload_at = 500;
assert!(flusher.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.pending_upload, 20);
assert_eq!(reloaded.last_upload_at, 500);
}
#[test]
fn concurrent_installed_agent_addition_survives_removal() {
let _home = test_home();
let mut initial = sample_config();
initial.installed_agents = vec!["claude".to_string(), "cursor".to_string()];
assert!(initial.save());
let mut remover = UserConfig::load();
let mut adder = UserConfig::load();
adder.installed_agents.push("copilot".to_string());
assert!(adder.save());
remover.installed_agents.retain(|agent| agent != "cursor");
assert!(remover.save());
assert_eq!(
UserConfig::load().installed_agents,
vec!["claude".to_string(), "copilot".to_string()]
);
}
#[test]
fn newer_timestamped_cache_and_version_markers_do_not_regress() {
let _home = test_home();
let mut initial = sample_config();
initial.cached_latest_version = "7.6.0".to_string();
initial.last_version_check_at = 100;
initial.last_installed_version = "7.6.0".to_string();
assert!(initial.save());
let mut stale = UserConfig::load();
let mut newer = UserConfig::load();
newer.cached_latest_version = "7.8.0".to_string();
newer.last_version_check_at = 500;
newer.last_installed_version = "7.8.0".to_string();
assert!(newer.save());
stale.cached_latest_version = "7.7.0".to_string();
stale.last_version_check_at = 300;
stale.last_installed_version = "7.7.0".to_string();
assert!(stale.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.cached_latest_version, "7.8.0");
assert_eq!(reloaded.last_version_check_at, 500);
assert_eq!(reloaded.last_installed_version, "7.8.0");
}
#[test]
fn intentionally_changed_version_markers_can_move_backward() {
let _home = test_home();
let mut initial = sample_config();
initial.last_installed_version = "7.8.0".to_string();
initial.previous_version = "7.8.0".to_string();
assert!(initial.save());
let mut downgraded = UserConfig::load();
downgraded.last_installed_version = "7.7.0".to_string();
downgraded.previous_version = "7.7.0".to_string();
assert!(downgraded.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.last_installed_version, "7.7.0");
assert_eq!(reloaded.previous_version, "7.7.0");
}
#[test]
fn missing_state_file_is_recreated_from_changes_since_load() {
let _home = test_home();
let mut initial = sample_config();
initial.pending_upload = 100;
initial.last_upload_at = 500;
initial.installed_agents = vec!["claude".to_string()];
assert!(initial.save());
let mut loaded = UserConfig::load();
std::fs::remove_file(state_path().expect("state path")).expect("remove state file");
loaded.pending_upload += 10;
loaded.installed_agents.push("copilot".to_string());
assert!(loaded.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.pending_upload, 10);
assert_eq!(reloaded.last_upload_at, 0);
assert_eq!(reloaded.installed_agents, vec!["copilot".to_string()]);
}
#[test]
fn save_advances_baseline_so_later_external_changes_are_not_reverted() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let mut long_lived = UserConfig::load();
long_lived.wildcard_permissions = true;
assert!(long_lived.save());
let mut other = UserConfig::load();
other.wildcard_permissions = false;
assert!(other.save());
long_lived.extraction_timeout_secs = 999;
assert!(long_lived.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.extraction_timeout_secs, 999);
assert!(
!reloaded.wildcard_permissions,
"save() re-wrote a field from a stale load()-time baseline instead of the advanced one, reverting a concurrent external change"
);
}
#[test]
fn save_advancing_baseline_to_merged_disk_value_does_not_revert_untouched_field_on_next_save() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let mut a = UserConfig::load();
let mut b = UserConfig::load();
b.wildcard_permissions = true;
assert!(b.save());
a.extraction_timeout_secs = 111;
assert!(a.save());
assert!(UserConfig::load().wildcard_permissions);
a.watcher_debounce = "45s".to_string();
assert!(a.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.watcher_debounce, "45s");
assert!(
reloaded.wildcard_permissions,
"a second consecutive save() reverted a field `a` never touched, because the baseline advanced to the merged/disk value instead of a's own in-memory value"
);
}
#[test]
fn config_lock_prevents_lost_updates_across_independent_file_handles() {
let dir = tempfile::tempdir().expect("tempdir");
let counter_path = dir.path().join("counter");
std::fs::write(&counter_path, "0").expect("seed counter");
let lock_path = dir.path().join("counter.lock");
let handles: Vec<_> = (0..16_u32)
.map(|_| {
let counter_path = counter_path.clone();
let lock_path = lock_path.clone();
std::thread::spawn(move || {
for _ in 0..25 {
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.expect("open lock file");
f.lock_exclusive().expect("acquire lock");
let contents = std::fs::read_to_string(&counter_path).expect("read");
let n: u64 = contents.trim().parse().expect("parse counter");
std::fs::write(&counter_path, (n + 1).to_string()).expect("write");
let _ = f.unlock();
}
})
})
.collect();
for h in handles {
h.join().expect("thread panicked");
}
let final_contents = std::fs::read_to_string(&counter_path).expect("read final");
let final_n: u64 = final_contents.trim().parse().expect("parse final counter");
assert_eq!(
final_n, 400,
"lock failed to exclude concurrent holders, losing increments"
);
}
#[test]
fn concurrent_saves_never_delete_destination_files() {
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
let handles: Vec<_> = (0..8_u64)
.map(|i| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
let mut config = sample_config();
config.pending_upload = i;
assert!(config.save(), "save() should not fail under contention");
})
})
.collect();
for handle in handles {
handle.join().expect("saving thread panicked");
}
assert!(tokensave_dir.join("config.toml").exists());
assert!(tokensave_dir.join("state.toml").exists());
}
#[test]
fn concurrent_saves_do_not_mix_config_and_state() {
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
let handles: Vec<_> = (0..8_u64)
.map(|i| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
for _ in 0..25 {
let mut config = sample_config();
config.pending_upload = i;
config.wildcard_permissions = i % 2 == 0;
assert!(config.save(), "save() should not fail under contention");
}
})
})
.collect();
for handle in handles {
handle.join().expect("saving thread panicked");
}
let tokensave_dir_for_check = tokensave_dir.clone();
let loaded = std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir_for_check));
UserConfig::load()
})
.join()
.expect("checker thread panicked");
assert_eq!(
loaded.wildcard_permissions,
loaded.pending_upload % 2 == 0,
"config.toml and state.toml came from different saves: pending_upload={}, wildcard_permissions={}",
loaded.pending_upload,
loaded.wildcard_permissions,
);
}
#[test]
fn load_never_returns_torn_config_state_pair_during_concurrent_saves() {
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
let saver_handles: Vec<_> = (0..4_u64)
.map(|i| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
for iter in 0..25_u64 {
let mut config = sample_config();
if (i + iter) % 2 == 0 {
config.pending_upload = 0;
config.wildcard_permissions = false;
} else {
config.pending_upload = i + 1;
config.wildcard_permissions = true;
}
assert!(config.save(), "save() should not fail under contention");
}
})
})
.collect();
let loader_handles: Vec<_> = (0..4_u64)
.map(|_| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
for _ in 0..50 {
let loaded = UserConfig::load();
assert_eq!(
loaded.wildcard_permissions,
loaded.pending_upload != 0,
"load() returned a torn config/state pair: pending_upload={}, wildcard_permissions={}",
loaded.pending_upload,
loaded.wildcard_permissions,
);
}
})
})
.collect();
for handle in saver_handles {
handle.join().expect("saving thread panicked");
}
for handle in loader_handles {
handle.join().expect("loading thread panicked");
}
}
#[test]
fn load_preserves_corrupt_state_file_instead_of_overwriting_it() {
let home = test_home();
let state_p = state_path().expect("state path");
if let Some(parent) = state_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_contents = "pending_upload = [not valid toml";
std::fs::write(&state_p, corrupt_contents).expect("seed corrupt state.toml");
let loaded = UserConfig::load();
assert_eq!(
loaded.pending_upload, 0,
"unrecoverable state fields should fall back to defaults"
);
assert!(
!state_p.exists(),
"corrupt state.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = state_p.parent().expect("state parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("state.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the corrupt state.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup"),
corrupt_contents
);
assert!(loaded.save());
assert!(state_p.exists());
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup after save"),
corrupt_contents
);
drop(home);
}
#[test]
fn load_preserves_corrupt_config_file_instead_of_overwriting_it() {
let home = test_home();
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_contents = "wildcard_permissions = [not valid toml";
std::fs::write(&config_p, corrupt_contents).expect("seed corrupt config.toml");
let loaded = UserConfig::load();
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
assert!(
!config_p.exists(),
"corrupt config.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = config_p.parent().expect("config parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("config.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the corrupt config.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup"),
corrupt_contents
);
assert!(loaded.save());
assert!(config_p.exists());
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup after save"),
corrupt_contents
);
drop(home);
}
#[test]
fn load_preserves_invalid_utf8_config_file_instead_of_overwriting_it() {
let home = test_home();
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_bytes: &[u8] = &[b'w', b'p', b'=', 0xFF, 0xFE, b'x'];
std::fs::write(&config_p, corrupt_bytes).expect("seed invalid-UTF-8 config.toml");
let loaded = UserConfig::load();
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
assert!(
!config_p.exists(),
"invalid-UTF-8 config.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = config_p.parent().expect("config parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("config.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the invalid-UTF-8 config.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup"),
corrupt_bytes
);
assert!(loaded.save());
assert!(config_p.exists());
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup after save"),
corrupt_bytes
);
drop(home);
}
#[test]
fn load_preserves_invalid_utf8_state_file_instead_of_overwriting_it() {
let home = test_home();
let state_p = state_path().expect("state path");
if let Some(parent) = state_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_bytes: &[u8] = &[b'p', b'u', b'=', 0xFF, 0xFE, b'x'];
std::fs::write(&state_p, corrupt_bytes).expect("seed invalid-UTF-8 state.toml");
let loaded = UserConfig::load();
assert_eq!(
loaded.pending_upload, 0,
"unrecoverable state fields should fall back to defaults"
);
assert!(
!state_p.exists(),
"invalid-UTF-8 state.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = state_p.parent().expect("state parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("state.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the invalid-UTF-8 state.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup"),
corrupt_bytes
);
assert!(loaded.save());
assert!(state_p.exists());
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup after save"),
corrupt_bytes
);
drop(home);
}
#[test]
fn load_blocks_on_config_lock_instead_of_racing_a_concurrent_writer() {
const HOLD: Duration = Duration::from_millis(300);
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
set_test_home_dir(Some(tokensave_dir.clone()));
std::fs::create_dir_all(&tokensave_dir).expect("create tokensave dir");
let config_p = tokensave_dir.join("config.toml");
std::fs::write(&config_p, "wildcard_permissions = [not valid toml")
.expect("seed corrupt config.toml");
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let other_process = {
let tokensave_dir = tokensave_dir.clone();
let barrier = std::sync::Arc::clone(&barrier);
std::thread::spawn(move || {
let lock_p = tokensave_dir.join("config.toml.lock");
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_p)
.expect("open lock file");
f.lock_exclusive().expect("acquire lock");
barrier.wait();
std::thread::sleep(HOLD);
let _ = f.unlock();
})
};
barrier.wait();
let start = std::time::Instant::now();
let loaded = UserConfig::load();
let elapsed = start.elapsed();
other_process.join().expect("other_process thread panicked");
assert!(
elapsed >= HOLD / 2,
"load() returned in {elapsed:?} without waiting for the concurrent lock holder \
(held for {HOLD:?}) -- it isn't taking the cross-process config lock at all"
);
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
assert!(
!config_p.exists(),
"corrupt config.toml should have been moved aside only after the lock was free"
);
set_test_home_dir(None);
}
#[cfg(unix)]
#[test]
fn load_preserves_corrupt_config_file_through_symlink_without_detaching_it() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
let corrupt_contents = "wildcard_permissions = [not valid toml";
std::fs::write(&repo_config, corrupt_contents).expect("seed corrupt repo config");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
let loaded = UserConfig::load();
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"the config.toml symlink itself should survive corrupt-file preservation"
);
assert!(
!repo_config.exists(),
"the corrupt content at the symlink's target should have been moved aside"
);
let backups: Vec<_> = std::fs::read_dir(repo_dir.path())
.expect("read repo dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("config.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the corrupt repo config"
);
assert_eq!(
std::fs::read_to_string(backups[0].path()).expect("read preserved backup"),
corrupt_contents
);
assert!(loaded.save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata after save");
assert!(
meta.file_type().is_symlink(),
"save() should write through the dangling symlink, not replace it"
);
assert!(
repo_config.exists(),
"save() should recreate the repo target"
);
drop(home);
}
#[test]
fn migrates_legacy_mixed_config_file() {
let home = test_home();
let legacy = sample_config();
let legacy_toml = toml::to_string_pretty(&legacy).expect("serialize legacy config");
std::fs::write(config_path().expect("config path"), legacy_toml).expect("write legacy");
assert!(!state_path().expect("state path").exists());
let loaded = UserConfig::load();
assert_eq!(loaded.pending_upload, legacy.pending_upload);
assert_eq!(loaded.previous_version, legacy.previous_version);
assert_eq!(loaded.cached_latest_version, legacy.cached_latest_version);
assert!(loaded.save());
assert!(state_path().expect("state path").exists());
let config_contents =
std::fs::read_to_string(config_path().expect("config path")).expect("read config");
assert!(!config_contents.contains("pending_upload"));
assert!(!config_contents.contains("previous_version"));
drop(home);
}
#[test]
fn legacy_migration_retry_after_config_failure_does_not_repeat_delta() {
let home = test_home();
let mut legacy = sample_config();
legacy.pending_upload = 100;
let legacy_toml = toml::to_string_pretty(&legacy).expect("serialize legacy config");
let config_p = config_path().expect("config path");
std::fs::write(&config_p, &legacy_toml).expect("write legacy");
let mut loaded = UserConfig::load();
loaded.pending_upload += 10;
set_test_write_failure(Some(config_p.clone()));
assert!(
!loaded.save(),
"the injected config rewrite failure must make migration fail"
);
let state_contents =
std::fs::read_to_string(state_path().expect("state path")).expect("read state");
let state: StateFile = toml::from_str(&state_contents).expect("parse state");
assert_eq!(state.pending_upload, 110);
assert!(
std::fs::read_to_string(&config_p)
.expect("read legacy config")
.contains("pending_upload"),
"legacy state must remain recoverable until the config rewrite succeeds"
);
set_test_write_failure(None);
assert!(loaded.save());
let reloaded = UserConfig::load();
assert_eq!(
reloaded.pending_upload, 110,
"retry applied the migration-time +10 delta more than once"
);
assert!(
!std::fs::read_to_string(config_p)
.expect("read migrated config")
.contains("pending_upload"),
"legacy state key survived the successful retry"
);
drop(home);
}
#[test]
fn failed_state_write_does_not_lose_legacy_config_state() {
let home = test_home();
let legacy = sample_config();
let legacy_toml = toml::to_string_pretty(&legacy).expect("serialize legacy config");
let config_p = config_path().expect("config path");
std::fs::write(&config_p, &legacy_toml).expect("write legacy");
let state_p = state_path().expect("state path");
std::fs::create_dir_all(&state_p).expect("create state dir");
let loaded = UserConfig::load();
assert_eq!(loaded.pending_upload, legacy.pending_upload);
assert_eq!(loaded.previous_version, legacy.previous_version);
assert!(!loaded.save());
let config_contents = std::fs::read_to_string(&config_p).expect("read config");
assert_eq!(config_contents, legacy_toml);
drop(home);
}
#[test]
fn replace_via_backup_replaces_destination_on_success() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("state.toml");
std::fs::write(&target, "original").expect("seed target");
let tmp_path = dir.path().join("state.tmp.0");
std::fs::write(&tmp_path, "updated").expect("seed tmp");
assert!(replace_via_backup(&tmp_path, &target, 0));
assert_eq!(
std::fs::read_to_string(&target).expect("read target"),
"updated"
);
assert!(!tmp_path.exists(), "temp file should be consumed");
let leftover_count = std::fs::read_dir(dir.path())
.expect("read dir")
.filter_map(Result::ok)
.count();
assert_eq!(leftover_count, 1, "backup file was not cleaned up");
}
#[test]
fn replace_via_backup_restores_destination_if_replacement_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("state.toml");
std::fs::write(&target, "original").expect("seed target");
let missing_tmp_path = dir.path().join("state.tmp.missing");
assert!(!replace_via_backup(&missing_tmp_path, &target, 0));
assert_eq!(
std::fs::read_to_string(&target).expect("read target"),
"original",
"destination was lost instead of restored"
);
}
#[cfg(unix)]
#[test]
fn save_writes_through_config_symlink() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
std::fs::write(&repo_config, "upload_enabled = true\n").expect("seed repo config");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
assert!(sample_config().save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
let repo_contents = std::fs::read_to_string(&repo_config).expect("read repo config");
assert!(repo_contents.contains("wildcard_permissions = true"));
drop(home);
}
#[cfg(unix)]
#[test]
fn save_writes_through_dangling_config_symlink() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
assert!(!repo_config.exists(), "repo config must start absent");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
assert!(sample_config().save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"save() replaced the dangling config.toml symlink with a regular file"
);
let repo_contents = std::fs::read_to_string(&repo_config).expect("read repo config");
assert!(repo_contents.contains("wildcard_permissions = true"));
drop(home);
}
#[cfg(unix)]
#[test]
fn save_writes_through_nested_dangling_config_symlink() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let missing_target = repo_dir.path().join("missing-target");
assert!(!missing_target.exists(), "chain target must start absent");
let managed_config = repo_dir.path().join("managed-config");
std::os::unix::fs::symlink(&missing_target, &managed_config)
.expect("symlink managed-config -> missing-target");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&managed_config, &config_p)
.expect("symlink config.toml -> managed-config");
assert!(sample_config().save());
let config_meta = std::fs::symlink_metadata(&config_p).expect("config symlink metadata");
assert!(
config_meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
let managed_meta =
std::fs::symlink_metadata(&managed_config).expect("managed-config symlink metadata");
assert!(
managed_meta.file_type().is_symlink(),
"save() replaced the managed-config symlink with a regular file"
);
let contents = std::fs::read_to_string(&missing_target).expect("read chain target");
assert!(contents.contains("wildcard_permissions = true"));
drop(home);
}
#[cfg(unix)]
#[test]
fn save_fails_on_cyclic_config_symlink() {
let home = test_home();
let config_p = config_path().expect("config path");
let other_p = config_p
.parent()
.expect("config parent")
.join("other-cycle-link");
std::os::unix::fs::symlink(&other_p, &config_p).expect("symlink config.toml -> other");
std::os::unix::fs::symlink(&config_p, &other_p).expect("symlink other -> config.toml");
assert!(
!sample_config().save(),
"save() must not succeed on a cyclic symlink chain"
);
let config_meta = std::fs::symlink_metadata(&config_p).expect("config symlink metadata");
assert!(
config_meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
let other_meta = std::fs::symlink_metadata(&other_p).expect("other symlink metadata");
assert!(
other_meta.file_type().is_symlink(),
"save() replaced the other-cycle-link symlink with a regular file"
);
drop(home);
}
#[cfg(unix)]
fn mode_of(path: &std::path::Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.expect("metadata")
.permissions()
.mode()
& 0o777
}
#[cfg(unix)]
#[test]
fn save_preserves_existing_file_mode() {
use std::os::unix::fs::PermissionsExt;
let home = test_home();
assert!(sample_config().save());
let config_p = config_path().expect("config path");
let state_p = state_path().expect("state path");
std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))
.expect("chmod config.toml");
std::fs::set_permissions(&state_p, std::fs::Permissions::from_mode(0o600))
.expect("chmod state.toml");
assert!(sample_config().save());
assert_eq!(
mode_of(&config_p),
0o600,
"save() widened config.toml's mode away from the caller-set 0o600"
);
assert_eq!(
mode_of(&state_p),
0o600,
"save() widened state.toml's mode away from the caller-set 0o600"
);
drop(home);
}
#[cfg(unix)]
#[test]
fn save_creates_files_with_restrictive_mode() {
let home = test_home();
assert!(sample_config().save());
let config_p = config_path().expect("config path");
let state_p = state_path().expect("state path");
assert_eq!(
mode_of(&config_p),
0o600,
"a freshly created config.toml should default to 0o600"
);
assert_eq!(
mode_of(&state_p),
0o600,
"a freshly created state.toml should default to 0o600"
);
drop(home);
}
#[cfg(unix)]
#[test]
fn save_through_symlink_preserves_target_mode() {
use std::os::unix::fs::PermissionsExt;
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
std::fs::write(&repo_config, "upload_enabled = true\n").expect("seed repo config");
std::fs::set_permissions(&repo_config, std::fs::Permissions::from_mode(0o600))
.expect("chmod repo config.toml");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
assert!(sample_config().save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
assert_eq!(
mode_of(&repo_config),
0o600,
"save() widened the dotfiles repo's config.toml mode away from 0o600"
);
drop(home);
}
}