1#![deny(clippy::print_stdout, clippy::print_stderr, clippy::unwrap_used)]
2
3mod config;
6mod course_refresher;
7mod data;
8mod error;
9mod submission_packaging;
10mod submission_processing;
11
12use crate::data::{DownloadTarget, DownloadTargetKind};
13pub use crate::{
14 config::{
15 Credentials, ProjectsConfig, ProjectsDirTmcExercise, TmcConfig, TmcCourseConfig,
16 list_local_tmc_course_exercises, migrate_exercise, move_projects_dir,
17 },
18 course_refresher::{RefreshData, RefreshExercise, refresh_course},
19 data::{
20 CombinedCourseData, ConfigValue, DownloadOrUpdateMoocCourseExercisesResult,
21 DownloadOrUpdateTmcCourseExercisesResult, DownloadResult, LocalExercise, LocalMoocExercise,
22 LocalTmcExercise, MoocExerciseDownload, TmcExerciseDownload, TmcParams,
23 },
24 error::{LangsError, ParamError},
25 submission_packaging::{PrepareSubmission, prepare_submission},
26 submission_processing::prepare_solution,
27};
28use hmac::{Hmac, Mac};
29use jwt::SignWithKey;
31use oauth2::{
32 AccessToken, EmptyExtraTokenFields, Scope, StandardTokenResponse, basic::BasicTokenType,
33};
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36use sha2::Sha256;
37use std::{
38 collections::{BTreeMap, HashMap},
39 convert::TryFrom,
40 ffi::OsStr,
41 io::Cursor,
42 path::{Path, PathBuf},
43 sync::{Arc, Mutex},
44};
45use tmc_langs_framework::Archive;
46pub use tmc_langs_framework::{
47 CommandError, Compression, ExerciseDesc, ExercisePackagingConfiguration, Language,
48 LanguagePlugin, PythonVer, RunResult, RunStatus, StyleValidationError, StyleValidationResult,
49 StyleValidationStrategy, TestDesc, TestResult, TmcProjectYml,
50};
51use tmc_langs_plugins::{
52 CSharpPlugin, MakePlugin, NoTestsPlugin, Plugin, PluginType, Python3Plugin, RPlugin,
53};
54use tmc_langs_util::file_util::LOCK_FILE_NAME;
55pub use tmc_langs_util::{file_util, notification_reporter, progress_reporter};
57pub use tmc_mooc_client as mooc;
58use tmc_mooc_client::{MoocClient, api::ExerciseUpdateData};
59pub use tmc_testmycode_client as tmc;
60use toml::Value as TomlValue;
61use url::Url;
62use walkdir::WalkDir;
63#[cfg(not(target_env = "musl"))]
64use {
65 tmc_langs_framework::TmcError,
66 tmc_langs_plugins::{AntPlugin, MavenPlugin},
67};
68
69const TMC_LANGS_CONFIG_DIR_VAR: &str = "TMC_LANGS_CONFIG_DIR";
70
71#[derive(Debug, Serialize, Deserialize, JsonSchema)]
72#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
73pub struct UpdatedExercise {
74 pub id: u32,
75}
76
77pub fn sign_with_jwt<T: Serialize>(value: T, secret: &[u8]) -> Result<String, LangsError> {
93 let key: Hmac<Sha256> = Hmac::<Sha256>::new_from_slice(secret)?;
94 let token = value.sign_with_key(&key)?;
95 Ok(token)
96}
97
98pub fn get_projects_dir(client_name: &str) -> Result<PathBuf, LangsError> {
101 let projects_dir = TmcConfig::load(client_name)?.projects_dir;
102 Ok(projects_dir)
103}
104
105pub fn check_exercise_updates(
108 client: &tmc::TestMyCodeClient,
109 projects_dir: &Path,
110) -> Result<Vec<u32>, LangsError> {
111 log::debug!("checking exercise updates in {}", projects_dir.display());
112
113 let mut updated_exercises = vec![];
114
115 let config = ProjectsConfig::load(projects_dir)?;
116 let local_exercises = config.get_all_tmc_exercises().collect::<Vec<_>>();
117
118 if !local_exercises.is_empty() {
120 let exercise_ids = local_exercises.iter().map(|e| e.id).collect::<Vec<_>>();
121 let server_exercises = client
122 .get_exercises_details(&exercise_ids)?
123 .into_iter()
124 .map(|e| (e.id, e))
125 .collect::<HashMap<_, _>>();
126 for local_exercise in local_exercises {
127 let server_exercise = server_exercises
128 .get(&local_exercise.id)
129 .ok_or(LangsError::ExerciseMissingOnServer(local_exercise.id))?;
130 if server_exercise.checksum != local_exercise.checksum {
131 updated_exercises.push(local_exercise.id);
133 }
134 }
135 }
136 Ok(updated_exercises)
137}
138
139pub fn download_old_submission(
143 client: &tmc::TestMyCodeClient,
144 exercise_id: u32,
145 output_path: &Path,
146 submission_id: u32,
147 save_old_state: bool,
148) -> Result<(), LangsError> {
149 log::debug!("downloading old submission {submission_id} for {exercise_id}");
150
151 if save_old_state {
152 let tmc_project_yml = TmcProjectYml::load_or_default(output_path)?;
154 client.submit(
155 exercise_id,
156 output_path,
157 tmc_project_yml.get_submission_size_limit_mb(),
158 None,
159 )?;
160 log::debug!("finished submission");
161 }
162
163 reset(client, exercise_id, output_path)?;
165 log::debug!("reset exercise");
166
167 let mut buf = vec![];
169 client.download_old_submission(submission_id, &mut buf)?;
170 log::debug!("downloaded old submission");
171
172 extract_student_files(Cursor::new(buf), Compression::Zip, output_path)?;
174 log::debug!("extracted project");
175 Ok(())
176}
177
178pub fn submit_exercise(
180 client: &tmc::TestMyCodeClient,
181 projects_dir: &Path,
182 course_slug: &str,
183 exercise_slug: &str,
184 locale: Option<Language>,
185) -> Result<tmc::response::NewSubmission, LangsError> {
186 let projects_config = ProjectsConfig::load(projects_dir)?;
187 let exercise = projects_config
188 .get_tmc_exercise(course_slug, exercise_slug)
189 .ok_or(LangsError::NoProjectExercise)?;
190
191 let exercise_path =
192 ProjectsConfig::get_tmc_exercise_download_target(projects_dir, course_slug, exercise_slug);
193
194 let tmc_project_yml = TmcProjectYml::load_or_default(&exercise_path)?;
195 client
196 .submit(
197 exercise.id,
198 exercise_path.as_path(),
199 tmc_project_yml.get_submission_size_limit_mb(),
200 locale,
201 )
202 .map_err(Into::into)
203}
204
205pub fn paste_exercise(
207 client: &tmc::TestMyCodeClient,
208 projects_dir: &Path,
209 course_slug: &str,
210 exercise_slug: &str,
211 paste_message: Option<String>,
212 locale: Option<Language>,
213) -> Result<tmc::response::NewSubmission, LangsError> {
214 let projects_config = ProjectsConfig::load(projects_dir)?;
215 let exercise = projects_config
216 .get_tmc_exercise(course_slug, exercise_slug)
217 .ok_or(LangsError::NoProjectExercise)?;
218
219 let exercise_path =
220 ProjectsConfig::get_tmc_exercise_download_target(projects_dir, course_slug, exercise_slug);
221
222 let tmc_project_yml = TmcProjectYml::load_or_default(&exercise_path)?;
223 client
224 .paste(
225 exercise.id,
226 exercise_path.as_path(),
227 paste_message,
228 locale,
229 tmc_project_yml.get_submission_size_limit_mb(),
230 )
231 .map_err(Into::into)
232}
233
234pub fn download_or_update_course_exercises(
241 client: &tmc::TestMyCodeClient,
242 projects_dir: &Path,
243 exercises: &[u32],
244 download_template: bool,
245) -> Result<DownloadResult, LangsError> {
246 log::debug!(
247 "downloading or updating course exercises in {}",
248 projects_dir.display()
249 );
250
251 client.require_authentication().map_err(Box::new)?;
252
253 let exercises_details = client.get_exercises_details(exercises)?;
254 let projects_config = ProjectsConfig::load(projects_dir)?;
255
256 let mut to_be_downloaded = vec![];
258 let mut to_be_skipped = vec![];
259
260 log::debug!("checking the checksum of each exercise on the server");
261 for exercise_detail in exercises_details {
262 let target = ProjectsConfig::get_tmc_exercise_download_target(
263 projects_dir,
264 &exercise_detail.course_name,
265 &exercise_detail.exercise_name,
266 );
267
268 if let Some(exercise) = projects_config
270 .get_tmc_exercise(&exercise_detail.course_name, &exercise_detail.exercise_name)
271 {
272 if exercise_detail.checksum == exercise.checksum {
274 log::info!(
276 "Skipping exercise {} ({} in {}) due to identical checksum",
277 exercise_detail.id,
278 exercise_detail.course_name,
279 exercise_detail.exercise_name
280 );
281 to_be_skipped.push(TmcExerciseDownload {
282 id: exercise_detail.id,
283 course_slug: exercise_detail.course_name,
284 exercise_slug: exercise_detail.exercise_name,
285 path: target,
286 });
287 continue;
288 }
289 } else {
290 if !download_template {
292 if let Some(latest_submission) = client
293 .get_exercise_submissions_for_current_user(exercise_detail.id)?
294 .into_iter()
295 .max_by_key(|s| s.created_at)
296 {
297 if !exercise_detail.hide_submission_results {
299 to_be_downloaded.push(DownloadTarget {
300 target: TmcExerciseDownload {
301 id: exercise_detail.id,
302 course_slug: exercise_detail.course_name,
303 exercise_slug: exercise_detail.exercise_name,
304 path: target,
305 },
306 checksum: exercise_detail.checksum,
307 kind: DownloadTargetKind::Submission {
308 submission_id: latest_submission.id,
309 },
310 });
311 continue;
312 }
313 }
314 }
315 }
316
317 to_be_downloaded.push(DownloadTarget {
319 target: TmcExerciseDownload {
320 id: exercise_detail.id,
321 course_slug: exercise_detail.course_name.clone(),
322 exercise_slug: exercise_detail.exercise_name.clone(),
323 path: target,
324 },
325 checksum: exercise_detail.checksum,
326 kind: DownloadTargetKind::Template,
327 });
328 }
329
330 let exercises_len = to_be_downloaded.len();
331 progress_reporter::start_stage::<()>(
332 u32::try_from(exercises_len).expect("should never happen") * 2 + 1, format!("Downloading {exercises_len} exercises"),
334 None,
335 );
336
337 log::debug!("downloading exercises");
338 let thread_count = to_be_downloaded.len().min(4); let mut handles = vec![];
341 let exercises = Arc::new(Mutex::new(to_be_downloaded));
342 let projects_config = Arc::new(Mutex::new(projects_config));
343 for _thread_id in 0..thread_count {
344 let client = client.clone();
345 let exercises = Arc::clone(&exercises);
346 let projects_config = Arc::clone(&projects_config);
347 let projects_dir = projects_dir.to_path_buf();
348
349 type ThreadErr = (Vec<DownloadTarget>, Vec<(DownloadTarget, LangsError)>);
351 let handle = std::thread::spawn(move || -> Result<Vec<DownloadTarget>, ThreadErr> {
352 let mut downloaded = vec![];
353 let mut failed = vec![];
354
355 loop {
357 let mut exercises = exercises.lock().expect("the threads should never panic");
358 let download_target = if let Some(download_target) = exercises.pop() {
359 download_target
360 } else {
361 break;
363 };
364 drop(exercises);
365 let exercise_download_result = || -> Result<(), LangsError> {
368 progress_reporter::progress_stage::<tmc::ClientUpdateData>(
369 format!(
370 "Downloading exercise {} to '{}'",
371 download_target.target.id,
372 download_target.target.path.display(),
373 ),
374 Some(tmc::ClientUpdateData::ExerciseDownload {
375 id: download_target.target.id,
376 path: download_target.target.path.clone(),
377 }),
378 );
379
380 match &download_target.kind {
382 DownloadTargetKind::Template => {
383 let mut buf = vec![];
384 client.download_exercise(download_target.target.id, &mut buf)?;
385 extract_project(
386 Cursor::new(buf),
387 &download_target.target.path,
388 Compression::Zip,
389 false,
390 false,
391 )?;
392 }
393 DownloadTargetKind::Submission { submission_id } => {
394 let mut buf = vec![];
395 client.download_exercise(download_target.target.id, &mut buf)?;
396 extract_project(
397 Cursor::new(buf),
398 &download_target.target.path,
399 Compression::Zip,
400 false,
401 false,
402 )?;
403
404 let plugin = PluginType::from_exercise(&download_target.target.path)?;
405 let config = plugin.get_exercise_packaging_configuration(
406 &download_target.target.path,
407 )?;
408 for student_file in config.student_file_paths {
409 let student_file = download_target.target.path.join(student_file);
410 if student_file.is_file() {
411 file_util::remove_file(&student_file)?;
412 } else {
413 file_util::remove_dir_all(&student_file)?;
414 }
415 }
416
417 let mut buf = vec![];
418 client.download_old_submission(*submission_id, &mut buf)?;
419 if let Err(err) = plugin.extract_student_files(
420 Cursor::new(buf),
421 Compression::Zip,
422 &download_target.target.path,
423 ) {
424 log::error!(
425 "Something went wrong when downloading old submission: {err}"
426 );
427 }
428 }
429 }
430 let mut projects_config =
432 projects_config.lock().map_err(|_| LangsError::MutexError)?; let course_config = projects_config
434 .get_or_init_tmc_course_config(download_target.target.course_slug.clone());
435 course_config.add_exercise(
436 download_target.target.exercise_slug.clone(),
437 download_target.target.id,
438 download_target.checksum.clone(),
439 );
440 course_config.save_to_projects_dir(&projects_dir)?;
441 drop(projects_config); progress_reporter::progress_stage::<tmc::ClientUpdateData>(
444 format!(
445 "Downloaded exercise {} to '{}'",
446 download_target.target.id,
447 download_target.target.path.display(),
448 ),
449 Some(tmc::ClientUpdateData::ExerciseDownload {
450 id: download_target.target.id,
451 path: download_target.target.path.clone(),
452 }),
453 );
454
455 Ok(())
456 }();
457
458 match exercise_download_result {
459 Ok(_) => {
460 downloaded.push(download_target);
461 }
462 Err(err) => {
463 failed.push((download_target, err));
464 }
465 }
466 }
467 if failed.is_empty() {
468 Ok(downloaded)
469 } else {
470 Err((downloaded, failed))
471 }
472 });
473 handles.push(handle);
474 }
475
476 let mut successful = vec![];
478 let mut failed = vec![];
479 for handle in handles {
480 match handle.join().expect("the threads should never panic") {
481 Ok(s) => successful.extend(s),
482 Err((s, f)) => {
483 successful.extend(s);
484 failed.extend(f);
485 }
486 }
487 }
488
489 let finish_message = if failed.is_empty() {
491 if successful.is_empty() && exercises_len == 0 {
492 "Exercises are already up-to-date!".to_string()
493 } else {
494 format!(
495 "Successfully downloaded {} out of {} exercises.",
496 successful.len(),
497 exercises_len
498 )
499 }
500 } else {
501 format!(
502 "Downloaded {} out of {} exercises ({} failed)",
503 successful.len(),
504 exercises_len,
505 failed.len(),
506 )
507 };
508 progress_reporter::finish_stage::<tmc::ClientUpdateData>(finish_message, None);
509
510 let downloaded = successful.into_iter().map(|t| t.target).collect();
512 if !failed.is_empty() {
513 let failed = failed
515 .into_iter()
516 .map(|(target, err)| {
517 let mut error = &err as &dyn std::error::Error;
518 let mut chain = vec![error.to_string()];
519 while let Some(source) = error.source() {
520 chain.push(source.to_string());
521 error = source;
522 }
523 (target.target, chain)
524 })
525 .collect();
526 return Ok(DownloadResult::Failure {
527 downloaded,
528 skipped: to_be_skipped,
529 failed,
530 });
531 }
532
533 Ok(DownloadResult::Success {
534 downloaded,
535 skipped: to_be_skipped,
536 })
537}
538
539pub fn get_course_data(
541 client: &tmc::TestMyCodeClient,
542 course_id: u32,
543) -> Result<CombinedCourseData, LangsError> {
544 log::debug!("getting course data for {course_id}");
545
546 let details = client.get_course_details(course_id)?;
547 let exercises = client.get_course_exercises(course_id)?;
548 let settings = client.get_course(course_id)?;
549 Ok(CombinedCourseData {
550 details,
551 exercises,
552 settings,
553 })
554}
555
556pub fn login_with_token(token: String) -> tmc::Token {
558 log::debug!("creating token from token string");
559
560 let mut token_response = StandardTokenResponse::new(
561 AccessToken::new(token),
562 BasicTokenType::Bearer,
563 EmptyExtraTokenFields {},
564 );
565 token_response.set_scopes(Some(vec![Scope::new("public".to_string())]));
566 token_response
567}
568
569pub fn login_with_password(
572 client: &mut tmc::TestMyCodeClient,
573 email: String,
574 password: String,
575) -> Result<tmc::Token, LangsError> {
576 log::debug!("logging in with password");
577 let token = client.authenticate(email, password)?;
578 Ok(token)
579}
580
581pub fn init_testmycode_client_with_credentials(
583 root_url: Url,
584 client_name: &str,
585 client_version: &str,
586) -> Result<(tmc::TestMyCodeClient, Option<Credentials>), LangsError> {
587 let mut client = tmc::TestMyCodeClient::new(
589 root_url,
590 client_name.to_string(),
591 client_version.to_string(),
592 )?;
593
594 let credentials = Credentials::load(client_name)?;
596 if let Some(credentials) = &credentials {
597 client.set_token(credentials.token());
598 }
599
600 Ok((client, credentials))
601}
602
603pub fn init_mooc_client_with_credentials(
605 root_url: Url,
606 client_name: &str,
607) -> Result<(mooc::MoocClient, Option<Credentials>), LangsError> {
608 let mut client = mooc::MoocClient::new(root_url);
610
611 let credentials = Credentials::load(client_name)?;
613 if let Some(credentials) = &credentials {
614 client.set_token(credentials.token());
615 }
616
617 Ok((client, credentials))
618}
619
620pub fn update_tmc_exercises(
623 client: &tmc::TestMyCodeClient,
624 projects_dir: &Path,
625) -> Result<DownloadOrUpdateTmcCourseExercisesResult, LangsError> {
626 log::debug!("updating exercises in {}", projects_dir.display());
627
628 let mut course_data = HashMap::<String, Vec<(String, String, u32)>>::new();
629
630 let mut projects_config = ProjectsConfig::load(projects_dir)?;
631
632 let exercises = projects_config
633 .tmc_courses
634 .values()
635 .flat_map(|cc| cc.exercises.values())
636 .collect::<Vec<_>>();
637
638 let mut exercises_to_update = vec![];
639 if !exercises.is_empty() {
641 let tmc_exercise_ids = exercises.iter().map(|e| e.id).collect::<Vec<_>>();
642 let mut tmc_server_exercises = client
643 .get_exercises_details(&tmc_exercise_ids)?
644 .into_iter()
645 .map(|e| (e.id, e))
646 .collect::<HashMap<_, _>>();
647
648 for course_config in projects_config.tmc_courses.values_mut() {
650 for local_exercise in course_config.exercises.values_mut() {
651 let server_exercise = tmc_server_exercises
652 .remove(&local_exercise.id)
653 .ok_or(LangsError::ExerciseMissingOnServer(local_exercise.id))?;
654 if server_exercise.checksum != local_exercise.checksum {
655 let target = ProjectsConfig::get_tmc_exercise_download_target(
657 projects_dir,
658 &server_exercise.course_name,
659 &server_exercise.exercise_name,
660 );
661 exercises_to_update.push(TmcExerciseDownload {
662 id: server_exercise.id,
663 course_slug: server_exercise.course_name.clone(),
664 exercise_slug: server_exercise.exercise_name.clone(),
665 path: target,
666 });
667 *local_exercise = ProjectsDirTmcExercise {
668 id: server_exercise.id,
669 checksum: server_exercise.checksum,
670 };
671 }
672 let data = course_data.entry(course_config.course.clone()).or_default();
673 data.push((
674 server_exercise.exercise_name,
675 local_exercise.checksum.clone(),
676 local_exercise.id,
677 ));
678 }
679 }
680 if !exercises_to_update.is_empty() {
681 for exercise in &exercises_to_update {
682 let mut buf = vec![];
683 client.download_exercise(exercise.id, &mut buf)?;
684 extract_project(
685 Cursor::new(buf),
686 &exercise.path,
687 Compression::Zip,
688 false,
689 false,
690 )?;
691 }
692 for (course_name, exercise_names) in course_data {
693 let mut exercises = BTreeMap::new();
694 for (exercise_name, checksum, id) in exercise_names {
695 exercises.insert(exercise_name, ProjectsDirTmcExercise { id, checksum });
696 }
697
698 if let Some(course_config) = projects_config.tmc_courses.get_mut(&course_name) {
699 course_config.exercises.extend(exercises);
700 course_config.save_to_projects_dir(projects_dir)?;
701 } else {
702 let course_config = TmcCourseConfig {
703 course: course_name,
704 exercises,
705 };
706 course_config.save_to_projects_dir(projects_dir)?;
707 };
708 }
709 }
710 }
711
712 Ok(DownloadOrUpdateTmcCourseExercisesResult {
713 downloaded: exercises_to_update,
714 skipped: vec![],
715 failed: None,
716 })
717}
718
719pub fn update_mooc_exercises(
721 client: &MoocClient,
722 projects_dir: &Path,
723) -> Result<DownloadOrUpdateMoocCourseExercisesResult, LangsError> {
724 let projects_config = ProjectsConfig::load(projects_dir)?;
725 let exercises = projects_config
726 .mooc_courses
727 .values()
728 .map(|cc| (cc, &cc.exercises))
729 .flat_map(|(cc, cce)| cce.values().map(move |e| (e.id, (cc, e))))
730 .collect::<HashMap<_, _>>();
731
732 let mut downloaded = Vec::new();
733 if !exercises.is_empty() {
734 let exercise_update_data = exercises
735 .values()
736 .map(|(_c, e)| ExerciseUpdateData {
737 id: e.id,
738 checksum: &e.checksum,
739 })
740 .collect::<Vec<_>>();
741 let exercise_updates = client.check_exercise_updates(&exercise_update_data)?;
742 for updated_exercise in exercise_updates.updated_exercises {
743 if let Some((course, exercise)) = exercises.get(&updated_exercise) {
744 let target = ProjectsConfig::get_mooc_exercise_download_target(
745 projects_dir,
746 &course.directory,
747 &exercise.directory,
748 );
749 let data = client.download_exercise(updated_exercise)?;
750 extract_project(Cursor::new(data), &target, Compression::Zip, false, false)?;
751 downloaded.push(MoocExerciseDownload {
752 id: updated_exercise,
753 path: target,
754 })
755 } else {
756 log::warn!("Server returned unexpected exercise id {updated_exercise}");
757 }
758 }
759 for _deleted_exercise in exercise_updates.deleted_exercises {
760 }
762 }
763
764 Ok(DownloadOrUpdateMoocCourseExercisesResult {
765 downloaded,
766 skipped: vec![],
767 failed: None,
768 })
769}
770
771pub fn get_setting(client_name: &str, key: &str) -> Result<ConfigValue, LangsError> {
773 log::debug!("fetching setting {key} in {client_name}");
774
775 let tmc_config = get_settings(client_name)?;
776 let value = match key {
777 "projects-dir" => ConfigValue::Path(tmc_config.get_projects_dir().to_path_buf()),
778 other => ConfigValue::Value(tmc_config.get(other).cloned()),
779 };
780 Ok(value)
781}
782
783pub fn get_settings(client_name: &str) -> Result<TmcConfig, LangsError> {
785 log::debug!("fetching settings for {client_name}");
786
787 TmcConfig::load(client_name)
788}
789
790pub fn set_setting<T: Serialize>(client_name: &str, key: &str, value: T) -> Result<(), LangsError> {
792 log::debug!("setting {key} in {client_name}");
793
794 let mut tmc_config = TmcConfig::load(client_name)?;
795
796 let value = TomlValue::try_from(value)?;
797 match key {
798 "projects-dir" => {
799 let TomlValue::String(value) = value else {
800 return Err(LangsError::ProjectsDirNotString);
801 };
802 tmc_config.set_projects_dir(PathBuf::from(value))?;
803 }
804 other => {
805 tmc_config.insert(other.to_string(), value);
806 }
807 }
808
809 tmc_config.save()?;
810 Ok(())
811}
812
813pub fn reset_settings(client_name: &str) -> Result<(), LangsError> {
815 log::debug!("resetting settings in {client_name}");
816
817 TmcConfig::reset(client_name)?;
818 Ok(())
819}
820
821pub fn unset_setting(client_name: &str, key: &str) -> Result<Option<TomlValue>, LangsError> {
823 log::debug!("unsetting setting {key} in {client_name}");
824
825 let mut tmc_config = TmcConfig::load(client_name)?;
826 let old_value = tmc_config.remove(key);
827 tmc_config.save()?;
828
829 Ok(old_value)
830}
831
832pub fn checkstyle(
834 exercise_path: &Path,
835 locale: Language,
836) -> Result<Option<StyleValidationResult>, LangsError> {
837 log::debug!("checking code style in {}", exercise_path.display());
838
839 let style_validation_result =
840 Plugin::from_exercise(exercise_path)?.check_code_style(exercise_path, locale)?;
841 Ok(style_validation_result)
842}
843
844pub fn clean(exercise_path: &Path) -> Result<(), LangsError> {
846 log::debug!("cleaning {}", exercise_path.display());
847
848 Plugin::from_exercise(exercise_path)?.clean(exercise_path)?;
849 Ok(())
850}
851
852pub fn compress_project_to(
854 source: &Path,
855 target: &Path,
856 compression: Compression,
857 deterministic: bool,
858 naive: bool,
859) -> Result<(), LangsError> {
860 log::debug!(
861 "compressing {} to {} ({})",
862 source.display(),
863 target.display(),
864 compression
865 );
866
867 let tmc_project_yml = TmcProjectYml::load_or_default(source)?;
868 let (data, _hash) = tmc_langs_plugins::compress_project(
869 source,
870 compression,
871 deterministic,
872 naive,
873 false,
874 tmc_project_yml.get_submission_size_limit_mb(),
875 )?;
876 file_util::write_to_file(data, target)?;
877 Ok(())
878}
879
880pub fn compress_project_to_with_hash(
883 source: &Path,
884 target: &Path,
885 compression: Compression,
886 deterministic: bool,
887 naive: bool,
888) -> Result<String, LangsError> {
889 log::debug!(
890 "compressing {} to {} ({})",
891 source.display(),
892 target.display(),
893 compression
894 );
895
896 let tmc_project_yml = TmcProjectYml::load_or_default(source)?;
897 let (data, hash) = tmc_langs_plugins::compress_project(
898 source,
899 compression,
900 deterministic,
901 naive,
902 true,
903 tmc_project_yml.get_submission_size_limit_mb(),
904 )?;
905 let hash = hash.expect("set hash to true");
906 file_util::write_to_file(data, target)?;
907 Ok(hash.to_string())
908}
909
910pub fn reset(
924 client: &tmc::TestMyCodeClient,
925 exercise_id: u32,
926 exercise_path: &Path,
927) -> Result<(), LangsError> {
928 if exercise_path.exists() {
929 file_util::remove_dir_all(exercise_path)?;
931 }
932 let mut buf = vec![];
933 client.download_exercise(exercise_id, &mut buf)?;
934 extract_project(
935 Cursor::new(buf),
936 exercise_path,
937 Compression::Zip,
938 false,
939 false,
940 )?;
941 Ok(())
942}
943
944pub fn extract_project(
946 compressed_project: impl std::io::Read + std::io::Seek,
947 target_location: &Path,
948 compression: Compression,
949 clean: bool,
950 naive: bool,
951) -> Result<(), LangsError> {
952 log::debug!(
953 "extracting compressed project to {}",
954 target_location.display()
955 );
956
957 if naive {
958 extract_project_overwrite(compressed_project, target_location, compression)?;
959 } else if let Ok(plugin) = PluginType::from_exercise(target_location) {
960 let mut archive = Archive::new(compressed_project, compression)?;
961 plugin.extract_project(&mut archive, target_location, clean)?;
962 } else {
963 let mut archive = Archive::new(compressed_project, compression)?;
964 if let Ok(plugin) = PluginType::from_archive(&mut archive) {
965 plugin.extract_project(&mut archive, target_location, clean)?;
966 } else {
967 log::debug!(
968 "no matching language plugin found for compressed project, extracting naively",
969 );
970 let compressed_project = archive.into_inner();
971 extract_project_overwrite(compressed_project, target_location, compression)?;
972 }
973 }
974 Ok(())
975}
976
977pub fn get_available_points(exercise_path: &Path) -> Result<Vec<String>, LangsError> {
979 log::debug!("parsing available points in {}", exercise_path.display());
980
981 let points = PluginType::from_exercise(exercise_path)?.get_available_points(exercise_path)?;
982 Ok(points)
983}
984
985pub fn find_exercise_directories(exercise_path: &Path) -> Result<Vec<PathBuf>, LangsError> {
987 log::info!(
988 "finding exercise directories in {}",
989 exercise_path.display()
990 );
991
992 let mut paths = vec![];
993 for entry in WalkDir::new(exercise_path).into_iter().filter_entry(|e| {
994 !submission_processing::is_hidden_dir(e)
995 && e.file_name() != "private"
996 && !submission_processing::contains_tmcignore(e)
997 }) {
998 let entry = entry?;
999 if Plugin::from_exercise(entry.path()).is_ok() {
1001 paths.push(entry.into_path())
1002 }
1003 }
1004 Ok(paths)
1005}
1006
1007pub fn get_exercise_packaging_configuration(
1009 path: &Path,
1010) -> Result<ExercisePackagingConfiguration, LangsError> {
1011 log::debug!("getting exercise packaging config for {}", path.display());
1012
1013 let plugin = PluginType::from_exercise(path)?;
1014 let config = plugin.get_exercise_packaging_configuration(path)?;
1015 Ok(config)
1016}
1017
1018pub fn prepare_stub(exercise_path: &Path, dest_path: &Path) -> Result<(), LangsError> {
1020 log::debug!(
1021 "preparing stub for {} in {}",
1022 exercise_path.display(),
1023 dest_path.display()
1024 );
1025
1026 submission_processing::prepare_stub(exercise_path, dest_path)?;
1027
1028 #[cfg(not(target_env = "musl"))]
1031 if let Ok(PluginType::Ant) = PluginType::from_exercise(exercise_path) {
1032 AntPlugin::copy_tmc_junit_runner(dest_path).map_err(|e| TmcError::Plugin(Box::new(e)))?;
1033 }
1034 Ok(())
1035}
1036
1037pub fn run_tests(path: &Path) -> Result<RunResult, LangsError> {
1039 log::debug!("running tests in {}", path.display());
1040
1041 Ok(Plugin::from_exercise(path)?.run_tests(path)?)
1042}
1043
1044pub fn scan_exercise(path: &Path, exercise_name: String) -> Result<ExerciseDesc, LangsError> {
1046 log::debug!("scanning exercise in {}", path.display());
1047
1048 Ok(Plugin::from_exercise(path)?.scan_exercise(path, exercise_name)?)
1049}
1050
1051pub fn extract_student_files(
1053 compressed_project: impl std::io::Read + std::io::Seek,
1054 compression: Compression,
1055 target_location: &Path,
1056) -> Result<(), LangsError> {
1057 log::debug!(
1058 "extracting student files from compressed project to {}",
1059 target_location.display()
1060 );
1061
1062 if let Ok(plugin) = PluginType::from_exercise(target_location) {
1063 plugin.extract_student_files(compressed_project, compression, target_location)?;
1064 } else {
1065 let mut archive = Archive::new(compressed_project, compression)?;
1066 if let Ok(plugin) = PluginType::from_archive(&mut archive) {
1067 let compressed_project = archive.into_inner();
1068 plugin.extract_student_files(compressed_project, compression, target_location)?;
1069 } else {
1070 log::debug!(
1071 "no matching language plugin found for {}, extracting naively",
1072 target_location.display()
1073 );
1074 archive.extract(target_location)?;
1075 }
1076 }
1077 Ok(())
1078}
1079
1080fn move_dir(source: &Path, target: &Path) -> Result<(), LangsError> {
1081 let mut file_count_copied = 0;
1082 let mut file_count_total = 0;
1083 for entry in WalkDir::new(source) {
1084 let entry = entry?;
1085 if entry.path().is_file() {
1086 file_count_total += 1;
1087 }
1088 }
1089 start_stage(
1090 file_count_total + 1,
1091 format!("Moving dir {} -> {}", source.display(), target.display()),
1092 );
1093
1094 for entry in WalkDir::new(source).contents_first(true).min_depth(1) {
1095 let entry = entry?;
1096 let entry_path = entry.path();
1097
1098 if entry_path.file_name() == Some(OsStr::new(LOCK_FILE_NAME)) {
1099 log::info!("skipping lock file");
1100 file_count_copied += 1;
1101 progress_stage(format!(
1102 "Skipped moving file {file_count_copied} / {file_count_total}"
1103 ));
1104 continue;
1105 }
1106
1107 if entry_path.is_file() {
1108 let relative = entry_path
1109 .strip_prefix(source)
1110 .expect("the entry is inside the source");
1111 let target_path = target.join(relative);
1112 log::debug!(
1113 "Moving {} -> {}",
1114 entry_path.display(),
1115 target_path.display()
1116 );
1117
1118 if let Some(parent) = target_path.parent() {
1120 file_util::create_dir_all(parent)?;
1121 }
1122 file_util::copy(entry_path, &target_path)?;
1123 file_util::remove_file(entry_path)?;
1124
1125 file_count_copied += 1;
1126 progress_stage(format!(
1127 "Moved file {file_count_copied} / {file_count_total}"
1128 ));
1129 } else if entry_path.is_dir() {
1130 log::debug!("Deleting {}", entry_path.display());
1131 file_util::remove_dir_empty(entry_path)?;
1132 }
1133 }
1134
1135 file_util::remove_file(source.join(file_util::LOCK_FILE_NAME)).ok();
1137 file_util::remove_dir_empty(source)?;
1138
1139 finish_stage("Finished moving project directory");
1140 Ok(())
1141}
1142
1143fn start_stage(steps: u32, message: impl Into<String>) {
1144 progress_reporter::start_stage::<()>(steps, message.into(), None)
1145}
1146
1147fn progress_stage(message: impl Into<String>) {
1148 progress_reporter::progress_stage::<()>(message.into(), None)
1149}
1150
1151fn finish_stage(message: impl Into<String>) {
1152 progress_reporter::finish_stage::<()>(message.into(), None)
1153}
1154
1155fn extract_project_overwrite(
1156 compressed_project: impl std::io::Read + std::io::Seek,
1157 target_location: &Path,
1158 compression: Compression,
1159) -> Result<(), LangsError> {
1160 match compression {
1161 Compression::Tar => {
1162 let mut archive = tar::Archive::new(compressed_project);
1163 archive
1164 .unpack(target_location)
1165 .map_err(|e| LangsError::TarExtract(target_location.to_path_buf(), e))?;
1166 }
1167 Compression::TarZstd => {
1168 let decoder = zstd::Decoder::new(compressed_project).map_err(LangsError::ZstdDecode)?;
1169 let mut archive = tar::Archive::new(decoder);
1170 archive
1171 .unpack(target_location)
1172 .map_err(|e| LangsError::TarExtract(target_location.to_path_buf(), e))?;
1173 }
1174 Compression::Zip => {
1175 let mut archive = zip::ZipArchive::new(compressed_project)?;
1176 archive
1177 .extract(target_location)
1178 .map_err(|e| LangsError::ZipExtract(target_location.to_path_buf(), e))?;
1179 }
1180 }
1181 Ok(())
1182}
1183
1184fn get_default_sandbox_image(path: &Path) -> Result<&'static str, LangsError> {
1185 let img = match PluginType::from_exercise(path)? {
1186 PluginType::CSharp => CSharpPlugin::DEFAULT_SANDBOX_IMAGE,
1187 PluginType::Make => MakePlugin::DEFAULT_SANDBOX_IMAGE,
1188 #[cfg(not(target_env = "musl"))]
1190 PluginType::Maven => MavenPlugin::DEFAULT_SANDBOX_IMAGE,
1191 #[cfg(not(target_env = "musl"))]
1193 PluginType::Ant => AntPlugin::DEFAULT_SANDBOX_IMAGE,
1194 PluginType::NoTests => NoTestsPlugin::DEFAULT_SANDBOX_IMAGE,
1195 PluginType::Python3 => Python3Plugin::DEFAULT_SANDBOX_IMAGE,
1196 PluginType::R => RPlugin::DEFAULT_SANDBOX_IMAGE,
1197 };
1198 Ok(img)
1199}
1200
1201#[cfg(test)]
1202#[allow(clippy::unwrap_used)]
1203mod test {
1204 use super::*;
1205 use mockito::Server;
1206 use std::io::Write;
1207 use tmc_testmycode_client::response::ExercisesDetails;
1208 use zip::write::SimpleFileOptions;
1209
1210 fn init() {
1211 use log::*;
1212 use simple_logger::*;
1213 let _ = SimpleLogger::new()
1214 .with_level(LevelFilter::Trace)
1215 .with_module_level("j4rs", LevelFilter::Warn)
1216 .with_module_level("mockito", LevelFilter::Warn)
1217 .with_module_level("reqwest", LevelFilter::Warn)
1218 .init();
1219 }
1220
1221 fn file_to(
1222 target_dir: impl AsRef<std::path::Path>,
1223 target_relative: impl AsRef<std::path::Path>,
1224 contents: impl AsRef<[u8]>,
1225 ) -> PathBuf {
1226 let target = target_dir.as_ref().join(target_relative);
1227 if let Some(parent) = target.parent() {
1228 std::fs::create_dir_all(parent).unwrap();
1229 }
1230 std::fs::write(&target, contents.as_ref()).unwrap();
1231 target
1232 }
1233
1234 fn mock_testmycode_client(server: &Server) -> tmc::TestMyCodeClient {
1235 let mut client = tmc::TestMyCodeClient::new(
1236 server.url().parse().unwrap(),
1237 "client".to_string(),
1238 "version".to_string(),
1239 )
1240 .unwrap();
1241 let token = tmc::Token::new(
1242 AccessToken::new("".to_string()),
1243 BasicTokenType::Bearer,
1244 EmptyExtraTokenFields {},
1245 );
1246 client.set_token(token);
1247 client
1248 }
1249
1250 #[test]
1251 fn signs_with_jwt() {
1252 init();
1253
1254 let value = "some string";
1255 let secret = "some secret".as_bytes();
1256 let signed = sign_with_jwt(value, secret).unwrap();
1257 assert_eq!(
1258 signed,
1259 "eyJhbGciOiJIUzI1NiJ9.InNvbWUgc3RyaW5nIg.FfWkq8BeQRe2vlrfLbJHObFAslXqK5_V_hH2TbBqggc"
1260 );
1261 }
1262
1263 #[test]
1264 fn gets_projects_dir() {
1265 init();
1266
1267 let projects_dir = get_projects_dir("client").unwrap();
1268 assert!(projects_dir.ends_with("client"));
1269 let parent = projects_dir.parent().unwrap();
1270 assert!(parent.ends_with("tmc"));
1271 }
1272
1273 #[test]
1274 fn checks_exercise_updates() {
1275 init();
1276 let mut server = Server::new();
1277
1278 let details = vec![
1279 ExercisesDetails {
1280 id: 1,
1281 course_name: "some course".to_string(),
1282 exercise_name: "some exercise".to_string(),
1283 checksum: "new checksum".to_string(),
1284 hide_submission_results: false,
1285 },
1286 ExercisesDetails {
1287 id: 2,
1288 course_name: "some course".to_string(),
1289 exercise_name: "another exercise".to_string(),
1290 checksum: "old checksum".to_string(),
1291 hide_submission_results: false,
1292 },
1293 ];
1294 let mut response = HashMap::new();
1295 response.insert("exercises", details);
1296 let response = serde_json::to_string(&response).unwrap();
1297 let _m = server
1298 .mock("GET", mockito::Matcher::Any)
1299 .with_body(response)
1300 .create();
1301
1302 let projects_dir = tempfile::tempdir().unwrap();
1303
1304 file_to(
1305 &projects_dir,
1306 "some course/course_config.toml",
1307 r#"
1308course = 'some course'
1309
1310[exercises."some exercise"]
1311id = 1
1312checksum = 'old checksum'
1313
1314[exercises."another exercise"]
1315id = 2
1316checksum = 'old checksum'
1317"#,
1318 );
1319 file_to(&projects_dir, "some course/some exercise/some file", "");
1320
1321 let client = mock_testmycode_client(&server);
1322 let updates = check_exercise_updates(&client, projects_dir.path()).unwrap();
1323 assert_eq!(updates.len(), 1);
1324 assert_eq!(&updates[0], &1);
1325 }
1326
1327 #[test]
1328 fn downloads_old_submission() {
1329 init();
1330 let mut server = Server::new();
1331
1332 let mut zw = zip::ZipWriter::new(std::io::Cursor::new(vec![]));
1333 zw.start_file("src/file", SimpleFileOptions::default())
1334 .unwrap();
1335 zw.write_all(b"file contents").unwrap();
1336 let z = zw.finish().unwrap();
1337 let _m = server
1338 .mock("GET", mockito::Matcher::Any)
1339 .with_body(z.into_inner())
1340 .create();
1341
1342 let output_dir = tempfile::tempdir().unwrap();
1343 let client = mock_testmycode_client(&server);
1344
1345 download_old_submission(&client, 1, output_dir.path(), 2, false).unwrap();
1346 let s = file_util::read_file_to_string(output_dir.path().join("src/file")).unwrap();
1347 assert_eq!(s, "file contents");
1348 }
1349
1350 #[test]
1351 fn downloads_or_updates_course_exercises() {
1352 init();
1353 let mut server = Server::new();
1354
1355 let projects_dir = tempfile::tempdir().unwrap();
1356 file_to(
1357 &projects_dir,
1358 "some course/course_config.toml",
1359 r#"
1360course = 'some course'
1361
1362[exercises."on disk exercise with update and submission"]
1363id = 1
1364checksum = 'old checksum'
1365
1366[exercises."on disk exercise without update"]
1367id = 2
1368checksum = 'new checksum'
1369"#,
1370 );
1371 file_to(
1372 &projects_dir,
1373 "some course/on disk exercise with update and submission/some file",
1374 "",
1375 );
1376 file_to(
1377 &projects_dir,
1378 "some course/on disk exercise without update/some file",
1379 "",
1380 );
1381
1382 let client = mock_testmycode_client(&server);
1383
1384 let exercises = vec![1, 2, 3];
1385
1386 let mut body = HashMap::new();
1387 body.insert(
1388 "exercises",
1389 vec![
1390 ExercisesDetails {
1391 id: 1,
1392 checksum: "new checksum".to_string(),
1393 course_name: "some course".to_string(),
1394 exercise_name: "on disk exercise with update and submission".to_string(),
1395 hide_submission_results: false,
1396 },
1397 ExercisesDetails {
1398 id: 2,
1399 checksum: "new checksum".to_string(),
1400 course_name: "some course".to_string(),
1401 exercise_name: "on disk exercise without update".to_string(),
1402 hide_submission_results: false,
1403 },
1404 ExercisesDetails {
1405 id: 3,
1406 checksum: "new checksum".to_string(),
1407 course_name: "another course".to_string(),
1408 exercise_name: "not on disk exercise with submission".to_string(),
1409 hide_submission_results: false,
1410 },
1411 ExercisesDetails {
1412 id: 4,
1413 checksum: "new checksum".to_string(),
1414 course_name: "another course".to_string(),
1415 exercise_name: "not on disk exercise without submission".to_string(),
1416 hide_submission_results: false,
1417 },
1418 ExercisesDetails {
1419 id: 5,
1420 checksum: "new checksum".to_string(),
1421 course_name: "another course".to_string(),
1422 exercise_name:
1423 "not on disk exercise with submission exercise hide submission result"
1424 .to_string(),
1425 hide_submission_results: true,
1426 },
1427 ],
1428 );
1429 let _m = server
1430 .mock(
1431 "GET",
1432 mockito::Matcher::Regex("exercises/details".to_string()),
1433 )
1434 .with_body(serde_json::to_string(&body).unwrap())
1435 .create();
1436
1437 let sub_body = vec![tmc::response::Submission {
1438 id: 1,
1439 user_id: 1,
1440 pretest_error: None,
1441 created_at: chrono::Utc::now()
1442 .with_timezone(&chrono::FixedOffset::east_opt(0).unwrap()),
1443 exercise_name: "e1".to_string(),
1444 course_id: 1,
1445 processed: true,
1446 all_tests_passed: true,
1447 points: None,
1448 processing_tried_at: None,
1449 processing_began_at: None,
1450 processing_completed_at: None,
1451 times_sent_to_sandbox: 1,
1452 processing_attempts_started_at: chrono::Utc::now()
1453 .with_timezone(&chrono::FixedOffset::east_opt(0).unwrap()),
1454 params_json: None,
1455 requires_review: false,
1456 requests_review: false,
1457 reviewed: false,
1458 message_for_reviewer: "".to_string(),
1459 newer_submission_reviewed: false,
1460 review_dismissed: false,
1461 paste_available: false,
1462 message_for_paste: "".to_string(),
1463 paste_key: None,
1464 }];
1465 let _m = server
1466 .mock(
1467 "GET",
1468 mockito::Matcher::AllOf(vec![
1469 mockito::Matcher::Regex("exercises/1".to_string()),
1470 mockito::Matcher::Regex("submissions".to_string()),
1471 ]),
1472 )
1473 .with_body(serde_json::to_string(&sub_body).unwrap())
1474 .create();
1475
1476 let _m = server
1477 .mock(
1478 "GET",
1479 mockito::Matcher::AllOf(vec![
1480 mockito::Matcher::Regex("exercises/2".to_string()),
1481 mockito::Matcher::Regex("submissions".to_string()),
1482 ]),
1483 )
1484 .with_body(serde_json::to_string(&[0; 0]).unwrap())
1485 .create();
1486
1487 let _m = server
1488 .mock(
1489 "GET",
1490 mockito::Matcher::AllOf(vec![
1491 mockito::Matcher::Regex("exercises/3".to_string()),
1492 mockito::Matcher::Regex("submissions".to_string()),
1493 ]),
1494 )
1495 .with_body(serde_json::to_string(&sub_body).unwrap())
1496 .create();
1497
1498 let _m = server
1499 .mock(
1500 "GET",
1501 mockito::Matcher::AllOf(vec![
1502 mockito::Matcher::Regex("exercises/4".to_string()),
1503 mockito::Matcher::Regex("submissions".to_string()),
1504 ]),
1505 )
1506 .with_body(serde_json::to_string(&[0; 0]).unwrap())
1507 .create();
1508
1509 let _m = server
1510 .mock(
1511 "GET",
1512 mockito::Matcher::AllOf(vec![
1513 mockito::Matcher::Regex("exercises/5".to_string()),
1514 mockito::Matcher::Regex("submissions".to_string()),
1515 ]),
1516 )
1517 .with_body(serde_json::to_string(&sub_body).unwrap())
1518 .create();
1519
1520 let mut template_zw = zip::ZipWriter::new(std::io::Cursor::new(vec![]));
1521 template_zw
1522 .start_file("src/student_file.py", SimpleFileOptions::default())
1523 .unwrap();
1524 template_zw.write_all(b"template").unwrap();
1525 template_zw
1526 .start_file(
1527 "src/template_only_student_file.py",
1528 SimpleFileOptions::default(),
1529 )
1530 .unwrap();
1531 template_zw.write_all(b"template").unwrap();
1532 template_zw
1533 .start_file("test/exercise_file.py", SimpleFileOptions::default())
1534 .unwrap();
1535 template_zw.write_all(b"template").unwrap();
1536 template_zw
1537 .start_file("setup.py", SimpleFileOptions::default())
1538 .unwrap();
1539 template_zw.write_all(b"template").unwrap();
1540 let template_z = template_zw.finish().unwrap();
1541 let template_z = template_z.into_inner();
1542 let _m = server
1543 .mock(
1544 "GET",
1545 mockito::Matcher::AllOf(vec![
1546 mockito::Matcher::Regex("exercises/1".to_string()),
1547 mockito::Matcher::Regex("download".to_string()),
1548 ]),
1549 )
1550 .with_body(&template_z)
1551 .create();
1552 let _m = server
1553 .mock(
1554 "GET",
1555 mockito::Matcher::AllOf(vec![
1556 mockito::Matcher::Regex("exercises/2".to_string()),
1557 mockito::Matcher::Regex("download".to_string()),
1558 ]),
1559 )
1560 .with_body(&template_z)
1561 .create();
1562 let _m = server
1563 .mock(
1564 "GET",
1565 mockito::Matcher::AllOf(vec![
1566 mockito::Matcher::Regex("exercises/3".to_string()),
1567 mockito::Matcher::Regex("download".to_string()),
1568 ]),
1569 )
1570 .with_body(&template_z)
1571 .create();
1572 let _m = server
1573 .mock(
1574 "GET",
1575 mockito::Matcher::AllOf(vec![
1576 mockito::Matcher::Regex("exercises/4".to_string()),
1577 mockito::Matcher::Regex("download".to_string()),
1578 ]),
1579 )
1580 .with_body(&template_z)
1581 .create();
1582 let _m = server
1583 .mock(
1584 "GET",
1585 mockito::Matcher::AllOf(vec![
1586 mockito::Matcher::Regex("exercises/5".to_string()),
1587 mockito::Matcher::Regex("download".to_string()),
1588 ]),
1589 )
1590 .with_body(&template_z)
1591 .create();
1592
1593 let mut sub_zw = zip::ZipWriter::new(std::io::Cursor::new(vec![]));
1594 sub_zw
1595 .start_file("src/student_file.py", SimpleFileOptions::default())
1596 .unwrap();
1597 sub_zw.write_all(b"submission").unwrap();
1598 sub_zw
1599 .start_file("test/exercise_file.py", SimpleFileOptions::default())
1600 .unwrap();
1601 sub_zw.write_all(b"submission").unwrap();
1602 sub_zw
1603 .start_file(
1604 "test/submission_only_exercise_file.py",
1605 SimpleFileOptions::default(),
1606 )
1607 .unwrap();
1608 sub_zw.write_all(b"submission").unwrap();
1609 sub_zw
1610 .start_file("setup.py", SimpleFileOptions::default())
1611 .unwrap();
1612 sub_zw.write_all(b"submission").unwrap();
1613 let sub_z = sub_zw.finish().unwrap();
1614 let sub_z = sub_z.into_inner();
1615 let _m = server
1616 .mock(
1617 "GET",
1618 mockito::Matcher::AllOf(vec![
1619 mockito::Matcher::Regex("submissions/1".to_string()),
1620 mockito::Matcher::Regex("download".to_string()),
1621 ]),
1622 )
1623 .with_body(sub_z)
1624 .create();
1625
1626 let res =
1627 download_or_update_course_exercises(&client, projects_dir.path(), &exercises, false)
1628 .unwrap();
1629 let (downloaded, skipped) = match res {
1630 DownloadResult::Success {
1631 downloaded,
1632 skipped,
1633 } => (downloaded, skipped),
1634 other => panic!("{other:?}"),
1635 };
1636
1637 assert_eq!(downloaded.len(), 4);
1638 assert_eq!(skipped.len(), 1);
1639
1640 let e1 = downloaded.iter().find(|e| e.id == 1).unwrap();
1641 let _e2 = skipped.iter().find(|e| e.id == 2).unwrap();
1642 let e3 = downloaded.iter().find(|e| e.id == 3).unwrap();
1643 let e4 = downloaded.iter().find(|e| e.id == 4).unwrap();
1644 let e5 = downloaded.iter().find(|e| e.id == 5).unwrap();
1645
1646 let f = file_util::read_file_to_string(e1.path.join("src/student_file.py")).unwrap();
1648 assert_eq!(f, "template");
1649 assert!(e1.path.join("src/template_only_student_file.py").exists());
1650 let f = file_util::read_file_to_string(e1.path.join("test/exercise_file.py")).unwrap();
1651 assert_eq!(f, "template");
1652
1653 let f = file_util::read_file_to_string(e3.path.join("src/student_file.py")).unwrap();
1655 assert_eq!(f, "submission");
1656 assert!(!e3.path.join("src/template_only_student_file.py").exists());
1657 assert!(
1658 !e3.path
1659 .join("test/submission_only_exercise_file.py")
1660 .exists()
1661 );
1662 let f = file_util::read_file_to_string(e3.path.join("test/exercise_file.py")).unwrap();
1663 assert_eq!(f, "template");
1664
1665 let f = file_util::read_file_to_string(e4.path.join("src/student_file.py")).unwrap();
1667 assert_eq!(f, "template");
1668 assert!(e4.path.join("src/template_only_student_file.py").exists());
1669 let f = file_util::read_file_to_string(e4.path.join("test/exercise_file.py")).unwrap();
1670 assert_eq!(f, "template");
1671
1672 let f = file_util::read_file_to_string(e5.path.join("src/student_file.py")).unwrap();
1674 assert_eq!(f, "template");
1675 assert!(e5.path.join("src/template_only_student_file.py").exists());
1676 let f = file_util::read_file_to_string(e5.path.join("test/exercise_file.py")).unwrap();
1677 assert_eq!(f, "template");
1678 }
1679
1680 #[test]
1681 fn download_old_submission_keeps_new_exercise_files() {
1682 init();
1683 let mut server = Server::new();
1684
1685 let output_dir = tempfile::tempdir().unwrap();
1686
1687 let mut template_zw = zip::ZipWriter::new(std::io::Cursor::new(vec![]));
1689 template_zw
1690 .start_file("pom.xml", SimpleFileOptions::default())
1691 .unwrap();
1692 template_zw.write_all(b"template").unwrap();
1693
1694 template_zw
1695 .start_file("src/main/java/File.java", SimpleFileOptions::default())
1696 .unwrap();
1697 template_zw.write_all(b"template").unwrap();
1698
1699 template_zw
1700 .start_file("src/test/java/FileTest.java", SimpleFileOptions::default())
1701 .unwrap();
1702 template_zw.write_all(b"template").unwrap();
1703
1704 let template_z = template_zw.finish().unwrap();
1705 let _m = server
1706 .mock(
1707 "GET",
1708 mockito::Matcher::AllOf(vec![
1709 mockito::Matcher::Regex("exercises".to_string()),
1710 mockito::Matcher::Regex("download".to_string()),
1711 ]),
1712 )
1713 .with_body(template_z.into_inner())
1714 .create();
1715
1716 let mut submission_zw = zip::ZipWriter::new(std::io::Cursor::new(vec![]));
1718 submission_zw
1719 .start_file("pom.xml", SimpleFileOptions::default())
1720 .unwrap();
1721 submission_zw.write_all(b"old submission").unwrap();
1722
1723 submission_zw
1724 .start_file("src/main/java/File.java", SimpleFileOptions::default())
1725 .unwrap();
1726 submission_zw.write_all(b"old submission").unwrap();
1727
1728 submission_zw
1729 .start_file("src/test/java/FileTest.java", SimpleFileOptions::default())
1730 .unwrap();
1731 submission_zw.write_all(b"old submission").unwrap();
1732
1733 let submission_z = submission_zw.finish().unwrap();
1734 let _m = server
1735 .mock(
1736 "GET",
1737 mockito::Matcher::AllOf(vec![
1738 mockito::Matcher::Regex("submission".to_string()),
1739 mockito::Matcher::Regex("download".to_string()),
1740 ]),
1741 )
1742 .with_body(submission_z.into_inner())
1743 .create();
1744
1745 let client = mock_testmycode_client(&server);
1746
1747 download_old_submission(&client, 1, output_dir.path(), 2, false).unwrap();
1748
1749 let s = file_util::read_file_to_string(output_dir.path().join("pom.xml")).unwrap();
1750 assert_eq!(s, "template");
1751 let s = file_util::read_file_to_string(output_dir.path().join("src/main/java/File.java"))
1752 .unwrap();
1753 assert_eq!(s, "old submission");
1754 let s =
1755 file_util::read_file_to_string(output_dir.path().join("src/test/java/FileTest.java"))
1756 .unwrap();
1757 assert_eq!(s, "template");
1758 }
1759}