diff --git a/docs/BACKEND_REFACTOR_PLAN.md b/docs/BACKEND_REFACTOR_PLAN.md index 48bb582..a11546f 100644 --- a/docs/BACKEND_REFACTOR_PLAN.md +++ b/docs/BACKEND_REFACTOR_PLAN.md @@ -79,6 +79,9 @@ - **阶段 3:补充测试 🚧** - `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。 - 扩展 `lib.rs` re-export,暴露 `AppType`、`MultiAppConfig`、`AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。 + - 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。 + - 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。 + - 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。 - 当前已覆盖配置、Codex/Claude MCP 核心路径及关键错误分支,后续仍需补齐命令层边界与导入导出异常回滚测试。 ## 渐进式重构路线 diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 2e7f779..cbaa46a 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -8,6 +8,7 @@ use tauri::State; use crate::app_config::AppType; use crate::codex_config; use crate::config::get_claude_settings_path; +use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; use crate::speedtest; use crate::store::AppState; @@ -310,44 +311,32 @@ pub async fn delete_provider( } /// 切换供应商 -#[tauri::command] -pub async fn switch_provider( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, - id: String, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); +fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { + use serde_json::Value; let mut config = state .config .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; + .map_err(|e| AppError::Message(format!("获取锁失败: {}", e)))?; let provider = { let manager = config .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; manager .providers - .get(&id) - .ok_or_else(|| format!("供应商不存在: {}", id))? - .clone() + .get(id) + .cloned() + .ok_or_else(|| AppError::Message(format!("供应商不存在: {}", id)))? }; match app_type { AppType::Codex => { - use serde_json::Value; - if !{ let cur = config .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; cur.current.is_empty() } { let auth_path = codex_config::get_codex_auth_path(); @@ -356,7 +345,11 @@ pub async fn switch_provider( let auth: Value = crate::config::read_json_file(&auth_path)?; let config_str = if config_path.exists() { std::fs::read_to_string(&config_path).map_err(|e| { - format!("读取 config.toml 失败: {}: {}", config_path.display(), e) + AppError::Message(format!( + "读取 config.toml 失败: {}: {}", + config_path.display(), + e + )) })? } else { String::new() @@ -370,12 +363,12 @@ pub async fn switch_provider( let cur_id2 = { let m = config .get_manager(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; m.current.clone() }; let m = config .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; if let Some(cur) = m.providers.get_mut(&cur_id2) { cur.settings_config = live; } @@ -385,7 +378,7 @@ pub async fn switch_provider( let auth = provider .settings_config .get("auth") - .ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?; + .ok_or_else(|| AppError::Message("目标供应商缺少 auth 配置".to_string()))?; let cfg_text = provider .settings_config .get("config") @@ -401,14 +394,14 @@ pub async fn switch_provider( let cur_id = { let m = config .get_manager(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; m.current.clone() }; if !cur_id.is_empty() { if let Ok(live) = read_json_file::(&settings_path) { let m = config .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; if let Some(cur) = m.providers.get_mut(&cur_id) { cur.settings_config = live; } @@ -417,7 +410,8 @@ pub async fn switch_provider( } if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + std::fs::create_dir_all(parent) + .map_err(|e| AppError::Message(format!("创建目录失败: {}", e)))?; } write_json_file(&settings_path, &provider.settings_config)?; @@ -426,8 +420,8 @@ pub async fn switch_provider( if let Ok(live_after) = read_json_file::(&settings_path) { let m = config .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - if let Some(target) = m.providers.get_mut(&id) { + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; + if let Some(target) = m.providers.get_mut(id) { target.settings_config = live_after; } } @@ -438,8 +432,8 @@ pub async fn switch_provider( { let manager = config .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - manager.current = id; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; + manager.current = id.to_string(); } if let AppType::Codex = app_type { @@ -450,12 +444,12 @@ pub async fn switch_provider( let cur_id = { let m = config .get_manager(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; m.current.clone() }; let m = config .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; if let Some(p) = m.providers.get_mut(&cur_id) { if let Some(obj) = p.settings_config.as_object_mut() { obj.insert( @@ -471,7 +465,34 @@ pub async fn switch_provider( drop(config); state.save()?; - Ok(true) + Ok(()) +} + +#[doc(hidden)] +pub fn switch_provider_test_hook( + state: &AppState, + app_type: AppType, + id: &str, +) -> Result<(), AppError> { + switch_provider_internal(state, app_type, id) +} + +#[tauri::command] +pub async fn switch_provider( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, + id: String, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + switch_provider_internal(&state, app_type, &id) + .map(|_| true) + .map_err(|e| e.to_string()) } /// 导入当前配置为默认供应商 diff --git a/src-tauri/src/import_export.rs b/src-tauri/src/import_export.rs index f0b58f2..8a6d43e 100644 --- a/src-tauri/src/import_export.rs +++ b/src-tauri/src/import_export.rs @@ -28,8 +28,8 @@ pub fn create_backup(config_path: &PathBuf) -> Result { let backup_path = backup_dir.join(format!("{}.json", backup_id)); - // 复制配置文件到备份 - fs::copy(config_path, &backup_path).map_err(|e| AppError::io(&backup_path, e))?; + let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?; + fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?; // 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份) cleanup_old_backups(&backup_dir, MAX_BACKUPS)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1b48bd..84e0b5a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,15 +16,16 @@ mod store; mod usage_script; pub use app_config::{AppType, MultiAppConfig}; -pub use codex_config::{get_codex_auth_path, get_codex_config_path}; +pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use import_export::{create_backup, sync_current_providers_to_live}; pub use provider::Provider; pub use settings::{update_settings, AppSettings}; pub use mcp::{import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex}; pub use error::AppError; +pub use store::AppState; +pub use commands::*; -use store::AppState; use tauri::{ menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}, tray::{TrayIconBuilder, TrayIconEvent}, diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 281fc32..2b74ca5 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -1,52 +1,14 @@ use std::fs; -use std::path::Path; use serde_json::json; -use std::sync::{Mutex, OnceLock}; use cc_switch_lib::{ - create_backup, get_claude_settings_path, read_json_file, sync_current_providers_to_live, - update_settings, AppSettings, AppType, MultiAppConfig, Provider, + create_backup, get_claude_settings_path, read_json_file, sync_current_providers_to_live, AppType, + MultiAppConfig, Provider, }; -fn ensure_test_home() -> &'static Path { - static HOME: OnceLock = OnceLock::new(); - HOME.get_or_init(|| { - let base = std::env::temp_dir().join("cc-switch-test-home"); - if base.exists() { - let _ = std::fs::remove_dir_all(&base); - } - std::fs::create_dir_all(&base).expect("create test home"); - std::env::set_var("HOME", &base); - #[cfg(windows)] - std::env::set_var("USERPROFILE", &base); - base - }) - .as_path() -} - -fn reset_test_fs() { - let home = ensure_test_home(); - for sub in [".claude", ".codex", ".cc-switch"] { - let path = home.join(sub); - if path.exists() { - if let Err(err) = fs::remove_dir_all(&path) { - eprintln!("failed to clean {}: {}", path.display(), err); - } - } - } - let claude_json = home.join(".claude.json"); - if claude_json.exists() { - let _ = fs::remove_file(&claude_json); - } - // 重置内存中的设置缓存,确保测试环境不受上一次调用影响 - // 写入默认设置即可刷新 OnceLock 中的缓存数据 - let _ = update_settings(AppSettings::default()); -} - -fn test_mutex() -> &'static Mutex<()> { - static MUTEX: OnceLock> = OnceLock::new(); - MUTEX.get_or_init(|| Mutex::new(())) -} +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; #[test] fn sync_claude_provider_writes_live_settings() { @@ -287,6 +249,129 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() { } } +#[test] +fn sync_codex_provider_missing_auth_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + let provider = Provider::with_id( + "codex-missing-auth".to_string(), + "No Auth".to_string(), + json!({ + "config": "model = \"test\"" + }), + None, + ); + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert(provider.id.clone(), provider); + manager.current = "codex-missing-auth".to_string(); + + let err = sync_current_providers_to_live(&mut config) + .expect_err("sync should fail when auth missing"); + match err { + cc_switch_lib::AppError::Config(msg) => { + assert!(msg.contains("auth"), "error message should mention auth"); + } + other => panic!("unexpected error variant: {other:?}"), + } + + // 确认未产生任何 live 配置文件 + assert!( + !cc_switch_lib::get_codex_auth_path().exists(), + "auth.json should not be created on failure" + ); + assert!( + !cc_switch_lib::get_codex_config_path().exists(), + "config.toml should not be created on failure" + ); +} + +#[test] +fn write_codex_live_atomic_persists_auth_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let auth = json!({ "OPENAI_API_KEY": "dev-key" }); + let config_text = r#" +[mcp_servers.echo] +type = "stdio" +command = "echo" +args = ["ok"] +"#; + + cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text)) + .expect("atomic write should succeed"); + + let auth_path = cc_switch_lib::get_codex_auth_path(); + let config_path = cc_switch_lib::get_codex_config_path(); + assert!(auth_path.exists(), "auth.json should be created"); + assert!(config_path.exists(), "config.toml should be created"); + + let stored_auth: serde_json::Value = + cc_switch_lib::read_json_file(&auth_path).expect("read auth"); + assert_eq!(stored_auth, auth, "auth.json should match input"); + + let stored_config = std::fs::read_to_string(&config_path).expect("read config"); + assert!( + stored_config.contains("mcp_servers.echo"), + "config.toml should contain serialized table" + ); +} + +#[test] +fn write_codex_live_atomic_rolls_back_auth_when_config_write_fails() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let auth_path = cc_switch_lib::get_codex_auth_path(); + if let Some(parent) = auth_path.parent() { + std::fs::create_dir_all(parent).expect("create codex dir"); + } + std::fs::write(&auth_path, r#"{"OPENAI_API_KEY":"legacy"}"#).expect("seed auth"); + + let config_path = cc_switch_lib::get_codex_config_path(); + std::fs::create_dir_all(&config_path).expect("create blocking directory"); + + let auth = json!({ "OPENAI_API_KEY": "new-key" }); + let config_text = r#"[mcp_servers.sample] +type = "stdio" +command = "noop" +"#; + + let err = cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text)) + .expect_err("config write should fail when target is directory"); + match err { + cc_switch_lib::AppError::Io { path, .. } => { + assert!( + path.ends_with("config.toml"), + "io error path should point to config.toml" + ); + } + cc_switch_lib::AppError::IoContext { context, .. } => { + assert!( + context.contains("config.toml"), + "error context should mention config path" + ); + } + other => panic!("unexpected error variant: {other:?}"), + } + + let stored = std::fs::read_to_string(&auth_path).expect("read existing auth"); + assert!( + stored.contains("legacy"), + "auth.json should roll back to legacy content" + ); + assert!( + std::fs::metadata(&config_path) + .expect("config path metadata") + .is_dir(), + "config path should remain a directory after failure" + ); +} + #[test] fn import_from_codex_adds_servers_from_mcp_servers_table() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -533,3 +618,56 @@ fn create_backup_generates_snapshot_file() { "backup content should match original config" ); } + +#[test] +fn create_backup_retains_only_latest_entries() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let config_dir = home.join(".cc-switch"); + let config_path = config_dir.join("config.json"); + fs::create_dir_all(&config_dir).expect("prepare config dir"); + fs::write(&config_path, r#"{"version":3}"#).expect("write config file"); + + let backups_dir = config_dir.join("backups"); + fs::create_dir_all(&backups_dir).expect("create backups dir"); + for idx in 0..12 { + let manual = backups_dir.join(format!("manual_{idx:02}.json")); + fs::write(&manual, format!("{{\"idx\":{idx}}}")).expect("seed manual backup"); + } + + std::thread::sleep(std::time::Duration::from_secs(1)); + + let latest_backup_id = create_backup(&config_path).expect("create backup with cleanup"); + assert!( + !latest_backup_id.is_empty(), + "backup id should not be empty when config exists" + ); + + let entries: Vec<_> = fs::read_dir(&backups_dir) + .expect("read backups dir") + .filter_map(|entry| entry.ok()) + .collect(); + assert!( + entries.len() <= 10, + "expected backups to be trimmed to at most 10 files, got {}", + entries.len() + ); + + let latest_path = backups_dir.join(format!("{latest_backup_id}.json")); + assert!( + latest_path.exists(), + "latest backup {} should be preserved", + latest_path.display() + ); + + // 进一步确认保留的条目包含一些历史文件,说明清理逻辑仅裁剪多余部分 + let manual_kept = entries + .iter() + .filter_map(|entry| entry.file_name().into_string().ok()) + .any(|name| name.starts_with("manual_")); + assert!( + manual_kept, + "cleanup should keep part of the older backups to maintain history" + ); +} diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs new file mode 100644 index 0000000..098304a --- /dev/null +++ b/src-tauri/tests/provider_commands.rs @@ -0,0 +1,159 @@ +use serde_json::json; + +use cc_switch_lib::{ + get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook, + write_codex_live_atomic, AppState, AppType, MultiAppConfig, Provider, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +#[test] +fn switch_provider_updates_codex_live_and_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy_auth = json!({"OPENAI_API_KEY": "legacy-key"}); + let legacy_config = r#"[mcp_servers.legacy] +type = "stdio" +command = "echo" +"#; + write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + .expect("seed existing codex live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "Legacy".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "stale"}, + "config": "stale-config" + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "Latest".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "fresh-key"}, + "config": r#"[mcp_servers.latest] +type = "stdio" +command = "say" +"# + }), + None, + ), + ); + } + + config.mcp.codex.servers.insert( + "echo-server".into(), + json!({ + "id": "echo-server", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo" + } + }), + ); + + let app_state = AppState { + config: std::sync::Mutex::new(config), + }; + + switch_provider_test_hook(&app_state, AppType::Codex, "new-provider") + .expect("switch provider should succeed"); + + let auth_value: serde_json::Value = + read_json_file(&get_codex_auth_path()).expect("read auth.json"); + assert_eq!( + auth_value + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .unwrap_or(""), + "fresh-key", + "live auth.json should reflect new provider" + ); + + let config_text = + std::fs::read_to_string(get_codex_config_path()).expect("read config.toml"); + assert!( + config_text.contains("mcp_servers.echo-server"), + "config.toml should contain synced MCP servers" + ); + + let locked = app_state + .config + .lock() + .expect("lock config after switch"); + let manager = locked + .get_manager(&AppType::Codex) + .expect("codex manager after switch"); + assert_eq!(manager.current, "new-provider", "current provider updated"); + + let new_provider = manager + .providers + .get("new-provider") + .expect("new provider exists"); + let new_config_text = new_provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!( + new_config_text, config_text, + "provider config snapshot should match live file" + ); + + let legacy = manager + .providers + .get("old-provider") + .expect("legacy provider still exists"); + let legacy_auth_value = legacy + .settings_config + .get("auth") + .and_then(|v| v.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!( + legacy_auth_value, "legacy-key", + "previous provider should be backfilled with live auth" + ); +} + +#[test] +fn switch_provider_missing_provider_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + config + .get_manager_mut(&AppType::Claude) + .expect("claude manager") + .current = "does-not-exist".to_string(); + + let app_state = AppState { + config: std::sync::Mutex::new(config), + }; + + let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider") + .expect_err("switching to a missing provider should fail"); + + assert!( + err.to_string().contains("供应商不存在"), + "error message should mention missing provider" + ); +} diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs new file mode 100644 index 0000000..21d947f --- /dev/null +++ b/src-tauri/tests/support.rs @@ -0,0 +1,47 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use cc_switch_lib::{update_settings, AppSettings}; + +/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。 +pub fn ensure_test_home() -> &'static Path { + static HOME: OnceLock = OnceLock::new(); + HOME.get_or_init(|| { + let base = std::env::temp_dir().join("cc-switch-test-home"); + if base.exists() { + let _ = std::fs::remove_dir_all(&base); + } + std::fs::create_dir_all(&base).expect("create test home"); + std::env::set_var("HOME", &base); + #[cfg(windows)] + std::env::set_var("USERPROFILE", &base); + base + }) + .as_path() +} + +/// 清理测试目录中生成的配置文件与缓存。 +pub fn reset_test_fs() { + let home = ensure_test_home(); + for sub in [".claude", ".codex", ".cc-switch"] { + let path = home.join(sub); + if path.exists() { + if let Err(err) = std::fs::remove_dir_all(&path) { + eprintln!("failed to clean {}: {}", path.display(), err); + } + } + } + let claude_json = home.join(".claude.json"); + if claude_json.exists() { + let _ = std::fs::remove_file(&claude_json); + } + + // 重置内存中的设置缓存,确保测试环境不受上一次调用影响 + let _ = update_settings(AppSettings::default()); +} + +/// 全局互斥锁,避免多测试并发写入相同的 HOME 目录。 +pub fn test_mutex() -> &'static Mutex<()> { + static MUTEX: OnceLock> = OnceLock::new(); + MUTEX.get_or_init(|| Mutex::new(())) +}