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}