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, 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/// Declares whether a profile is allowed to request a distributed read. A
34/// server connection alone remains a legacy/local profile until this is set to
35/// `cluster`, preventing accidental remote routing after an upgrade.
36#[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/// Cluster-only read-mode policy configured by the profile owner. The server
45/// still checks committed cluster policy; this object only determines which
46/// client-side overrides are eligible to be sent to that server.
47#[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    /// Retained in the resolved configuration so SQL execution can reject an
155    /// invalid mode before opening either local storage or a server request.
156    pub execution_scope: ExecutionScope,
157    pub cluster_read: Option<ClusterReadConfig>,
158}
159
160/// The deterministic profile-side result of resolving `SqlCommand` read mode.
161/// A cluster candidate must still be accepted by the server's committed read
162/// policy; callers must not reinterpret a rejection as permission to run
163/// locally.
164#[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}