1use crate::LangsError;
4use serde::{Deserialize, Serialize};
5use std::{
6 collections::{BTreeMap, HashMap},
7 path::{Path, PathBuf},
8};
9use tmc_langs_util::{
10 FileError, deserialize,
11 file_util::{self, Lock, LockOptions},
12};
13use uuid::Uuid;
14use walkdir::WalkDir;
15
16const COURSE_CONFIG_FILE_NAME: &str = "course_config.toml";
18
19#[derive(Debug)]
20pub struct ProjectsConfig {
21 pub tmc_courses: HashMap<String, TmcCourseConfig>,
24 pub mooc_courses: HashMap<Uuid, MoocCourseConfig>,
26}
27
28impl ProjectsConfig {
29 pub fn load(projects_dir: &Path) -> Result<ProjectsConfig, LangsError> {
30 let mut lock = Lock::dir(projects_dir, LockOptions::Read)?;
31 let _guard = lock.lock()?;
32
33 let mut tmc_course_configs = HashMap::<String, TmcCourseConfig>::new();
34 let mut mooc_course_configs = HashMap::<Uuid, MoocCourseConfig>::new();
35
36 let mut unexpected_entries = Vec::new();
40 if projects_dir.exists() {
41 for entry in WalkDir::new(projects_dir).min_depth(1).max_depth(1) {
42 let entry = entry?;
43 let file_name = entry.file_name();
44 if file_name == "mooc" {
45 continue;
47 }
48
49 let course_config_path = entry.path().join(COURSE_CONFIG_FILE_NAME);
50 if course_config_path.exists() {
51 let course_dir_name = file_name.to_str().ok_or_else(|| {
52 LangsError::FileError(FileError::NoFileName(entry.path().to_path_buf()))
53 })?;
54 let file = file_util::read_file_to_string(&course_config_path)?;
55 let course_config = deserialize::toml_from_str(&file)?;
56 tmc_course_configs.insert(course_dir_name.to_string(), course_config);
57 } else {
58 unexpected_entries.push(entry);
59 }
60 }
61 }
62
63 let mooc_projects_dir = projects_dir.join("mooc");
65 if mooc_projects_dir.exists() {
66 for entry in WalkDir::new(mooc_projects_dir).min_depth(1).max_depth(1) {
67 let entry = entry?;
68
69 let course_config_path = entry.path().join(COURSE_CONFIG_FILE_NAME);
70 if course_config_path.exists() {
71 let file = file_util::read_file_to_string(&course_config_path)?;
72 let course_config = deserialize::toml_from_str::<MoocCourseConfig>(&file)?;
73 mooc_course_configs.insert(course_config.instance_id, course_config);
74 } else {
75 unexpected_entries.push(entry);
76 }
77 }
78
79 if !(tmc_course_configs.is_empty() && mooc_course_configs.is_empty()) {
81 log::warn!(
82 "Files or directories with no config files found \
83 while loading projects from {}: [{}]",
84 projects_dir.display(),
85 unexpected_entries
86 .iter()
87 .filter_map(|ue| ue.path().as_os_str().to_str())
88 .collect::<Vec<_>>()
89 .join(", "),
90 );
91 }
92 }
93
94 for (_, course_config) in tmc_course_configs.iter_mut() {
97 let mut deleted_exercises = vec![];
98 for exercise_name in course_config.exercises.keys() {
99 let expected_dir = Self::get_tmc_exercise_download_target(
100 projects_dir,
101 &course_config.course,
102 exercise_name,
103 );
104 if !expected_dir.exists() {
105 log::debug!(
106 "local exercise {} not found, deleting from config",
107 expected_dir.display()
108 );
109 deleted_exercises.push(exercise_name.clone());
110 }
111 }
112 for deleted_exercise in &deleted_exercises {
113 course_config
114 .exercises
115 .remove(deleted_exercise)
116 .expect("this should never fail");
117 }
118 if !deleted_exercises.is_empty() {
119 course_config.save_to_projects_dir(projects_dir)?;
120 }
121 }
122
123 Ok(Self {
124 tmc_courses: tmc_course_configs,
125 mooc_courses: mooc_course_configs,
126 })
127 }
128
129 pub fn get_tmc_exercise_download_target(
130 projects_dir: &Path,
131 course_name: &str,
132 exercise_name: &str,
133 ) -> PathBuf {
134 projects_dir.join(course_name).join(exercise_name)
135 }
136
137 pub fn get_mooc_exercise_download_target(
138 projects_dir: &Path,
139 instance_directory: &str,
140 exercise_directory: &str,
141 ) -> PathBuf {
142 projects_dir
143 .join(instance_directory)
144 .join(exercise_directory)
145 }
146
147 pub fn get_tmc_exercise(
148 &self,
149 course_name: &str,
150 exercise_name: &str,
151 ) -> Option<&ProjectsDirTmcExercise> {
152 self.tmc_courses
153 .get(course_name)
154 .and_then(|c| c.exercises.get(exercise_name))
155 }
156
157 pub fn get_mooc_exercise(
158 &self,
159 instance_id: Uuid,
160 exercise_id: Uuid,
161 ) -> Option<&ProjectsDirMoocExercise> {
162 self.mooc_courses
163 .get(&instance_id)
164 .and_then(|c| c.exercises.get(&exercise_id))
165 }
166
167 pub fn get_all_tmc_exercises(&self) -> impl Iterator<Item = &ProjectsDirTmcExercise> {
168 self.tmc_courses
169 .iter()
170 .flat_map(|c| &c.1.exercises)
171 .map(|e| e.1)
172 }
173
174 pub fn get_all_mooc_exercises(&self) -> impl Iterator<Item = &ProjectsDirMoocExercise> {
175 self.mooc_courses
176 .iter()
177 .flat_map(|c| &c.1.exercises)
178 .map(|e| e.1)
179 }
180
181 pub fn get_or_init_tmc_course_config(&mut self, course_name: String) -> &mut TmcCourseConfig {
183 self.tmc_courses
184 .entry(course_name.clone())
185 .or_insert(TmcCourseConfig {
186 course: course_name,
187 exercises: BTreeMap::new(),
188 })
189 }
190
191 pub fn get_or_init_mooc_course_config(
193 &mut self,
194 instance_id: Uuid,
195 course_id: Uuid,
196 course_name: String,
197 ) -> &mut MoocCourseConfig {
198 let existing_dirs = self
199 .mooc_courses
200 .values()
201 .map(|mc| &mc.directory)
202 .collect::<Vec<_>>();
203 let kebab_name = simple_kebab_case(&course_name);
204 let directory = if existing_dirs.contains(&&kebab_name) {
205 let mut dir = None;
207 for i in 1.. {
208 let proposal = format!("{kebab_name}-{i}");
209 if !existing_dirs.contains(&&proposal) {
210 dir = Some(proposal);
211 break;
212 }
213 }
214 dir.expect("unreachable")
215 } else {
216 kebab_name
217 };
218 self.mooc_courses
219 .entry(instance_id)
220 .or_insert(MoocCourseConfig {
221 course: course_name,
222 exercises: BTreeMap::new(),
223 course_id,
224 instance_id,
225 directory,
226 })
227 }
228}
229
230fn simple_kebab_case(s: &str) -> String {
231 s.to_lowercase().replace(" ", "-")
232}
233
234#[derive(Debug, Serialize, Deserialize)]
236pub struct TmcCourseConfig {
237 pub course: String,
239 #[serde(default)]
241 pub exercises: BTreeMap<String, ProjectsDirTmcExercise>,
242}
243
244impl TmcCourseConfig {
245 pub fn add_exercise(&mut self, exercise_name: String, id: u32, checksum: String) {
246 let exercise = ProjectsDirTmcExercise { id, checksum };
247 self.exercises.insert(exercise_name, exercise);
248 }
249
250 pub fn save_to_projects_dir(&self, projects_dir: &Path) -> Result<(), LangsError> {
251 let course_dir = projects_dir.join(&self.course);
252 if !course_dir.exists() {
253 file_util::create_dir_all(&course_dir)?;
254 }
255 let target = course_dir.join(COURSE_CONFIG_FILE_NAME);
256 let s = toml::to_string_pretty(&self)?;
257 file_util::write_to_file(s.as_bytes(), target)?;
258 Ok(())
259 }
260}
261
262#[derive(Debug, Serialize, Deserialize)]
264pub struct MoocCourseConfig {
265 pub course_id: Uuid,
266 pub instance_id: Uuid,
267 pub course: String,
269 pub directory: String,
270 #[serde(default)]
272 pub exercises: BTreeMap<Uuid, ProjectsDirMoocExercise>,
273}
274
275#[derive(Debug, Serialize, Deserialize)]
277pub struct ProjectsDirTmcExercise {
278 pub id: u32,
279 pub checksum: String,
280}
281
282#[derive(Debug, Serialize, Deserialize)]
284pub struct ProjectsDirMoocExercise {
285 pub name: String,
286 pub id: Uuid,
287 pub checksum: String,
288 pub directory: String,
289}
290
291#[cfg(test)]
292#[allow(clippy::unwrap_used)]
293mod test {
294 use super::*;
295
296 fn init_logging() {
297 use log::*;
298 use simple_logger::*;
299 let _ = SimpleLogger::new()
300 .with_level(LevelFilter::Debug)
301 .with_module_level("j4rs", LevelFilter::Warn)
302 .init();
303 }
304
305 fn file_to(
306 target_dir: impl AsRef<std::path::Path>,
307 target_relative: impl AsRef<std::path::Path>,
308 contents: impl AsRef<[u8]>,
309 ) {
310 let target = target_dir.as_ref().join(target_relative);
311 if let Some(parent) = target.parent() {
312 std::fs::create_dir_all(parent).unwrap();
313 }
314 std::fs::write(target, contents.as_ref()).unwrap();
315 }
316
317 fn dir_to(temp: impl AsRef<std::path::Path>, relative_path: impl AsRef<std::path::Path>) {
318 let target = temp.as_ref().join(relative_path);
319 std::fs::create_dir_all(target).unwrap();
320 }
321
322 #[test]
323 fn serializes() {
324 init_logging();
325
326 let mut exercises = BTreeMap::new();
327 exercises.insert(
328 "ex 1".to_string(),
329 ProjectsDirTmcExercise {
330 id: 4321,
331 checksum: "abcd1234".to_string(),
332 },
333 );
334 let course_config = TmcCourseConfig {
335 course: "course 1".to_string(),
336 exercises,
337 };
338 let s = toml::to_string(&course_config).unwrap();
339 assert_eq!(
340 s,
341 r#"course = "course 1"
342
343[exercises."ex 1"]
344id = 4321
345checksum = "abcd1234"
346"#
347 )
348 }
349
350 #[test]
351 fn deserializes() {
352 init_logging();
353
354 let s = r#"
355course = "python course"
356
357[exercises.ex1]
358id = 4321
359checksum = "abcd1234"
360
361[exercises."ex 2"]
362id = 5432
363checksum = "bcde2345"
364"#;
365
366 let _course_config: TmcCourseConfig = deserialize::toml_from_str(s).unwrap();
367 }
368
369 #[test]
370 fn loads() {
371 init_logging();
372
373 let temp = tempfile::TempDir::new().unwrap();
374 file_to(
375 &temp,
376 "python/course_config.toml",
377 r#"
378course = "python"
379
380[exercises.ex1]
381id = 4321
382checksum = "abcd1234"
383
384[exercises."ex 2"]
385id = 5432
386checksum = "bcde2345"
387"#,
388 );
389 dir_to(&temp, "python/ex1");
390 dir_to(&temp, "python/ex 2");
391 file_to(
392 &temp,
393 "java/course_config.toml",
394 r#"
395course = "java"
396
397[exercises.ex3]
398id = 6543
399checksum = "cdef3456"
400
401[exercises."ex 4"]
402id = 7654
403checksum = "defg4567"
404"#,
405 );
406 dir_to(&temp, "java/ex3");
407 dir_to(&temp, "java/ex 4");
408
409 let mut pc = ProjectsConfig::load(temp.path()).unwrap();
410 assert_eq!(pc.tmc_courses.len(), 2);
411
412 let mut cc = pc.tmc_courses.remove("python").unwrap();
413 assert_eq!(cc.course, "python");
414 assert_eq!(cc.exercises.len(), 2);
415 let ex = cc.exercises.remove("ex1").unwrap();
416 assert_eq!(ex.id, 4321);
417 assert_eq!(ex.checksum, "abcd1234");
418 let ex = cc.exercises.remove("ex 2").unwrap();
419 assert_eq!(ex.id, 5432);
420 assert_eq!(ex.checksum, "bcde2345");
421
422 let mut cc = pc.tmc_courses.remove("java").unwrap();
423 assert_eq!(cc.course, "java");
424 assert_eq!(cc.exercises.len(), 2);
425 let ex = cc.exercises.remove("ex3").unwrap();
426 assert_eq!(ex.id, 6543);
427 assert_eq!(ex.checksum, "cdef3456");
428 let ex = cc.exercises.remove("ex 4").unwrap();
429 assert_eq!(ex.id, 7654);
430 assert_eq!(ex.checksum, "defg4567");
431 }
432}