dev_scope/shared/
config_load.rs

1use crate::models::prelude::{ModelMetadata, ModelRoot};
2use crate::models::HelpMetadata;
3use crate::shared::models::prelude::{
4    DoctorGroup, KnownError, ParsedConfig, ReportDefinition, ReportUploadLocation,
5};
6use crate::shared::RUN_ID_ENV_VAR;
7use anyhow::{anyhow, Result};
8use clap::{ArgGroup, Parser};
9use colored::*;
10use directories::{BaseDirs, UserDirs};
11use itertools::Itertools;
12use serde::Deserialize;
13use serde_yaml::{Deserializer, Value};
14
15use std::collections::BTreeMap;
16use std::ffi::OsStr;
17use std::fs::{self, File};
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use tracing::{debug, error, warn};
21
22#[derive(Parser, Debug)]
23#[clap(group = ArgGroup::new("config"))]
24pub struct ConfigOptions {
25    /// Add a paths to search for configuration. By default, `scope` will search up
26    /// for `.scope` directories and attempt to load `.yml` and `.yaml` files for config.
27    /// If the config directory is somewhere else, specifying this option will _add_
28    /// the paths/files to the loaded config.
29    #[clap(long, env = "SCOPE_CONFIG_DIR", global(true))]
30    extra_config: Vec<String>,
31
32    /// When set, default config files will not be loaded and only specified config will be loaded.
33    #[arg(
34        long,
35        env = "SCOPE_DISABLE_DEFAULT_CONFIG",
36        default_value = "false",
37        global(true)
38    )]
39    disable_default_config: bool,
40
41    /// Override the working directory
42    #[arg(long, short = 'C', global(true))]
43    working_dir: Option<String>,
44
45    /// When outputting logs, or other files, the run-id is the unique value that will define where these go.
46    /// In the case that the run-id is re-used, the old values will be overwritten.
47    #[arg(long, global(true), env = RUN_ID_ENV_VAR)]
48    run_id: Option<String>,
49}
50
51impl ConfigOptions {
52    pub fn generate_run_id() -> String {
53        let id = nanoid::nanoid!(4, &nanoid::alphabet::SAFE);
54        let now = chrono::Local::now();
55        let current_time = now.format("%Y%m%d");
56        format!("{}-{}", current_time, id)
57    }
58    pub fn get_run_id(&self) -> String {
59        self.run_id.clone().unwrap_or_else(Self::generate_run_id)
60    }
61
62    pub async fn load_config(&self) -> Result<FoundConfig> {
63        let current_dir = std::env::current_dir();
64        let working_dir = match (current_dir, &self.working_dir) {
65            (Ok(cwd), None) => cwd,
66            (_, Some(dir)) => PathBuf::from(&dir),
67            _ => {
68                error!(target: "user", "Unable to get a working dir");
69                return Err(anyhow!("Unable to get a working dir"));
70            }
71        };
72
73        let config_path = self.find_scope_paths(&working_dir);
74        let found_config = FoundConfig::new(self, working_dir, config_path).await;
75
76        debug!("Loaded config {:?}", found_config);
77
78        Ok(found_config)
79    }
80
81    fn find_scope_paths(&self, working_dir: &Path) -> Vec<PathBuf> {
82        let mut config_paths = Vec::new();
83
84        if !self.disable_default_config {
85            for scope_dir in build_config_path(working_dir) {
86                debug!("Checking if {} exists", scope_dir.display().to_string());
87                if scope_dir.exists() {
88                    config_paths.push(scope_dir)
89                }
90            }
91        }
92
93        for extra_config in &self.extra_config {
94            let scope_dir = Path::new(&extra_config);
95            debug!("Checking if {} exists", scope_dir.display().to_string());
96            if scope_dir.exists() {
97                config_paths.push(scope_dir.to_path_buf())
98            }
99        }
100
101        config_paths
102    }
103}
104
105#[derive(Debug, Clone)]
106pub struct FoundConfig {
107    pub working_dir: PathBuf,
108    pub raw_config: Vec<ModelRoot<Value>>,
109    pub doctor_group: BTreeMap<String, DoctorGroup>,
110    pub known_error: BTreeMap<String, KnownError>,
111    pub report_upload: BTreeMap<String, ReportUploadLocation>,
112    pub report_definition: Option<ReportDefinition>,
113    pub config_path: Vec<PathBuf>,
114    pub bin_path: String,
115    pub run_id: String,
116}
117
118impl FoundConfig {
119    pub fn empty(working_dir: PathBuf) -> Self {
120        let bin_path = std::env::var("PATH").unwrap_or_default();
121
122        Self {
123            working_dir,
124            raw_config: Vec::new(),
125            doctor_group: BTreeMap::new(),
126            known_error: BTreeMap::new(),
127            report_upload: BTreeMap::new(),
128            report_definition: None,
129            config_path: Vec::new(),
130            run_id: ConfigOptions::generate_run_id(),
131            bin_path,
132        }
133    }
134    pub async fn new(
135        config_options: &ConfigOptions,
136        working_dir: PathBuf,
137        config_path: Vec<PathBuf>,
138    ) -> Self {
139        let default_path = std::env::var("PATH").unwrap_or_default();
140
141        let mut config_path = config_path.to_vec();
142        let exe_path = std::env::current_exe().unwrap();
143        let shared_path = exe_path.parent().unwrap().join("../etc/scope");
144        config_path.push(shared_path);
145
146        let scope_path = config_path
147            .iter()
148            .map(|x| x.join("bin").display().to_string())
149            .join(":");
150
151        let mut raw_config = load_all_config(&config_path).await;
152        raw_config.sort_by_key(|x| x.full_name());
153
154        let mut this = Self {
155            working_dir,
156            raw_config: raw_config.clone(),
157            doctor_group: BTreeMap::new(),
158            known_error: BTreeMap::new(),
159            report_upload: BTreeMap::new(),
160            report_definition: None,
161            config_path,
162            bin_path: [scope_path, default_path].join(":"),
163            run_id: config_options.get_run_id(),
164        };
165
166        for raw_config in raw_config {
167            if let Ok(value) = raw_config.try_into() {
168                this.add_model(value);
169            }
170        }
171
172        this
173    }
174
175    pub fn write_raw_config_to_disk(&self) -> Result<PathBuf> {
176        let json = serde_json::to_string(&self.raw_config)?;
177        let json_bytes = json.as_bytes();
178        let file_path = PathBuf::from_iter(vec![
179            "/tmp",
180            "scope",
181            &format!("config-{}.json", self.run_id),
182        ]);
183
184        debug!("Merged config destination is to {}", file_path.display());
185
186        let mut file = File::create(&file_path)?;
187        file.write_all(json_bytes)?;
188
189        Ok(file_path)
190    }
191
192    pub fn get_report_definition(&self) -> ReportDefinition {
193        self.report_definition
194            .clone()
195            .unwrap_or_else(|| ReportDefinition {
196                full_name: "ReportDefinition/generated".to_string(),
197                metadata: ModelMetadata::new("generated"),
198                template: "== Error report for {{ command }}.".to_string(),
199                additional_data: Default::default(),
200            })
201    }
202
203    fn add_model(&mut self, parsed_config: ParsedConfig) {
204        match parsed_config {
205            ParsedConfig::DoctorGroup(exec) => {
206                insert_if_absent(&mut self.doctor_group, exec);
207            }
208            ParsedConfig::KnownError(known_error) => {
209                insert_if_absent(&mut self.known_error, known_error);
210            }
211            ParsedConfig::ReportUpload(report_upload) => {
212                insert_if_absent(&mut self.report_upload, report_upload);
213            }
214            ParsedConfig::ReportDefinition(report_definition) => {
215                if self.report_definition.is_none() {
216                    self.report_definition.replace(report_definition);
217                } else {
218                    warn!(target: "user", "A ReportDefinition with duplicate name found, dropping ReportUpload {} in {}", report_definition.name(), report_definition.metadata().file_path());
219                }
220            }
221        }
222    }
223}
224
225fn insert_if_absent<T: HelpMetadata>(map: &mut BTreeMap<String, T>, entry: T) {
226    let name = entry.name().to_string();
227    if map.contains_key(&name) {
228        warn!(target: "user", "Duplicate {} found, dropping {} in {}", entry.full_name().to_string().bold(), entry.name().bold(), entry.metadata().file_path());
229    } else {
230        map.insert(name.to_string(), entry);
231    }
232}
233
234async fn load_all_config(paths: &Vec<PathBuf>) -> Vec<ModelRoot<Value>> {
235    let mut loaded_values = Vec::new();
236
237    for file_path in expand_to_files(paths) {
238        let file_contents = match fs::read_to_string(&file_path) {
239            Err(e) => {
240                warn!(target: "user", "Unable to read file {} because {}", file_path.display().to_string(), e);
241                continue;
242            }
243            Ok(content) => content,
244        };
245        for doc in Deserializer::from_str(&file_contents) {
246            if let Some(parsed_model) = parse_model(doc, &file_path) {
247                loaded_values.push(parsed_model)
248            }
249        }
250    }
251
252    loaded_values
253}
254
255pub(crate) fn parse_model(doc: Deserializer, file_path: &Path) -> Option<ModelRoot<Value>> {
256    let value = match Value::deserialize(doc) {
257        Ok(value) => value,
258        Err(e) => {
259            warn!(target: "user", "Unable to load document from {} because {}", file_path.display(), e);
260            return None;
261        }
262    };
263
264    match serde_yaml::from_value::<ModelRoot<Value>>(value) {
265        Ok(mut value) => {
266            value.metadata.annotations.file_path = Some(file_path.display().to_string());
267
268            value.metadata.annotations.file_dir =
269                Some(file_path.parent().unwrap().display().to_string());
270
271            value.metadata.annotations.bin_path = Some(build_exec_path(file_path));
272            Some(value)
273        }
274        Err(e) => {
275            warn!(target: "user", "Unable to parse model from {} because {}", file_path.display(), e);
276            None
277        }
278    }
279}
280
281fn build_exec_path(file_path: &Path) -> String {
282    let mut paths = vec![file_path.parent().unwrap().display().to_string()];
283    for ancestor in file_path.ancestors() {
284        let bin_path = ancestor.join("bin");
285        if bin_path.exists() {
286            paths.push(bin_path.display().to_string());
287        }
288    }
289
290    paths.push(std::env::var("PATH").unwrap_or_default());
291
292    paths.join(":")
293}
294
295fn expand_to_files(paths: &Vec<PathBuf>) -> Vec<PathBuf> {
296    let mut config_files = Vec::new();
297    for path in paths {
298        let expanded_paths = expand_path(path).unwrap_or_else(|e| {
299            warn!(target: "user", "Unable to access filesystem because {}", e);
300            Vec::new()
301        });
302        config_files.extend(expanded_paths);
303    }
304
305    config_files
306}
307
308fn expand_path(path: &Path) -> Result<Vec<PathBuf>> {
309    if !path.exists() {
310        return Ok(Vec::new());
311    }
312
313    if path.is_file() {
314        return Ok(vec![path.to_path_buf()]);
315    }
316
317    if path.is_dir() {
318        let mut files = Vec::new();
319        for dir_entry in fs::read_dir(path)?.flatten() {
320            if !dir_entry.path().is_file() {
321                continue;
322            }
323
324            let file_path = dir_entry.path();
325            let extension = file_path.extension();
326            if extension == Some(OsStr::new("yaml")) || extension == Some(OsStr::new("yml")) {
327                debug!(target: "user", "Found file {:?}", file_path);
328                files.push(file_path);
329            }
330        }
331
332        return Ok(files);
333    }
334
335    warn!("Unknown file type {}", path.display().to_string());
336    Ok(Vec::new())
337}
338
339pub fn build_config_path(working_dir: &Path) -> Vec<PathBuf> {
340    let mut scope_path = Vec::new();
341
342    let working_dir = fs::canonicalize(working_dir).expect("working dir to be a path");
343    let search_dir = working_dir.to_path_buf();
344    for search_dir in search_dir.ancestors() {
345        let scope_dir: PathBuf = search_dir.join(".scope");
346        scope_path.push(scope_dir)
347    }
348
349    if let Some(user_dirs) = UserDirs::new() {
350        scope_path.push(user_dirs.home_dir().join(".scope"));
351    }
352
353    if let Some(base_dirs) = BaseDirs::new() {
354        scope_path.push(base_dirs.config_dir().join(".scope"));
355    }
356
357    scope_path
358}