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
32pub 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 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#[derive(Serialize, Deserialize)]
103pub struct OsedaConfig {
104 pub title: String,
105 pub author: String,
106 pub category: Vec<Category>,
107 pub last_updated: DateTime<Utc>,
109 pub color: String
110}
111
112pub fn create_conf() -> Result<OsedaConfig, Box<dyn Error>> {
118 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
155fn 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
186pub 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
202fn get_time() -> DateTime<Utc> {
207 chrono::offset::Utc::now()
208}
209
210pub 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 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}