openstack_cli/
config.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12//
13// SPDX-License-Identifier: Apache-2.0
14//! OpenStackClient configuration
15//!
16//! It is possible to configure different aspects of the OpenStackClient (not the clouds connection
17//! credentials) using the configuration file (`$XDG_CONFIG_DIR/osc/config.yaml`). This enables
18//! user to configurate which columns should be returned when no corresponding run time arguments
19//! on a resource base.
20//!
21//! ```yaml
22//! views:
23//!   compute.server:
24//!     # Listing compute servers will only return ID, NAME and IMAGE columns unless `-o wide` or
25//!     `-f XXX` parameters are being passed
26//!     fields: [id, name, image]
27//!   dns.zone/recordset:
28//!     # DNS zone recordsets are listed in the wide mode by default.
29//!     wide: true
30//! ```
31
32use eyre::Result;
33use serde::Deserialize;
34use std::{
35    collections::HashMap,
36    fmt,
37    path::{Path, PathBuf},
38};
39use thiserror::Error;
40use tracing::error;
41
42const CONFIG: &str = include_str!("../.config/config.yaml");
43
44/// Errors which may occur when dealing with OpenStack connection
45/// configuration data.
46#[derive(Debug, Error)]
47#[non_exhaustive]
48pub enum ConfigError {
49    /// Parsing error
50    #[error("failed to parse config: {}", source)]
51    Parse {
52        /// The source of the error.
53        #[from]
54        source: config::ConfigError,
55    },
56    /// Parsing error
57    #[error("failed to parse config: {}", source)]
58    Builder {
59        /// The source of the error.
60        #[from]
61        source: ConfigBuilderError,
62    },
63}
64
65impl ConfigError {
66    /// Build a `[ConfigError::Parse]` error from `[ConfigError]`
67    pub fn parse(source: config::ConfigError) -> Self {
68        ConfigError::Parse { source }
69    }
70    /// Build a `[ConfigError::Builder]` error from `[ConfigBuilderError]`
71    pub fn builder(source: ConfigBuilderError) -> Self {
72        ConfigError::Builder { source }
73    }
74}
75
76/// Errors which may occur when adding sources to the [`ConfigBuilder`].
77#[derive(Error)]
78#[non_exhaustive]
79pub enum ConfigBuilderError {
80    /// File parsing error
81    #[error("failed to parse file {path:?}: {source}")]
82    FileParse {
83        /// Error source
84        source: Box<config::ConfigError>,
85        /// Builder object
86        builder: ConfigBuilder,
87        /// Error file path
88        path: PathBuf,
89    },
90    /// Config file deserialization error
91    #[error("failed to deserialize config {path:?}: {source}")]
92    ConfigDeserialize {
93        /// Error source
94        source: Box<config::ConfigError>,
95        /// Builder object
96        builder: ConfigBuilder,
97        /// Error file path
98        path: PathBuf,
99    },
100}
101///
102/// Output configuration
103///
104/// This structure is controlling how the table table is being built for a structure.
105#[derive(Clone, Debug, Default, Deserialize)]
106pub struct ViewConfig {
107    /// Limit fields (their titles) to be returned
108    #[serde(default)]
109    pub default_fields: Vec<String>,
110    /// Fields configurations
111    #[serde(default)]
112    pub fields: Vec<FieldConfig>,
113    /// Defaults to wide mode
114    #[serde(default)]
115    pub wide: Option<bool>,
116}
117
118/// Field output configuration
119#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialOrd, PartialEq)]
120pub struct FieldConfig {
121    /// Attribute name
122    pub name: String,
123    /// Fixed width of the column
124    #[serde(default)]
125    pub width: Option<usize>,
126    /// Min width of the column
127    #[serde(default)]
128    pub min_width: Option<usize>,
129    /// Max width of the column
130    #[serde(default)]
131    pub max_width: Option<usize>,
132    /// [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901) to extract data from the
133    /// field
134    #[serde(default)]
135    pub json_pointer: Option<String>,
136}
137
138const fn _default_true() -> bool {
139    true
140}
141
142/// OpenStackClient configuration
143#[derive(Clone, Debug, Default, Deserialize)]
144pub struct Config {
145    /// Map of views with the key being the resource key `<SERVICE_TYPE>.<RESOURCE>[/<SUBRESOURCE>]`)
146    /// and the value being an `[OutputConfig]`
147    #[serde(default)]
148    pub views: HashMap<String, ViewConfig>,
149    /// List of CLI hints per resource
150    #[serde(default)]
151    pub command_hints: HashMap<String, HashMap<String, Vec<String>>>,
152    /// General hints for the CLI to be used independent on the command
153    #[serde(default)]
154    pub hints: Vec<String>,
155    /// Enable/disable show the hints after successful command execution. Enabled by default
156    #[serde(default = "_default_true")]
157    pub enable_hints: bool,
158}
159
160/// A builder to create a [`ConfigFile`] by specifying which files to load.
161pub struct ConfigBuilder {
162    /// Config source files
163    sources: Vec<config::Config>,
164}
165
166impl ConfigBuilder {
167    /// Add a source to the builder. This will directly parse the config and check if it is valid.
168    /// Values of sources added first will be overridden by later added sources, if the keys match.
169    /// In other words, the sources will be merged, with the later taking precedence over the
170    /// earlier ones.
171    pub fn add_source(mut self, source: impl AsRef<Path>) -> Result<Self, ConfigBuilderError> {
172        let config = match config::Config::builder()
173            .add_source(config::File::from(source.as_ref()))
174            .build()
175        {
176            Ok(config) => config,
177            Err(error) => {
178                return Err(ConfigBuilderError::FileParse {
179                    source: Box::new(error),
180                    builder: self,
181                    path: source.as_ref().to_owned(),
182                });
183            }
184        };
185
186        if let Err(error) = config.clone().try_deserialize::<Config>() {
187            return Err(ConfigBuilderError::ConfigDeserialize {
188                source: Box::new(error),
189                builder: self,
190                path: source.as_ref().to_owned(),
191            });
192        }
193
194        self.sources.push(config);
195        Ok(self)
196    }
197
198    /// This will build a [`ConfigFile`] with the previously specified sources. Since
199    /// the sources have already been checked on errors, this will not fail.
200    pub fn build(self) -> Result<Config, ConfigError> {
201        let mut config = config::Config::builder();
202
203        for source in self.sources {
204            config = config.add_source(source);
205        }
206
207        Ok(config.build()?.try_deserialize::<Config>()?)
208    }
209}
210
211impl fmt::Debug for ConfigBuilderError {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        match self {
214            ConfigBuilderError::FileParse { source, path, .. } => f
215                .debug_struct("FileParse")
216                .field("source", source)
217                .field("path", path)
218                .finish_non_exhaustive(),
219            ConfigBuilderError::ConfigDeserialize { source, path, .. } => f
220                .debug_struct("ConfigDeserialize")
221                .field("source", source)
222                .field("path", path)
223                .finish_non_exhaustive(),
224        }
225    }
226}
227
228impl Config {
229    /// Get the config builder
230    pub fn builder() -> ConfigBuilder {
231        let default_config: config::Config = config::Config::builder()
232            .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
233            .build()
234            .expect("default config must be valid");
235
236        ConfigBuilder {
237            sources: Vec::from([default_config]),
238        }
239    }
240
241    /// Instantiate new config reading default config updating it with local configuration
242    pub fn new() -> Result<Self, ConfigError> {
243        let default_config: config::Config = config::Config::builder()
244            .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
245            .build()?;
246
247        let config_dir = get_config_dir();
248        let mut builder = ConfigBuilder {
249            sources: Vec::from([default_config]),
250        };
251
252        let config_files = [
253            ("config.yaml", config::FileFormat::Yaml),
254            ("views.yaml", config::FileFormat::Yaml),
255        ];
256        let mut found_config = false;
257        for (file, _format) in &config_files {
258            if config_dir.join(file).exists() {
259                found_config = true;
260
261                builder = match builder.add_source(config_dir.join(file)) {
262                    Ok(builder) => builder,
263                    Err(ConfigBuilderError::FileParse { source, .. }) => {
264                        return Err(ConfigError::parse(*source));
265                    }
266                    Err(ConfigBuilderError::ConfigDeserialize {
267                        source,
268                        builder,
269                        path,
270                    }) => {
271                        error!(
272                            "The file {path:?} could not be deserialized and will be ignored: {source}"
273                        );
274                        builder
275                    }
276                }
277            }
278        }
279        if !found_config {
280            tracing::error!("No configuration file found. Application may not behave as expected");
281        }
282
283        builder.build()
284    }
285}
286
287fn get_config_dir() -> PathBuf {
288    dirs::config_dir()
289        .expect("Cannot determine users XDG_CONFIG_HOME")
290        .join("osc")
291}
292
293impl fmt::Display for Config {
294    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
295        write!(f, "")
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use std::io::Write;
303    use tempfile::Builder;
304
305    #[test]
306    fn test_parse_config() {
307        let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap();
308
309        const CONFIG_DATA: &str = r#"
310            views:
311              foo:
312                default_fields: ["a", "b", "c"]
313              bar:
314                fields:
315                  - name: "b"
316                    min_width: 1
317            command_hints:
318              res:
319                cmd:
320                  - hint1
321                  - hint2
322            hints:
323              - hint1
324              - hint2
325            enable_hints: true
326        "#;
327
328        write!(config_file, "{CONFIG_DATA}").unwrap();
329
330        let _cfg = Config::builder()
331            .add_source(config_file.path())
332            .unwrap()
333            .build();
334    }
335}