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