Skip to main content

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