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
15/// Reads and validates an oseda-config.json file in the working directory
16///
17/// This checks a few things:
18/// - the file exists and parses correctly
19/// - the git `user.name` matches the config author (unless --skip-git is passed)
20/// - the config `title` matches the name of the working directory
21///
22/// # Arguments
23/// * `skip_git` - skips the git author validation, primarily used for CI, not by the end user hopefully lol
24///
25/// # Returns
26/// * `Ok(OsedaConfig)` if the file is valid and all checks pass
27/// * `Err(OsedaCheckError)` if any check fails
28pub fn read_and_validate_config(skip_git: bool) -> Result<OsedaConfig, OsedaCheckError> {
29    let config_str = fs::read_to_string("oseda-config.json").map_err(|_| {
30        OsedaCheckError::MissingConfig(format!(
31            "Could not find config file in {}",
32            std::env::current_dir().unwrap().to_str().unwrap()
33        ))
34    })?;
35
36    let conf: OsedaConfig = serde_json::from_str(&config_str)
37        .map_err(|_| OsedaCheckError::BadConfig("Could not parse oseda config file".to_owned()))?;
38
39    if !skip_git {
40        println!("Running git checks");
41        let gh_name = github::get_config("user.name").ok_or_else(|| {
42            OsedaCheckError::BadGitCredentials(
43                "Could not get git user.name from git config".to_owned(),
44            )
45        })?;
46
47        if gh_name != conf.author {
48            return Err(OsedaCheckError::BadGitCredentials(
49                "Config author does not match git credentials".to_owned(),
50            ));
51        }
52    }
53
54    let path = std::env::current_dir().map_err(|_| {
55        OsedaCheckError::DirectoryNameMismatch("Could not get path of working directory".to_owned())
56    })?;
57
58    let cwd = path.file_name().ok_or_else(|| {
59        OsedaCheckError::DirectoryNameMismatch("Could not resolve path name".to_owned())
60    })?;
61
62    if cwd != OsString::from(conf.title.clone()) {
63        return Err(OsedaCheckError::DirectoryNameMismatch(
64            "Config title does not match directory name".to_owned(),
65        ));
66    }
67
68    Ok(conf)
69}
70
71/// Structure for an oseda-config.json
72#[derive(Serialize, Deserialize)]
73pub struct OsedaConfig {
74    pub title: String,
75    pub author: String,
76    pub category: Vec<Category>,
77    // effectively mutable. Will get updated on each deployment
78    pub last_updated: DateTime<Utc>,
79}
80
81/// Prompts the user for everything needed to generate a new OsedaConfig
82///
83/// # Returns
84/// * `Ok(OsedaConfig)` containing validated project config options
85/// * `Err` if a required input conf is invalid
86pub fn create_conf() -> Result<OsedaConfig, Box<dyn Error>> {
87    // let mut title = String::new();
88    // std::io::stdin().read_line(&mut title)?;
89
90    let validator = |input: &str| {
91        if input.chars().count() < 2 {
92            Ok(Validation::Invalid(
93                ("Title must be longer than two characters").into(),
94            ))
95        } else {
96            Ok(Validation::Valid)
97        }
98    };
99
100    let mut title = inquire::Text::new("Title: ")
101        .with_validator(validator)
102        .prompt()?;
103
104    title = title.replace(" ", "-");
105
106    let categories = get_categories()?;
107
108    let user_name = github::get_config("user.name")
109        .ok_or("Could not get github username. Please ensure you are signed into github")?;
110
111    Ok(OsedaConfig {
112        title: title.trim().to_owned(),
113        author: user_name,
114        category: categories,
115        last_updated: get_time(),
116    })
117}
118
119/// Prompts user for categories associated with their Oseda project
120///
121/// # Returns
122/// * `Ok(Vec<Category>)` with selected categories
123/// * `Err` if the prompting went wrong somewhere
124fn get_categories() -> Result<Vec<Category>, Box<dyn Error>> {
125    let options: Vec<Category> = Category::iter().collect();
126
127    let selected_categories =
128        inquire::MultiSelect::new("Select categories", options.clone()).prompt()?;
129
130    println!("You selected:");
131    for category in selected_categories.iter() {
132        println!("- {:?}", category);
133    }
134
135    Ok(selected_categories)
136}
137
138/// Updates the configs last-updated
139/// Currently this is used on creation only, TODO fix this
140///
141/// # Arguments
142/// * `conf` - a previously loaded or generated OsedaConfig
143///
144/// # Returns
145/// * `Ok(())` if the file is successfully updated
146/// * `Err` if file writing fails
147pub fn update_time(mut conf: OsedaConfig) -> Result<(), Box<dyn Error>> {
148    conf.last_updated = get_time();
149
150    write_config(".", &conf)?;
151    Ok(())
152}
153
154/// Gets the current system time in UTC
155///
156/// # Returns
157/// * a `DateTime<Utc>` representing the current time
158fn get_time() -> DateTime<Utc> {
159    chrono::offset::Utc::now()
160}
161
162/// Write an OsedaConfig to the provided directory
163///
164/// # Arguments
165/// * `path` - the directory path to write into
166/// * `conf` - the `OsedaConfig` instance to serialize via serde
167///
168/// # Returns
169/// * `Ok(())` if the file is written successfully
170/// * `Err` if file creation or serialization fails
171pub fn write_config(path: &str, conf: &OsedaConfig) -> Result<(), Box<dyn Error>> {
172    let file = File::create(format!("{}/oseda-config.json", path))?;
173    let writer = BufWriter::new(file);
174
175    serde_json::to_writer_pretty(writer, &conf)?;
176
177    Ok(())
178}