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