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