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::Tag;
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 Ok(())
99}
100
101#[derive(Serialize, Deserialize)]
103pub struct OsedaConfig {
104 pub title: String,
105 pub author: String,
106 pub tags: Vec<Tag>,
107 pub last_updated: DateTime<Utc>,
109 pub color: String,
110 pub description: String
111}
112
113pub fn prompt_for_title() -> Result<String, Box<dyn Error>> {
114 let validator = |input: &str| {
115 if input.chars().count() < 2 {
116 Ok(Validation::Invalid(
117 ("Title must be longer than two characters").into(),
118 ))
119 } else {
120 Ok(Validation::Valid)
121 }
122 };
123
124 Ok(inquire::Text::new("Title: ")
125 .with_validator(validator)
126 .prompt()?)
127}
128pub fn create_conf(options: InitOptions) -> Result<OsedaConfig, Box<dyn Error>> {
134 let title = match options.title {
135 Some(arg_title) => arg_title,
136 None => prompt_for_title()?.replace(" ", "-"),
137 };
138
139 let tags = match options.tags {
140 Some(arg_tags) => {
141 arg_tags
142 .iter()
143 .map(|arg_tag| Tag::from_str(arg_tag))
144 .collect::<Result<Vec<Tag>, _>>()
145 .map_err(|_| "Invalid tag. Custom Tags may be added to the oseda-config.json after initialization".to_string())?
146 },
147 None => prompt_for_tags()?
148 };
149
150 let color = match options.color {
151 Some(arg_color) => Color::from_str(&arg_color)
152 .map_err(|_| "Invalid color. Please use traditional english color names".to_string())?,
153 None => prompt_for_color()?,
154 };
155
156 let user_name = github::get_config_from_user_git("user.name")
157 .ok_or("Could not get github username. Please ensure you are signed into github")?;
158
159 Ok(OsedaConfig {
160 title: title.trim().to_owned(),
161 author: user_name,
162 tags,
163 last_updated: get_time(),
164 color: color.into_hex(),
165 description: String::new(),
167 })
168}
169
170fn prompt_for_tags() -> Result<Vec<Tag>, Box<dyn Error>> {
176 let options: Vec<Tag> = Tag::iter().collect();
177
178 let selected_tags =
179 inquire::MultiSelect::new("Select categories (type to search):", options.clone())
180 .prompt()?;
181
182 println!("You selected:");
183 for tags in selected_tags.iter() {
184 println!("- {:?}", tags);
185 }
186
187 Ok(selected_tags)
188}
189
190fn prompt_for_color() -> Result<Color, Box<dyn Error>> {
191 let options: Vec<Color> = Color::iter().collect();
192
193 let selected_color = inquire::Select::new(
194 "Select the color for your course (type to search):",
195 options.clone(),
196 )
197 .prompt()?;
198
199 println!("You selected: {:?}", selected_color);
200
201 Ok(selected_color)
202}
203
204pub fn update_time(mut conf: OsedaConfig) -> Result<(), Box<dyn Error>> {
214 conf.last_updated = get_time();
215
216 write_config(".", &conf)?;
217 Ok(())
218}
219
220fn get_time() -> DateTime<Utc> {
225 chrono::offset::Utc::now()
226}
227
228pub fn write_config(path: &str, conf: &OsedaConfig) -> Result<(), Box<dyn Error>> {
238 let file = File::create(format!("{}/oseda-config.json", path))?;
239 let writer = BufWriter::new(file);
240
241 serde_json::to_writer_pretty(writer, &conf)?;
242
243 Ok(())
244}
245
246#[cfg(test)]
247mod test {
248 use std::path::Path;
249 use tempfile::tempdir;
250
251 use super::*;
252
253 #[allow(dead_code)]
254 fn mock_config_json() -> String {
255 r#"
256 {
257 "title": "TestableRust",
258 "author": "JaneDoe",
259 "category": ["ComputerScience"],
260 "last_updated": "2024-07-10T12:34:56Z"
261 }
262 "#
263 .trim()
264 .to_string()
265 }
266
267 #[test]
268 fn test_read_config_file_missing() {
269 let dir = tempdir().unwrap();
270 let config_path = dir.path().join("oseda-config.json");
271
272 let result = read_config_file(&config_path);
273 assert!(matches!(result, Err(OsedaCheckError::MissingConfig(_))));
274 }
275
276 #[test]
277 fn test_validate_config_success() {
278 let conf = OsedaConfig {
279 title: "my-project".to_string(),
280 author: "JaneDoe".to_string(),
281 tags: vec![Tag::ComputerScience],
282 last_updated: chrono::Utc::now(),
283 color: Color::Black.into_hex(),
284 description: String::new(),
285 };
286
287 let fake_dir = Path::new("/tmp/my-project");
288 let result = validate_config(&conf, fake_dir, false, || Some("JaneDoe".to_string()));
290
291 assert!(result.is_ok());
292 }
293
294 #[test]
295 fn test_validate_config_bad_git_user() {
296 let conf = OsedaConfig {
297 title: "my-project".to_string(),
298 author: "JaneDoe".to_string(),
299 tags: vec![Tag::ComputerScience],
300 last_updated: chrono::Utc::now(),
301 color: Color::Black.into_hex(),
302 description: String::new(),
303 };
304
305 let fake_dir = Path::new("/tmp/oseda");
306
307 let result = validate_config(&conf, fake_dir, false, || Some("NotJane".to_string()));
308
309 assert!(matches!(result, Err(OsedaCheckError::BadGitCredentials(_))));
310 }
311
312 #[test]
313 fn test_validate_config_bad_dir_name() {
314 let conf = OsedaConfig {
315 title: "correct-name".to_string(),
316 author: "JaneDoe".to_string(),
317 tags: vec![Tag::ComputerScience],
318 last_updated: chrono::Utc::now(),
319 color: Color::Black.into_hex(),
320 description: String::new(),
321 };
322
323 let fake_dir = Path::new("/tmp/wrong-name");
324
325 let result = validate_config(&conf, fake_dir, false, || Some("JaneDoe".to_string()));
326 assert!(matches!(
327 result,
328 Err(OsedaCheckError::DirectoryNameMismatch(_))
329 ));
330 }
331
332 #[test]
333 fn test_validate_config_skip_git() {
334 let conf = OsedaConfig {
335 title: "oseda".to_string(),
336 author: "JaneDoe".to_string(),
337 tags: vec![Tag::ComputerScience],
338 last_updated: chrono::Utc::now(),
339 color: Color::Black.into_hex(),
340 description: String::new(),
341 };
342
343 let fake_dir = Path::new("/tmp/oseda");
344
345 let result = validate_config(&conf, fake_dir, true, || None);
346 assert!(result.is_ok());
347 }
348}