#![cfg(feature = "profiles")]
mod common;
use rcman::{SettingsConfig, SettingsManager, SubSettingsConfig};
use serde_json::json;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
#[cfg(feature = "backup")]
use rcman::{BackupOptions, RestoreOptions, SettingsConfigBuilder};
#[cfg(feature = "backup")]
use std::fs;
#[cfg(feature = "backup")]
use tempfile::tempdir;
#[cfg(all(feature = "encrypted-file", not(feature = "keychain")))]
use rcman::{SettingMetadata, SettingsSchema, settings};
#[cfg(all(feature = "encrypted-file", not(feature = "keychain")))]
use serde::{Deserialize, Serialize};
#[cfg(all(feature = "encrypted-file", not(feature = "keychain")))]
use std::collections::HashMap;
#[test]
fn test_create_profile() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let profiles = remotes.profiles().unwrap();
profiles.create("work").unwrap();
let list = profiles.list().unwrap();
assert!(list.contains(&"default".to_string()));
assert!(list.contains(&"work".to_string()));
}
#[test]
fn test_switch_profile() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let profiles = remotes.profiles().unwrap();
profiles.create("work").unwrap();
profiles.switch("work").unwrap();
assert_eq!(profiles.active().unwrap(), "work");
}
#[test]
fn test_seamless_profile_switching() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
remotes
.set("personal-gdrive", &json!({"type": "drive"}))
.unwrap();
assert!(remotes.exists("personal-gdrive").unwrap());
remotes.profiles().unwrap().create("work").unwrap();
remotes.switch_profile("work").unwrap();
assert!(!remotes.exists("personal-gdrive").unwrap());
remotes
.set("company-drive", &json!({"type": "sharepoint"}))
.unwrap();
assert!(remotes.exists("company-drive").unwrap());
remotes.switch_profile("default").unwrap();
assert!(remotes.exists("personal-gdrive").unwrap());
assert!(!remotes.exists("company-drive").unwrap());
}
#[test]
fn test_delete_profile() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let profiles = remotes.profiles().unwrap();
profiles.create("temp").unwrap();
assert!(profiles.exists("temp").unwrap());
profiles.delete("temp").unwrap();
assert!(!profiles.exists("temp").unwrap());
}
#[test]
fn test_cannot_delete_active_profile() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let profiles = remotes.profiles().unwrap();
let result = profiles.delete("default");
assert!(result.is_err());
}
#[test]
fn test_rename_profile() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let profiles = remotes.profiles().unwrap();
profiles.create("old-name").unwrap();
profiles.rename("old-name", "new-name").unwrap();
let list = profiles.list().unwrap();
assert!(!list.contains(&"old-name".to_string()));
assert!(list.contains(&"new-name".to_string()));
}
#[test]
fn test_duplicate_profile() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
remotes.set("gdrive", &json!({"type": "drive"})).unwrap();
let profiles = remotes.profiles().unwrap();
profiles.duplicate("default", "backup").unwrap();
let list = profiles.list().unwrap();
assert!(list.contains(&"default".to_string()));
assert!(list.contains(&"backup".to_string()));
let backup_dir = temp_dir
.path()
.join("remotes")
.join("profiles")
.join("backup");
assert!(backup_dir.join("gdrive.json").exists());
}
#[test]
fn test_profile_events() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let profiles = remotes.profiles().unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
profiles.set_on_event(move |event| {
events_clone.lock().unwrap().push(format!("{event:?}"));
});
profiles.create("work").unwrap();
profiles.switch("work").unwrap();
profiles.rename("work", "job").unwrap();
let recorded = events.lock().unwrap();
assert_eq!(recorded.len(), 3);
}
#[test]
fn test_profiles_not_enabled_error() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes")) .build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let result = remotes.profiles();
assert!(result.is_err());
}
#[test]
fn test_profile_directory_structure() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.build()
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
remotes.set("gdrive", &json!({"type": "drive"})).unwrap();
let profiles = remotes.profiles().unwrap();
profiles.create("work").unwrap();
let remotes_dir = temp_dir.path().join("remotes");
assert!(remotes_dir.join(".profiles.json").exists());
assert!(remotes_dir.join("profiles").join("default").is_dir());
assert!(remotes_dir.join("profiles").join("work").is_dir());
assert!(
remotes_dir
.join("profiles")
.join("default")
.join("gdrive.json")
.exists()
);
}
#[test]
fn test_single_file_mode_with_profiles() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::singlefile("backends").with_profiles())
.build()
.unwrap();
let backends = manager.sub_settings("backends").unwrap();
backends
.set("local", &json!({"host": "localhost"}))
.unwrap();
let profiles = backends.profiles().unwrap();
profiles.create("work").unwrap();
let backends_dir = temp_dir.path().join("backends");
assert!(backends_dir.join(".profiles.json").exists());
assert!(
backends_dir
.join("profiles")
.join("default")
.join("backends.json")
.exists()
);
}
#[test]
fn test_single_file_profile_migration() {
let temp_dir = TempDir::new().unwrap();
let backends_file = temp_dir.path().join("backends.json");
std::fs::write(&backends_file, r#"{ "local": { "type": "local" } }"#).unwrap();
let _manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_sub_settings(SubSettingsConfig::singlefile("backends").with_profiles())
.build()
.unwrap();
assert!(
!backends_file.exists(),
"Legacy file should have been moved"
);
let backends_dir = temp_dir.path().join("backends");
assert!(backends_dir.is_dir());
assert!(backends_dir.join(".profiles.json").exists());
let migrated_file = backends_dir
.join("profiles")
.join("default")
.join("backends.json");
assert!(
migrated_file.exists(),
"File should be migrated to default profile"
);
let content = std::fs::read_to_string(migrated_file).unwrap();
assert!(content.contains("local"));
}
#[test]
fn test_main_settings_profiles() {
use rcman::{SettingMetadata, SettingsSchema};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Default)]
struct TestSettings {
#[serde(default)]
general: GeneralSettings,
}
#[derive(Serialize, Deserialize)]
struct GeneralSettings {
#[serde(default = "default_theme")]
theme: String,
}
fn default_theme() -> String {
"light".to_string()
}
impl Default for GeneralSettings {
fn default() -> Self {
Self {
theme: default_theme(),
}
}
}
impl SettingsSchema for TestSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
let mut map = HashMap::new();
map.insert(
"general.theme".to_string(),
SettingMetadata::text("light").meta_str("label", "Theme"),
);
map
}
}
let temp_dir = TempDir::new().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_profiles() .build();
let manager = SettingsManager::new(config).unwrap();
assert!(manager.is_profiles_enabled());
assert_eq!(manager.active_profile().unwrap(), "default");
manager
.save_setting("general", "theme", &json!("dark"))
.unwrap();
manager.create_profile("work").unwrap();
manager.switch_profile("work").unwrap();
assert_eq!(manager.active_profile().unwrap(), "work");
let settings: TestSettings = manager.get_all().unwrap();
assert_eq!(settings.general.theme, "light");
manager
.save_setting("general", "theme", &json!("ocean"))
.unwrap();
manager.switch_profile("default").unwrap();
let settings: TestSettings = manager.get_all().unwrap();
assert_eq!(settings.general.theme, "dark");
manager.switch_profile("work").unwrap();
let settings: TestSettings = manager.get_all().unwrap();
assert_eq!(settings.general.theme, "ocean");
}
#[test]
fn test_main_profile_switch_emits_changed_setting_callbacks() {
use rcman::{SettingMetadata, SettingsSchema};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Default, Serialize, Deserialize)]
struct TestSettings {
general: GeneralSettings,
}
#[derive(Default, Serialize, Deserialize)]
struct GeneralSettings {
theme: String,
}
impl SettingsSchema for TestSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
let mut map = HashMap::new();
map.insert(
"general.theme".to_string(),
SettingMetadata::text("light").meta_str("label", "Theme"),
);
map
}
}
let temp_dir = TempDir::new().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_profiles()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.save_setting("general", "theme", &json!("dark"))
.unwrap();
manager.create_profile("work").unwrap();
manager.switch_profile("work").unwrap();
manager
.save_setting("general", "theme", &json!("ocean"))
.unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
manager.events().on_change(move |key, old, new| {
events_clone
.lock()
.unwrap()
.push((key.to_string(), old.clone(), new.clone()));
});
manager.switch_profile("default").unwrap();
manager.switch_profile("work").unwrap();
let recorded = events.lock().unwrap();
assert_eq!(recorded.len(), 2);
assert!(recorded.iter().any(|(key, old, new)| key == "general.theme"
&& *old == json!("ocean")
&& *new == json!("dark")));
assert!(recorded.iter().any(|(key, old, new)| key == "general.theme"
&& *old == json!("dark")
&& *new == json!("ocean")));
}
#[test]
fn test_main_profile_switch_without_value_change_emits_no_callbacks() {
use rcman::{SettingMetadata, SettingsSchema};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Default, Serialize, Deserialize)]
struct TestSettings {
general: GeneralSettings,
}
#[derive(Default, Serialize, Deserialize)]
struct GeneralSettings {
theme: String,
}
impl SettingsSchema for TestSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
let mut map = HashMap::new();
map.insert(
"general.theme".to_string(),
SettingMetadata::text("light").meta_str("label", "Theme"),
);
map
}
}
let temp_dir = TempDir::new().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_profiles()
.build();
let manager = SettingsManager::new(config).unwrap();
manager.create_profile("work").unwrap();
let callback_count = Arc::new(Mutex::new(0usize));
let callback_count_clone = Arc::clone(&callback_count);
manager.events().on_change(move |_key, _old, _new| {
let mut guard = callback_count_clone.lock().unwrap();
*guard += 1;
});
manager.switch_profile("work").unwrap();
manager.switch_profile("default").unwrap();
assert_eq!(*callback_count.lock().unwrap(), 0);
}
#[test]
fn test_main_settings_profiles_directory_structure() {
use rcman::{SettingMetadata, SettingsSchema};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Default, Serialize, Deserialize)]
struct TestSettings {
ui: UiSettings,
}
#[derive(Default, Serialize, Deserialize)]
struct UiSettings {
theme: String,
}
impl SettingsSchema for TestSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
let mut map = HashMap::new();
map.insert(
"ui.theme".to_string(),
SettingMetadata::text("light").meta_str("label", "Theme"),
);
map
}
}
let temp_dir = TempDir::new().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_profiles()
.build();
let manager = SettingsManager::new(config).unwrap();
manager.save_setting("ui", "theme", &json!("dark")).unwrap();
manager.create_profile("work").unwrap();
assert!(
temp_dir.path().join(".profiles.json").exists(),
".profiles.json should exist"
);
let profiles_dir = temp_dir.path().join("profiles");
assert!(profiles_dir.exists(), "profiles/ directory should exist");
assert!(
profiles_dir.join("work").exists(),
"work profile directory should exist"
);
assert!(
profiles_dir.join("default").exists(),
"default profile directory should exist after saving"
);
assert!(
profiles_dir.join("default").join("settings.json").exists(),
"settings.json should exist in default profile"
);
}
#[test]
fn test_main_profile_propagation_with_mixed_sub_settings() {
let temp_dir = TempDir::new().unwrap();
let manager = SettingsManager::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_profiles()
.with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
.with_sub_settings(SubSettingsConfig::new("shared"))
.build()
.unwrap();
manager.create_profile("work").unwrap();
manager.switch_profile("work").unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
assert_eq!(remotes.profiles().unwrap().active().unwrap(), "work");
let shared = manager.sub_settings("shared").unwrap();
assert!(matches!(
shared.profiles(),
Err(rcman::Error::ProfilesNotEnabled)
));
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
#[cfg_attr(
feature = "keychain",
ignore = "Requires Secret Service daemon (not available in CI)"
)]
fn test_secret_reset_is_profile_scoped() {
use rcman::{SettingMetadata, SettingsSchema};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Default, Serialize, Deserialize)]
struct TestSettings {
api: ApiSettings,
}
#[derive(Default, Serialize, Deserialize)]
struct ApiSettings {
key: String,
}
impl SettingsSchema for TestSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
let mut map = HashMap::new();
map.insert(
"api.key".to_string(),
SettingMetadata::text("")
.meta_str("label", "API Key")
.secret(),
);
map
}
}
let temp_dir = TempDir::new().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_profiles()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.save_setting("api", "key", &json!("default-secret"))
.unwrap();
manager.create_profile("work").unwrap();
manager.switch_profile("work").unwrap();
manager
.save_setting("api", "key", &json!("work-secret"))
.unwrap();
manager.reset_setting("api", "key").unwrap();
let work_metadata = manager.metadata().unwrap();
assert_eq!(work_metadata.get("api.key").unwrap().value, Some(json!("")));
manager.switch_profile("default").unwrap();
let default_metadata = manager.metadata().unwrap();
assert_eq!(
default_metadata.get("api.key").unwrap().value,
Some(json!("default-secret"))
);
}
#[cfg(feature = "backup")]
#[test]
fn test_profile_backup_restore_full() {
let temp = tempdir().unwrap();
let config_dir = temp.path().join("config");
fs::create_dir_all(&config_dir).unwrap();
let config = SettingsConfigBuilder::new("test-app", "1.0.0")
.with_config_dir(&config_dir)
.with_profiles()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.register_sub_settings(SubSettingsConfig::new("items").with_profiles())
.unwrap();
if !manager.profiles().unwrap().exists("default").unwrap() {
manager.create_profile("default").unwrap();
}
manager.create_profile("work").unwrap();
manager.switch_profile("default").unwrap();
let items = manager.sub_settings("items").unwrap();
items.set("item1", &json!({"val": 1})).unwrap();
manager.switch_profile("work").unwrap();
let items = manager.sub_settings("items").unwrap();
items.set("item1", &json!({"val": 2})).unwrap();
items.set("item2", &json!({"val": 3})).unwrap();
let backup_mgr = manager.backup();
let backup_path = backup_mgr
.create(&BackupOptions {
output_dir: temp.path().join("backups"),
include_settings: true,
include_sub_settings: vec!["items".into()],
include_profiles: vec![], ..Default::default()
})
.unwrap();
let temp2 = tempdir().unwrap();
let restore_config_dir = temp2.path().join("config");
fs::create_dir_all(&restore_config_dir).unwrap();
let config2 = SettingsConfigBuilder::new("test-app", "1.0.0")
.with_config_dir(&restore_config_dir)
.with_profiles()
.build();
let manager2 = SettingsManager::new(config2).unwrap();
manager2
.register_sub_settings(SubSettingsConfig::new("items").with_profiles())
.unwrap();
let result = manager2
.backup()
.restore(&RestoreOptions {
backup_path: backup_path.clone(),
flags: rcman::backup::RestoreFlags {
scope: rcman::backup::RestoreScope {
restore_settings: true,
},
..Default::default()
},
restore_sub_settings: vec!["items".into()]
.into_iter()
.map(|s| (s, vec![]))
.collect(),
..Default::default()
})
.unwrap();
assert!(result.has_changes());
manager2.switch_profile("default").unwrap();
let items2 = manager2.sub_settings("items").unwrap();
let item1_def = items2.get_value("item1").unwrap();
assert_eq!(item1_def["val"], 1);
assert!(!items2.exists("item2").unwrap());
manager2.switch_profile("work").unwrap();
let items2_work = manager2.sub_settings("items").unwrap();
let item1_work = items2_work.get_value("item1").unwrap();
assert_eq!(item1_work["val"], 2);
let item2_work = items2_work.get_value("item2").unwrap();
assert_eq!(item2_work["val"], 3);
}
#[cfg(all(feature = "encrypted-file", not(feature = "keychain")))]
#[derive(Default, Serialize, Deserialize)]
struct ProfileSecretSettings;
#[cfg(all(feature = "encrypted-file", not(feature = "keychain")))]
impl SettingsSchema for ProfileSecretSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
settings! {
"secrets.api_key" => SettingMetadata::text("").secret(),
}
}
}
#[cfg(all(feature = "encrypted-file", not(feature = "keychain")))]
#[test]
fn test_profile_restore_rehydrates_main_secrets_with_credentials() {
let temp = tempdir().unwrap();
let source_config_dir = temp.path().join("source");
fs::create_dir_all(&source_config_dir).unwrap();
let source_config = SettingsConfigBuilder::new("test-app", "1.0.0")
.with_config_dir(&source_config_dir)
.with_schema::<ProfileSecretSettings>()
.with_profiles()
.with_credentials()
.build();
let source = SettingsManager::new(source_config).unwrap();
source.create_profile("work").unwrap();
source.switch_profile("work").unwrap();
source
.save_setting("secrets", "api_key", &json!("work-profile-secret"))
.unwrap();
let backup_path = source
.backup()
.create(
&BackupOptions::new()
.output_dir(temp.path().join("backups"))
.include_profile("work")
.secret_policy(rcman::SecretBackupPolicy::Include),
)
.unwrap();
let target_config_dir = temp.path().join("target");
fs::create_dir_all(&target_config_dir).unwrap();
let target_config = SettingsConfigBuilder::new("test-app", "1.0.0")
.with_config_dir(&target_config_dir)
.with_schema::<ProfileSecretSettings>()
.with_profiles()
.with_credentials()
.build();
let target = SettingsManager::new(target_config).unwrap();
target
.backup()
.restore(
&RestoreOptions::from_path(&backup_path)
.overwrite(true)
.restore_profile("work"),
)
.unwrap();
let secret = target
.credentials()
.unwrap()
.get_with_profile("secrets.api_key", Some("work"))
.unwrap();
assert_eq!(secret, Some("work-profile-secret".to_string()));
let settings_path = target
.config()
.config_dir
.join("profiles")
.join("work")
.join(&target.config().settings_file);
let restored_settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(settings_path).unwrap()).unwrap();
let persisted_key = restored_settings
.get("secrets")
.and_then(|secrets| secrets.get("api_key"));
assert!(persisted_key.is_none() || persisted_key == Some(&serde_json::Value::Null));
}