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}