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