rcman/profiles/mod.rs
1//! Profile management for rcman
2//!
3//! This module provides profile support for settings and sub-settings,
4//! allowing multiple named configurations that can be switched at runtime.
5//!
6//! # Overview
7//!
8//! Profiles allow users to maintain multiple configurations (e.g., "work", "personal", "testing")
9//! and switch between them dynamically. Profiles can be scoped to:
10//!
11//!
12//! # When to Use Profiles
13//!
14//! Profiles add complexity to the storage structure and API interaction. They should be chosen deliberately.
15//!
16//! ## ✅ Good Use Cases
17//!
18//! - **Multi-tenant Applications**: Where different users/tenants need completely isolated configurations.
19//! - **Environment Switching**: Dev/Staging/Prod environments that need to swap entirely different sets of remotes or settings.
20//! - **Workspace Management**: Applications that support distinct workspaces (like VS Code profiles).
21//!
22//! ## ❌ Avoid If
23//!
24//! - **Simple Presets**: If you just want to save a few combinations of settings, use a "presets" list in your main settings instead.
25//! - **Single User Apps**: If the app is for a single user, profiles often add confusion.
26//! - **Small Configs**: If your total config is < 10 items, profiling is likely over-engineering.
27//!
28//! # Performance & Complexity Impact
29//!
30//! Enabling profiles changes the on-disk structure:
31//!
32//! - **Standard:** `config_dir/remotes.json` (simple, fast)
33//! - **Profiled:** `config_dir/remotes/profiles/{profile_name}/...` + `.profiles.json` manifest
34//!
35//! This introduces:
36//! - **Initialization Cost:** Migration logic must run on startup to move flat files into the default profile.
37//! - **I/O Overhead:** Switching profiles invalidates in-memory caches and requires re-reading from disk.
38//! - **API Complexity:** You must manage profile lifecycle (create/switch/delete).
39//!
40//! # Implementation Details
41//! # Example
42//!
43//! ```rust,ignore
44//! use rcman::{SettingsManager, SubSettingsConfig};
45//!
46//! // Enable profiles for remotes sub-settings
47//! let manager = SettingsManager::builder("my-app", "1.0.0")
48//! .with_sub_settings(SubSettingsConfig::new("remotes").with_profiles())
49//! .build()?;
50//!
51//! // Manage profiles
52//! let remotes = manager.sub_settings("remotes")?;
53//! remotes.profiles()?.create("work")?;
54//! remotes.profiles()?.switch("work")?;
55//!
56//! // CRUD now operates on "work" profile
57//! remotes.set("company-drive", &json!({...}))?;
58//! ```
59
60mod manager;
61mod migrator;
62
63pub use manager::{ProfileEvent, ProfileManager, ProfileManifest};
64pub use migrator::{ProfileMigrator, migrate, rollback_migration};
65
66/// Default profile name used when migrating or initializing
67pub const DEFAULT_PROFILE: &str = "default";
68
69/// Directory name containing profile subdirectories
70pub const PROFILES_DIR: &str = "profiles";
71
72/// Validate a profile name
73///
74/// Valid names can contain spaces and most printable characters.
75/// Names cannot be empty, start with a dot, or contain path separators.
76///
77/// # Errors
78///
79/// Returns an error if the name is invalid.
80pub fn validate_profile_name(name: &str) -> crate::Result<()> {
81 use crate::Error;
82
83 if name.is_empty() {
84 return Err(Error::InvalidProfileName(format!(
85 "{name}: Profile name cannot be empty",
86 )));
87 }
88
89 if name.starts_with('.') {
90 return Err(Error::InvalidProfileName(format!(
91 "{name}: Profile name cannot start with a dot",
92 )));
93 }
94
95 if name.contains('/') || name.contains('\\') || name.contains("..") {
96 return Err(Error::InvalidProfileName(format!(
97 "{name}: Profile name cannot contain path separators",
98 )));
99 }
100
101 // Only reject control characters and path-unsafe chars
102 // Allows: letters, numbers, spaces, punctuation, unicode, etc.
103 if name.chars().any(char::is_control) {
104 return Err(Error::InvalidProfileName(format!(
105 "{name}: Profile name cannot contain control characters"
106 )));
107 }
108
109 Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn test_valid_profile_names() {
118 assert!(validate_profile_name("default").is_ok());
119 assert!(validate_profile_name("work").is_ok());
120 assert!(validate_profile_name("my-profile").is_ok());
121 assert!(validate_profile_name("profile_123").is_ok());
122 assert!(validate_profile_name("Work").is_ok());
123 assert!(validate_profile_name("PROD").is_ok());
124
125 // Spaces and special characters are now allowed!
126 assert!(validate_profile_name("Test 1").is_ok());
127 assert!(validate_profile_name("My Backend!").is_ok());
128 assert!(validate_profile_name("NAS #2").is_ok());
129 assert!(validate_profile_name("Work (Personal)").is_ok());
130 assert!(validate_profile_name("Backend @ Home").is_ok());
131 }
132
133 #[test]
134 fn test_invalid_profile_names() {
135 assert!(validate_profile_name("").is_err()); // Empty
136 assert!(validate_profile_name(".hidden").is_err()); // Starts with dot
137 assert!(validate_profile_name("path/to").is_err()); // Path separator
138 assert!(validate_profile_name("path\\to").is_err()); // Path separator
139 assert!(validate_profile_name("..").is_err()); // Path traversal
140 assert!(validate_profile_name("has\nnewline").is_err()); // Control char
141 assert!(validate_profile_name("has\ttab").is_err()); // Control char
142 }
143}