oseda_cli/
config.rs

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