Skip to main content

openstack_cli_core/
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 configure 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
57    /// Config dir cannot be identified.
58    #[error("config dir cannot be identified")]
59    ConfigDirCannotBeIdentified,
60
61    /// Parsing error.
62    #[error("failed to parse config: {}", source)]
63    Builder {
64        /// The source of the error.
65        #[from]
66        source: ConfigBuilderError,
67    },
68}
69
70impl ConfigError {
71    /// Build a `[ConfigError::Parse]` error from `[ConfigError]`
72    pub fn parse(source: config::ConfigError) -> Self {
73        ConfigError::Parse { source }
74    }
75    /// Build a `[ConfigError::Builder]` error from `[ConfigBuilderError]`
76    pub fn builder(source: ConfigBuilderError) -> Self {
77        ConfigError::Builder { source }
78    }
79}
80
81/// Errors which may occur when adding sources to the [`ConfigBuilder`].
82#[derive(Error)]
83#[non_exhaustive]
84pub enum ConfigBuilderError {
85    /// File parsing error
86    #[error("failed to parse file {path:?}: {source}")]
87    FileParse {
88        /// Error source
89        source: Box<config::ConfigError>,
90        /// Builder object
91        builder: ConfigBuilder,
92        /// Error file path
93        path: PathBuf,
94    },
95    /// Config file deserialization error
96    #[error("failed to deserialize config {path:?}: {source}")]
97    ConfigDeserialize {
98        /// Error source
99        source: Box<config::ConfigError>,
100        /// Builder object
101        builder: ConfigBuilder,
102        /// Error file path
103        path: PathBuf,
104    },
105}
106///
107/// Output configuration
108///
109/// This structure is controlling how the table table is being built for a structure.
110#[derive(Clone, Debug, Default, Deserialize)]
111pub struct ViewConfig {
112    /// Limit fields (their titles) to be returned
113    #[serde(default)]
114    pub default_fields: Vec<String>,
115    /// Fields configurations
116    #[serde(default)]
117    pub fields: Vec<FieldConfig>,
118    /// Defaults to wide mode
119    #[serde(default)]
120    pub wide: Option<bool>,
121}
122
123/// Field output configuration
124#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialOrd, PartialEq)]
125pub struct FieldConfig {
126    /// Attribute name
127    pub name: String,
128    /// Fixed width of the column
129    #[serde(default)]
130    pub width: Option<usize>,
131    /// Min width of the column
132    #[serde(default)]
133    pub min_width: Option<usize>,
134    /// Max width of the column
135    #[serde(default)]
136    pub max_width: Option<usize>,
137    /// [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901) to extract data from the
138    /// field
139    #[serde(default)]
140    pub json_pointer: Option<String>,
141    /// Fallback [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901) used when
142    /// `json_pointer` does not resolve to a value (e.g. the field is not present at the
143    /// currently negotiated microversion).
144    #[serde(default)]
145    pub json_pointer_fallback: Option<String>,
146}
147
148const fn _default_true() -> bool {
149    true
150}
151
152/// OpenStackClient configuration
153#[derive(Clone, Debug, Default, Deserialize)]
154pub struct Config {
155    /// Map of views with the key being the resource key `<SERVICE_TYPE>.<RESOURCE>[/<SUBRESOURCE>]`)
156    /// and the value being an `[OutputConfig]`
157    #[serde(default)]
158    pub views: HashMap<String, ViewConfig>,
159    /// List of CLI hints per resource
160    #[serde(default)]
161    pub command_hints: HashMap<String, HashMap<String, Vec<String>>>,
162    /// General hints for the CLI to be used independent on the command
163    #[serde(default)]
164    pub hints: Vec<String>,
165    /// Enable/disable show the hints after successful command execution. Enabled by default
166    #[serde(default = "_default_true")]
167    pub enable_hints: bool,
168}
169
170/// A builder to create a [`ConfigFile`] by specifying which files to load.
171pub struct ConfigBuilder {
172    /// Config source files
173    sources: Vec<config::Config>,
174}
175
176impl ConfigBuilder {
177    /// Add a source to the builder. This will directly parse the config and check if it is valid.
178    /// Values of sources added first will be overridden by later added sources, if the keys match.
179    /// In other words, the sources will be merged, with the later taking precedence over the
180    /// earlier ones.
181    pub fn add_source(mut self, source: impl AsRef<Path>) -> Result<Self, ConfigBuilderError> {
182        let config = match config::Config::builder()
183            .add_source(config::File::from(source.as_ref()))
184            .build()
185        {
186            Ok(config) => config,
187            Err(error) => {
188                return Err(ConfigBuilderError::FileParse {
189                    source: Box::new(error),
190                    builder: self,
191                    path: source.as_ref().to_owned(),
192                });
193            }
194        };
195
196        if let Err(error) = config.clone().try_deserialize::<Config>() {
197            return Err(ConfigBuilderError::ConfigDeserialize {
198                source: Box::new(error),
199                builder: self,
200                path: source.as_ref().to_owned(),
201            });
202        }
203
204        self.sources.push(config);
205        Ok(self)
206    }
207
208    /// This will build a [`ConfigFile`] with the previously specified sources. Since
209    /// the sources have already been checked on errors, this will not fail.
210    pub fn build(self) -> Result<Config, ConfigError> {
211        let mut config = config::Config::builder();
212
213        for source in self.sources {
214            config = config.add_source(source);
215        }
216
217        Ok(config.build()?.try_deserialize::<Config>()?)
218    }
219}
220
221impl fmt::Debug for ConfigBuilderError {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        match self {
224            ConfigBuilderError::FileParse { source, path, .. } => f
225                .debug_struct("FileParse")
226                .field("source", source)
227                .field("path", path)
228                .finish_non_exhaustive(),
229            ConfigBuilderError::ConfigDeserialize { source, path, .. } => f
230                .debug_struct("ConfigDeserialize")
231                .field("source", source)
232                .field("path", path)
233                .finish_non_exhaustive(),
234        }
235    }
236}
237
238impl Config {
239    /// Get the config builder
240    pub fn builder() -> Result<ConfigBuilder, ConfigError> {
241        let default_config: config::Config = config::Config::builder()
242            .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
243            .build()?;
244
245        Ok(ConfigBuilder {
246            sources: Vec::from([default_config]),
247        })
248    }
249
250    /// Instantiate new config reading default config updating it with local configuration
251    pub fn new() -> Result<Self, ConfigError> {
252        let default_config: config::Config = config::Config::builder()
253            .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
254            .build()?;
255
256        let config_dir =
257            get_config_dir().ok_or_else(|| ConfigError::ConfigDirCannotBeIdentified)?;
258        let mut builder = ConfigBuilder {
259            sources: Vec::from([default_config]),
260        };
261
262        let config_files = [
263            ("config.yaml", config::FileFormat::Yaml),
264            ("views.yaml", config::FileFormat::Yaml),
265        ];
266        let mut found_config = false;
267        for (file, _format) in &config_files {
268            if config_dir.join(file).exists() {
269                found_config = true;
270
271                builder = match builder.add_source(config_dir.join(file)) {
272                    Ok(builder) => builder,
273                    Err(ConfigBuilderError::FileParse { source, .. }) => {
274                        return Err(ConfigError::parse(*source));
275                    }
276                    Err(ConfigBuilderError::ConfigDeserialize {
277                        source,
278                        builder,
279                        path,
280                    }) => {
281                        error!(
282                            "The file {path:?} could not be deserialized and will be ignored: {source}"
283                        );
284                        builder
285                    }
286                }
287            }
288        }
289        if !found_config {
290            tracing::error!("No configuration file found. Application may not behave as expected");
291        }
292
293        builder.build()
294    }
295}
296
297fn get_config_dir() -> Option<PathBuf> {
298    dirs::config_dir().map(|val| val.join("osc"))
299}
300
301impl fmt::Display for Config {
302    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
303        write!(f, "")
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use std::io::Write;
311    use tempfile::Builder;
312
313    #[test]
314    fn test_parse_config() {
315        let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap();
316
317        const CONFIG_DATA: &str = r#"
318            views:
319              foo:
320                default_fields: ["a", "b", "c"]
321              bar:
322                fields:
323                  - name: "b"
324                    min_width: 1
325            command_hints:
326              res:
327                cmd:
328                  - hint1
329                  - hint2
330            hints:
331              - hint1
332              - hint2
333            enable_hints: true
334        "#;
335
336        write!(config_file, "{CONFIG_DATA}").unwrap();
337
338        let _cfg = Config::builder()
339            .unwrap()
340            .add_source(config_file.path())
341            .unwrap()
342            .build();
343    }
344}