tmc_langs/
lib.rs

1#![deny(clippy::print_stdout, clippy::print_stderr, clippy::unwrap_used)]
2
3//! The main tmc-langs library. Provides a convenient API to all of the functionality provided by the tmc-langs project.
4
5mod 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};
29// use heim::disk;
30use 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;
55// the Java plugin is disabled on musl
56pub 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
77/// Signs the given serializable value with the given secret using JWT.
78///
79/// # Example
80/// ```
81/// #[derive(serde::Serialize)]
82/// struct TestResult {
83///     passed: bool,
84/// }
85///
86/// let token = tmc_langs::sign_with_jwt(TestResult { passed: true }, "secret".as_bytes()).unwrap();
87/// assert_eq!(token, "eyJhbGciOiJIUzI1NiJ9.eyJwYXNzZWQiOnRydWV9.y-jXHgxZ_5wRqursLTb1hJOYob6LKj0mYBPnZSGtsnU");
88/// ```
89///
90/// # Errors
91/// Should never fail, but returns an error to be safe against changes in external libraries.
92pub 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
98/// Returns the projects directory for the given client name.
99/// The return value for `my-client` might look something like `/home/username/.local/share/tmc/my-client` on Linux.
100pub 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
105/// Checks the server for any updates for exercises within the given projects directory.
106/// Returns the ids of each exercise that can be updated.
107pub 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    // request would fail with empty id list
119    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                // server has an updated exercise
132                updated_exercises.push(local_exercise.id);
133            }
134        }
135    }
136    Ok(updated_exercises)
137}
138
139/// Downloads the user's old submission from the server.
140/// Resets the exercise at the path before the download.
141/// If a submission_url is given, the current state of the exercise is submitted to that URL before the download.
142pub 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        // submit old exercise
153        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 old exercise
164    reset(client, exercise_id, output_path)?;
165    log::debug!("reset exercise");
166
167    // dl submission
168    let mut buf = vec![];
169    client.download_old_submission(submission_id, &mut buf)?;
170    log::debug!("downloaded old submission");
171
172    // extract submission
173    extract_student_files(Cursor::new(buf), Compression::Zip, output_path)?;
174    log::debug!("extracted project");
175    Ok(())
176}
177
178/// Submits the exercise to the server
179pub 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
205/// Sends the paste to the server
206pub 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
234/// Downloads the given exercises, by either downloading the exercise template, updating the exercise or downloading an old submission.
235/// Requires authentication.
236/// If the exercise doesn't exist on disk yet...
237///   if there are previous submissions and download_template is not set, the latest submission is downloaded.
238///   otherwise, the exercise template is downloaded.
239/// If the exercise exists on disk, it is updated using the course template.
240pub 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    // separate exercises into downloads and skipped
257    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        // check if the exercise is already on disk
269        if let Some(exercise) = projects_config
270            .get_tmc_exercise(&exercise_detail.course_name, &exercise_detail.exercise_name)
271        {
272            // exercise is on disk, check if the checksum is identical
273            if exercise_detail.checksum == exercise.checksum {
274                // skip this exercise
275                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            // not on disk, if flag isn't set check if there are any previous submissions and take the latest one if so
291            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                    // previous submission found, check if exercise submission results hidden (part of exam)
298                    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        // not skipped, either not on disk or no previous submissions or submission result hidden, downloading template
318        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, // each download progresses at 2 points, plus the final finishing step
333        format!("Downloading {exercises_len} exercises"),
334        None,
335    );
336
337    log::debug!("downloading exercises");
338    // download and divide the results into successful and failed downloads
339    let thread_count = to_be_downloaded.len().min(4); // max 4 threads
340    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        // each thread returns either a list of successful downloads, or a tuple of successful downloads and errors
350        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            // repeat until out of exercises
356            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                    // no exercises left, break loop and exit thread
362                    break;
363                };
364                drop(exercises);
365                // dropped mutex
366
367                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                    // execute download based on type
381                    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                    // download successful, save to course config
431                    let mut projects_config =
432                        projects_config.lock().map_err(|_| LangsError::MutexError)?; // lock mutex
433                    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); // drop mutex
442
443                    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    // gather results from each thread
477    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    // report
490    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    // return information about the downloads
511    let downloaded = successful.into_iter().map(|t| t.target).collect();
512    if !failed.is_empty() {
513        // add an error trace to each failed download
514        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
539/// Fetches the given course's details, exercises and course data.
540pub 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
556/// Creates a login Token from a token string.
557pub 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
569/// Authenticates with the server, returning a login Token.
570/// Reads the password from stdin.
571pub 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
581/// Initializes a TestMyCodeClient, using and returning the stored credentials, if any.
582pub 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    // create client
588    let mut client = tmc::TestMyCodeClient::new(
589        root_url,
590        client_name.to_string(),
591        client_version.to_string(),
592    )?;
593
594    // set token from the credentials file if one exists
595    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
603/// Initializes a MoocClient, using and returning the stored credentials, if any.
604pub fn init_mooc_client_with_credentials(
605    root_url: Url,
606    client_name: &str,
607) -> Result<(mooc::MoocClient, Option<Credentials>), LangsError> {
608    // create client
609    let mut client = mooc::MoocClient::new(root_url);
610
611    // set token from the credentials file if one exists
612    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
620/// Updates the tmc exercises in the local projects directory.
621// TODO: parallel downloads
622pub 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    // request would error with 0 exercise ids
640    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        // first, handle tmc
649        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                    // server has an updated exercise
656                    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
719/// Updates the mooc exercises in the local projects directory.
720pub 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            // todo
761        }
762    }
763
764    Ok(DownloadOrUpdateMoocCourseExercisesResult {
765        downloaded,
766        skipped: vec![],
767        failed: None,
768    })
769}
770
771/// Fetches a setting from the config.
772pub 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
783/// Fetches all the settings from the config.
784pub 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
790/// Saves a setting in the config.
791pub 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
813/// Resets all settings in the config, removing those without a default value.
814pub 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
821/// Unsets the given setting.
822pub 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
832/// Checks the exercise's code quality.
833pub 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
844/// Cleans the exercise.
845pub 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
852/// Compresses the exercise to the target path.
853pub 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
880/// Compresses the exercise to the target path.
881/// Returns the BLAKE3 hash of the resulting file.
882pub 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
910/*
911/// Checks how many megabytes are available on the disk containing the target path.
912pub fn free_disk_space_megabytes(path: &Path) -> Result<u64, LangsError> {
913    log::debug!("checking disk usage in {}", path.display());
914
915    let usage = smol::block_on(disk::usage(path))?
916        .free()
917        .get::<heim::units::information::megabyte>();
918    Ok(usage)
919}
920*/
921
922/// Resets the given exercise
923pub fn reset(
924    client: &tmc::TestMyCodeClient,
925    exercise_id: u32,
926    exercise_path: &Path,
927) -> Result<(), LangsError> {
928    if exercise_path.exists() {
929        // clear out the exercise directory
930        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
944/// Extracts the compressed project to the target location.
945pub 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
977/// Parses the available points from the exercise.
978pub 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
985/// Finds valid exercises from the given path.
986pub 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        // check if the path contains a valid exercise for some plugin
1000        if Plugin::from_exercise(entry.path()).is_ok() {
1001            paths.push(entry.into_path())
1002        }
1003    }
1004    Ok(paths)
1005}
1006
1007/// Gets the exercise packaging configuration.
1008pub 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
1018/// Prepares the exercise stub, copying tmc-junit-runner for Ant exercises.
1019pub 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    // The Ant plugin needs some additional files to be copied over.
1029    // the Java plugin is disabled on musl
1030    #[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
1037/// Runs tests for the exercise.
1038pub 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
1044/// Scans the exercise.
1045pub 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
1051/// Extracts student files from the compressed exercise.
1052pub 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            // create parent dir for target and copy it, remove source file after
1119            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    // remove lock file if any
1136    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        // the Java plugin is disabled on musl
1189        #[cfg(not(target_env = "musl"))]
1190        PluginType::Maven => MavenPlugin::DEFAULT_SANDBOX_IMAGE,
1191        // the Java plugin is disabled on musl
1192        #[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        // did not download submission even though it was available because it was on disk
1647        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        // downloaded template, removed all student files and added all student files from submission
1654        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        // did not download submission because one was not available
1666        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        // did not download submission because exercise hides submission results, for example exam exercise
1673        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        // exercise template
1688        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        // submission
1717        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}