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