use std::fs::{self, OpenOptions};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use tokmesh_core::scanner::ScannerSettings;
use crate::leaderboard::Leaderboard;
use super::themes::ThemeName;
const DEFAULT_AUTO_REFRESH_MS: u64 = 60_000;
const MIN_AUTO_REFRESH_MS: u64 = 30_000;
const MAX_AUTO_REFRESH_MS: u64 = 3_600_000;
const DEFAULT_NATIVE_TIMEOUT_MS: u64 = 300_000;
const MIN_NATIVE_TIMEOUT_MS: u64 = 5_000;
const MAX_NATIVE_TIMEOUT_MS: u64 = 3_600_000;
pub const DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES: u64 = 24 * 60;
pub const MIN_AUTOSUBMIT_INTERVAL_MINUTES: u64 = 15;
pub const MAX_AUTOSUBMIT_INTERVAL_MINUTES: u64 = 7 * 24 * 60;
#[derive(Debug, Clone, Copy)]
enum ExplicitHomeConfigLayout {
UnixDotConfig,
WindowsRoaming,
}
impl ExplicitHomeConfigLayout {
fn current() -> Self {
if cfg!(target_os = "windows") {
Self::WindowsRoaming
} else {
Self::UnixDotConfig
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LightSettings {
#[serde(default)]
pub write_cache: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutosubmitSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_autosubmit_interval_minutes")]
pub interval_minutes: u64,
#[serde(default, deserialize_with = "deserialize_string_array_lossy")]
pub clients: Vec<String>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[serde(default)]
pub year: Option<String>,
#[serde(default)]
pub today: bool,
#[serde(default)]
pub yesterday: bool,
#[serde(default)]
pub week: bool,
#[serde(default)]
pub month: bool,
#[serde(default)]
pub scheduler: Option<String>,
#[serde(default)]
pub managed_executable: Option<String>,
#[serde(default)]
pub managed_executable_version: Option<String>,
#[serde(default)]
pub last_run_at_ms: Option<i64>,
#[serde(default)]
pub last_error: Option<String>,
}
impl Default for AutosubmitSettings {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES,
clients: Vec::new(),
since: None,
until: None,
year: None,
today: false,
yesterday: false,
week: false,
month: false,
scheduler: None,
managed_executable: None,
managed_executable_version: None,
last_run_at_ms: None,
last_error: None,
}
}
}
impl AutosubmitSettings {
fn normalize(mut self) -> Self {
self.interval_minutes = self.interval_minutes.clamp(
MIN_AUTOSUBMIT_INTERVAL_MINUTES,
MAX_AUTOSUBMIT_INTERVAL_MINUTES,
);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default = "default_color_palette")]
pub color_palette: String,
#[serde(default)]
pub auto_refresh_enabled: bool,
#[serde(default = "default_auto_refresh_ms")]
pub auto_refresh_ms: u64,
#[serde(default)]
pub include_unused_models: bool,
#[serde(default = "default_native_timeout_ms")]
pub native_timeout_ms: u64,
#[serde(default)]
pub scanner: ScannerSettings,
#[serde(default, deserialize_with = "deserialize_string_array_lossy")]
pub default_clients: Vec<String>,
#[serde(default)]
pub light: LightSettings,
#[serde(default)]
pub minutely_tab_enabled: bool,
#[serde(default)]
pub bucket_timezone: Option<String>,
#[serde(default)]
pub tokscale_autosubmit: AutosubmitSettings,
#[serde(default)]
pub tokensci_autosubmit: AutosubmitSettings,
#[serde(default)]
pub model_aliases: tokmesh_core::ModelAliasMap,
}
fn deserialize_string_array_lossy<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value: Option<Vec<serde_json::Value>> = Option::deserialize(deserializer).ok().flatten();
Ok(value
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect())
}
fn default_color_palette() -> String {
"blue".to_string()
}
fn default_auto_refresh_ms() -> u64 {
DEFAULT_AUTO_REFRESH_MS
}
fn default_native_timeout_ms() -> u64 {
DEFAULT_NATIVE_TIMEOUT_MS
}
fn default_autosubmit_interval_minutes() -> u64 {
DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES
}
impl Default for Settings {
fn default() -> Self {
Self {
color_palette: default_color_palette(),
auto_refresh_enabled: false,
auto_refresh_ms: DEFAULT_AUTO_REFRESH_MS,
include_unused_models: false,
native_timeout_ms: DEFAULT_NATIVE_TIMEOUT_MS,
scanner: ScannerSettings::default(),
default_clients: Vec::new(),
light: LightSettings::default(),
minutely_tab_enabled: false,
bucket_timezone: None,
tokscale_autosubmit: AutosubmitSettings::default(),
tokensci_autosubmit: AutosubmitSettings::default(),
model_aliases: tokmesh_core::ModelAliasMap::default(),
}
}
}
pub fn load_scanner_settings() -> ScannerSettings {
Settings::load().scanner
}
pub fn load_scanner_settings_for_home(home_dir: &Option<String>) -> ScannerSettings {
Settings::load_for_home_override(home_dir.as_deref().map(Path::new)).scanner
}
pub fn load_model_aliases_for_home(home_dir: &Option<String>) -> tokmesh_core::ModelAliasMap {
Settings::load_for_home_override(home_dir.as_deref().map(Path::new)).model_aliases
}
pub fn load_default_clients() -> Vec<String> {
Settings::load().default_clients
}
pub fn load_default_clients_for_home(home_dir: &Option<String>) -> Vec<String> {
Settings::load_for_home_override(home_dir.as_deref().map(Path::new)).default_clients
}
impl Settings {
pub fn with_settings_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
let dir = crate::paths::get_config_dir().join("autosubmit");
fs::create_dir_all(&dir)?;
let path = dir.join("autosubmit-state.lock");
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("Could not open settings lock at {}", path.display()))?;
file.lock_exclusive()
.with_context(|| format!("Could not lock settings at {}", path.display()))?;
operation()
}
fn normalize(mut self) -> Self {
self.auto_refresh_ms = self
.auto_refresh_ms
.clamp(MIN_AUTO_REFRESH_MS, MAX_AUTO_REFRESH_MS);
self.native_timeout_ms = self
.native_timeout_ms
.clamp(MIN_NATIVE_TIMEOUT_MS, MAX_NATIVE_TIMEOUT_MS);
self.tokscale_autosubmit = self.tokscale_autosubmit.normalize();
self.tokensci_autosubmit = self.tokensci_autosubmit.normalize();
self
}
pub fn autosubmit(&self, board: Leaderboard) -> &AutosubmitSettings {
match board {
Leaderboard::Tokscale => &self.tokscale_autosubmit,
Leaderboard::TokensCi => &self.tokensci_autosubmit,
}
}
pub fn autosubmit_mut(&mut self, board: Leaderboard) -> &mut AutosubmitSettings {
match board {
Leaderboard::Tokscale => &mut self.tokscale_autosubmit,
Leaderboard::TokensCi => &mut self.tokensci_autosubmit,
}
}
fn config_path() -> Result<PathBuf> {
let config_dir = crate::paths::get_config_dir();
if !config_dir.exists() {
fs::create_dir_all(&config_dir)?;
}
Ok(config_dir.join("settings.json"))
}
fn explicit_home_config_path_for_layout(
home_dir: &Path,
layout: ExplicitHomeConfigLayout,
) -> PathBuf {
match layout {
ExplicitHomeConfigLayout::UnixDotConfig => home_dir
.join(".config")
.join("tokmesh")
.join("settings.json"),
ExplicitHomeConfigLayout::WindowsRoaming => home_dir
.join("AppData")
.join("Roaming")
.join("tokmesh")
.join("settings.json"),
}
}
fn explicit_home_config_path(home_dir: &Path) -> PathBuf {
Self::explicit_home_config_path_for_layout(home_dir, ExplicitHomeConfigLayout::current())
}
pub fn load() -> Self {
Self::config_path()
.ok()
.and_then(|path| fs::read_to_string(path).ok())
.and_then(|content| serde_json::from_str(&content).ok())
.map(Settings::normalize)
.unwrap_or_default()
}
pub fn load_for_home_override(home_dir: Option<&Path>) -> Self {
let Some(home_dir) = home_dir else {
return Self::load();
};
fs::read_to_string(Self::explicit_home_config_path(home_dir))
.ok()
.and_then(|content| serde_json::from_str(&content).ok())
.map(Settings::normalize)
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
let content = serde_json::to_string_pretty(self)?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let tmp_filename = format!(".settings.{}.{:x}.tmp", std::process::id(), nanos);
let temp_path = path
.parent()
.unwrap_or(std::path::Path::new("."))
.join(&tmp_filename);
let write_result = (|| -> Result<()> {
let mut file = fs::File::create(&temp_path)?;
use std::io::Write;
file.write_all(content.as_bytes())?;
file.sync_all()?;
tokmesh_core::fs_atomic::replace_file(&temp_path, &path)?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temp_path);
}
write_result
}
pub fn save_preserving_autosubmit(&mut self) -> Result<()> {
Self::with_settings_lock(|| {
let latest = Self::load();
self.tokscale_autosubmit = latest.tokscale_autosubmit;
self.tokensci_autosubmit = latest.tokensci_autosubmit;
self.save()
})
}
pub fn load_or_detect_bucket_timezone() -> Result<Option<String>> {
Self::with_settings_lock(|| {
let path = Self::config_path()?;
let mut settings = match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Settings>(&content) {
Ok(settings) => settings.normalize(),
Err(_) => return Ok(iana_time_zone::get_timezone().ok()),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Settings::default(),
Err(_) => return Ok(iana_time_zone::get_timezone().ok()),
};
if settings.bucket_timezone.is_none() {
settings.bucket_timezone = iana_time_zone::get_timezone().ok();
if settings.bucket_timezone.is_some() {
settings.save()?;
}
}
Ok(settings.bucket_timezone)
})
}
pub fn theme_name(&self) -> ThemeName {
self.color_palette.parse().unwrap_or(ThemeName::Blue)
}
pub fn set_theme(&mut self, theme: ThemeName) {
self.color_palette = theme.as_str().to_string();
}
pub fn get_auto_refresh_interval(&self) -> Option<Duration> {
if self.auto_refresh_enabled && self.auto_refresh_ms > 0 {
Some(Duration::from_millis(self.auto_refresh_ms))
} else {
None
}
}
pub fn get_native_timeout(&self) -> Duration {
let timeout_ms = if let Ok(env_val) = std::env::var("TOKMESH_NATIVE_TIMEOUT_MS") {
env_val.parse::<u64>().unwrap_or(self.native_timeout_ms)
} else {
self.native_timeout_ms
};
let clamped = timeout_ms.clamp(MIN_NATIVE_TIMEOUT_MS, MAX_NATIVE_TIMEOUT_MS);
Duration::from_millis(clamped)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
let previous = env::var_os(key);
env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => env::set_var(self.key, value),
None => env::remove_var(self.key),
}
}
}
#[test]
fn explicit_home_config_path_uses_unix_dot_config_layout() {
assert_eq!(
Settings::explicit_home_config_path_for_layout(
Path::new("/home/alice"),
ExplicitHomeConfigLayout::UnixDotConfig,
),
PathBuf::from("/home/alice/.config/tokmesh/settings.json")
);
}
#[test]
fn explicit_home_config_path_uses_windows_roaming_layout() {
assert_eq!(
Settings::explicit_home_config_path_for_layout(
Path::new("C:/Users/Alice"),
ExplicitHomeConfigLayout::WindowsRoaming,
),
PathBuf::from("C:/Users/Alice/AppData/Roaming/tokmesh/settings.json")
);
}
#[test]
fn load_for_home_override_reads_current_platform_config_path() {
let temp = tempfile::TempDir::new().unwrap();
let path = Settings::explicit_home_config_path(temp.path());
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{"colorPalette":"halloween","defaultClients":["codex"]}"#,
)
.unwrap();
let loaded = Settings::load_for_home_override(Some(temp.path()));
assert_eq!(loaded.color_palette, "halloween");
assert_eq!(loaded.default_clients, vec!["codex".to_string()]);
}
#[test]
fn settings_load_backfills_scanner_when_missing_from_json() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(parsed.scanner.opencode_db_paths.is_empty());
}
#[test]
fn settings_load_backfills_autosubmit_interval_when_missing_from_json() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(!parsed.tokscale_autosubmit.enabled);
assert!(!parsed.tokensci_autosubmit.enabled);
assert_eq!(
parsed.tokscale_autosubmit.interval_minutes,
DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES
);
assert_eq!(
parsed.tokensci_autosubmit.interval_minutes,
DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES
);
assert_eq!(
AutosubmitSettings::default().interval_minutes,
DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES
);
}
#[test]
#[serial_test::serial]
fn save_preserving_autosubmit_keeps_newer_board_state() {
let temp = tempfile::TempDir::new().unwrap();
let _guard = EnvVarGuard::set("TOKMESH_CONFIG_DIR", temp.path());
let mut stale = Settings::default();
let mut latest = Settings::default();
latest.tokscale_autosubmit.enabled = true;
latest.tokscale_autosubmit.interval_minutes = 45;
latest.tokscale_autosubmit.scheduler = Some("cron".to_string());
latest.tokensci_autosubmit.enabled = true;
latest.tokensci_autosubmit.interval_minutes = 90;
latest.tokensci_autosubmit.last_error = Some("retry pending".to_string());
latest.save().unwrap();
stale.color_palette = "halloween".to_string();
stale.save_preserving_autosubmit().unwrap();
let restored = Settings::load();
assert_eq!(restored.color_palette, "halloween");
assert!(restored.tokscale_autosubmit.enabled);
assert_eq!(restored.tokscale_autosubmit.interval_minutes, 45);
assert_eq!(
restored.tokscale_autosubmit.scheduler.as_deref(),
Some("cron")
);
assert!(restored.tokensci_autosubmit.enabled);
assert_eq!(restored.tokensci_autosubmit.interval_minutes, 90);
assert_eq!(
restored.tokensci_autosubmit.last_error.as_deref(),
Some("retry pending")
);
}
#[test]
#[serial_test::serial]
fn configured_bucket_timezone_is_preserved_by_detection() {
let temp = tempfile::TempDir::new().unwrap();
let _guard = EnvVarGuard::set("TOKMESH_CONFIG_DIR", temp.path());
let settings = Settings {
bucket_timezone: Some("Pacific/Kiritimati".to_string()),
..Settings::default()
};
settings.save().unwrap();
assert_eq!(
Settings::load_or_detect_bucket_timezone()
.unwrap()
.as_deref(),
Some("Pacific/Kiritimati")
);
assert_eq!(
Settings::load().bucket_timezone.as_deref(),
Some("Pacific/Kiritimati")
);
}
#[test]
#[serial_test::serial]
fn timezone_detection_does_not_overwrite_malformed_settings() {
let temp = tempfile::TempDir::new().unwrap();
let _guard = EnvVarGuard::set("TOKMESH_CONFIG_DIR", temp.path());
let path = temp.path().join("settings.json");
let malformed = r#"{"colorPalette":"halloween","broken":}"#;
fs::write(&path, malformed).unwrap();
Settings::load_or_detect_bucket_timezone().unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), malformed);
}
#[test]
fn settings_backfills_model_aliases_when_missing_from_json() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(parsed.model_aliases.entries.is_empty());
}
#[test]
fn settings_malformed_model_aliases_does_not_wipe_other_fields() {
let json = r#"{
"colorPalette": "custom",
"modelAliases": ["oops", 5]
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(parsed.model_aliases.entries.is_empty());
assert_eq!(parsed.color_palette, "custom");
}
#[test]
fn settings_load_reads_scanner_opencode_db_paths() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000,
"scanner": {
"opencodeDbPaths": [
"/custom/one.db",
"/custom/opencode-stable.db"
]
}
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert_eq!(
parsed.scanner.opencode_db_paths,
vec![
PathBuf::from("/custom/one.db"),
PathBuf::from("/custom/opencode-stable.db"),
]
);
}
#[test]
fn settings_load_reads_scanner_extra_scan_paths() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000,
"scanner": {
"extraScanPaths": {
"codex": ["/tmp/project-a/.codex/sessions"],
"openclaw": ["/tmp/imports/openclaw/agents"]
}
}
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_value(&parsed).unwrap();
assert_eq!(
serialized["scanner"]["extraScanPaths"]["codex"][0],
serde_json::json!("/tmp/project-a/.codex/sessions")
);
assert_eq!(
serialized["scanner"]["extraScanPaths"]["openclaw"][0],
serde_json::json!("/tmp/imports/openclaw/agents")
);
}
#[test]
fn settings_accepts_empty_scanner_object() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000,
"scanner": {}
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(parsed.scanner.opencode_db_paths.is_empty());
}
#[test]
fn settings_round_trips_scanner_section_through_json() {
let mut settings = Settings::default();
settings.scanner.opencode_db_paths = vec![PathBuf::from("/a/b/opencode.db")];
let serialized = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&serialized).unwrap();
assert_eq!(
parsed.scanner.opencode_db_paths,
vec![PathBuf::from("/a/b/opencode.db")]
);
}
#[test]
fn settings_round_trips_scanner_extra_scan_paths_through_json() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000,
"scanner": {
"extraScanPaths": {
"gemini": ["/tmp/imports/gemini/tmp"]
}
}
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&parsed).unwrap();
let round_trip: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(
round_trip["scanner"]["extraScanPaths"]["gemini"][0],
serde_json::json!("/tmp/imports/gemini/tmp")
);
}
#[test]
fn settings_default_clients_defaults_to_empty() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(parsed.default_clients.is_empty());
}
#[test]
fn settings_default_clients_round_trips() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000,
"defaultClients": ["opencode", "claude", "synthetic"]
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert_eq!(
parsed.default_clients,
vec![
"opencode".to_string(),
"claude".to_string(),
"synthetic".to_string()
]
);
let serialized = serde_json::to_string(&parsed).unwrap();
let round_trip: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(
round_trip["defaultClients"],
serde_json::json!(["opencode", "claude", "synthetic"])
);
}
#[test]
fn settings_default_clients_drops_non_string_elements_silently() {
let json = r#"{
"colorPalette": "halloween",
"defaultClients": ["opencode", 123, null, "claude", true, {"x":1}]
}"#;
let parsed: Settings = serde_json::from_str(json).expect("settings should still load");
assert_eq!(parsed.color_palette, "halloween");
assert_eq!(
parsed.default_clients,
vec!["opencode".to_string(), "claude".to_string()]
);
}
#[test]
fn settings_load_accepts_legacy_json_without_light_section() {
let json = r#"{
"colorPalette": "blue",
"autoRefreshEnabled": false,
"autoRefreshMs": 60000,
"includeUnusedModels": false,
"nativeTimeoutMs": 300000
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(!parsed.light.write_cache);
}
#[test]
fn light_settings_round_trip() {
let light = LightSettings { write_cache: true };
let serialized = serde_json::to_string(&light).unwrap();
let parsed: LightSettings = serde_json::from_str(&serialized).unwrap();
assert!(parsed.write_cache);
}
#[test]
fn settings_minutely_tab_enabled_defaults_to_false() {
let json = r#"{ "colorPalette": "blue" }"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(!parsed.minutely_tab_enabled);
assert!(!Settings::default().minutely_tab_enabled);
}
#[test]
fn settings_minutely_tab_enabled_round_trips_when_set() {
let json = r#"{
"colorPalette": "blue",
"minutelyTabEnabled": true
}"#;
let parsed: Settings = serde_json::from_str(json).unwrap();
assert!(parsed.minutely_tab_enabled);
let serialized = serde_json::to_string(&parsed).unwrap();
let round_trip: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(
round_trip["minutelyTabEnabled"],
serde_json::Value::Bool(true)
);
}
}