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::license::License;
16use crate::tags::DefinedTag;
17use crate::{github, license};
18
19pub fn read_config_file<P: AsRef<std::path::Path>>(
20 path: P,
21) -> Result<OsedaConfig, OsedaCheckError> {
22 let config_str = fs::read_to_string(path.as_ref()).map_err(|_| {
23 OsedaCheckError::MissingConfig(format!(
24 "Could not find config file in {}",
25 path.as_ref().display()
26 ))
27 })?;
28
29 let conf: OsedaConfig = serde_json::from_str(&config_str)
30 .map_err(|_| OsedaCheckError::BadConfig("Could not parse oseda config file".to_owned()))?;
31
32 Ok(conf)
33}
34
35pub fn read_and_validate_config() -> Result<OsedaConfig, OsedaCheckError> {
49 let path = std::env::current_dir().map_err(|_| {
50 OsedaCheckError::DirectoryNameMismatch("Could not get path of working directory".to_owned())
51 })?;
52
53 let config_path = path.join("oseda-config.json");
54
55 let conf = read_config_file(config_path)?;
56
57 let in_ci = std::env::var("GITHUB_ACTIONS").is_ok_and(|v| v == "true");
58 let skip_git = in_ci;
59
60 validate_config(&conf, &path, skip_git, || {
61 github::get_config_from_user_git("user.name")
62 })?;
63
64 Ok(conf)
65}
66
67pub fn validate_config(
68 conf: &OsedaConfig,
69 current_dir: &std::path::Path,
70 skip_git: bool,
71 get_git_user: impl Fn() -> Option<String>,
74) -> Result<(), OsedaCheckError> {
75 if !skip_git {
76 let gh_name = get_git_user().ok_or_else(|| {
77 OsedaCheckError::BadGitCredentials(
78 "Could not get git user.name from git config".to_owned(),
79 )
80 })?;
81
82 if gh_name != conf.author {
83 return Err(OsedaCheckError::BadGitCredentials(
84 "Config author does not match git credentials".to_owned(),
85 ));
86 }
87 }
88
89 let cwd = current_dir.file_name().ok_or_else(|| {
90 OsedaCheckError::DirectoryNameMismatch("Could not resolve path name".to_owned())
91 })?;
92
93 if cwd != OsString::from(conf.title.clone()) {
94 return Err(OsedaCheckError::DirectoryNameMismatch(
95 "Config title does not match directory name".to_owned(),
96 ));
97 }
98
99 if conf.description.is_empty() || conf.description.eq_ignore_ascii_case(DEFAULT_DESCIPTION) {
100 return Err(OsedaCheckError::MissingDescription(
101 "Description is missing or empty. Please update the oseda-config.json".to_owned(),
102 ));
103 }
104
105 if conf.tags.is_empty() {
106 return Err(OsedaCheckError::MissingTags(
107 "Please add tags to oseda-config.json".to_owned(),
108 ));
109 }
110
111 Ok(())
112}
113
114#[derive(Serialize, Deserialize)]
116pub struct OsedaConfig {
117 pub title: String,
118 pub author: String,
119 pub tags: Vec<String>,
120 pub last_updated: DateTime<Utc>,
122 pub color: String,
123 pub description: String,
125 pub license: License,
126}
127
128const DEFAULT_DESCIPTION: &str = "Fill in project description";
129
130pub fn prompt_for_title() -> Result<String, Box<dyn Error>> {
131 let validator = |input: &str| {
132 if input.chars().count() < 2 {
133 Ok(Validation::Invalid(
134 ("Title must be longer than two characters").into(),
135 ))
136 } else {
137 Ok(Validation::Valid)
138 }
139 };
140
141 Ok(inquire::Text::new("Title: ")
142 .with_validator(validator)
143 .prompt()?)
144}
145pub fn create_conf(options: InitOptions) -> Result<OsedaConfig, Box<dyn Error>> {
151 let title = match options.title {
152 Some(arg_title) => arg_title,
153 None => prompt_for_title()?.replace(" ", "-"),
154 };
155
156 let defined_tags = match options.tags {
157 Some(arg_tags) => {
158 arg_tags
159 .iter()
160 .map(|arg_tag| DefinedTag::from_str(arg_tag.trim()))
161 .collect::<Result<Vec<DefinedTag>, _>>()
162 .map_err(|_| "Invalid tag. Custom Tags may be added to the oseda-config.json after initialization".to_string())?
163 },
164 None => prompt_for_tags()?
165 };
166
167 let color = match options.color {
168 Some(arg_color) => Color::from_str(&arg_color)
169 .map_err(|_| "Invalid color. Please use traditional english color names".to_string())?,
170 None => prompt_for_color()?,
171 };
172
173 let user_name = github::get_config_from_user_git("user.name")
174 .ok_or("Could not get GitHub username. Please ensure you are signed into github")?;
175
176 let description = match options.description {
177 Some(desc) => desc,
178 None => DEFAULT_DESCIPTION.to_owned(),
179 };
180
181 let license = match options.license {
182 Some(proposed_license) => License::try_from(proposed_license.clone()).or_else(|_| {
183 eprintln!(
184 "Error: Invalid license '{}', please select from the following:",
185 proposed_license
186 );
187 prompt_for_license()
188 })?,
189 None => prompt_for_license()?,
190 };
191
192 Ok(OsedaConfig {
193 title: title.trim().to_owned(),
194 author: user_name,
195 tags: defined_tags
196 .into_iter()
197 .map(|t: DefinedTag| DefinedTag::to_string(&t))
198 .collect(),
199 last_updated: get_time(),
200 color: color.into_hex(),
201 license,
202 description,
203 })
204}
205
206fn prompt_for_license() -> Result<License, Box<dyn Error>> {
207 let options: Vec<String> = license::License::iter()
208 .map(|lic: License| license::License::spdx_id(&lic))
209 .map(|lic_str| lic_str.into())
210 .collect();
211
212 let selected_license =
213 inquire::Select::new("Select license: (type to search):", options).prompt()?;
214
215 Ok(License::try_from(selected_license)?)
216}
217
218fn prompt_for_tags() -> Result<Vec<DefinedTag>, Box<dyn Error>> {
224 let options: Vec<DefinedTag> = DefinedTag::iter().collect();
225
226 let selected_tags =
227 inquire::MultiSelect::new("Select categories (type to search):", options.clone())
228 .prompt()?;
229
230 println!("Selected Tags:");
231 for tags in selected_tags.iter() {
232 println!("- {:?}", tags);
233 }
234
235 Ok(selected_tags)
236}
237
238fn prompt_for_color() -> Result<Color, Box<dyn Error>> {
239 let options: Vec<Color> = Color::iter().collect();
240
241 let selected_color = inquire::Select::new(
242 "Select the color for your course (type to search):",
243 options.clone(),
244 )
245 .prompt()?;
246
247 println!("Selected Color: {:?}", selected_color);
248
249 Ok(selected_color)
250}
251
252pub fn update_time(mut conf: OsedaConfig) -> Result<(), Box<dyn Error>> {
262 conf.last_updated = get_time();
263
264 write_config(".", &conf)?;
265 Ok(())
266}
267
268fn get_time() -> DateTime<Utc> {
273 chrono::offset::Utc::now()
274}
275
276pub fn write_config(path: &str, conf: &OsedaConfig) -> Result<(), Box<dyn Error>> {
286 let file = File::create(format!("{}/oseda-config.json", path))?;
287 let writer = BufWriter::new(file);
288
289 serde_json::to_writer_pretty(writer, &conf)?;
290
291 Ok(())
292}
293
294#[cfg(test)]
295mod test {
296 use crate::tags::DefinedTag;
297 use std::path::Path;
298 use tempfile::tempdir;
299
300 use super::*;
301
302 #[allow(dead_code)]
303 fn mock_config_json() -> String {
304 r#"
305 {
306 "title": "TestableRust",
307 "author": "JaneDoe",
308 "category": ["ComputerScience"],
309 "last_updated": "2024-07-10T12:34:56Z"
310 }
311 "#
312 .trim()
313 .to_string()
314 }
315
316 #[test]
317 fn test_read_config_file_missing() {
318 let dir = tempdir().unwrap();
319 let config_path = dir.path().join("oseda-config.json");
320
321 let result = read_config_file(&config_path);
322 assert!(matches!(result, Err(OsedaCheckError::MissingConfig(_))));
323 }
324
325 #[test]
326 fn test_validate_config_success() {
327 let conf = OsedaConfig {
328 title: "my-project".to_string(),
329 author: "JaneDoe".to_string(),
330 tags: vec![DefinedTag::ComputerScience.to_string()],
331 last_updated: chrono::Utc::now(),
332 license: License::Apache2_0,
333 color: Color::Black.into_hex(),
334 description: String::from("Test Description"),
335 };
336
337 let fake_dir = Path::new("/tmp/my-project");
338 let result = validate_config(&conf, fake_dir, false, || Some("JaneDoe".to_string()));
340
341 assert!(result.is_ok());
342 }
343
344 #[test]
345 fn test_validate_config_bad_git_user() {
346 let conf = OsedaConfig {
347 title: "my-project".to_string(),
348 author: "JaneDoe".to_string(),
349 tags: vec![DefinedTag::ComputerScience.to_string()],
350 last_updated: chrono::Utc::now(),
351 license: License::Mit,
352 color: Color::Black.into_hex(),
353 description: String::from("Test Description"),
354 };
355
356 let fake_dir = Path::new("/tmp/oseda");
357
358 let result = validate_config(&conf, fake_dir, false, || Some("NotJane".to_string()));
359
360 assert!(matches!(result, Err(OsedaCheckError::BadGitCredentials(_))));
361 }
362
363 #[test]
364 fn test_validate_config_bad_dir_name() {
365 let conf = OsedaConfig {
366 title: "correct-name".to_string(),
367 author: "JaneDoe".to_string(),
368 tags: vec![DefinedTag::ComputerScience.to_string()],
369 last_updated: chrono::Utc::now(),
370 license: License::Bsd3Clause,
371 color: Color::Black.into_hex(),
372 description: String::new(),
373 };
374
375 let fake_dir = Path::new("/tmp/wrong-name");
376
377 let result = validate_config(&conf, fake_dir, false, || Some("JaneDoe".to_string()));
378 assert!(matches!(
379 result,
380 Err(OsedaCheckError::DirectoryNameMismatch(_))
381 ));
382 }
383
384 #[test]
385 fn test_validate_config_skip_git() {
386 let conf = OsedaConfig {
387 title: "oseda".to_string(),
388 author: "JaneDoe".to_string(),
389 tags: vec![DefinedTag::ComputerScience.to_string()],
390 last_updated: chrono::Utc::now(),
391 color: Color::Black.into_hex(),
392 license: License::Gpl3_0,
393 description: String::from("Test Description"),
394 };
395
396 let fake_dir = Path::new("/tmp/oseda");
397
398 let result = validate_config(&conf, fake_dir, true, || None);
399 assert!(result.is_ok());
400 }
401}