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.toml";
16
17#[derive(Debug, Serialize, Deserialize, Default)]
18pub struct ProfileConfig {
19    pub default: Option<String>,
20    #[serde(default)]
21    pub profiles: HashMap<String, Profile>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Profile {
26    pub data_dir: String,
27}
28
29#[derive(Debug)]
30pub struct ResolvedConfig {
31    pub data_dir: Option<String>,
32    pub in_memory: bool,
33    #[allow(dead_code)]
34    pub profile_name: Option<String>,
35}
36
37#[derive(Debug)]
38pub struct ProfileManager {
39    config_path: PathBuf,
40    profiles: HashMap<String, Profile>,
41    default_profile: Option<String>,
42}
43
44impl ProfileManager {
45    pub fn load() -> Result<Self> {
46        let config_path = default_config_path()?;
47        Self::load_from_path(config_path)
48    }
49
50    pub fn load_from_path(config_path: PathBuf) -> Result<Self> {
51        let config = if config_path.exists() {
52            let contents = fs::read_to_string(&config_path)?;
53            if contents.trim().is_empty() {
54                ProfileConfig::default()
55            } else {
56                toml::from_str::<ProfileConfig>(&contents)
57                    .map_err(|err| CliError::Parse(err.to_string()))?
58            }
59        } else {
60            ProfileConfig::default()
61        };
62
63        Ok(Self {
64            config_path,
65            profiles: config.profiles,
66            default_profile: config.default,
67        })
68    }
69
70    pub fn save(&self) -> Result<()> {
71        if let Some(parent) = self.config_path.parent() {
72            fs::create_dir_all(parent)?;
73        }
74
75        let config = ProfileConfig {
76            default: self.default_profile.clone(),
77            profiles: self.profiles.clone(),
78        };
79        let serialized =
80            toml::to_string_pretty(&config).map_err(|err| CliError::Parse(err.to_string()))?;
81
82        let mut options = OpenOptions::new();
83        options.write(true).create(true).truncate(true);
84        #[cfg(unix)]
85        {
86            options.mode(0o600);
87        }
88        let mut file = options.open(&self.config_path)?;
89        file.write_all(serialized.as_bytes())?;
90        file.flush()?;
91
92        #[cfg(unix)]
93        fs::set_permissions(&self.config_path, fs::Permissions::from_mode(0o600))?;
94
95        Ok(())
96    }
97
98    pub fn create(&mut self, name: &str, profile: Profile) -> Result<()> {
99        self.profiles.insert(name.to_string(), profile);
100        Ok(())
101    }
102
103    pub fn delete(&mut self, name: &str) -> Result<()> {
104        if self.profiles.remove(name).is_none() {
105            return Err(CliError::ProfileNotFound(name.to_string()));
106        }
107
108        if self.default_profile.as_deref() == Some(name) {
109            self.default_profile = None;
110        }
111
112        Ok(())
113    }
114
115    pub fn get(&self, name: &str) -> Option<&Profile> {
116        self.profiles.get(name)
117    }
118
119    pub fn list(&self) -> Vec<&str> {
120        let mut names: Vec<&str> = self.profiles.keys().map(|name| name.as_str()).collect();
121        names.sort_unstable();
122        names
123    }
124
125    pub fn set_default(&mut self, name: &str) -> Result<()> {
126        if !self.profiles.contains_key(name) {
127            return Err(CliError::ProfileNotFound(name.to_string()));
128        }
129
130        self.default_profile = Some(name.to_string());
131        Ok(())
132    }
133
134    pub fn default_profile(&self) -> Option<&str> {
135        self.default_profile.as_deref()
136    }
137
138    pub fn resolve(&self, cli: &Cli) -> Result<ResolvedConfig> {
139        if cli.profile.is_some() && cli.data_dir.is_some() {
140            return Err(CliError::ConflictingOptions);
141        }
142
143        if let Some(profile_name) = cli.profile.as_deref() {
144            let profile = self
145                .profiles
146                .get(profile_name)
147                .ok_or_else(|| CliError::ProfileNotFound(profile_name.to_string()))?;
148            return Ok(ResolvedConfig {
149                data_dir: Some(profile.data_dir.clone()),
150                in_memory: false,
151                profile_name: Some(profile_name.to_string()),
152            });
153        }
154
155        if let Some(data_dir) = cli.data_dir.as_ref() {
156            return Ok(ResolvedConfig {
157                data_dir: Some(data_dir.clone()),
158                in_memory: false,
159                profile_name: None,
160            });
161        }
162
163        if let Some(default_name) = self.default_profile.as_deref() {
164            let profile = self
165                .profiles
166                .get(default_name)
167                .ok_or_else(|| CliError::ProfileNotFound(default_name.to_string()))?;
168            return Ok(ResolvedConfig {
169                data_dir: Some(profile.data_dir.clone()),
170                in_memory: false,
171                profile_name: Some(default_name.to_string()),
172            });
173        }
174
175        Ok(ResolvedConfig {
176            data_dir: None,
177            in_memory: true,
178            profile_name: None,
179        })
180    }
181}
182
183fn default_config_path() -> Result<PathBuf> {
184    let home = dirs::home_dir().ok_or_else(|| {
185        CliError::InvalidArgument("Home directory could not be determined".to_string())
186    })?;
187    Ok(home.join(CONFIG_DIR).join(CONFIG_FILE))
188}