gcloud_ctx/
configuration.rs

1use crate::{properties::Properties, Error, Result};
2use fs::File;
3use lazy_static::lazy_static;
4use regex::Regex;
5use std::{cmp::Ordering, collections::HashMap, fs, io::BufReader, path::PathBuf};
6
7lazy_static! {
8    static ref NAME_REGEX: Regex = Regex::new("^[a-z][-a-z0-9]*$").unwrap();
9}
10
11#[derive(Debug, Clone)]
12/// Represents a gcloud named configuration
13pub struct Configuration {
14    /// Name of the configuration
15    name: String,
16
17    /// Path to the configuration file
18    path: PathBuf,
19}
20
21impl Configuration {
22    /// Name of the configuration
23    pub fn name(&self) -> &str {
24        &self.name
25    }
26
27    /// Is the given name a valid configuration name?
28    ///
29    /// Names must start with a lowercase ASCII character
30    /// then zero or more ASCII alphanumerics and hyphens
31    pub fn is_valid_name(name: &str) -> bool {
32        NAME_REGEX.is_match(name)
33    }
34}
35
36impl Ord for Configuration {
37    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
38        self.name.cmp(&other.name)
39    }
40}
41
42impl PartialOrd for Configuration {
43    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
44        Some(self.cmp(other))
45    }
46}
47
48impl PartialEq for Configuration {
49    fn eq(&self, other: &Self) -> bool {
50        self.name == other.name
51    }
52}
53
54impl Eq for Configuration {}
55
56/// Action to perform when a naming conflict occurs
57#[derive(Copy, Clone, Debug, PartialEq)]
58pub enum ConflictAction {
59    /// Abort the operation
60    Abort,
61
62    /// Overwrite the existing configuration
63    Overwrite,
64}
65
66impl From<bool> for ConflictAction {
67    fn from(value: bool) -> Self {
68        if value {
69            ConflictAction::Overwrite
70        } else {
71            ConflictAction::Abort
72        }
73    }
74}
75
76#[derive(Debug)]
77/// Represents the store of gcloud configurations
78pub struct ConfigurationStore {
79    /// Location of the configuration store on disk
80    location: PathBuf,
81
82    /// Path to the configurations sub-folder
83    configurations_path: PathBuf,
84
85    /// Available configurations
86    configurations: HashMap<String, Configuration>,
87
88    /// Name of the active configuration
89    active: String,
90}
91
92impl ConfigurationStore {
93    /// Opens the configuration store using the OS-specific defaults
94    ///
95    /// If the `CLOUDSDK_CONFIG` environment variable is set then this will be used, otherwise an
96    /// OS-specific default location will be used, as defined by the [dirs] crate, e.g.:
97    ///
98    /// - Windows: `%APPDATA%\gcloud`
99    /// - Linux: `~/.config/gcloud`
100    /// - Mac: `~/.config/gcloud` - note that this does not follow the Apple Developer Guidelines
101    ///
102    /// [dirs]: https://crates.io/crates/dirs
103    pub fn with_default_location() -> Result<Self> {
104        let gcloud_path: PathBuf = if let Ok(value) = std::env::var("CLOUDSDK_CONFIG") {
105            value.into()
106        } else {
107            let gcloud_path = if cfg!(target_os = "macos") {
108                dirs::home_dir()
109                    .ok_or(Error::ConfigurationDirectoryNotFound)?
110                    .join(".config")
111            } else {
112                dirs::config_dir().ok_or(Error::ConfigurationDirectoryNotFound)?
113            };
114
115            gcloud_path.join("gcloud")
116        };
117
118        Self::with_location(gcloud_path)
119    }
120
121    /// Opens a configuration store at the given path
122    pub fn with_location(gcloud_path: PathBuf) -> Result<Self> {
123        if !gcloud_path.is_dir() {
124            return Err(Error::ConfigurationStoreNotFound(gcloud_path));
125        }
126
127        let configurations_path = gcloud_path.join("configurations");
128
129        if !configurations_path.is_dir() {
130            return Err(Error::ConfigurationStoreNotFound(configurations_path));
131        }
132
133        let mut configurations: HashMap<String, Configuration> = HashMap::new();
134
135        for file in fs::read_dir(&configurations_path)? {
136            if file.is_err() {
137                // ignore files we're unable to read - e.g. permissions errors
138                continue;
139            }
140
141            let file = file.unwrap();
142            let name = file.file_name();
143            let name = match name.to_str() {
144                Some(name) => name,
145                None => continue, // ignore files that aren't valid utf8
146            };
147            let name = name.trim_start_matches("config_");
148
149            if !Configuration::is_valid_name(name) {
150                continue;
151            }
152
153            configurations.insert(
154                name.to_owned(),
155                Configuration {
156                    name: name.to_owned(),
157                    path: file.path(),
158                },
159            );
160        }
161
162        if configurations.is_empty() {
163            return Err(Error::NoConfigurationsFound(configurations_path));
164        }
165
166        let active = gcloud_path.join("active_config");
167        let active = fs::read_to_string(active)?;
168
169        Ok(ConfigurationStore {
170            location: gcloud_path,
171            configurations_path,
172            configurations,
173            active,
174        })
175    }
176
177    /// Get the name of the currently active configuration
178    pub fn active(&self) -> &str {
179        &self.active
180    }
181
182    /// Get the collection of currently available configurations
183    pub fn configurations(&self) -> Vec<&Configuration> {
184        let mut value: Vec<&Configuration> = self.configurations.values().collect();
185        value.sort();
186        value
187    }
188
189    /// Check if the given configuration is active
190    pub fn is_active(&self, configuration: &Configuration) -> bool {
191        configuration.name == self.active
192    }
193
194    /// Activate a configuration by name
195    pub fn activate(&mut self, name: &str) -> Result<()> {
196        let configuration = self
197            .find_by_name(name)
198            .ok_or_else(|| Error::UnknownConfiguration(name.to_owned()))?;
199
200        let path = self.location.join("active_config");
201        std::fs::write(path, &configuration.name)?;
202
203        self.active = configuration.name.to_owned();
204
205        Ok(())
206    }
207
208    /// Copy an existing configuration, preserving all properties
209    pub fn copy(&mut self, src_name: &str, dest_name: &str, conflict: ConflictAction) -> Result<()> {
210        let src = self
211            .configurations
212            .get(src_name)
213            .ok_or_else(|| Error::UnknownConfiguration(src_name.to_owned()))?;
214
215        if !Configuration::is_valid_name(dest_name) {
216            return Err(Error::InvalidName(dest_name.to_owned()));
217        }
218
219        if conflict == ConflictAction::Abort && self.configurations.contains_key(dest_name) {
220            return Err(Error::ExistingConfiguration(dest_name.to_owned()));
221        }
222
223        // just copy the file on disk so that any properties which aren't directly supported are maintained
224        let filename = self.configurations_path.join(format!("config_{}", dest_name));
225        fs::copy(&src.path, &filename)?;
226
227        let dest = Configuration {
228            name: dest_name.to_owned(),
229            path: filename,
230        };
231
232        self.configurations.insert(dest_name.to_owned(), dest);
233
234        Ok(())
235    }
236
237    /// Create a new configuration
238    pub fn create(&mut self, name: &str, properties: &Properties, conflict: ConflictAction) -> Result<()> {
239        if !Configuration::is_valid_name(name) {
240            return Err(Error::InvalidName(name.to_owned()));
241        }
242
243        if conflict == ConflictAction::Abort && self.configurations.contains_key(name) {
244            return Err(Error::ExistingConfiguration(name.to_owned()));
245        }
246
247        let filename = self.configurations_path.join(format!("config_{}", name));
248        let file = File::create(&filename)?;
249        properties.to_writer(file)?;
250
251        self.configurations.insert(
252            name.to_owned(),
253            Configuration {
254                name: name.to_owned(),
255                path: filename,
256            },
257        );
258
259        Ok(())
260    }
261
262    /// Delete a configuration
263    pub fn delete(&mut self, name: &str) -> Result<()> {
264        let configuration = self
265            .find_by_name(name)
266            .ok_or_else(|| Error::UnknownConfiguration(name.to_owned()))?;
267
268        if self.is_active(configuration) {
269            return Err(Error::DeleteActiveConfiguration);
270        }
271
272        let path = &configuration.path;
273        fs::remove_file(&path)?;
274
275        self.configurations.remove(name);
276
277        Ok(())
278    }
279
280    /// Describe the properties in the given configuration
281    pub fn describe(&self, name: &str) -> Result<Properties> {
282        let configuration = self
283            .find_by_name(name)
284            .ok_or_else(|| Error::UnknownConfiguration(name.to_owned()))?;
285
286        let path = &configuration.path;
287        let handle = File::open(path)?;
288        let reader = BufReader::new(handle);
289
290        let properties = Properties::from_reader(reader)?;
291
292        Ok(properties)
293    }
294
295    /// Rename a configuration
296    pub fn rename(&mut self, old_name: &str, new_name: &str, conflict: ConflictAction) -> Result<()> {
297        let src = self
298            .configurations
299            .get(old_name)
300            .ok_or_else(|| Error::UnknownConfiguration(old_name.to_owned()))?;
301
302        let active = self.is_active(src);
303
304        if !Configuration::is_valid_name(new_name) {
305            return Err(Error::InvalidName(new_name.to_owned()));
306        }
307
308        if conflict == ConflictAction::Abort && self.configurations.contains_key(new_name) {
309            return Err(Error::ExistingConfiguration(new_name.to_owned()));
310        }
311
312        let new_value = Configuration {
313            name: new_name.to_owned(),
314            path: src.path.with_file_name(format!("config_{}", new_name)),
315        };
316
317        std::fs::rename(&src.path, &new_value.path)?;
318
319        self.configurations.remove(old_name);
320        self.configurations.insert(new_name.to_owned(), new_value);
321
322        // check if the active configuration is the one being renamed
323        if active {
324            self.activate(new_name)?;
325        }
326
327        Ok(())
328    }
329
330    /// Find a configuration by name
331    pub fn find_by_name(&self, name: &str) -> Option<&Configuration> {
332        self.configurations.get(&name.to_owned())
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    pub fn test_is_valid_name_with_valid_name() {
342        assert!(Configuration::is_valid_name("foo"));
343        assert!(Configuration::is_valid_name("f"));
344        assert!(Configuration::is_valid_name("f123"));
345        assert!(Configuration::is_valid_name("foo-bar"));
346        assert!(Configuration::is_valid_name("foo-123"));
347        assert!(Configuration::is_valid_name("foo-a1b2c3"));
348    }
349
350    #[test]
351    pub fn test_is_valid_name_with_invalid_name() {
352        // too short
353        assert!(!Configuration::is_valid_name(""));
354
355        // doesn't start with lowercase ASCII
356        assert!(!Configuration::is_valid_name("F"));
357        assert!(!Configuration::is_valid_name("1"));
358        assert!(!Configuration::is_valid_name("-"));
359
360        // doesn't contain only alphanumerics and ASCII
361        assert!(!Configuration::is_valid_name("foo_bar"));
362        assert!(!Configuration::is_valid_name("foo.bar"));
363        assert!(!Configuration::is_valid_name("foo|bar"));
364        assert!(!Configuration::is_valid_name("foo$bar"));
365        assert!(!Configuration::is_valid_name("foo#bar"));
366        assert!(!Configuration::is_valid_name("foo@bar"));
367        assert!(!Configuration::is_valid_name("foo;bar"));
368        assert!(!Configuration::is_valid_name("foo?bar"));
369
370        // doesn't contain only lowercase
371        assert!(!Configuration::is_valid_name("camelCase"));
372    }
373}