tmc_langs/config/
projects_config.rs

1//! Structs for managing projects directories.
2
3use 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
16/// A project directory is a directory which contains directories of courses (which contain a `course_config.toml`).
17const COURSE_CONFIG_FILE_NAME: &str = "course_config.toml";
18
19#[derive(Debug)]
20pub struct ProjectsConfig {
21    // BTreeMap used so the exercises in the config file are ordered by key
22    // slug => course
23    pub tmc_courses: HashMap<String, TmcCourseConfig>,
24    // instance_id => course
25    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        // the projects dir has a separate directory for each TMC course, which are all expected to contain a `course_config.toml` file for TMC courses
37        // MOOC courses are in a `mooc` subdirectory to prevent the course slugs (which are used as the directory names) from conflicting
38        // process tmc courses first
39        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                    // skip the special `mooc` dir
46                    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        // then mooc
64        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            // no need to warn if the directory has no valid course directories at all
80            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        // maintenance: check that the exercises in the config actually exist on disk
95        // if any are found that do not, update the course config file accordingly
96        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    /// Note: does not save the config on initialization.
182    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    /// Note: does not save the config on initialization.
192    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            // need to use another course name to avoid conflicts
206            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/// A course configuration file. Contains information of all of the exercises of this course in the projects directory.
235#[derive(Debug, Serialize, Deserialize)]
236pub struct TmcCourseConfig {
237    /// The course's name.
238    pub course: String,
239    /// The course's exercises in a map with the exercise's name as the key.
240    #[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/// A course configuration file. Contains information of all of the exercises of this course in the projects directory.
263#[derive(Debug, Serialize, Deserialize)]
264pub struct MoocCourseConfig {
265    pub course_id: Uuid,
266    pub instance_id: Uuid,
267    /// The course's name.
268    pub course: String,
269    pub directory: String,
270    /// The course's exercises in a map with the exercise's id as the key.
271    #[serde(default)]
272    pub exercises: BTreeMap<Uuid, ProjectsDirMoocExercise>,
273}
274
275/// A TMC exercise in the projects directory.
276#[derive(Debug, Serialize, Deserialize)]
277pub struct ProjectsDirTmcExercise {
278    pub id: u32,
279    pub checksum: String,
280}
281
282/// A MOOC exercise in the projects directory.
283#[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}