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, SqlReadMode};
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)]
37#[serde(rename_all = "lowercase")]
38pub enum ExecutionScope {
39 #[default]
40 Local,
41 Cluster,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ClusterReadConfig {
49 #[serde(default)]
50 pub permitted_read_modes: Vec<SqlReadMode>,
51 #[serde(default = "default_cluster_read_mode")]
52 pub default_read_mode: SqlReadMode,
53}
54
55fn default_cluster_read_mode() -> SqlReadMode {
56 SqlReadMode::Inherit
57}
58
59impl Default for ClusterReadConfig {
60 fn default() -> Self {
61 Self {
62 permitted_read_modes: Vec::new(),
63 default_read_mode: default_cluster_read_mode(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
69#[serde(rename_all = "lowercase")]
70pub enum AuthType {
71 #[default]
72 None,
73 Token,
74 Basic,
75 MTls,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct LocalConfig {
80 pub path: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ServerConfig {
85 pub url: String,
86 #[serde(default)]
87 pub insecure: bool,
88 #[serde(default)]
89 pub auth: Option<AuthType>,
90 #[serde(default)]
91 pub token: Option<String>,
92 #[serde(default)]
93 pub username: Option<String>,
94 #[serde(default)]
95 pub password_command: Option<String>,
96 #[serde(default)]
97 pub cert_path: Option<PathBuf>,
98 #[serde(default)]
99 pub key_path: Option<PathBuf>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Profile {
104 #[serde(default)]
105 pub connection_type: ConnectionType,
106 #[serde(default)]
107 pub local: Option<LocalConfig>,
108 #[serde(default)]
109 pub server: Option<ServerConfig>,
110 #[serde(default)]
111 pub data_dir: Option<String>,
112 #[serde(default)]
113 pub execution_scope: ExecutionScope,
114 #[serde(default)]
115 pub cluster_read: Option<ClusterReadConfig>,
116}
117
118impl Profile {
119 fn normalized(&self) -> Self {
120 let mut profile = self.clone();
121 if profile.local.is_none() {
122 if let Some(data_dir) = profile.data_dir.clone() {
123 profile.local = Some(LocalConfig { path: data_dir });
124 }
125 }
126 if profile.connection_type == ConnectionType::Local
127 && profile.local.is_none()
128 && profile.server.is_some()
129 {
130 profile.connection_type = ConnectionType::Server;
131 }
132 profile
133 }
134
135 pub fn local_path(&self) -> Option<String> {
136 self.local
137 .as_ref()
138 .map(|local| local.path.clone())
139 .or_else(|| self.data_dir.clone())
140 }
141}
142
143#[derive(Debug, Clone)]
144pub struct ResolvedConfig {
145 pub data_dir: Option<String>,
146 pub in_memory: bool,
147 #[allow(dead_code)]
148 pub profile_name: Option<String>,
149 pub connection_type: ConnectionType,
150 #[allow(dead_code)]
151 pub server: Option<ServerConfig>,
152 #[allow(dead_code)]
153 pub fallback_local: Option<String>,
154 pub execution_scope: ExecutionScope,
157 pub cluster_read: Option<ClusterReadConfig>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum ResolvedSqlReadMode {
166 Local,
167 Cluster(SqlReadMode),
168}
169
170impl ResolvedConfig {
171 pub fn resolve_sql_read_mode(
172 &self,
173 requested: Option<SqlReadMode>,
174 ) -> Result<ResolvedSqlReadMode> {
175 match self.execution_scope {
176 ExecutionScope::Local => match requested.unwrap_or(SqlReadMode::Local) {
177 SqlReadMode::Local => Ok(ResolvedSqlReadMode::Local),
178 mode => Err(CliError::InvalidArgument(format!(
179 "read mode '{}' requires an explicit cluster profile",
180 read_mode_name(mode)
181 ))),
182 },
183 ExecutionScope::Cluster => {
184 let cluster_read = self.cluster_read.as_ref().ok_or_else(|| {
185 CliError::InvalidArgument(
186 "cluster profile requires a [cluster_read] configuration".into(),
187 )
188 })?;
189 let requested = requested.unwrap_or(SqlReadMode::Inherit);
190 if requested == SqlReadMode::Local {
191 return Err(CliError::InvalidArgument(
192 "local_not_permitted_for_cluster_profile".into(),
193 ));
194 }
195 let candidate = if requested == SqlReadMode::Inherit {
196 cluster_read.default_read_mode
197 } else {
198 if !cluster_read.permitted_read_modes.contains(&requested) {
199 return Err(CliError::InvalidArgument(format!(
200 "read_mode_not_permitted: '{}' is not permitted by the cluster profile",
201 read_mode_name(requested)
202 )));
203 }
204 requested
205 };
206 if candidate == SqlReadMode::Local {
207 return Err(CliError::InvalidArgument(
208 "cluster profile default_read_mode cannot be local".into(),
209 ));
210 }
211 Ok(ResolvedSqlReadMode::Cluster(candidate))
212 }
213 }
214 }
215}
216
217fn read_mode_name(mode: SqlReadMode) -> &'static str {
218 match mode {
219 SqlReadMode::Local => "local",
220 SqlReadMode::Inherit => "inherit",
221 SqlReadMode::Strong => "strong",
222 SqlReadMode::Stale => "stale",
223 }
224}
225
226#[derive(Debug)]
227pub struct ProfileManager {
228 config_path: PathBuf,
229 profiles: HashMap<String, Profile>,
230 default_profile: Option<String>,
231}
232
233impl ProfileManager {
234 pub fn load() -> Result<Self> {
235 let config_path = default_config_path()?;
236 Self::load_from_path(config_path)
237 }
238
239 pub fn load_from_path(config_path: PathBuf) -> Result<Self> {
240 if config_path.exists() {
241 validate_config_permissions(&config_path)?;
242 }
243
244 let config = if config_path.exists() {
245 let contents = fs::read_to_string(&config_path)?;
246 if contents.trim().is_empty() {
247 ProfileConfig::default()
248 } else {
249 toml::from_str::<ProfileConfig>(&contents)
250 .map_err(|err| CliError::Parse(err.to_string()))?
251 }
252 } else {
253 ProfileConfig::default()
254 };
255
256 Ok(Self {
257 config_path,
258 profiles: config.profiles,
259 default_profile: config.default_profile,
260 })
261 }
262
263 pub fn save(&self) -> Result<()> {
264 if let Some(parent) = self.config_path.parent() {
265 fs::create_dir_all(parent)?;
266 }
267
268 let config = ProfileConfig {
269 default_profile: self.default_profile.clone(),
270 profiles: self.profiles.clone(),
271 };
272 let serialized =
273 toml::to_string_pretty(&config).map_err(|err| CliError::Parse(err.to_string()))?;
274
275 let mut options = OpenOptions::new();
276 options.write(true).create(true).truncate(true);
277 #[cfg(unix)]
278 {
279 options.mode(0o600);
280 }
281 let mut file = options.open(&self.config_path)?;
282 file.write_all(serialized.as_bytes())?;
283 file.flush()?;
284
285 #[cfg(unix)]
286 fs::set_permissions(&self.config_path, fs::Permissions::from_mode(0o600))?;
287
288 Ok(())
289 }
290
291 pub fn create(&mut self, name: &str, profile: Profile) -> Result<()> {
292 self.profiles.insert(name.to_string(), profile);
293 Ok(())
294 }
295
296 pub fn delete(&mut self, name: &str) -> Result<()> {
297 if self.profiles.remove(name).is_none() {
298 return Err(CliError::ProfileNotFound(name.to_string()));
299 }
300
301 if self.default_profile.as_deref() == Some(name) {
302 self.default_profile = None;
303 }
304
305 Ok(())
306 }
307
308 pub fn get(&self, name: &str) -> Option<&Profile> {
309 self.profiles.get(name)
310 }
311
312 pub fn list(&self) -> Vec<&str> {
313 let mut names: Vec<&str> = self.profiles.keys().map(|name| name.as_str()).collect();
314 names.sort_unstable();
315 names
316 }
317
318 pub fn set_default(&mut self, name: &str) -> Result<()> {
319 if !self.profiles.contains_key(name) {
320 return Err(CliError::ProfileNotFound(name.to_string()));
321 }
322
323 self.default_profile = Some(name.to_string());
324 Ok(())
325 }
326
327 pub fn default_profile(&self) -> Option<&str> {
328 self.default_profile.as_deref()
329 }
330
331 pub fn resolve(&self, cli: &Cli) -> Result<ResolvedConfig> {
332 if cli.profile.is_some() && cli.data_dir.is_some() {
333 return Err(CliError::ConflictingOptions);
334 }
335
336 if let Some(profile_name) = cli.profile.as_deref() {
337 let profile = self
338 .profiles
339 .get(profile_name)
340 .ok_or_else(|| CliError::ProfileNotFound(profile_name.to_string()))?
341 .normalized();
342 let mut resolved = resolve_profile(profile, Some(profile_name.to_string()))?;
343 apply_cli_overrides(cli, &mut resolved);
344 return Ok(resolved);
345 }
346
347 if let Some(data_dir) = cli.data_dir.as_ref() {
348 return Ok(ResolvedConfig {
349 data_dir: Some(data_dir.clone()),
350 in_memory: false,
351 profile_name: None,
352 connection_type: ConnectionType::Local,
353 server: None,
354 fallback_local: None,
355 execution_scope: ExecutionScope::Local,
356 cluster_read: None,
357 });
358 }
359
360 if let Some(default_name) = self.default_profile.as_deref() {
361 let profile = self
362 .profiles
363 .get(default_name)
364 .ok_or_else(|| CliError::ProfileNotFound(default_name.to_string()))?
365 .normalized();
366 let mut resolved = resolve_profile(profile, Some(default_name.to_string()))?;
367 apply_cli_overrides(cli, &mut resolved);
368 return Ok(resolved);
369 }
370
371 Ok(ResolvedConfig {
372 data_dir: None,
373 in_memory: true,
374 profile_name: None,
375 connection_type: ConnectionType::Local,
376 server: None,
377 fallback_local: None,
378 execution_scope: ExecutionScope::Local,
379 cluster_read: None,
380 })
381 }
382}
383
384fn apply_cli_overrides(cli: &Cli, resolved: &mut ResolvedConfig) {
385 if cli.insecure {
386 if let Some(server) = resolved.server.as_mut() {
387 server.insecure = true;
388 }
389 }
390}
391
392fn resolve_profile(profile: Profile, profile_name: Option<String>) -> Result<ResolvedConfig> {
393 if profile.execution_scope == ExecutionScope::Cluster {
394 if profile.connection_type != ConnectionType::Server {
395 return Err(CliError::InvalidArgument(
396 "cluster profile requires connection_type = 'server'".into(),
397 ));
398 }
399 if profile.cluster_read.is_none() {
400 return Err(CliError::InvalidArgument(
401 "cluster profile requires a [cluster_read] configuration".into(),
402 ));
403 }
404 }
405 match profile.connection_type {
406 ConnectionType::Local => {
407 let local_path = profile.local_path().ok_or_else(|| {
408 CliError::InvalidArgument("Local profile requires a data directory".to_string())
409 })?;
410 Ok(ResolvedConfig {
411 data_dir: Some(local_path),
412 in_memory: false,
413 profile_name,
414 connection_type: ConnectionType::Local,
415 server: None,
416 fallback_local: None,
417 execution_scope: ExecutionScope::Local,
418 cluster_read: None,
419 })
420 }
421 ConnectionType::Server => {
422 let execution_scope = profile.execution_scope;
423 let cluster_read = profile.cluster_read.clone();
424 let fallback_local = profile.local_path();
425 let server = profile.server.ok_or_else(|| {
426 CliError::InvalidArgument(
427 "Server profile requires a server configuration".to_string(),
428 )
429 })?;
430 let fallback_local = (execution_scope == ExecutionScope::Local)
431 .then_some(fallback_local)
432 .flatten();
433 Ok(ResolvedConfig {
434 data_dir: fallback_local.clone(),
435 in_memory: false,
436 profile_name,
437 connection_type: ConnectionType::Server,
438 server: Some(server),
439 fallback_local,
440 execution_scope,
441 cluster_read,
442 })
443 }
444 }
445}
446
447fn default_config_path() -> Result<PathBuf> {
448 let home = dirs::home_dir().ok_or_else(|| {
449 CliError::InvalidArgument("Home directory could not be determined".to_string())
450 })?;
451 Ok(home.join(CONFIG_DIR).join(CONFIG_FILE))
452}
453
454#[cfg(unix)]
455fn validate_config_permissions(path: &PathBuf) -> Result<()> {
456 let metadata = fs::metadata(path)?;
457 let mode = metadata.permissions().mode() & 0o777;
458 if mode != 0o600 {
459 return Err(CliError::InvalidArgument(format!(
460 "Config file permissions must be 600: {}",
461 path.display()
462 )));
463 }
464 Ok(())
465}
466
467#[cfg(not(unix))]
468fn validate_config_permissions(_path: &PathBuf) -> Result<()> {
469 Ok(())
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 fn server_profile(
477 execution_scope: ExecutionScope,
478 cluster_read: Option<ClusterReadConfig>,
479 ) -> Profile {
480 Profile {
481 connection_type: ConnectionType::Server,
482 local: Some(LocalConfig {
483 path: "/tmp/local-fallback".into(),
484 }),
485 server: Some(ServerConfig {
486 url: "https://cluster.example.test".into(),
487 insecure: false,
488 auth: None,
489 token: None,
490 username: None,
491 password_command: None,
492 cert_path: None,
493 key_path: None,
494 }),
495 data_dir: None,
496 execution_scope,
497 cluster_read,
498 }
499 }
500
501 #[test]
502 fn legacy_server_profile_remains_local_and_allows_legacy_fallback() {
503 let resolved = resolve_profile(
504 server_profile(ExecutionScope::Local, None),
505 Some("legacy".into()),
506 )
507 .unwrap();
508 assert_eq!(resolved.execution_scope, ExecutionScope::Local);
509 assert_eq!(
510 resolved.fallback_local.as_deref(),
511 Some("/tmp/local-fallback")
512 );
513 assert_eq!(
514 resolved.resolve_sql_read_mode(None).unwrap(),
515 ResolvedSqlReadMode::Local
516 );
517 assert!(matches!(
518 resolved.resolve_sql_read_mode(Some(SqlReadMode::Strong)),
519 Err(CliError::InvalidArgument(message)) if message.contains("explicit cluster profile")
520 ));
521 }
522
523 #[test]
524 fn explicit_cluster_profile_resolves_permitted_overrides_without_local_fallback() {
525 let resolved = resolve_profile(
526 server_profile(
527 ExecutionScope::Cluster,
528 Some(ClusterReadConfig {
529 permitted_read_modes: vec![SqlReadMode::Strong, SqlReadMode::Stale],
530 default_read_mode: SqlReadMode::Strong,
531 }),
532 ),
533 Some("cluster".into()),
534 )
535 .unwrap();
536 assert_eq!(resolved.fallback_local, None);
537 assert_eq!(
538 resolved.resolve_sql_read_mode(None).unwrap(),
539 ResolvedSqlReadMode::Cluster(SqlReadMode::Strong)
540 );
541 assert_eq!(
542 resolved
543 .resolve_sql_read_mode(Some(SqlReadMode::Stale))
544 .unwrap(),
545 ResolvedSqlReadMode::Cluster(SqlReadMode::Stale)
546 );
547 assert!(matches!(
548 resolved.resolve_sql_read_mode(Some(SqlReadMode::Local)),
549 Err(CliError::InvalidArgument(message)) if message == "local_not_permitted_for_cluster_profile"
550 ));
551 assert!(matches!(
552 resolved.resolve_sql_read_mode(Some(SqlReadMode::Strong)),
553 Ok(ResolvedSqlReadMode::Cluster(SqlReadMode::Strong))
554 ));
555 }
556
557 #[test]
558 fn cluster_profile_requires_cluster_read_configuration() {
559 assert!(matches!(
560 resolve_profile(server_profile(ExecutionScope::Cluster, None), None),
561 Err(CliError::InvalidArgument(message)) if message.contains("cluster_read")
562 ));
563 }
564}