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}
142
143const fn _default_true() -> bool {
144    true
145}
146
147/// OpenStackClient configuration
148#[derive(Clone, Debug, Default, Deserialize)]
149pub struct Config {
150    /// Map of views with the key being the resource key `<SERVICE_TYPE>.<RESOURCE>[/<SUBRESOURCE>]`)
151    /// and the value being an `[OutputConfig]`
152    #[serde(default)]
153    pub views: HashMap<String, ViewConfig>,
154    /// List of CLI hints per resource
155    #[serde(default)]
156    pub command_hints: HashMap<String, HashMap<String, Vec<String>>>,
157    /// General hints for the CLI to be used independent on the command
158    #[serde(default)]
159    pub hints: Vec<String>,
160    /// Enable/disable show the hints after successful command execution. Enabled by default
161    #[serde(default = "_default_true")]
162    pub enable_hints: bool,
163}
164
165/// A builder to create a [`ConfigFile`] by specifying which files to load.
166pub struct ConfigBuilder {
167    /// Config source files
168    sources: Vec<config::Config>,
169}
170
171impl ConfigBuilder {
172    /// Add a source to the builder. This will directly parse the config and check if it is valid.
173    /// Values of sources added first will be overridden by later added sources, if the keys match.
174    /// In other words, the sources will be merged, with the later taking precedence over the
175    /// earlier ones.
176    pub fn add_source(mut self, source: impl AsRef<Path>) -> Result<Self, ConfigBuilderError> {
177        let config = match config::Config::builder()
178            .add_source(config::File::from(source.as_ref()))
179            .build()
180        {
181            Ok(config) => config,
182            Err(error) => {
183                return Err(ConfigBuilderError::FileParse {
184                    source: Box::new(error),
185                    builder: self,
186                    path: source.as_ref().to_owned(),
187                });
188            }
189        };
190
191        if let Err(error) = config.clone().try_deserialize::<Config>() {
192            return Err(ConfigBuilderError::ConfigDeserialize {
193                source: Box::new(error),
194                builder: self,
195                path: source.as_ref().to_owned(),
196            });
197        }
198
199        self.sources.push(config);
200        Ok(self)
201    }
202
203    /// This will build a [`ConfigFile`] with the previously specified sources. Since
204    /// the sources have already been checked on errors, this will not fail.
205    pub fn build(self) -> Result<Config, ConfigError> {
206        let mut config = config::Config::builder();
207
208        for source in self.sources {
209            config = config.add_source(source);
210        }
211
212        Ok(config.build()?.try_deserialize::<Config>()?)
213    }
214}
215
216impl fmt::Debug for ConfigBuilderError {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match self {
219            ConfigBuilderError::FileParse { source, path, .. } => f
220                .debug_struct("FileParse")
221                .field("source", source)
222                .field("path", path)
223                .finish_non_exhaustive(),
224            ConfigBuilderError::ConfigDeserialize { source, path, .. } => f
225                .debug_struct("ConfigDeserialize")
226                .field("source", source)
227                .field("path", path)
228                .finish_non_exhaustive(),
229        }
230    }
231}
232
233impl Config {
234    /// Get the config builder
235    pub fn builder() -> Result<ConfigBuilder, ConfigError> {
236        let default_config: config::Config = config::Config::builder()
237            .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
238            .build()?;
239
240        Ok(ConfigBuilder {
241            sources: Vec::from([default_config]),
242        })
243    }
244
245    /// Instantiate new config reading default config updating it with local configuration
246    pub fn new() -> Result<Self, ConfigError> {
247        let default_config: config::Config = config::Config::builder()
248            .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
249            .build()?;
250
251        let config_dir =
252            get_config_dir().ok_or_else(|| ConfigError::ConfigDirCannotBeIdentified)?;
253        let mut builder = ConfigBuilder {
254            sources: Vec::from([default_config]),
255        };
256
257        let config_files = [
258            ("config.yaml", config::FileFormat::Yaml),
259            ("views.yaml", config::FileFormat::Yaml),
260        ];
261        let mut found_config = false;
262        for (file, _format) in &config_files {
263            if config_dir.join(file).exists() {
264                found_config = true;
265
266                builder = match builder.add_source(config_dir.join(file)) {
267                    Ok(builder) => builder,
268                    Err(ConfigBuilderError::FileParse { source, .. }) => {
269                        return Err(ConfigError::parse(*source));
270                    }
271                    Err(ConfigBuilderError::ConfigDeserialize {
272                        source,
273                        builder,
274                        path,
275                    }) => {
276                        error!(
277                            "The file {path:?} could not be deserialized and will be ignored: {source}"
278                        );
279                        builder
280                    }
281                }
282            }
283        }
284        if !found_config {
285            tracing::error!("No configuration file found. Application may not behave as expected");
286        }
287
288        builder.build()
289    }
290}
291
292fn get_config_dir() -> Option<PathBuf> {
293    dirs::config_dir().map(|val| val.join("osc"))
294}
295
296impl fmt::Display for Config {
297    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
298        write!(f, "")
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use std::io::Write;
306    use tempfile::Builder;
307
308    #[test]
309    fn test_parse_config() {
310        let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap();
311
312        const CONFIG_DATA: &str = r#"
313            views:
314              foo:
315                default_fields: ["a", "b", "c"]
316              bar:
317                fields:
318                  - name: "b"
319                    min_width: 1
320            command_hints:
321              res:
322                cmd:
323                  - hint1
324                  - hint2
325            hints:
326              - hint1
327              - hint2
328            enable_hints: true
329        "#;
330
331        write!(config_file, "{CONFIG_DATA}").unwrap();
332
333        let _cfg = Config::builder()
334            .unwrap()
335            .add_source(config_file.path())
336            .unwrap()
337            .build();
338    }
339}