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 #[clap(long, env = "SCOPE_CONFIG_DIR", global(true))]
30 extra_config: Vec<String>,
31
32 #[arg(
34 long,
35 env = "SCOPE_DISABLE_DEFAULT_CONFIG",
36 default_value = "false",
37 global(true)
38 )]
39 disable_default_config: bool,
40
41 #[arg(long, short = 'C', global(true))]
43 working_dir: Option<String>,
44
45 #[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}