alopex_cli/profile/
config.rs

1use std::collections::HashMap;
2use std::fs::{self, OpenOptions};
3use std::io::Write;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::cli::Cli;
9use crate::error::{CliError, Result};
10
11#[cfg(unix)]
12use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
13
14const CONFIG_DIR: &str = ".alopex";
15const CONFIG_FILE: &str = "config";
16
17#[derive(Debug, Serialize, Deserialize, Default)]
18pub struct ProfileConfig {
19    #[serde(alias = "default")]
20    pub default_profile: Option<String>,
21    #[serde(default)]
22    pub profiles: HashMap<String, Profile>,
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
26#[serde(rename_all = "lowercase")]
27pub enum ConnectionType {
28    #[default]
29    Local,
30    Server,
31}
32
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
34#[serde(rename_all = "lowercase")]
35pub enum AuthType {
36    #[default]
37    None,
38    Token,
39    Basic,
40    MTls,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct LocalConfig {
45    pub path: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ServerConfig {
50    pub url: String,
51    #[serde(default)]
52    pub auth: Option<AuthType>,
53    #[serde(default)]
54    pub token: Option<String>,
55    #[serde(default)]
56    pub username: Option<String>,
57    #[serde(default)]
58    pub password_command: Option<String>,
59    #[serde(default)]
60    pub cert_path: Option<PathBuf>,
61    #[serde(default)]
62    pub key_path: Option<PathBuf>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Profile {
67    #[serde(default)]
68    pub connection_type: ConnectionType,
69    #[serde(default)]
70    pub local: Option<LocalConfig>,
71    #[serde(default)]
72    pub server: Option<ServerConfig>,
73    #[serde(default)]
74    pub data_dir: Option<String>,
75}
76
77impl Profile {
78    fn normalized(&self) -> Self {
79        let mut profile = self.clone();
80        if profile.local.is_none() {
81            if let Some(data_dir) = profile.data_dir.clone() {
82                profile.local = Some(LocalConfig { path: data_dir });
83            }
84        }
85        if profile.connection_type == ConnectionType::Local
86            && profile.local.is_none()
87            && profile.server.is_some()
88        {
89            profile.connection_type = ConnectionType::Server;
90        }
91        profile
92    }
93
94    pub fn local_path(&self) -> Option<String> {
95        self.local
96            .as_ref()
97            .map(|local| local.path.clone())
98            .or_else(|| self.data_dir.clone())
99    }
100}
101
102#[derive(Debug, Clone)]
103pub struct ResolvedConfig {
104    pub data_dir: Option<String>,
105    pub in_memory: bool,
106    #[allow(dead_code)]
107    pub profile_name: Option<String>,
108    pub connection_type: ConnectionType,
109    #[allow(dead_code)]
110    pub server: Option<ServerConfig>,
111    #[allow(dead_code)]
112    pub fallback_local: Option<String>,
113}
114
115#[derive(Debug)]
116pub struct ProfileManager {
117    config_path: PathBuf,
118    profiles: HashMap<String, Profile>,
119    default_profile: Option<String>,
120}
121
122impl ProfileManager {
123    pub fn load() -> Result<Self> {
124        let config_path = default_config_path()?;
125        Self::load_from_path(config_path)
126    }
127
128    pub fn load_from_path(config_path: PathBuf) -> Result<Self> {
129        if config_path.exists() {
130            validate_config_permissions(&config_path)?;
131        }
132
133        let config = if config_path.exists() {
134            let contents = fs::read_to_string(&config_path)?;
135            if contents.trim().is_empty() {
136                ProfileConfig::default()
137            } else {
138                toml::from_str::<ProfileConfig>(&contents)
139                    .map_err(|err| CliError::Parse(err.to_string()))?
140            }
141        } else {
142            ProfileConfig::default()
143        };
144
145        Ok(Self {
146            config_path,
147            profiles: config.profiles,
148            default_profile: config.default_profile,
149        })
150    }
151
152    pub fn save(&self) -> Result<()> {
153        if let Some(parent) = self.config_path.parent() {
154            fs::create_dir_all(parent)?;
155        }
156
157        let config = ProfileConfig {
158            default_profile: self.default_profile.clone(),
159            profiles: self.profiles.clone(),
160        };
161        let serialized =
162            toml::to_string_pretty(&config).map_err(|err| CliError::Parse(err.to_string()))?;
163
164        let mut options = OpenOptions::new();
165        options.write(true).create(true).truncate(true);
166        #[cfg(unix)]
167        {
168            options.mode(0o600);
169        }
170        let mut file = options.open(&self.config_path)?;
171        file.write_all(serialized.as_bytes())?;
172        file.flush()?;
173
174        #[cfg(unix)]
175        fs::set_permissions(&self.config_path, fs::Permissions::from_mode(0o600))?;
176
177        Ok(())
178    }
179
180    pub fn create(&mut self, name: &str, profile: Profile) -> Result<()> {
181        self.profiles.insert(name.to_string(), profile);
182        Ok(())
183    }
184
185    pub fn delete(&mut self, name: &str) -> Result<()> {
186        if self.profiles.remove(name).is_none() {
187            return Err(CliError::ProfileNotFound(name.to_string()));
188        }
189
190        if self.default_profile.as_deref() == Some(name) {
191            self.default_profile = None;
192        }
193
194        Ok(())
195    }
196
197    pub fn get(&self, name: &str) -> Option<&Profile> {
198        self.profiles.get(name)
199    }
200
201    pub fn list(&self) -> Vec<&str> {
202        let mut names: Vec<&str> = self.profiles.keys().map(|name| name.as_str()).collect();
203        names.sort_unstable();
204        names
205    }
206
207    pub fn set_default(&mut self, name: &str) -> Result<()> {
208        if !self.profiles.contains_key(name) {
209            return Err(CliError::ProfileNotFound(name.to_string()));
210        }
211
212        self.default_profile = Some(name.to_string());
213        Ok(())
214    }
215
216    pub fn default_profile(&self) -> Option<&str> {
217        self.default_profile.as_deref()
218    }
219
220    pub fn resolve(&self, cli: &Cli) -> Result<ResolvedConfig> {
221        if cli.profile.is_some() && cli.data_dir.is_some() {
222            return Err(CliError::ConflictingOptions);
223        }
224
225        if let Some(profile_name) = cli.profile.as_deref() {
226            let profile = self
227                .profiles
228                .get(profile_name)
229                .ok_or_else(|| CliError::ProfileNotFound(profile_name.to_string()))?
230                .normalized();
231            return resolve_profile(profile, Some(profile_name.to_string()));
232        }
233
234        if let Some(data_dir) = cli.data_dir.as_ref() {
235            return Ok(ResolvedConfig {
236                data_dir: Some(data_dir.clone()),
237                in_memory: false,
238                profile_name: None,
239                connection_type: ConnectionType::Local,
240                server: None,
241                fallback_local: None,
242            });
243        }
244
245        if let Some(default_name) = self.default_profile.as_deref() {
246            let profile = self
247                .profiles
248                .get(default_name)
249                .ok_or_else(|| CliError::ProfileNotFound(default_name.to_string()))?
250                .normalized();
251            return resolve_profile(profile, Some(default_name.to_string()));
252        }
253
254        Ok(ResolvedConfig {
255            data_dir: None,
256            in_memory: true,
257            profile_name: None,
258            connection_type: ConnectionType::Local,
259            server: None,
260            fallback_local: None,
261        })
262    }
263}
264
265fn resolve_profile(profile: Profile, profile_name: Option<String>) -> Result<ResolvedConfig> {
266    match profile.connection_type {
267        ConnectionType::Local => {
268            let local_path = profile.local_path().ok_or_else(|| {
269                CliError::InvalidArgument("Local profile requires a data directory".to_string())
270            })?;
271            Ok(ResolvedConfig {
272                data_dir: Some(local_path),
273                in_memory: false,
274                profile_name,
275                connection_type: ConnectionType::Local,
276                server: None,
277                fallback_local: None,
278            })
279        }
280        ConnectionType::Server => {
281            let fallback_local = profile.local_path();
282            let server = profile.server.ok_or_else(|| {
283                CliError::InvalidArgument(
284                    "Server profile requires a server configuration".to_string(),
285                )
286            })?;
287            Ok(ResolvedConfig {
288                data_dir: fallback_local.clone(),
289                in_memory: false,
290                profile_name,
291                connection_type: ConnectionType::Server,
292                server: Some(server),
293                fallback_local,
294            })
295        }
296    }
297}
298
299fn default_config_path() -> Result<PathBuf> {
300    let home = dirs::home_dir().ok_or_else(|| {
301        CliError::InvalidArgument("Home directory could not be determined".to_string())
302    })?;
303    Ok(home.join(CONFIG_DIR).join(CONFIG_FILE))
304}
305
306#[cfg(unix)]
307fn validate_config_permissions(path: &PathBuf) -> Result<()> {
308    let metadata = fs::metadata(path)?;
309    let mode = metadata.permissions().mode() & 0o777;
310    if mode != 0o600 {
311        return Err(CliError::InvalidArgument(format!(
312            "Config file permissions must be 600: {}",
313            path.display()
314        )));
315    }
316    Ok(())
317}
318
319#[cfg(not(unix))]
320fn validate_config_permissions(_path: &PathBuf) -> Result<()> {
321    Ok(())
322}