feat: establish core application structure, CLI/server modes, and interactive configuration wizard

This commit is contained in:
Brooklyn
2026-01-06 23:09:46 -05:00
parent 6d105bd8a8
commit 7273e85fa6
7 changed files with 574 additions and 13 deletions

12
.gitattributes vendored Normal file
View File

@@ -0,0 +1,12 @@
# Linguist overrides for GitHub language statistics
# Exclude Dockerfile from language stats
Dockerfile linguist-vendored
# Exclude generated/build files
target/** linguist-generated
*.lock linguist-generated
Cargo.lock linguist-generated
# Mark as documentation (optional)
*.md linguist-documentation

137
Cargo.lock generated
View File

@@ -36,6 +36,7 @@ dependencies = [
"console_log",
"futures",
"gloo-net 0.5.0",
"inquire",
"leptos",
"leptos_axum",
"leptos_dom",
@@ -290,6 +291,12 @@ version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -605,6 +612,31 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
dependencies = [
"bitflags 1.3.2",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -726,6 +758,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
@@ -915,6 +953,24 @@ dependencies = [
"slab",
]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1351,6 +1407,23 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "inquire"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a"
dependencies = [
"bitflags 2.10.0",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"fxhash",
"newline-converter",
"once_cell",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -1665,7 +1738,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"libc",
"redox_syscall 0.7.0",
]
@@ -1803,6 +1876,18 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -1831,6 +1916,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "newline-converter"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -2252,7 +2346,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.10.0",
]
[[package]]
@@ -2261,7 +2355,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
"bitflags",
"bitflags 2.10.0",
]
[[package]]
@@ -2339,7 +2433,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
@@ -2601,6 +2695,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 0.8.11",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -2782,7 +2897,7 @@ checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
dependencies = [
"atoi",
"base64 0.21.7",
"bitflags",
"bitflags 2.10.0",
"byteorder",
"bytes",
"chrono",
@@ -2825,7 +2940,7 @@ checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
dependencies = [
"atoi",
"base64 0.21.7",
"bitflags",
"bitflags 2.10.0",
"byteorder",
"chrono",
"crc",
@@ -3071,7 +3186,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.1.1",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -3191,7 +3306,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"bytes",
"futures-util",
"http 1.4.0",
@@ -3349,6 +3464,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-xid"
version = "0.2.6"

View File

@@ -19,6 +19,7 @@ subprocess = { version = "=0.2.9", optional = true }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "macros", "chrono"], optional = true }
chrono = { version = "0.4", features = ["serde"] }
num_cpus = "1.16"
inquire = { version = "0.7", optional = true }
futures = { version = "0.3" }
toml = "0.8"
leptos = { version = "=0.6.14", features = ["ssr"] }
@@ -53,6 +54,7 @@ ssr = [
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:inquire",
]
[package.metadata.leptos]

View File

@@ -36,6 +36,24 @@ cargo build --release
./target/release/alchemist --server
```
### First-Boot Configuration
When you start Alchemist server for the first time, an interactive configuration wizard will run automatically:
```bash
# First time running the server
./target/release/alchemist --server
# The wizard runs automatically and guides you through:
# - Transcoding settings (quality thresholds, concurrency)
# - Hardware configuration (GPU/CPU encoding preferences)
# - Auto-scan directories (media library paths)
#
# After completion, config.toml is created and the server starts
```
On subsequent runs, the server loads your saved configuration.
### Docker Deployment
```bash

View File

@@ -13,6 +13,8 @@ pub mod orchestrator;
pub mod processor;
#[cfg(feature = "ssr")]
pub mod server;
#[cfg(feature = "ssr")]
pub mod wizard;
pub mod app;

View File

@@ -56,12 +56,36 @@ async fn main() -> Result<()> {
let args = Args::parse();
// 0. Load Configuration
// 0. Load Configuration (with first-boot wizard)
let config_path = std::path::Path::new("config.toml");
let config = config::Config::load(config_path).unwrap_or_else(|e| {
warn!("Failed to load config.toml: {}. Using defaults.", e);
config::Config::default()
});
let config = if !config_path.exists() && args.server {
// First boot: Run configuration wizard
info!("No configuration file found. Starting configuration wizard...");
info!("");
match alchemist::wizard::ConfigWizard::run(config_path) {
Ok(cfg) => {
info!("");
info!("Configuration complete! Continuing with server startup...");
info!("");
cfg
}
Err(e) => {
error!("Configuration wizard failed: {}", e);
error!("You can:");
error!(" 1. Run the wizard again: alchemist --server");
error!(" 2. Create config.toml manually");
error!(" 3. Use Python wizard: python setup/configure.py");
return Err(e);
}
}
} else {
// Normal boot or CLI mode: Load config or use defaults
config::Config::load(config_path).unwrap_or_else(|e| {
warn!("Failed to load config.toml: {}. Using defaults.", e);
config::Config::default()
})
};
// Log Configuration
info!("Configuration:");

382
src/wizard.rs Normal file
View File

@@ -0,0 +1,382 @@
use crate::config::Config;
use crate::error::{AlchemistError, Result};
use inquire::{Confirm, Select, Text};
use std::path::Path;
/// Interactive configuration wizard
pub struct ConfigWizard;
impl ConfigWizard {
/// Run the configuration wizard and create config.toml
pub fn run(config_path: &Path) -> Result<Config> {
println!("\n╔═══════════════════════════════════════════════════════════════╗");
println!("║ ⚗️ ALCHEMIST CONFIGURATION WIZARD ║");
println!("╚═══════════════════════════════════════════════════════════════╝\n");
println!("Welcome! This wizard will help you configure Alchemist.");
println!("Press Enter to accept the default value shown in [brackets].\n");
if config_path.exists() {
let overwrite = Confirm::new("config.toml already exists. Overwrite it?")
.with_default(false)
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
if !overwrite {
return Err(AlchemistError::Config("Configuration cancelled".into()));
}
}
// Section 1: Transcoding
println!("\n┌─────────────────────────────────────────┐");
println!("│ Section 1/3: Transcoding Settings │");
println!("└─────────────────────────────────────────┘\n");
let size_threshold = Self::prompt_size_threshold()?;
let min_bpp = Self::prompt_min_bpp()?;
let min_file_size = Self::prompt_min_file_size()?;
let concurrent_jobs = Self::prompt_concurrent_jobs()?;
// Section 2: Hardware
println!("\n┌─────────────────────────────────────────┐");
println!("│ Section 2/3: Hardware Settings │");
println!("└─────────────────────────────────────────┘\n");
let allow_cpu_fallback = Self::prompt_cpu_fallback()?;
let allow_cpu_encoding = Self::prompt_cpu_encoding()?;
let cpu_preset = Self::prompt_cpu_preset()?;
let preferred_vendor = Self::prompt_preferred_vendor()?;
// Section 3: Scanner
println!("\n┌─────────────────────────────────────────┐");
println!("│ Section 3/3: Scanner Settings │");
println!("└─────────────────────────────────────────┘\n");
let directories = Self::prompt_directories()?;
// Build config
let config = Config {
transcode: crate::config::TranscodeConfig {
size_reduction_threshold: size_threshold,
min_bpp_threshold: min_bpp,
min_file_size_mb: min_file_size,
concurrent_jobs,
},
hardware: crate::config::HardwareConfig {
preferred_vendor,
device_path: None,
allow_cpu_fallback,
cpu_preset,
allow_cpu_encoding,
},
scanner: crate::config::ScannerConfig { directories },
};
// Show summary
Self::show_summary(&config);
let confirm = Confirm::new("Save this configuration?")
.with_default(true)
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
if !confirm {
return Err(AlchemistError::Config("Configuration cancelled".into()));
}
// Write to file
Self::write_config(config_path, &config)?;
println!("\n✅ Configuration saved to {}", config_path.display());
println!("\nYou can now use Alchemist!");
println!(" • Run: alchemist --server");
println!(" • Edit: {}\n", config_path.display());
Ok(config)
}
fn prompt_size_threshold() -> Result<f64> {
println!("📏 Size Reduction Threshold");
println!(" How much smaller must the output file be to keep it?\n");
println!(" • 0.3 (30%) - Balanced (recommended)");
println!(" • 0.2 (20%) - More aggressive");
println!(" • 0.5 (50%) - Conservative\n");
println!(" Files that don't compress enough are kept as-is.\n");
let input = Text::new("Size reduction threshold:")
.with_default("0.3")
.with_help_message("Enter a value between 0.0 and 1.0")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
input
.parse::<f64>()
.map_err(|_| AlchemistError::Config("Invalid number".into()))
}
fn prompt_min_bpp() -> Result<f64> {
println!("\n🎨 Minimum Bits Per Pixel (BPP)");
println!(" Skip files that are already heavily compressed.\n");
println!(" • 0.10 - Good default (recommended)");
println!(" • 0.05 - Very aggressive");
println!(" • 0.20 - Conservative\n");
println!(" Lower BPP = already compressed. Re-encoding destroys quality.\n");
let input = Text::new("Minimum BPP threshold:")
.with_default("0.1")
.with_help_message("Typical range: 0.05 to 0.30")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
input
.parse::<f64>()
.map_err(|_| AlchemistError::Config("Invalid number".into()))
}
fn prompt_min_file_size() -> Result<u64> {
println!("\n📦 Minimum File Size");
println!(" Skip small files to avoid wasting time.\n");
println!(" • 50 MB - Good for most libraries (recommended)");
println!(" • 100 MB - Focus on movies/TV episodes");
println!(" • 10 MB - Process everything\n");
let input = Text::new("Minimum file size (MB):")
.with_default("50")
.with_help_message("Files smaller than this are skipped")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
input
.parse::<u64>()
.map_err(|_| AlchemistError::Config("Invalid number".into()))
}
fn prompt_concurrent_jobs() -> Result<usize> {
println!("\n⚡ Concurrent Jobs");
println!(" How many videos to transcode simultaneously.\n");
println!(" • 1 - Safe default (CPU or single GPU)");
println!(" • 2-4 - Powerful systems with dedicated GPU");
println!(" • More = faster BUT uses more resources\n");
println!(" ⚠️ CPU users: Use 1 only (CPU is very slow)\n");
let input = Text::new("Number of concurrent jobs:")
.with_default("1")
.with_help_message("Recommended: 1-2 for most systems")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
input
.parse::<usize>()
.map_err(|_| AlchemistError::Config("Invalid number".into()))
}
fn prompt_cpu_fallback() -> Result<bool> {
println!("💻 CPU Fallback");
println!(" Allow software encoding if no GPU is detected?\n");
println!(" If enabled: App uses CPU when no GPU found (SLOW)");
println!(" If disabled: App fails to start without GPU\n");
println!(" ⚠️ CPU encoding is 10-50x slower than GPU!\n");
Confirm::new("Enable CPU fallback?")
.with_default(true)
.with_help_message("Recommended: yes for compatibility")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))
}
fn prompt_cpu_encoding() -> Result<bool> {
println!("\n🔧 CPU Encoding");
println!(" Explicitly allow CPU encoding in production?\n");
println!(" Safety flag to prevent accidental slow transcodes.");
println!(" Even with fallback enabled, you can block CPU jobs.\n");
Confirm::new("Allow CPU encoding?")
.with_default(true)
.with_help_message("Recommended: yes (same as fallback)")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))
}
fn prompt_cpu_preset() -> Result<String> {
println!("\n⚙️ CPU Encoding Preset");
println!(" How fast/slow should CPU encoding be?\n");
println!(" • slow (0-4) - Best quality, very slow");
println!(" • medium (5-8) - Balanced (recommended)");
println!(" • fast (9-12) - Lower quality, faster");
println!(" • faster (13) - Lowest quality, fastest\n");
let choices = vec!["medium", "slow", "fast", "faster"];
Select::new("CPU preset:", choices)
.with_help_message("Recommendation: medium for CPU encoding")
.prompt()
.map(|s| s.to_string())
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))
}
fn prompt_preferred_vendor() -> Result<Option<String>> {
println!("\n🎯 Preferred GPU Vendor (Optional)");
println!(" Force a specific GPU if multiple are available.\n");
println!(" Leave blank to auto-detect (recommended).\n");
let set_vendor = Confirm::new("Set preferred vendor?")
.with_default(false)
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
if !set_vendor {
return Ok(None);
}
let choices = vec!["nvidia", "intel", "amd", "apple"];
Select::new("Vendor:", choices)
.prompt()
.map(|s| Some(s.to_string()))
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))
}
fn prompt_directories() -> Result<Vec<String>> {
println!("📁 Auto-Scan Directories");
println!(" Directories to automatically scan for media files.\n");
println!(" Used in server mode for automatic discovery.");
println!(" In CLI mode, you specify directories at runtime.\n");
let add_dirs = Confirm::new("Add auto-scan directories?")
.with_default(false)
.with_help_message("Optional: can configure later")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
if !add_dirs {
return Ok(vec![]);
}
let mut directories = Vec::new();
println!("\nEnter directories one at a time. Leave empty to finish.\n");
loop {
let dir = Text::new(&format!("Directory {}:", directories.len() + 1))
.with_default("")
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
if dir.is_empty() {
break;
}
let path = Path::new(&dir);
if !path.exists() {
println!("⚠️ Warning: {} does not exist", dir);
let add_anyway = Confirm::new("Add anyway?")
.with_default(true)
.prompt()
.map_err(|e| AlchemistError::Config(format!("Prompt failed: {}", e)))?;
if !add_anyway {
continue;
}
}
directories.push(dir);
println!("✓ Added");
}
Ok(directories)
}
fn show_summary(config: &Config) {
println!("\n┌─────────────────────────────────────────┐");
println!("│ 📋 Configuration Summary │");
println!("└─────────────────────────────────────────┘\n");
println!("Transcoding:");
println!(
" Size Reduction: {:.0}%",
config.transcode.size_reduction_threshold * 100.0
);
println!(" Min BPP: {:.2}", config.transcode.min_bpp_threshold);
println!(" Min File Size: {} MB", config.transcode.min_file_size_mb);
println!(" Concurrent Jobs: {}\n", config.transcode.concurrent_jobs);
println!("Hardware:");
println!(
" CPU Fallback: {}",
if config.hardware.allow_cpu_fallback {
"Enabled"
} else {
"Disabled"
}
);
println!(
" CPU Encoding: {}",
if config.hardware.allow_cpu_encoding {
"Enabled"
} else {
"Disabled"
}
);
println!(" CPU Preset: {}", config.hardware.cpu_preset);
if let Some(ref vendor) = config.hardware.preferred_vendor {
println!(" Preferred Vendor: {}\n", vendor);
} else {
println!(" Preferred Vendor: Auto-detect\n");
}
println!("Scanner:");
if config.scanner.directories.is_empty() {
println!(" (No auto-scan directories)\n");
} else {
for d in &config.scanner.directories {
println!(" 📁 {}", d);
}
println!();
}
}
fn write_config(path: &Path, config: &Config) -> Result<()> {
let toml_content = format!(
r#"# Alchemist Configuration File
# Generated by configuration wizard
[transcode]
size_reduction_threshold = {} # Require this % size reduction
min_bpp_threshold = {} # Skip files with BPP below this
min_file_size_mb = {} # Skip files smaller than this (MB)
concurrent_jobs = {} # Number of parallel transcodes
[hardware]
allow_cpu_fallback = {} # Allow software encoding
allow_cpu_encoding = {} # Enable CPU encoding
cpu_preset = "{}" # CPU encoding speed: slow|medium|fast|faster
{}
[scanner]
directories = [ # Auto-scan directories (server mode)
{}]
"#,
config.transcode.size_reduction_threshold,
config.transcode.min_bpp_threshold,
config.transcode.min_file_size_mb,
config.transcode.concurrent_jobs,
config.hardware.allow_cpu_fallback,
config.hardware.allow_cpu_encoding,
config.hardware.cpu_preset,
config
.hardware
.preferred_vendor
.as_ref()
.map(|v| format!("preferred_vendor = \"{}\"\n", v))
.unwrap_or_default(),
config
.scanner
.directories
.iter()
.map(|d| format!(" \"{}\",", d))
.collect::<Vec<_>>()
.join("\n")
);
std::fs::write(path, toml_content)
.map_err(|e| AlchemistError::Config(format!("Failed to write config: {}", e)))
}
}