Skip to main content

oseda_cli/
config.rs

1use std::error::Error;
2use std::fs::File;
3use std::io::BufWriter;
4use std::str::FromStr;
5use std::{ffi::OsString, fs};
6
7use chrono::{DateTime, Utc};
8use inquire::validator::Validation;
9use serde::{Deserialize, Serialize};
10use strum::IntoEnumIterator;
11
12use crate::cmd::check::OsedaCheckError;
13use crate::cmd::init::InitOptions;
14use crate::color::Color;
15use crate::license::License;
16use crate::tags::DefinedTag;
17use crate::{github, license};
18
19pub const CONFIG_FILE_NAME: &str = "oseda-config.json";
20
21pub fn read_config_file<P: AsRef<std::path::Path>>(
22    path: P,
23) -> Result<OsedaConfig, OsedaCheckError> {
24    let config_str = fs::read_to_string(path.as_ref()).map_err(|_| {
25        OsedaCheckError::MissingConfig(format!(
26            "Could not find config file in {}",
27            path.as_ref().display()
28        ))
29    })?;
30
31    let conf: OsedaConfig = serde_json::from_str(&config_str).map_err(|err| {
32        OsedaCheckError::BadConfig(format!("Could not parse oseda config file: {}", err).to_owned())
33    })?;
34
35    Ok(conf)
36}
37
38/// Reads and validates an oseda-config.json file in the working directory
39///
40/// This checks a few things:
41/// - the file exists and parses correctly
42/// - the git `user.name` matches the config author (unless --skip-git is passed)
43/// - the config `title` matches the name of the working directory
44///
45/// # Arguments
46/// * `skip_git` - skips the git author validation, primarily used for CI, not by the end user hopefully lol
47///
48/// # Returns
49/// * `Ok(OsedaConfig)` if the file is valid and all checks pass
50/// * `Err(OsedaCheckError)` if any check fails
51pub fn read_and_validate_config() -> Result<OsedaConfig, OsedaCheckError> {
52    let path = std::env::current_dir().map_err(|_| {
53        OsedaCheckError::DirectoryNameMismatch("Could not get path of working directory".to_owned())
54    })?;
55
56    let config_path = path.join(CONFIG_FILE_NAME);
57
58    let conf = read_config_file(config_path)?;
59
60    let in_ci = std::env::var("GITHUB_ACTIONS").is_ok_and(|v| v == "true");
61    let skip_git = in_ci;
62
63    validate_config(&conf, &path, skip_git, || {
64        github::get_config_from_user_git("user.name")
65    })?;
66
67    Ok(conf)
68}
69
70pub fn validate_config(
71    conf: &OsedaConfig,
72    current_dir: &std::path::Path,
73    skip_git: bool,
74    // very cool pass in a lambda, swap that lambda out in the tests
75    // https://danielbunte.medium.com/a-guide-to-testing-and-mocking-in-rust-a73d022b4075
76    get_git_user: impl Fn() -> Option<String>,
77) -> Result<(), OsedaCheckError> {
78    if !skip_git {
79        let gh_name = get_git_user().ok_or_else(|| {
80            OsedaCheckError::BadGitCredentials(
81                "Could not get git user.name from git config".to_owned(),
82            )
83        })?;
84
85        if gh_name != conf.author {
86            return Err(OsedaCheckError::BadGitCredentials(
87                "Config author does not match git credentials".to_owned(),
88            ));
89        }
90    }
91
92    let cwd = current_dir.file_name().ok_or_else(|| {
93        OsedaCheckError::DirectoryNameMismatch("Could not resolve path name".to_owned())
94    })?;
95
96    if cwd != OsString::from(conf.title.clone()) {
97        return Err(OsedaCheckError::DirectoryNameMismatch(
98            "Config title does not match directory name".to_owned(),
99        ));
100    }
101
102    if conf.description.is_empty() || conf.description.eq_ignore_ascii_case(DEFAULT_DESCIPTION) {
103        return Err(OsedaCheckError::MissingDescription(
104            "Description is missing or empty. Please update the oseda-config.json".to_owned(),
105        ));
106    }
107
108    if conf.tags.is_empty() {
109        return Err(OsedaCheckError::MissingTags(
110            "Please add tags to oseda-config.json".to_owned(),
111        ));
112    }
113
114    Ok(())
115}
116
117/// Structure for an oseda-config.json
118#[derive(Serialize, Deserialize)]
119pub struct OsedaConfig {
120    pub title: String,
121    pub author: String,
122    pub tags: Vec<String>,
123    // effectively mutable. Will get updated on each deployment
124    pub last_updated: DateTime<Utc>,
125    pub color: String,
126    // description must not be empty for check/deploy
127    pub description: String,
128    pub license: License,
129}
130
131const DEFAULT_DESCIPTION: &str = "Fill in project description";
132
133pub fn prompt_for_title() -> Result<String, Box<dyn Error>> {
134    let validator = |input: &str| {
135        if input.chars().count() < 2 {
136            Ok(Validation::Invalid(
137                ("Title must be longer than two characters").into(),
138            ))
139        } else {
140            Ok(Validation::Valid)
141        }
142    };
143
144    Ok(inquire::Text::new("Title: ")
145        .with_validator(validator)
146        .prompt()?)
147}
148/// Prompts the user for everything needed to generate a new OsedaConfig
149///
150/// # Returns
151/// * `Ok(OsedaConfig)` containing validated project config options
152/// * `Err` if a required input conf is invalid
153pub fn create_conf(options: InitOptions) -> Result<OsedaConfig, Box<dyn Error>> {
154    let title = match options.title {
155        Some(arg_title) => arg_title,
156        None => prompt_for_title()?.replace(" ", "-"),
157    };
158
159    let defined_tags = match options.tags {
160        Some(arg_tags) => {
161            arg_tags
162                .iter()
163                .map(|arg_tag| DefinedTag::from_str(arg_tag.trim()))
164                .collect::<Result<Vec<DefinedTag>, _>>()
165                .map_err(|_| "Invalid tag. Custom Tags may be added to the oseda-config.json after initialization".to_string())?
166        },
167        None => prompt_for_tags()?
168    };
169
170    let color = match options.color {
171        Some(arg_color) => Color::from_str(&arg_color)
172            .map_err(|_| "Invalid color. Please use traditional english color names".to_string())?,
173        None => prompt_for_color()?,
174    };
175
176    let user_name = github::get_config_from_user_git("user.name")
177        .ok_or("Could not get GitHub username. Please ensure you are signed into github")?;
178
179    let description = match options.description {
180        Some(desc) => desc,
181        None => DEFAULT_DESCIPTION.to_owned(),
182    };
183
184    let license = match options.license {
185        Some(proposed_license) => License::try_from(proposed_license.clone()).or_else(|_| {
186            eprintln!(
187                "Error: Invalid license '{}', please select from the following:",
188                proposed_license
189            );
190            prompt_for_license()
191        })?,
192        None => prompt_for_license()?,
193    };
194
195    Ok(OsedaConfig {
196        title: title.trim().to_owned(),
197        author: user_name,
198        tags: defined_tags
199            .into_iter()
200            .map(|t: DefinedTag| DefinedTag::to_string(&t))
201            .collect(),
202        last_updated: get_time(),
203        color: color.into_hex(),
204        license,
205        description,
206    })
207}
208
209fn prompt_for_license() -> Result<License, Box<dyn Error>> {
210    let options: Vec<String> = license::License::iter()
211        .map(|lic: License| license::License::spdx_id(&lic))
212        .map(|lic_str| lic_str.into())
213        .collect();
214
215    let selected_license =
216        inquire::Select::new("Select license: (type to search):", options).prompt()?;
217
218    Ok(License::try_from(selected_license)?)
219}
220
221/// Prompts user for categories associated with their Oseda project
222///
223/// # Returns
224/// * `Ok(Vec<Category>)` with selected categories
225/// * `Err` if the prompting went wrong somewhere
226fn prompt_for_tags() -> Result<Vec<DefinedTag>, Box<dyn Error>> {
227    let options: Vec<DefinedTag> = DefinedTag::iter().collect();
228
229    let selected_tags =
230        inquire::MultiSelect::new("Select categories (type to search):", options.clone())
231            .prompt()?;
232
233    println!("Selected Tags:");
234    for tags in selected_tags.iter() {
235        println!("- {:?}", tags);
236    }
237
238    Ok(selected_tags)
239}
240
241fn prompt_for_color() -> Result<Color, Box<dyn Error>> {
242    let options: Vec<Color> = Color::iter().collect();
243
244    let selected_color = inquire::Select::new(
245        "Select the color for your course (type to search):",
246        options.clone(),
247    )
248    .prompt()?;
249
250    println!("Selected Color: {:?}", selected_color);
251
252    Ok(selected_color)
253}
254
255/// Updates the configs last-updated
256/// Currently this is used on creation only, TODO fix this
257///
258/// # Arguments
259/// * `conf` - a previously loaded or generated OsedaConfig
260///
261/// # Returns
262/// * `Ok(())` if the file is successfully updated
263/// * `Err` if file writing fails
264pub fn update_time(mut conf: OsedaConfig) -> Result<(), Box<dyn Error>> {
265    conf.last_updated = get_time();
266
267    write_config(".", &conf)?;
268    Ok(())
269}
270
271/// Gets the current system time in UTC
272///
273/// # Returns
274/// * a `DateTime<Utc>` representing the current time
275fn get_time() -> DateTime<Utc> {
276    chrono::offset::Utc::now()
277}
278
279/// Write an OsedaConfig to the provided directory
280///
281/// # Arguments
282/// * `path` - the directory path to write into
283/// * `conf` - the `OsedaConfig` instance to serialize via serde
284///
285/// # Returns            color: Color::Black
286/// * `Ok(())` if the file is written successfully
287/// * `Err` if file creation or serialization fails
288pub fn write_config(path: &str, conf: &OsedaConfig) -> Result<(), Box<dyn Error>> {
289    let file = File::create(format!("{}/oseda-config.json", path))?;
290    let writer = BufWriter::new(file);
291
292    serde_json::to_writer_pretty(writer, &conf)?;
293
294    Ok(())
295}
296
297#[cfg(test)]
298mod test {
299    use crate::tags::DefinedTag;
300    use std::path::Path;
301    use tempfile::tempdir;
302
303    use super::*;
304
305    #[allow(dead_code)]
306    fn mock_config_json() -> String {
307        r#"
308           {
309               "title": "TestableRust",
310               "author": "JaneDoe",
311               "category": ["ComputerScience"],
312               "last_updated": "2024-07-10T12:34:56Z"
313           }
314           "#
315        .trim()
316        .to_string()
317    }
318
319    #[test]
320    fn test_read_config_file_missing() {
321        let dir = tempdir().unwrap();
322        let config_path = dir.path().join("oseda-config.json");
323
324        let result = read_config_file(&config_path);
325        assert!(matches!(result, Err(OsedaCheckError::MissingConfig(_))));
326    }
327
328    #[test]
329    fn test_validate_config_success() {
330        let conf = OsedaConfig {
331            title: "my-project".to_string(),
332            author: "JaneDoe".to_string(),
333            tags: vec![DefinedTag::ComputerScience.to_string()],
334            last_updated: chrono::Utc::now(),
335            license: License::Apache2_0,
336            color: Color::Black.into_hex(),
337            description: String::from("Test Description"),
338        };
339
340        let fake_dir = Path::new("/tmp/my-project");
341        // can mock the git credentials easier
342        let result = validate_config(&conf, fake_dir, false, || Some("JaneDoe".to_string()));
343
344        assert!(result.is_ok());
345    }
346
347    #[test]
348    fn test_validate_config_bad_git_user() {
349        let conf = OsedaConfig {
350            title: "my-project".to_string(),
351            author: "JaneDoe".to_string(),
352            tags: vec![DefinedTag::ComputerScience.to_string()],
353            last_updated: chrono::Utc::now(),
354            license: License::Mit,
355            color: Color::Black.into_hex(),
356            description: String::from("Test Description"),
357        };
358
359        let fake_dir = Path::new("/tmp/oseda");
360
361        let result = validate_config(&conf, fake_dir, false, || Some("NotJane".to_string()));
362
363        assert!(matches!(result, Err(OsedaCheckError::BadGitCredentials(_))));
364    }
365
366    #[test]
367    fn test_validate_config_bad_dir_name() {
368        let conf = OsedaConfig {
369            title: "correct-name".to_string(),
370            author: "JaneDoe".to_string(),
371            tags: vec![DefinedTag::ComputerScience.to_string()],
372            last_updated: chrono::Utc::now(),
373            license: License::Bsd3Clause,
374            color: Color::Black.into_hex(),
375            description: String::new(),
376        };
377
378        let fake_dir = Path::new("/tmp/wrong-name");
379
380        let result = validate_config(&conf, fake_dir, false, || Some("JaneDoe".to_string()));
381        assert!(matches!(
382            result,
383            Err(OsedaCheckError::DirectoryNameMismatch(_))
384        ));
385    }
386
387    #[test]
388    fn test_validate_config_skip_git() {
389        let conf = OsedaConfig {
390            title: "oseda".to_string(),
391            author: "JaneDoe".to_string(),
392            tags: vec![DefinedTag::ComputerScience.to_string()],
393            last_updated: chrono::Utc::now(),
394            color: Color::Black.into_hex(),
395            license: License::Gpl3_0,
396            description: String::from("Test Description"),
397        };
398
399        let fake_dir = Path::new("/tmp/oseda");
400
401        let result = validate_config(&conf, fake_dir, true, || None);
402        assert!(result.is_ok());
403    }
404}