1use crate::{error::LangsError, progress_reporter};
4use md5::Context;
5use serde::{Deserialize, Serialize};
6use serde_yaml::Mapping;
7use std::{
8 io::Write,
9 path::{Path, PathBuf},
10 time::Duration,
11};
12use tmc_langs_framework::{TmcCommand, TmcProjectYml};
13use tmc_langs_util::{deserialize, file_util};
14use walkdir::WalkDir;
15use zip::write::SimpleFileOptions;
16
17#[cfg(unix)]
18pub type ModeBits = nix::sys::stat::mode_t;
19
20#[derive(Debug, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
24pub struct RefreshData {
25 pub new_cache_path: PathBuf,
26 #[cfg_attr(feature = "ts-rs", ts(type = "object"))]
27 pub course_options: Mapping,
28 pub exercises: Vec<RefreshExercise>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
35pub struct RefreshExercise {
36 name: String,
37 checksum: String,
38 points: Vec<String>,
39 #[serde(skip)]
40 path: PathBuf,
41 sandbox_image: String,
42 #[cfg_attr(feature = "ts-rs", ts(type = "TmcProjectYml | null"))]
43 tmcproject_yml: Option<TmcProjectYml>,
44}
45
46pub fn refresh_course(
48 course_name: String,
49 course_cache_path: PathBuf,
50 source_url: String,
51 git_branch: String,
52 cache_root: PathBuf,
53) -> Result<RefreshData, LangsError> {
54 log::info!("refreshing course {course_name}");
55 start_stage(10, "Refreshing course");
56
57 let old_version = course_cache_path
59 .to_str()
60 .and_then(|s| s.split('-').next_back())
61 .and_then(|s| s.parse::<u32>().ok())
62 .ok_or_else(|| LangsError::InvalidCachePath(course_cache_path.clone()))?;
63 let new_cache_path = cache_root.join(format!("{}-{}", course_name, old_version + 1));
64 log::info!("next cache path: {}", new_cache_path.display());
65
66 if new_cache_path.exists() {
67 log::info!("clearing new cache path at {}", new_cache_path.display());
68 file_util::remove_dir_all(&new_cache_path)?;
69 }
70 file_util::create_dir_all(&new_cache_path)?;
71 progress_stage("Created new cache dir");
72
73 let new_clone_path = new_cache_path.join("clone");
75 let old_clone_path = course_cache_path.join("clone");
76 initialize_new_cache_clone(
77 &new_cache_path,
78 &new_clone_path,
79 &old_clone_path,
80 &source_url,
81 &git_branch,
82 )?;
83 check_directory_names(&new_clone_path)?;
84 progress_stage("Updated repository");
85
86 let course_options = get_course_options(&new_clone_path, &course_name)?;
87 progress_stage("Fetched course options");
88
89 let new_solution_path = new_cache_path.join("solution");
90 let new_stub_path = new_cache_path.join("stub");
91
92 let exercise_dirs = super::find_exercise_directories(&new_clone_path)?
93 .into_iter()
94 .map(|ed| {
95 ed.strip_prefix(&new_clone_path)
96 .expect("exercise directories are inside new_clone_path")
97 .to_path_buf()
98 })
99 .collect::<Vec<_>>();
100
101 let root_tmcproject_yml = TmcProjectYml::load(&new_clone_path)?;
103 let exercise_dirs_and_tmcprojects =
104 get_and_merge_tmcproject_configs(root_tmcproject_yml, &new_clone_path, exercise_dirs)?;
105 progress_stage("Merged .tmcproject.yml files in exercise directories to the root file, if any");
106
107 log::info!("preparing solutions to {}", new_solution_path.display());
109 for (exercise, merged_tmcproject) in &exercise_dirs_and_tmcprojects {
110 let dest_root = new_solution_path.join(exercise);
112 super::prepare_solution(&new_clone_path.join(exercise), &dest_root)?;
113 if let Some(merged_tmcproject) = merged_tmcproject {
114 merged_tmcproject.save_to_dir(&dest_root)?;
115 }
116 }
117 progress_stage("Prepared solutions");
118
119 log::info!("preparing stubs to {}", new_stub_path.display());
121 for (exercise, merged_tmcproject) in &exercise_dirs_and_tmcprojects {
122 let dest_root = new_stub_path.join(exercise);
124 super::prepare_stub(&new_clone_path.join(exercise), &dest_root)?;
125 if let Some(merged_tmcproject) = merged_tmcproject {
126 merged_tmcproject.save_to_dir(&dest_root)?;
127 }
128 }
129 progress_stage("Prepared stubs");
130
131 let exercises = get_exercises(
132 exercise_dirs_and_tmcprojects,
133 &new_clone_path,
134 &new_stub_path,
135 )?;
136 progress_stage("Located exercises");
137
138 let new_solution_zip_path = new_cache_path.join("solution_zip");
140 execute_zip(&exercises, &new_solution_path, &new_solution_zip_path)?;
141 progress_stage("Compressed solutions");
142
143 let new_stub_zip_path = new_cache_path.join("stub_zip");
145 log::info!(
146 "compressing stubs from {} to {}",
147 new_stub_path.display(),
148 new_stub_zip_path.display()
149 );
150 execute_zip(&exercises, &new_stub_path, &new_stub_zip_path)?;
151 progress_stage("Compressed stubs");
152
153 set_permissions(&new_cache_path)?;
155
156 finish_stage("Refreshed course");
157 Ok(RefreshData {
158 new_cache_path,
159 course_options,
160 exercises,
161 })
162}
163
164fn initialize_new_cache_clone(
169 new_course_root: &Path,
170 new_clone_path: &Path,
171 old_clone_path: &Path,
172 course_source_url: &str,
173 course_git_branch: &str,
174) -> Result<(), LangsError> {
175 log::info!("initializing repository at {}", new_clone_path.display());
176
177 if old_clone_path.join(".git").exists() {
178 log::info!(
179 "trying to copy clone from previous cache at {}",
180 old_clone_path.display()
181 );
182
183 let copy_and_update_repository = || -> Result<(), LangsError> {
185 file_util::copy(old_clone_path, new_course_root)?;
186
187 let run_git = |args: &[&str]| {
188 TmcCommand::piped("git")
189 .with(|e| e.cwd(new_clone_path).args(args))
190 .output_with_timeout_checked(Duration::from_secs(60 * 2))
191 };
192
193 run_git(&["remote", "set-url", "origin", course_source_url])?;
194 run_git(&["fetch", "origin"])?;
195 run_git(&["checkout", &format!("origin/{course_git_branch}")])?;
196 run_git(&["clean", "-df"])?;
197 run_git(&["checkout", "."])?;
198 Ok(())
199 };
200 match copy_and_update_repository() {
201 Ok(_) => {
202 log::info!("updated repository");
203 return Ok(());
204 }
205 Err(error) => {
206 log::warn!("failed to update repository: {error}");
207
208 file_util::remove_dir_all(new_clone_path)?;
209 }
210 }
211 };
212
213 log::info!("could not copy from previous cache, cloning");
214
215 TmcCommand::piped("git")
217 .with(|e| {
218 e.args(&["clone", "-q", "-b"])
219 .arg(course_git_branch)
220 .arg(course_source_url)
221 .arg(new_clone_path)
222 })
223 .output_with_timeout_checked(Duration::from_secs(60 * 2))?;
224 Ok(())
225}
226
227fn check_directory_names(path: &Path) -> Result<(), LangsError> {
230 log::info!("checking directory names for dashes");
231
232 for exercise_dir in super::find_exercise_directories(path)? {
234 let relative = exercise_dir
235 .strip_prefix(path)
236 .expect("the exercise dirs are all inside the path");
237 if relative.to_string_lossy().contains('-') {
238 return Err(LangsError::InvalidDirectory(exercise_dir));
239 }
240 }
241 Ok(())
242}
243
244fn get_and_merge_tmcproject_configs(
245 root_tmcproject: Option<TmcProjectYml>,
246 clone_path: &Path,
247 exercise_dirs: Vec<PathBuf>,
248) -> Result<Vec<(PathBuf, Option<TmcProjectYml>)>, LangsError> {
249 let mut res = vec![];
250 for exercise_dir in exercise_dirs {
251 let target_dir = clone_path.join(&exercise_dir);
252 let exercise_tmcproject = TmcProjectYml::load(&target_dir)?;
253 match (&root_tmcproject, exercise_tmcproject) {
254 (Some(root), Some(mut exercise)) => {
255 exercise.merge(root.clone());
256 res.push((exercise_dir, Some(exercise)));
257 }
258 (Some(root), None) => {
259 res.push((exercise_dir, Some(root.clone())));
260 }
261 (None, Some(exercise)) => res.push((exercise_dir, Some(exercise))),
262 (None, None) => res.push((exercise_dir, None)),
263 }
264 }
265 Ok(res)
266}
267
268fn get_course_options(course_clone_path: &Path, course_name: &str) -> Result<Mapping, LangsError> {
272 log::info!(
273 "collecting course options for {} in {}",
274 course_name,
275 course_clone_path.display()
276 );
277
278 let options_file = course_clone_path.join("course_options.yml");
279 if options_file.exists() {
280 let file = file_util::open_file(&options_file)?;
281 let course_options: Mapping = deserialize::yaml_from_reader(file)
282 .map_err(|e| LangsError::DeserializeYaml(options_file, e))?;
283 Ok(course_options)
284 } else {
285 Ok(Mapping::new())
286 }
287}
288
289fn get_exercises(
292 exercise_dirs_and_tmcprojects: Vec<(PathBuf, Option<TmcProjectYml>)>,
293 course_clone_path: &Path,
294 course_stub_path: &Path,
295) -> Result<Vec<RefreshExercise>, LangsError> {
296 log::info!("finding exercise checksums and points");
297
298 let exercises = exercise_dirs_and_tmcprojects
299 .into_iter()
300 .map(|(exercise_dir, tmcproject_yml)| {
301 log::debug!(
302 "processing points and checksum for {}",
303 exercise_dir.display()
304 );
305 let name = exercise_dir.to_string_lossy().replace('/', "-");
306 let checksum = calculate_checksum(&course_stub_path.join(&exercise_dir))?;
307 let exercise_path = course_clone_path.join(&exercise_dir);
308 let points = super::get_available_points(&exercise_path)?;
309
310 let sandbox_image = if let Some(image_override) = tmcproject_yml
311 .as_ref()
312 .and_then(|y| y.sandbox_image.as_ref())
313 {
314 image_override.clone()
315 } else {
316 crate::get_default_sandbox_image(&exercise_path)?.to_string()
317 };
318
319 Ok(RefreshExercise {
320 name,
321 points,
322 checksum,
323 path: exercise_dir,
324 sandbox_image,
325 tmcproject_yml,
326 })
327 })
328 .collect::<Result<_, LangsError>>()?;
329 Ok(exercises)
330}
331
332fn calculate_checksum(exercise_dir: &Path) -> Result<String, LangsError> {
333 let mut digest = Context::new();
334
335 for entry in WalkDir::new(exercise_dir)
337 .min_depth(1) .sort_by(|a, b| a.file_name().cmp(b.file_name()))
339 {
340 let entry = entry?;
341 let relative = entry
342 .path()
343 .strip_prefix(exercise_dir)
344 .expect("the entry is inside the exercise dir");
345 let string = relative.as_os_str().to_string_lossy();
346 digest.consume(string.as_ref());
347 if entry.path().is_file() {
348 let file = file_util::read_file(entry.path())?;
349 digest.consume(file);
350 }
351 }
352
353 let digest = digest.finalize();
355 Ok(format!("{digest:x}"))
356}
357
358fn execute_zip(
359 course_exercises: &[RefreshExercise],
360 root_path: &Path,
361 zip_dir: &Path,
362) -> Result<(), LangsError> {
363 log::info!(
364 "compressing exercises from from {} to {}",
365 root_path.display(),
366 zip_dir.display()
367 );
368
369 file_util::create_dir_all(zip_dir)?;
370 for exercise in course_exercises {
371 let exercise_root = root_path.join(&exercise.path);
372 let zip_file_path = zip_dir.join(format!("{}.zip", exercise.name));
373
374 let mut writer = zip::ZipWriter::new(file_util::create_file(zip_file_path)?);
375 for entry in WalkDir::new(exercise_root) {
376 let entry = entry?;
377 let relative_path = entry
378 .path()
379 .strip_prefix(root_path)
380 .expect("entries are inside root_path");
381
382 if entry.path().is_file() {
383 writer.start_file(
384 relative_path.to_string_lossy(),
385 SimpleFileOptions::default().unix_permissions(0o755),
386 )?;
387 let bytes = file_util::read_file(entry.path())?;
388 writer.write_all(&bytes).map_err(LangsError::ZipWrite)?;
389 } else {
390 writer.start_file(
392 relative_path.join("").to_string_lossy(), SimpleFileOptions::default().unix_permissions(0o755),
394 )?;
395 }
396 }
397 writer.finish()?;
398 }
399 Ok(())
400}
401
402#[cfg(not(unix))]
403fn set_permissions(_path: &Path) -> Result<(), LangsError> {
404 Ok(())
406}
407
408#[cfg(unix)]
409fn set_permissions(path: &Path) -> Result<(), LangsError> {
410 use nix::sys::stat;
411 use std::os::fd::AsFd;
412
413 log::info!("setting permissions in {}", path.display());
414
415 let chmod: ModeBits = 0o775; for entry in WalkDir::new(path) {
417 let entry = entry?;
418 let file = file_util::open_file(entry.path())?;
419 stat::fchmod(
420 file.as_fd(),
421 stat::Mode::from_bits(chmod).ok_or(LangsError::NixFlag(chmod))?,
422 )
423 .map_err(|e| LangsError::NixPermissionChange(path.to_path_buf(), e))?;
424 }
425
426 Ok(())
427}
428
429fn start_stage(steps: u32, message: impl Into<String>) {
430 progress_reporter::start_stage::<()>(steps, message.into(), None)
431}
432
433fn progress_stage(message: impl Into<String>) {
434 progress_reporter::progress_stage::<()>(message.into(), None)
435}
436
437fn finish_stage(message: impl Into<String>) {
438 progress_reporter::finish_stage::<()>(message.into(), None)
439}
440
441#[cfg(test)]
442#[cfg(unix)] #[allow(clippy::unwrap_used)]
444mod test {
445 use super::*;
446 use crate::find_exercise_directories;
447 use serde_yaml::Value;
448 use std::io::Read;
449 use tempfile::tempdir;
450
451 fn init() {
452 use log::*;
453 use simple_logger::*;
454 let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
455 }
456
457 fn file_to(
458 target_dir: impl AsRef<std::path::Path>,
459 target_relative: impl AsRef<std::path::Path>,
460 contents: impl AsRef<[u8]>,
461 ) -> PathBuf {
462 let target = target_dir.as_ref().join(target_relative);
463 if let Some(parent) = target.parent() {
464 std::fs::create_dir_all(parent).unwrap();
465 }
466 std::fs::write(&target, contents.as_ref()).unwrap();
467 target
468 }
469
470 #[test]
471 fn checks_directory_names() {
472 init();
473
474 let temp = tempfile::tempdir().unwrap();
475 file_to(&temp, "course/valid_part/valid_ex/setup.py", "");
476 assert!(check_directory_names(&temp.path().join("course")).is_ok());
477
478 let course = tempfile::tempdir().unwrap();
479 file_to(course.path(), "course/part1/invalid-ex1/setup.py", "");
480 assert!(check_directory_names(&course.path().join("course")).is_err());
481
482 let course = tempfile::tempdir().unwrap();
483 file_to(course.path(), "course/invalid-part/valid_ex/setup.py", "");
484 assert!(check_directory_names(&course.path().join("course")).is_err());
485 }
486
487 #[test]
488 fn gets_course_options() {
489 init();
490
491 let temp = tempfile::tempdir().unwrap();
492 file_to(&temp, "course_options.yml", "option: true");
493 let options = get_course_options(temp.path(), "some course").unwrap();
494 assert_eq!(options.len(), 1);
495 assert!(
496 options
497 .get(Value::String("option".to_string()))
498 .unwrap()
499 .as_bool()
500 .unwrap()
501 )
502 }
503
504 #[test]
505 fn gets_exercises() {
506 init();
507
508 let temp = tempfile::tempdir().unwrap();
509 file_to(&temp, "course/part1/ex1/setup.py", "");
510 file_to(
511 &temp,
512 "course/part1/ex1/test/test.py",
513 "@points('1') @points('2')",
514 );
515 let exercise_dirs = find_exercise_directories(&temp.path().join("course"))
516 .unwrap()
517 .into_iter()
518 .map(|ed| {
519 (
520 ed.strip_prefix(temp.path().join("course"))
521 .unwrap()
522 .to_path_buf(),
523 None,
524 )
525 })
526 .collect();
527 let exercises = get_exercises(
528 exercise_dirs,
529 &temp.path().join("course"),
530 &temp.path().join("course"),
531 )
532 .unwrap();
533 assert_eq!(exercises.len(), 1);
534 assert_eq!(exercises[0].path, Path::new("part1/ex1"));
535 assert_eq!(exercises[0].points.len(), 2);
536 assert_eq!(exercises[0].points[0], "1");
537 assert_eq!(exercises[0].points[1], "2");
538 assert_eq!(exercises[0].checksum, "129e7e898698465c4f24494219f06df9");
539 }
540
541 #[test]
542 fn executes_zip() {
543 init();
544
545 let temp = tempfile::tempdir().unwrap();
546 file_to(&temp, "clone/part1/ex1/setup.py", "");
547 file_to(&temp, "clone/part1/ex2/setup.py", "");
548 file_to(&temp, "clone/part2/ex1/setup.py", "");
549 file_to(&temp, "clone/part2/ex2/setup.py", "");
550 file_to(&temp, "clone/part2/ex2/dir/subdir/file", "");
551 file_to(&temp, "clone/part2/ex2/.tmcproject.yml", "some: 'yaml'");
552 file_to(&temp, "stub/part1/ex1/setup.py", "");
553 file_to(&temp, "stub/part1/ex2/setup.py", "");
554 file_to(&temp, "stub/part2/ex1/setup.py", "");
555 file_to(&temp, "stub/part2/ex2/setup.py", "");
556 file_to(&temp, "stub/part2/ex2/dir/subdir/file", "some file");
557 file_to(&temp, "stub/part2/ex2/.tmcproject.yml", "some: 'yaml'");
558
559 let exercise_dirs = find_exercise_directories(&temp.path().join("clone"))
560 .unwrap()
561 .into_iter()
562 .map(|ed| {
563 (
564 ed.strip_prefix(temp.path().join("clone"))
565 .unwrap()
566 .to_path_buf(),
567 None,
568 )
569 })
570 .collect();
571 let exercises = get_exercises(
572 exercise_dirs,
573 &temp.path().join("clone"),
574 &temp.path().join("stub"),
575 )
576 .unwrap();
577
578 execute_zip(&exercises, &temp.path().join("stub"), temp.path()).unwrap();
579
580 let zip = temp.path().join("part1-ex1.zip");
581 assert!(zip.exists());
582 let zip = temp.path().join("part1-ex2.zip");
583 assert!(zip.exists());
584 let zip = temp.path().join("part2-ex1.zip");
585 assert!(zip.exists());
586 let zip = temp.path().join("part2-ex2.zip");
587 assert!(zip.exists());
588
589 let mut fz = zip::ZipArchive::new(file_util::open_file(&zip).unwrap()).unwrap();
590 for i in fz.file_names() {
591 log::debug!("{i}");
592 }
593 assert!(
594 fz.by_name(
595 &Path::new("part2")
596 .join("ex2")
597 .join("dir")
598 .join("subdir")
599 .join("")
600 .to_string_lossy(),
601 )
602 .is_ok()
603 ); let mut file = fz
605 .by_name(
606 &Path::new("part2")
607 .join("ex2")
608 .join("dir")
609 .join("subdir")
610 .join("file")
611 .to_string_lossy(),
612 )
613 .unwrap(); let mut buf = String::new();
615 file.read_to_string(&mut buf).unwrap();
616 drop(file);
617
618 assert_eq!(buf, "some file");
619 let mut file = fz
620 .by_name(
621 &Path::new("part2")
622 .join("ex2")
623 .join(".tmcproject.yml")
624 .to_string_lossy(),
625 )
626 .unwrap();
627 let mut buf = String::new();
628 file.read_to_string(&mut buf).unwrap();
629 assert_eq!(buf, "some: 'yaml'");
630 }
631
632 #[test]
633 #[ignore = "issues in CI, maybe due to the user ID"]
634 fn sets_permissions() {
635 init();
636
637 let temp = tempdir().unwrap();
638 file_to(&temp, "file", "contents");
639
640 set_permissions(temp.path()).unwrap();
641 }
642
643 #[test]
644 fn checksum_matches_old_implementation() {
645 init();
646
647 let temp = tempfile::tempdir().unwrap();
648 file_to(
649 &temp,
650 "test/test.py",
651 r#"@points("test_point")
652@points("ex_and_test_point")
653"#,
654 );
655 file_to(
656 &temp,
657 ".hidden file that should be included in the hash",
658 "",
659 );
660 file_to(&temp, "invalid-but-not-dir", "");
661 file_to(&temp, "setup.py", "");
662
663 let checksum = calculate_checksum(temp.path()).unwrap();
664 assert_eq!(checksum, "6cacf02f21f9242674a876954132fb11");
665 }
666
667 #[test]
668 fn merges_tmcproject_configs() {
669 init();
670
671 let temp = tempfile::tempdir().unwrap();
672 let exap = PathBuf::from("exa");
673 let exap_path = temp.path().join(&exap);
674 file_util::create_dir(&exap_path).unwrap();
675 let exbp = PathBuf::from("exb");
676 let exbp_path = temp.path().join(&exbp);
677 file_util::create_dir(&exbp_path).unwrap();
678
679 let root = TmcProjectYml {
680 tests_timeout_ms: Some(1234),
681 fail_on_valgrind_error: Some(true),
682 ..Default::default()
683 };
684 let tpya = TmcProjectYml {
685 tests_timeout_ms: Some(2345),
686 ..Default::default()
687 };
688 tpya.save_to_dir(&exap_path).unwrap();
689 let tpyb = TmcProjectYml {
690 fail_on_valgrind_error: Some(false),
691 ..Default::default()
692 };
693 tpyb.save_to_dir(&exbp_path).unwrap();
694 let exercise_dirs = vec![exap, exbp];
695
696 let dirs_configs =
697 get_and_merge_tmcproject_configs(Some(root), temp.path(), exercise_dirs).unwrap();
698
699 let (_, tpya) = &dirs_configs
700 .iter()
701 .find(|(p, _)| p.ends_with("exa"))
702 .unwrap();
703 let tpya = tpya.as_ref().unwrap();
704 assert_eq!(tpya.tests_timeout_ms, Some(2345));
705 assert_eq!(tpya.fail_on_valgrind_error, Some(true));
706
707 let (_, tpyb) = &dirs_configs
708 .iter()
709 .find(|(p, _)| p.ends_with("exb"))
710 .unwrap();
711 let tpyb = tpyb.as_ref().unwrap();
712 assert_eq!(tpyb.tests_timeout_ms, Some(1234));
713 assert_eq!(tpyb.fail_on_valgrind_error, Some(false));
714 }
715
716 #[test]
717 fn merges_tmcproject_configs_exercise_overrides_root() {
718 init();
719
720 let temp = tempfile::tempdir().unwrap();
721 let exercise_path = PathBuf::from("exercise");
722 let exercise_dir = temp.path().join(&exercise_path);
723 file_util::create_dir(&exercise_dir).unwrap();
724
725 let root = TmcProjectYml {
727 tests_timeout_ms: Some(1000),
728 fail_on_valgrind_error: Some(true),
729 ..Default::default()
730 };
731
732 let exercise_config = TmcProjectYml {
734 tests_timeout_ms: Some(2000),
735 ..Default::default()
736 };
737 exercise_config.save_to_dir(&exercise_dir).unwrap();
738
739 let exercise_dirs = vec![exercise_path];
740
741 let dirs_configs =
742 get_and_merge_tmcproject_configs(Some(root), temp.path(), exercise_dirs).unwrap();
743
744 let (_, merged_config) = &dirs_configs[0];
745 let merged_config = merged_config.as_ref().unwrap();
746
747 assert_eq!(merged_config.tests_timeout_ms, Some(2000));
749 assert_eq!(merged_config.fail_on_valgrind_error, Some(true));
751 }
752}