refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
+144
View File
@@ -0,0 +1,144 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! SQLite database connection pool initialisation and schema migrations.
//!
//! Uses `r2d2` + `r2d2_sqlite` for connection pooling with the same
//! `rusqlite` backend the rest of the project uses, avoiding native
//! library conflicts between `rusqlite` and `sqlx`.
use anyhow::{Context, Result};
use std::sync::Arc;
use std::sync::Mutex;
/// A shared SQLite connection wrapped for thread-safe access.
/// Uses a simple Mutex-guarded connection rather than a full pool,
/// since the daemon is single-threaded for database operations.
#[derive(Clone)]
pub struct DbConn {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl DbConn {
/// Execute a closure with a reference to the underlying connection.
pub fn with<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&rusqlite::Connection) -> Result<T>,
{
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?;
f(&conn)
}
}
/// Embedded SQL schema for all zesdex tables.
///
/// Uses `CREATE TABLE IF NOT EXISTS` so repeated runs are idempotent.
const SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
workspace_roots TEXT NOT NULL DEFAULT '[]',
message_count INTEGER NOT NULL DEFAULT 0,
token_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
summary TEXT
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
session_id TEXT PRIMARY KEY,
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
name TEXT PRIMARY KEY,
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS edit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
entry TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_edit_logs_session
ON edit_logs (session_id);
"#;
/// Initialise a shared SQLite connection at the given path.
///
/// Opens (or creates) the database, enables WAL mode, and returns a
/// thread-safe `DbConn` handle.
///
/// # Errors
///
/// Returns an error if the database cannot be opened or created.
pub fn init_db(db_path: &str) -> Result<DbConn> {
let conn = rusqlite::Connection::open(db_path)
.with_context(|| format!("failed to open SQLite database at '{db_path}'"))?;
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
tracing::info!("connected to SQLite database at '{db_path}'");
Ok(DbConn {
conn: Arc::new(Mutex::new(conn)),
})
}
/// Run embedded SQL schema migrations.
///
/// Executes the [`SCHEMA_SQL`] string which creates all tables using
/// `CREATE TABLE IF NOT EXISTS`, making it safe to call on every startup.
///
/// # Errors
///
/// Returns an error if any SQL statement fails.
pub fn run_migrations(db: &DbConn) -> Result<()> {
db.with(|conn| {
conn.execute_batch(SCHEMA_SQL)
.context("failed to execute database schema migrations")
})?;
tracing::info!("database schema migrations applied");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_db_and_migrate() {
let tmp = std::env::temp_dir().join(format!("zesdex-test-db-{}", uuid::Uuid::new_v4()));
let db_path = tmp.to_str().unwrap().to_string();
let db = init_db(&db_path).unwrap();
run_migrations(&db).unwrap();
// Verify sessions table exists
db.with(|conn| {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
Ok(())
})
.unwrap();
let _ = std::fs::remove_file(&db_path);
}
}
+141
View File
@@ -0,0 +1,141 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
//!
//! Uses the `jsonwebtoken` crate under the hood.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
/// Standard JWT claims with optional session binding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
/// Subject (usually a user or session identifier).
pub sub: String,
/// Expiration time (UNIX epoch seconds).
pub exp: u64,
/// Issued-at time (UNIX epoch seconds).
pub iat: u64,
/// Optional session id for binding the token to a specific session.
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
impl JwtClaims {
/// Create a new set of claims with the current time as `iat` and the
/// given `exp` offset.
///
/// * `sub` — subject identifier.
/// * `exp` — absolute expiry as a UNIX timestamp (seconds).
/// * `session_id` — optional session binding.
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
let iat = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
sub,
exp,
iat,
session_id,
}
}
}
/// Sign a set of claims into a JWT string using HS256.
///
/// * `secret` — HMAC secret key (at least 32 bytes recommended).
/// * `claims` — the claims to encode and sign.
///
/// # Errors
///
/// Returns an error if encoding or signing fails (e.g. malformed secret
/// or serialisation error).
pub fn create_token(secret: &str, claims: JwtClaims) -> Result<String> {
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
jsonwebtoken::encode(&header, &claims, &key)
.context("failed to encode JWT")
}
/// Verify a JWT string and return its claims.
///
/// * `secret` — the same HMAC secret used to sign the token.
/// * `token` — the encoded JWT string.
///
/// Validation includes signature verification and expiration check.
///
/// # Errors
///
/// Returns an error if the token is malformed, expired, or has an invalid
/// signature.
pub fn verify_token(secret: &str, token: &str) -> Result<JwtClaims> {
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.validate_exp = true;
validation.required_spec_claims = ["sub", "exp", "iat"]
.iter()
.map(|&s| s.to_string())
.collect();
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)
.context("failed to verify JWT")?;
Ok(token_data.claims)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "this-is-a-test-secret-that-is-at-least-32-bytes-long!";
#[test]
fn test_create_and_verify_token() {
let claims = JwtClaims::new(
"test-user".to_string(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
+ 3600,
Some("sess-123".to_string()),
);
let token = create_token(TEST_SECRET, claims.clone()).unwrap();
let verified = verify_token(TEST_SECRET, &token).unwrap();
assert_eq!(verified.sub, claims.sub);
assert_eq!(verified.session_id, claims.session_id);
}
#[test]
fn test_verify_expired_token_fails() {
let claims = JwtClaims {
sub: "expired-user".to_string(),
exp: 1, // expired long ago
iat: 1,
session_id: None,
};
let token = create_token(TEST_SECRET, claims).unwrap();
let result = verify_token(TEST_SECRET, &token);
assert!(result.is_err());
}
#[test]
fn test_verify_invalid_signature_fails() {
let claims = JwtClaims::new(
"test-user".to_string(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
+ 3600,
None,
);
let token = create_token(TEST_SECRET, claims).unwrap();
let result = verify_token("wrong-secret", &token);
assert!(result.is_err());
}
}
+11
View File
@@ -0,0 +1,11 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
pub mod database;
pub mod jwt;
pub mod password;
pub mod state;
+86
View File
@@ -0,0 +1,86 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Argon2 password hashing and verification utilities.
//!
//! Uses the `argon2` crate (Argon2id variant) with default parameters,
//! which provide a good security / performance trade-off for interactive
//! authentication.
use anyhow::Result;
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rand_core::OsRng;
/// Hash a plaintext password using Argon2id with a random salt.
///
/// The returned string is in the PHC string format
/// (`$argon2id$v=19$...`) and can be stored directly in the database.
///
/// # Errors
///
/// Returns an error if the argon2 library fails (extremely rare —
/// typically indicates an OOM or system-level crypto failure).
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
Ok(hash.to_string())
}
/// Verify a plaintext password against a previously-hashed PHC string.
///
/// Returns `Ok(true)` if the password matches, `Ok(false)` if it does not,
/// and `Err` if the hash string is malformed.
///
/// # Errors
///
/// Returns an error if the hash string is not a valid PHC string or if
/// the argon2 library encounters an internal failure.
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash =
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify() {
let password = "my-secure-password-123!";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
}
#[test]
fn test_wrong_password_fails() {
let hash = hash_password("correct-password").unwrap();
assert!(!verify_password("wrong-password", &hash).unwrap());
}
#[test]
fn test_hashes_are_different() {
let h1 = hash_password("same-password").unwrap();
let h2 = hash_password("same-password").unwrap();
// Different salts → different hashes.
assert_ne!(h1, h2);
}
#[test]
fn test_invalid_hash_returns_error() {
let result = verify_password("password", "not-a-valid-hash");
assert!(result.is_err());
}
}
+365
View File
@@ -0,0 +1,365 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Application state initialisation and wiring.
//!
//! This module acts as the composition root for the zesdex daemon (and
//! any other binary that needs a full set of services). It:
//!
//! 1. Defines [`IamServiceProvider`] and [`CmsServiceProvider`] trait
//! objects so callers depend on interfaces, not generics.
//! 2. Provides default implementations that wire together the
//! infrastructure/repository adapters with the domain service traits.
//! 3. Exposes [`initialize_app_context`] as a one-call entry point.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use uuid::Uuid;
use zesdex_cms::domain::app_config::ProviderConfig;
use zesdex_cms::domain::conversation::Conversation;
use zesdex_cms::domain::memory::Memory;
use zesdex_cms::domain::settings::Settings;
use zesdex_cms::infrastructure::persistence::{
JsonAppConfigRepository, JsonConversationRepository, JsonSettingsRepository,
MarkdownMemoryRepository,
};
use zesdex_entities::seaorm::common::store::Store;
use zesdex_cms::domain::repository::{
AppConfigRepository, ConversationRepository, MemoryRepository, SettingsRepository,
};
use zesdex_iam::domain::repository::SessionRepository;
use zesdex_iam::domain::session::Session;
use zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository;
use crate::database;
// ---------------------------------------------------------------------------
// Trait definitions
// ---------------------------------------------------------------------------
/// Session-management service provider.
///
/// Abstracts session CRUD behind a trait object so the HTTP / CLI layers
/// do not depend on concrete repository generics.
pub trait IamServiceProvider: Send + Sync {
/// Create a new session with a generated UUID and default fields.
fn create_session(&self) -> Result<Session>;
/// List all available sessions.
fn list_all(&self) -> Result<Vec<Session>>;
/// Archive a session by id (sets `archived = true`).
fn archive_session(&self, id: &str) -> Result<()>;
}
/// CMS (content-management) service provider.
///
/// Combines settings, conversation, and memory operations behind a single
/// trait object.
pub trait CmsServiceProvider: Send + Sync {
// -- Settings --
/// Load current settings from the default store.
fn load_settings(&self) -> Result<Settings>;
/// Persist updated settings.
fn save_settings(&self, settings: &Settings) -> Result<()>;
/// Update the provider configuration (name and details).
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()>;
// -- Conversations --
/// Load a conversation for the given session id.
fn load_conversation(&self, session_id: &str) -> Result<Conversation>;
/// Persist a conversation.
fn save_conversation(&self, conv: &Conversation) -> Result<()>;
// -- Memories --
/// List all memory slugs.
fn list_memories(&self) -> Result<Vec<String>>;
/// Save (create or update) a memory.
fn save_memory(&self, memory: &Memory) -> Result<()>;
/// Delete a memory by name.
fn delete_memory(&self, name: &str) -> Result<()>;
}
// ---------------------------------------------------------------------------
// Default IAM provider
// ---------------------------------------------------------------------------
/// Default [`IamServiceProvider`] backed by the filesystem session
/// repository.
pub struct DefaultIamServiceProvider {
session_repo: FileSystemSessionRepository,
base_dir: PathBuf,
}
impl DefaultIamServiceProvider {
/// Create a new provider using the given data directory.
pub fn new(base_dir: PathBuf) -> Self {
Self {
session_repo: FileSystemSessionRepository::new(),
base_dir,
}
}
}
impl IamServiceProvider for DefaultIamServiceProvider {
fn create_session(&self) -> Result<Session> {
let id = Uuid::new_v4().to_string();
let session = Session::new(id, "New Session".to_string());
self.session_repo
.save_session(&self.base_dir, &session)
.context("failed to persist new session")?;
Ok(session)
}
fn list_all(&self) -> Result<Vec<Session>> {
self.session_repo
.list_sessions(&self.base_dir)
.context("failed to list sessions")
}
fn archive_session(&self, id: &str) -> Result<()> {
let mut session = self
.session_repo
.load_session(&self.base_dir, id)
.with_context(|| format!("session not found: {id}"))?;
session.archived = true;
session.updated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
self.session_repo
.save_session(&self.base_dir, &session)
.context("failed to save archived session")
}
}
// ---------------------------------------------------------------------------
// Default CMS provider
// ---------------------------------------------------------------------------
/// Default [`CmsServiceProvider`] backed by filesystem repositories.
pub struct DefaultCmsServiceProvider {
settings_repo: JsonSettingsRepository,
app_config_repo: JsonAppConfigRepository,
conversation_repo: JsonConversationRepository,
memory_repo: MarkdownMemoryRepository,
base_dir: PathBuf,
memory_dir: PathBuf,
}
impl DefaultCmsServiceProvider {
/// Create a new provider.
pub fn new(store: &Store) -> Self {
Self {
settings_repo: JsonSettingsRepository::new(),
app_config_repo: JsonAppConfigRepository::new(),
conversation_repo: JsonConversationRepository::new(),
memory_repo: MarkdownMemoryRepository::new(),
base_dir: store.base_dir.clone(),
memory_dir: store.memory_dir.clone(),
}
}
/// Compute the session directory for a given session id.
fn session_dir(&self, session_id: &str) -> PathBuf {
self.base_dir.join("sessions").join(session_id)
}
}
impl CmsServiceProvider for DefaultCmsServiceProvider {
// -- Settings --
fn load_settings(&self) -> Result<Settings> {
self.settings_repo
.load(&self.base_dir)
.context("failed to load settings")
}
fn save_settings(&self, settings: &Settings) -> Result<()> {
self.settings_repo
.save(&self.base_dir, settings)
.context("failed to save settings")
}
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> {
let mut app_config = self
.app_config_repo
.load(&self.base_dir)
.context("failed to load app config")?;
app_config
.providers
.insert(name.to_string(), config.clone());
self.app_config_repo
.save(&self.base_dir, &app_config)
.context("failed to save app config after provider update")
}
// -- Conversations --
fn load_conversation(&self, session_id: &str) -> Result<Conversation> {
let dir = self.session_dir(session_id);
self.conversation_repo
.load(&dir)
.with_context(|| format!("failed to load conversation for session '{session_id}'"))
}
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
let dir = self.session_dir(&conv.session_id);
self.conversation_repo
.save(&dir, conv)
.with_context(|| {
format!(
"failed to save conversation for session '{}'",
conv.session_id
)
})
}
// -- Memories --
fn list_memories(&self) -> Result<Vec<String>> {
self.memory_repo
.list(&self.memory_dir)
.context("failed to list memories")
}
fn save_memory(&self, memory: &Memory) -> Result<()> {
self.memory_repo
.save(&self.memory_dir, memory)
.with_context(|| format!("failed to save memory '{}'", memory.name))
}
fn delete_memory(&self, name: &str) -> Result<()> {
self.memory_repo
.delete(&self.memory_dir, name)
.with_context(|| format!("failed to delete memory '{name}'"))
}
}
// ---------------------------------------------------------------------------
// AppContext
// ---------------------------------------------------------------------------
/// Aggregated shared state for the zesdex daemon (or any binary using the
/// full service stack).
pub struct AppContext {
/// Filesystem store (resolved paths for all data directories).
pub store: Store,
/// IAM service provider (session management).
pub iam_service: Box<dyn IamServiceProvider>,
/// CMS service provider (settings, conversations, memories).
pub cms_service: Box<dyn CmsServiceProvider>,
/// SQLite database connection.
pub db: database::DbConn,
/// JWT HMAC secret used to sign / verify tokens.
pub jwt_secret: String,
}
impl AppContext {
/// Return a shared `Arc<AppContext>` for use with Axum's
/// `axum::extract::State`.
pub fn into_arc(self) -> Arc<Self> {
Arc::new(self)
}
}
// ---------------------------------------------------------------------------
// Initialisation
// ---------------------------------------------------------------------------
/// Wire together the full application stack and return an [`AppContext`].
///
/// Steps:
/// 1. Initialise [`Store`] and create all data directories.
/// 2. Connect to the SQLite database and run migrations.
/// 3. Instantiate the IAM and CMS service providers.
/// 4. Determine the JWT secret (env var `ZESDEX_JWT_SECRET` or a default).
///
/// # Errors
///
/// Returns an error if any step fails (directory creation, DB connection,
/// migration execution, etc.).
pub fn initialize_app_context() -> Result<AppContext> {
// -- Store --
let store = Store::new();
store
.ensure_dirs()
.context("failed to create store directories")?;
// -- Database --
let db_path = store.base_dir.join("zesdex.db");
let db_path_str = db_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("invalid db path: {}", db_path.display()))?;
let db = database::init_db(db_path_str)
.context("failed to initialise database")?;
database::run_migrations(&db)
.context("failed to run database migrations")?;
// -- Services --
let iam_service: Box<dyn IamServiceProvider> =
Box::new(DefaultIamServiceProvider::new(store.base_dir.clone()));
let cms_service: Box<dyn CmsServiceProvider> =
Box::new(DefaultCmsServiceProvider::new(&store));
// -- JWT secret --
let jwt_secret = std::env::var("ZESDEX_JWT_SECRET")
.unwrap_or_else(|_| "zesdex-dev-secret-do-not-use-in-production".to_string());
Ok(AppContext {
store,
iam_service,
cms_service,
db,
jwt_secret,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_iam_provider_list_all_empty() {
let tmp = std::env::temp_dir().join(format!("zesdex-test-iam-{}", Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let provider = DefaultIamServiceProvider::new(tmp.clone());
let sessions = provider.list_all().unwrap();
assert!(sessions.is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_default_iam_provider_create_and_list() {
let tmp = std::env::temp_dir().join(format!("zesdex-test-iam-{}", Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let provider = DefaultIamServiceProvider::new(tmp.clone());
let session = provider.create_session().unwrap();
assert!(!session.id.is_empty());
let sessions = provider.list_all().unwrap();
assert_eq!(sessions.len(), 1);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_default_cms_provider_default_settings() {
let tmp = std::env::temp_dir().join(format!("zesdex-test-cms-{}", Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let mut store = Store::new();
store.base_dir = tmp.clone();
store.memory_dir = tmp.join("memory");
let provider = DefaultCmsServiceProvider::new(&store);
let settings = provider.load_settings().unwrap();
assert_eq!(settings.provider, "zen");
let _ = std::fs::remove_dir_all(&tmp);
}
}