A generic, framework-agnostic Rust library for managing application settings with backup/restore, sub-settings, and credential management.
Built with modern Rust best practices — Comprehensive test coverage, CI-enforced quality gates (fmt, clippy, cargo-deny), and production-ready error handling.
| Feature | Description |
|---|---|
| Settings Management | Load/save with rich schema metadata for UI rendering |
| Sub-Settings | Per-entity configs (e.g., one JSON per remote) |
| Profiles | Multiple named configurations (work, personal, etc.) |
| Schema Migration | Lazy migration for transparent data upgrades |
| Backup & Restore | Encrypted ZIP backups with AES-256 |
| Secret Settings | Auto-routes secrets to OS keychain |
| External Configs | Include external files/commands in backups |
| Env Var Overrides | Override settings via environment variables (Docker/K8s) |
| Atomic Writes | Crash-safe file writes (temp file + rename) |
| Cross-Platform | Pure Rust - Windows, macOS, Linux, Android |
cargo add rcman| Feature | Description | Default? |
|---|---|---|
json |
JSON storage | ✅ |
backup |
Backup/restore (zip) | ✅ |
derive |
#[derive(SettingsSchema)] macro |
❌ |
keychain |
OS keychain support | ❌ |
encrypted-file |
AES-256 encrypted file | ❌ |
profiles |
Multiple named configurations | ❌ |
full |
All features | ❌ |
Examples:
# Default (settings + backup)
cargo add rcman
# Minimal (just settings, no backup)
cargo add rcman --no-default-features --features json
# With OS keychain support
cargo add rcman --features keychain
# Everything
cargo add rcman --features fullrcman offers two primary patterns depending on your needs:
Best for: Applications with a defined schema and need compile-time safety.
use rcman::{TypedManager, SettingsSchema, SettingMetadata, settings};
use serde::{Serialize, Deserialize};
#[derive(Default, Serialize, Deserialize)]
struct MySettings { theme: String }
impl SettingsSchema for MySettings {
fn get_metadata() -> std::collections::HashMap<String, SettingMetadata> {
settings! { "ui.theme" => SettingMetadata::text("Theme", "dark") }
}
}
let manager = TypedManager::<MySettings>::builder("my-app", "1.0.0")
.with_schema::<MySettings>()
.build()?;
// Type-safe access!
let settings: MySettings = manager.settings()?;Best for: Plugins, dynamic configs, or when schema is defined externally.
use rcman::DynamicManager;
let manager = DynamicManager::builder("my-app", "1.0.0").build()?;
// Runtime access via HashMap
let settings = manager.load_settings()?;📖 See examples/api_patterns.rs for comprehensive comparisons
Define settings using the clean builder API:
use rcman::{settings, SettingsSchema, SettingMetadata, opt};
#[derive(Default, Serialize, Deserialize)]
struct AppSettings {
dark_mode: bool,
language: String,
api_key: String,
}
impl SettingsSchema for AppSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
settings! {
// Toggle setting
"ui.dark_mode" => SettingMetadata::toggle("Dark Mode", false)
.category("appearance")
.order(1),
// Select with options
"ui.language" => SettingMetadata::select("Language", "en", vec![
opt("en", "English"),
opt("tr", "Turkish"),
opt("de", "German"),
]),
// Number with range
"ui.font_size" => SettingMetadata::number("Font Size", 14.0)
.min(8.0).max(32.0).step(1.0),
// Secret (auto-stored in keychain!)
"api.key" => SettingMetadata::password("API Key", "")
.secret(),
// List of strings
"network.allowed_ips" => SettingMetadata::list("Allowed IPs", vec!["127.0.0.1".to_string()])
.description("IP addresses allowed to connect")
.category("network"),
}
}
}| Constructor | Description |
|---|---|
text(label, default) |
Text input |
password(label, default) |
Password input |
number(label, default) |
Number input |
toggle(label, default) |
Boolean toggle |
select(label, default, options) |
Dropdown |
color(label, default) |
Color picker |
path(label, default) |
Directory path |
file(label, default) |
File path |
list(label, default) |
List of strings |
info(label, default) |
Read-only display |
.description() .min() .max() .step() .placeholder() .category() .order() .requires_restart() .advanced() .disabled() .secret() .pattern() .pattern_error()
Instead of implementing SettingsSchema manually, use the derive macro:
rcman = { version = "0.1", features = ["derive"] }use rcman::DeriveSettingsSchema;
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize, DeriveSettingsSchema)]
#[schema(category = "general")]
struct GeneralSettings {
#[setting(label = "Enable Tray", description = "Show tray icon")]
tray_enabled: bool,
#[setting(label = "Port", min = 1024, max = 65535)]
port: u16,
#[setting(label = "Theme", options(("light", "Light"), ("dark", "Dark")))]
theme: String,
}Available field attributes:
label,description,categorymin,max,step(for numbers)options((...))(for selects)secret,advanced,requires_restart,skip
Per-entity configuration files (e.g., one config per "remote"):
use rcman::{SettingsManager, SubSettingsConfig};
use serde_json::json;
// Register sub-settings via builder
let manager = SettingsManager::builder("my-app", "1.0.0")
.with_sub_settings(SubSettingsConfig::new("remotes")) // Multi-file mode
.with_sub_settings(SubSettingsConfig::new("backends").single_file()) // Single-file mode
.build()?;
// Access sub-settings
let remotes = manager.sub_settings("remotes")?;
// CRUD operations
remotes.set("gdrive", &json!({"type": "drive"}))?;
let gdrive_config = remotes.get::<serde_json::Value>("gdrive")?;
let all_remotes = remotes.list()?;
remotes.delete("onedrive")?;Storage Modes:
| Mode | Files Created | Use Case |
|---|---|---|
| Multi-file (default) | remotes/gdrive.json, remotes/s3.json |
Large configs, many entities |
| Single-file | backends.json |
Small collections, simpler file structure |
Profiles let you maintain multiple named configurations. Enable with the profiles feature:
cargo add rcman --features profilesEnable profiles for your main settings.json to switch entire app configurations:
use rcman::SettingsManager;
let manager = SettingsManager::builder("my-app", "1.0.0")
.with_profiles() // Enable profiles for main settings
.build()?;
// Profile management for main settings
manager.create_profile("work")?;
manager.switch_profile("work")?;
manager.active_profile()? // "work"
// All settings are now isolated per profile
manager.save_setting::<MySettings>("ui", "theme", json!("dark"))?;Directory structure:
my-app/
├── .profiles.json
└── profiles/
├── default/
│ └── settings.json
└── work/
└── settings.json
Enable profiles for specific sub-settings (e.g., different remote configs):
use rcman::{SettingsManager, SubSettingsConfig};
use serde_json::json;
// Enable profiles only for remotes
let manager = SettingsManager::builder("my-app", "1.0.0")
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()?;
let remotes = manager.sub_settings("remotes")?;
// Add data to default profile
remotes.set("personal-gdrive", &json!({"type": "drive"}))?;
// Create and switch to work profile
remotes.profiles()?.create("work")?;
remotes.switch_profile("work")?; // Seamless switch
// Now operations use the work profile
remotes.set("company-drive", &json!({"type": "sharepoint"}))?;
// Profile management
let profiles = remotes.profiles()?;
profiles.list()?; // ["default", "work"]
profiles.duplicate("work", "work-backup")?; // Copy a profile
profiles.rename("work-backup", "archived")?; // Rename
profiles.delete("archived")?; // Delete (can't delete active)Directory structure:
remotes/
├── .profiles.json
└── profiles/
├── default/
│ └── gdrive.json
└── work/
└── company-drive.json
Automatically upgrade old data formats when loading settings:
use rcman::{SettingsManager, SubSettingsConfig};
use serde_json::json;
// Main settings migration
let manager = SettingsManager::builder("my-app", "2.0.0")
.with_migrator(|mut value| {
// Upgrade v1 -> v2: rename "color" to "theme"
if let Some(obj) = value.as_object_mut() {
if let Some(ui) = obj.get_mut("ui").and_then(|v| v.as_object_mut()) {
if let Some(color) = ui.remove("color") {
ui.insert("theme".to_string(), color);
}
}
}
value
})
.build()?;
// Sub-settings migration (per-entry for multi-file mode)
let remotes_config = SubSettingsConfig::new("remotes")
.with_migrator(|mut value| {
// Add version field to each remote
if let Some(obj) = value.as_object_mut() {
if !obj.contains_key("version") {
obj.insert("version".into(), json!(2));
}
}
value
});
// Sub-settings migration (whole-file for single-file mode)
let backends_config = SubSettingsConfig::new("backends")
.single_file()
.with_migrator(|mut value| {
// Migrate all backends at once
if let Some(obj) = value.as_object_mut() {
for (_name, backend) in obj.iter_mut() {
if let Some(b) = backend.as_object_mut() {
b.insert("migrated".into(), json!(true));
}
}
}
value
});How it works:
- Migrator runs automatically on first load after app update
- If data changes, it's immediately written back to disk
- Subsequent loads skip migration (no performance impact)
- Multi-file mode: Migrator runs per-entry (each remote.json)
- Single-file mode: Migrator runs on whole file (all entries at once)
Settings marked with .secret() are automatically stored in the OS keychain:
// In schema
"api.key" => SettingMetadata::password("API Key", "")
.secret(),
// Usage - automatically routes to keychain!
manager.save_setting::<MySettings>("api", "key", json!("sk-123"))?;
// → Stored in OS keychain, NOT in settings.jsonBackends:
- macOS: Keychain
- Windows: Credential Manager
- Linux: Secret Service (via libsecret)
- Fallback: Encrypted file with Argon2id + AES-256-GCM
Create, analyze, and restore encrypted backups using the builder pattern:
use rcman::{BackupOptions, RestoreOptions};
// Create full backup with builder pattern
let backup_path = manager.backup()
.create(BackupOptions::new()
.output_dir("./backups")
.password("backup_password")
.note("Weekly backup")
.filename_suffix("full")) // Custom filename: app_timestamp_full.rcman
?;
// Create partial backup (only specific sub-settings)
let remotes_backup = manager.backup()
.create(BackupOptions::new()
.output_dir("./backups")
.export_type(ExportType::SettingsOnly)
.include_settings(false) // Don't include main settings
.include_sub_settings("remotes") // Only backup remotes
.filename_suffix("remotes")) // Creates: app_timestamp_remotes.rcman
?;
// Create backup for specific profiles (requires `profiles` feature)
#[cfg(feature = "profiles")]
let profile_backup = manager.backup()
.create(BackupOptions::new()
.output_dir("./backups")
.include_profiles(vec!["work".to_string()]) // Only backup 'work' profile
.filename_suffix("work_only"))
?;
// Analyze a backup before restoring (inspect contents, check encryption)
let analysis = manager.backup().analyze(&backup_path)?;
println!("Encrypted: {}", analysis.requires_password);
println!("Valid: {}", analysis.is_valid);
println!("Created by app v{}", analysis.manifest.app_version);
if !analysis.warnings.is_empty() {
println!("Warnings: {:?}", analysis.warnings);
}
// Restore with builder pattern
manager.backup()
.restore(RestoreOptions::from_path(&backup_path)
.password("backup_password")
.overwrite(true))
?;When you save a setting that equals its default, rcman removes it from storage:
- Regular settings: Removed from JSON file
- Secret settings: Removed from keychain
This keeps files minimal and allows changing defaults in code to auto-apply to users.
# Save non-default value (stored)
manager.save_setting::<S>("ui", "theme", json!("dark"))?;
// Save default value (removed from storage)
manager.save_setting::<S>("ui", "theme", json!("light"))?; // "light" is default
// Or use reset_setting() to explicitly reset
manager.reset_setting::<S>("ui", "theme")?;Override settings via environment variables for Docker/Kubernetes deployments:
// Enable with prefix
let config = SettingsConfig::builder("my-app", "1.0.0")
.with_env_prefix("MYAPP")
.build();Format: {PREFIX}_{CATEGORY}_{KEY} (all uppercase)
| Setting Key | Environment Variable |
|---|---|
ui.theme |
MYAPP_UI_THEME=dark |
core.port |
MYAPP_CORE_PORT=9090 |
general.debug |
MYAPP_GENERAL_DEBUG=true |
Priority: Env Var > Stored Value > Default
Type Parsing:
true/false→ boolean- Numbers → i64/f64
- JSON → parsed as JSON
- Everything else → string
UI Detection:
let settings = manager.load_settings::<MySettings>()?;
for (key, meta) in settings {
if meta.env_override {
println!("🔒 {} is overridden by env var", key);
}
}Note: Secret settings (stored in keychain) are NOT affected by env var overrides by default. To enable, use
.env_overrides_secrets(true):SettingsConfig::builder("my-app", "1.0.0") .with_env_prefix("MYAPP") .env_overrides_secrets(true) // Allow MYAPP_API_KEY to override keychain .build()
rcman supports transparent schema migration for evolving your settings over time without breaking existing user configs.
Migrations run lazily on first settings load. If the migrator returns a modified value, rcman automatically saves the upgraded config.
use rcman::SettingsConfig;
use serde_json::Value;
let config = SettingsConfig::builder("my-app", "2.0.0")
.with_migrator(|mut value| {
// Runs once on load if config exists
if let Some(obj) = value.as_object_mut() {
// Example: Rename field
if let Some(ui) = obj.get_mut("ui").and_then(|v| v.as_object_mut()) {
if let Some(old_field) = ui.remove("color") {
ui.insert("theme".to_string(), old_field);
}
}
// Example: Add new field with default
if !obj.contains_key("features") {
obj.insert("features".to_string(), serde_json::json!({
"telemetry": false
}));
}
}
value // Return modified value
})
.build();.with_migrator(|mut value| {
if let Some(obj) = value.as_object_mut() {
// Rename "network.timeout_ms" → "network.request_timeout"
if let Some(net) = obj.get_mut("network").and_then(|v| v.as_object_mut()) {
if let Some(timeout) = net.remove("timeout_ms") {
net.insert("request_timeout".to_string(), timeout);
}
}
}
value
}).with_migrator(|mut value| {
if let Some(obj) = value.as_object_mut() {
// Add new category if missing
if !obj.contains_key("experimental") {
obj.insert("experimental".to_string(), serde_json::json!({
"beta_features": false,
"debug_mode": false
}));
}
}
value
}).with_migrator(|mut value| {
if let Some(obj) = value.as_object_mut() {
// Convert port from string to number
if let Some(port) = obj.get("server").and_then(|v| v.get("port")) {
if let Some(port_str) = port.as_str() {
if let Ok(port_num) = port_str.parse::<u16>() {
obj.get_mut("server")
.and_then(|v| v.as_object_mut())
.map(|server| {
server.insert("port".to_string(), serde_json::json!(port_num));
});
}
}
}
}
value
}).with_migrator(|mut value| {
// Check current schema version
let version = value.get("_schema_version")
.and_then(|v| v.as_u64())
.unwrap_or(1);
if version < 2 {
// Migrate v1 → v2
if let Some(obj) = value.as_object_mut() {
// ... migration logic ...
obj.insert("_schema_version".to_string(), serde_json::json!(2));
}
}
if version < 3 {
// Migrate v2 → v3
if let Some(obj) = value.as_object_mut() {
// ... migration logic ...
obj.insert("_schema_version".to_string(), serde_json::json!(3));
}
}
value
})When using profiles, you can migrate all profiles automatically:
#[cfg(feature = "profiles")]
use rcman::profiles::ProfileMigrator;
let config = SettingsConfig::builder("my-app", "2.0.0")
.enable_profiles(ProfileMigrator::Auto) // Applies main migrator to all profiles
.with_migrator(|mut value| {
// This runs for main settings AND all profiles
// ... migration logic ...
value
})
.build();Always test your migrations with real user data:
#[test]
fn test_migration_v1_to_v2() {
use serde_json::json;
// Old format
let old_config = json!({
"ui": { "color": "dark" }
});
// Apply migration
let migrator = |mut value: Value| {
if let Some(obj) = value.as_object_mut() {
if let Some(ui) = obj.get_mut("ui").and_then(|v| v.as_object_mut()) {
if let Some(color) = ui.remove("color") {
ui.insert("theme".to_string(), color);
}
}
}
value
};
let new_config = migrator(old_config);
// Verify
assert_eq!(new_config["ui"]["theme"], "dark");
assert!(new_config["ui"].get("color").is_none());
}- Never delete data - Rename or move instead
- Version your schema - Use
_schema_versionfield to track changes - Test with real data - Use copies of actual user configs
- Document breaking changes - In CHANGELOG.md and migration comments
- Keep migrations forever - Users might upgrade from any version
- One-way only - Don't try to support downgrade paths
- Fail gracefully - Log errors, don't crash on migration failure
.with_migrator(|mut value| {
log::info!("Running schema migration to v2.0.0");
// ... migration logic ...
log::info!("Migration completed successfully");
value
})rcman uses dependency injection for env vars, making tests clean:
use rcman::{EnvSource, MockEnvSource};
let mock_env = Arc::new(MockEnvSource::new());
mock_env.set("MYAPP_THEME", "dark");
let config = SettingsConfig::builder("my-app", "1.0")
.with_env_source(mock_env)
.build();- In-Memory Caching: Reads are O(1) after first load.
- Lazy Computation: Merged views are computed only when needed.
- Smart Writes: Disk I/O only occurs when values actually change.
- Configurable Caching: Choose between
Full,LRU, orNonestrategies for sub-settings.
All operations return typed errors:
use rcman::{Error, Result};
match manager.save_setting::<MySettings>("ui", "theme", json!("dark")) {
Ok(()) => println!("Saved!"),
Err(Error::InvalidSettingValue { reason, .. }) => println!("Invalid: {}", reason),
Err(e) => println!("Error: {}", e),
}This project follows modern Rust library best practices. See CONTRIBUTING.md for development guidelines.
cargo fmt -- --check # Format code
cargo clippy -- -D clippy::all # Run linter
cargo test -- --test-threads=1 # Run tests
cargo test docs # Build docs
cargo deny check # Check dependencies- MSRV: Rust 1.70+
- Code Quality:
clippy -D warningsenforced in CI - Test Coverage: Comprehensive test suite with unit, integration, and edge case tests
- Documentation: Comprehensive doctests and API docs
- Dependencies: Audited via
cargo-deny(licenses, advisories, duplicates)
git config core.hooksPath .githooks
chmod +x .githooks/pre-commitMIT