1use crate::{CSharpError, cs_test_result::CSTestResult, policy::CSharpStudentFilePolicy};
4use std::{
5 collections::{HashMap, HashSet},
6 env,
7 ffi::{OsStr, OsString},
8 io::{BufReader, Cursor, Read, Seek},
9 ops::ControlFlow::{Break, Continue},
10 path::{Path, PathBuf},
11 time::Duration,
12};
13use tmc_langs_framework::{
14 Archive, CommandError, ExerciseDesc, Language, LanguagePlugin, RunResult, RunStatus,
15 StyleValidationResult, StyleValidationStrategy, TestDesc, TestResult, TmcCommand, TmcError,
16 nom::{IResult, Parser, bytes, character, combinator, sequence},
17 nom_language::error::VerboseError,
18};
19use tmc_langs_util::{deserialize, file_util, parse_util, path_util};
20use walkdir::WalkDir;
21use zip::ZipArchive;
22
23const TMC_CSHARP_RUNNER: &[u8] = include_bytes!("../deps/tmc-csharp-runner-2.0.zip");
24const TMC_CSHARP_RUNNER_VERSION: &str = "2.0";
25
26#[derive(Default)]
27pub struct CSharpPlugin {}
28
29impl CSharpPlugin {
30 pub fn new() -> Self {
31 Self {}
32 }
33
34 fn extract_runner_to_dir(target: &Path) -> Result<(), CSharpError> {
36 log::debug!("extracting C# runner to {}", target.display());
37
38 let mut zip = ZipArchive::new(Cursor::new(TMC_CSHARP_RUNNER))?;
39 for i in 0..zip.len() {
40 let file = zip.by_index(i)?;
41 if file.is_file() {
42 let target_file_path = target.join(Path::new(file.name()));
43 if let Some(parent) = target_file_path.parent() {
44 file_util::create_dir_all(parent)?;
45 }
46 let bytes = file_util::read_reader(file)?;
47 file_util::write_to_file(bytes, target_file_path)?;
48 }
49 }
50 Ok(())
51 }
52
53 fn get_or_init_runner_dir() -> Result<PathBuf, CSharpError> {
58 log::debug!("getting C# runner dir");
59 match dirs::cache_dir() {
60 Some(cache_dir) => {
61 let runner_dir = cache_dir.join("tmc").join("tmc-csharp-runner");
62 let version_path = runner_dir.join("VERSION");
63
64 let needs_update = if version_path.exists() {
65 let version = file_util::read_file_to_string(&version_path)?;
66 version != TMC_CSHARP_RUNNER_VERSION
67 } else {
68 true
69 };
70
71 if needs_update {
72 log::debug!("updating the cached C# runner");
73 if runner_dir.exists() {
74 file_util::remove_dir_all(&runner_dir)?;
76 }
77 Self::extract_runner_to_dir(&runner_dir)?;
78 file_util::write_to_file(TMC_CSHARP_RUNNER_VERSION.as_bytes(), version_path)?;
79 }
80 Ok(runner_dir)
81 }
82 None => Err(CSharpError::CacheDir),
83 }
84 }
85
86 fn get_bootstrap_path() -> Result<PathBuf, CSharpError> {
88 if let Ok(var) = env::var("TMC_CSHARP_BOOTSTRAP_PATH") {
89 log::debug!("using bootstrap path TMC_CSHARP_BOOTSTRAP_PATH={var}");
90 Ok(PathBuf::from(var))
91 } else {
92 let runner_path = Self::get_or_init_runner_dir()?;
93 let bootstrap_path = runner_path.join("TestMyCode.CSharp.Bootstrap.dll");
94 if bootstrap_path.exists() {
95 log::debug!("found boostrap dll at {}", bootstrap_path.display());
96 Ok(bootstrap_path)
97 } else {
98 Err(CSharpError::MissingBootstrapDll(bootstrap_path))
99 }
100 }
101 }
102
103 fn parse_test_results(
105 test_results_path: &Path,
106 ) -> Result<(RunStatus, Vec<TestResult>), CSharpError> {
107 log::debug!("parsing C# test results");
108 let test_results = file_util::open_file(test_results_path)?;
109 let test_results: Vec<CSTestResult> = deserialize::json_from_reader(test_results)
110 .map_err(|e| CSharpError::ParseTestResults(test_results_path.to_path_buf(), e))?;
111
112 let mut status = RunStatus::Passed;
113 let mut failed_points = HashSet::new();
114 for test_result in &test_results {
115 if !test_result.passed {
116 status = RunStatus::TestsFailed;
117 failed_points.extend(test_result.points.iter().cloned());
118 }
119 }
120
121 let test_results = test_results
123 .into_iter()
124 .map(|t| t.into_test_result(&failed_points))
125 .collect();
126 Ok((status, test_results))
127 }
128}
129
130impl LanguagePlugin for CSharpPlugin {
133 const PLUGIN_NAME: &'static str = "csharp";
134 const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-csharp:latest";
135 const LINE_COMMENT: &'static str = "//";
136 const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("/*", "*/"));
137 type StudentFilePolicy = CSharpStudentFilePolicy;
138
139 fn is_exercise_type_correct(path: &Path) -> bool {
140 WalkDir::new(path.join("src"))
141 .max_depth(2)
142 .into_iter()
143 .filter_map(|e| e.ok())
144 .any(|e| {
145 let ext = e.path().extension();
146 ext == Some(&OsString::from("cs")) || ext == Some(&OsString::from("csproj"))
147 })
148 }
149
150 fn find_project_dir_in_archive<R: Read + Seek>(
151 archive: &mut Archive<R>,
152 ) -> Result<PathBuf, TmcError> {
153 let mut iter = archive.iter()?;
154 let project_dir = loop {
155 let next = iter.with_next(|entry| {
156 let file_path = entry.path()?;
157 let ext = file_path.extension();
158
159 if entry.is_file()
160 && (ext == Some(OsStr::new("cs")) || ext == Some(OsStr::new("csproj")))
161 && !file_path.components().any(|c| c.as_os_str() == "__MACOSX")
162 {
163 if let Some(parent) = file_path.parent() {
164 if let Some(src_parent) = path_util::get_parent_of_named(parent, "src") {
165 return Ok(Break(Some(src_parent)));
166 }
167 if let Some(parent) = parent.parent() {
168 if let Some(src_parent) = path_util::get_parent_of_named(parent, "src")
169 {
170 return Ok(Break(Some(src_parent)));
171 }
172 }
173 }
174 }
175 Ok(Continue(()))
176 });
177 match next? {
178 Continue(_) => continue,
179 Break(project_dir) => break project_dir,
180 }
181 };
182 match project_dir {
183 Some(project_dir) => Ok(project_dir),
184 None => Err(TmcError::NoProjectDirInArchive),
185 }
186 }
187
188 fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
190 let exercise_desc_json_path = path.join(".tmc_available_points.json");
192 if exercise_desc_json_path.exists() {
193 file_util::remove_file(&exercise_desc_json_path)?;
194 }
195
196 let bootstrap_path = Self::get_bootstrap_path()?;
198 let _output = TmcCommand::piped("dotnet")
199 .with(|e| {
200 e.cwd(path)
201 .arg(bootstrap_path)
202 .arg("--generate-points-file")
203 })
204 .output_checked()?;
205
206 let exercise_desc_json = file_util::open_file(&exercise_desc_json_path)?;
209 let json: HashMap<String, Vec<String>> =
210 deserialize::json_from_reader(BufReader::new(exercise_desc_json))
211 .map_err(|e| CSharpError::ParseExerciseDesc(exercise_desc_json_path, e))?;
212
213 let mut tests = vec![];
214 for (key, value) in json {
215 tests.push(TestDesc::new(key, value));
216 }
217
218 Ok(ExerciseDesc {
219 name: exercise_name,
220 tests,
221 })
222 }
223
224 fn run_tests_with_timeout(
226 &self,
227 path: &Path,
228 timeout: Option<Duration>,
229 ) -> Result<RunResult, TmcError> {
230 let test_results_path = path.join(".tmc_test_results.json");
232 if test_results_path.exists() {
233 file_util::remove_file(&test_results_path)?;
234 }
235
236 let bootstrap_path = Self::get_bootstrap_path()?;
238 let command = TmcCommand::piped("dotnet")
239 .with(|e| e.cwd(path).arg(bootstrap_path).arg("--run-tests"));
240 let output = if let Some(timeout) = timeout {
241 command.output_with_timeout(timeout)
242 } else {
243 command.output()
244 };
245
246 match output {
247 Ok(output) => {
248 let stdout = String::from_utf8_lossy(&output.stdout);
249 let stderr = String::from_utf8_lossy(&output.stderr);
250 if !output.status.success() {
251 log::warn!("stdout: {stdout}");
252 log::error!("stderr: {stderr}");
253 let mut logs = HashMap::new();
254 logs.insert("stdout".to_string(), stdout.into_owned());
255 logs.insert("stderr".to_string(), stderr.into_owned());
256 return Ok(RunResult {
257 status: RunStatus::CompileFailed,
258 test_results: vec![],
259 logs,
260 });
261 }
262
263 log::trace!("stdout: {stdout}");
264 log::debug!("stderr: {stderr}");
265
266 if !test_results_path.exists() {
267 return Err(CSharpError::MissingTestResults {
268 path: test_results_path,
269 stdout: stdout.into_owned(),
270 stderr: stderr.into_owned(),
271 }
272 .into());
273 }
274 let (status, test_results) = Self::parse_test_results(&test_results_path)?;
275 file_util::remove_file(&test_results_path)?;
276
277 let mut logs = HashMap::new();
278 logs.insert("stdout".to_string(), stdout.into_owned());
279 logs.insert("stderr".to_string(), stderr.into_owned());
280 Ok(RunResult {
281 status,
282 test_results,
283 logs,
284 })
285 }
286 Err(TmcError::Command(CommandError::TimeOut { stdout, stderr, .. })) => {
287 let mut logs = HashMap::new();
288 logs.insert("stdout".to_string(), stdout);
289 logs.insert("stderr".to_string(), stderr);
290 Ok(RunResult {
291 status: RunStatus::TestsFailed,
292 test_results: vec![TestResult {
293 name: "Timeout test".to_string(),
294 successful: false,
295 points: vec![],
296 message:
297 "Tests timed out.\nMake sure you don't have an infinite loop in your code."
298 .to_string(),
299 exception: vec![],
300 }],
301 logs,
302 })
303 }
304 Err(error) => Err(error),
305 }
306 }
307
308 fn check_code_style(
310 &self,
311 _path: &Path,
312 _locale: Language,
313 ) -> Result<Option<StyleValidationResult>, TmcError> {
314 Ok(Some(StyleValidationResult {
315 strategy: StyleValidationStrategy::Disabled,
316 validation_errors: None,
317 }))
318 }
319
320 fn clean(&self, path: &Path) -> Result<(), TmcError> {
322 let test_results_path = path.join(".tmc_test_results.json");
324 if test_results_path.exists() {
325 log::info!("removing old test results file");
326 file_util::remove_file(&test_results_path)?;
327 }
328
329 for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
331 let file_name = entry.path().file_name();
332 if entry.path().is_dir()
333 && (file_name == Some(&OsString::from("bin"))
334 || file_name == Some(&OsString::from("obj")))
335 {
336 log::info!("cleaning directory {}", entry.path().display());
337 file_util::remove_dir_all(entry.path())?;
338 }
339 }
340 Ok(())
341 }
342
343 fn get_default_student_file_paths() -> Vec<PathBuf> {
344 vec![PathBuf::from("src")]
345 }
346
347 fn get_default_exercise_file_paths() -> Vec<PathBuf> {
348 vec![PathBuf::from("test")]
349 }
350
351 fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
352 combinator::map(
353 sequence::delimited(
354 (
355 character::complete::char('['),
356 character::complete::multispace0,
357 bytes::complete::tag_no_case("points"),
358 character::complete::multispace0,
359 character::complete::char('('),
360 character::complete::multispace0,
361 ),
362 parse_util::comma_separated_strings,
363 (
364 character::complete::multispace0,
365 character::complete::char(')'),
366 character::complete::multispace0,
367 character::complete::char(']'),
368 ),
369 ),
370 |points| {
372 points
373 .into_iter()
374 .flat_map(|p| p.split_whitespace())
375 .collect()
376 },
377 )
378 .parse(i)
379 }
380}
381
382#[cfg(test)]
383#[allow(clippy::unwrap_used)]
384mod test {
385 use super::*;
386 use once_cell::sync::Lazy;
387 use std::sync::{Mutex, Once};
388 use tempfile::TempDir;
389 use zip::write::SimpleFileOptions;
390
391 static INIT_RUNNER: Once = Once::new();
392 static MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
394
395 fn init() {
396 use log::*;
397 use simple_logger::*;
398 let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
399 INIT_RUNNER.call_once(|| {
400 let _ = CSharpPlugin::get_or_init_runner_dir().unwrap();
401 });
402 }
403
404 fn file_to(
405 target_dir: impl AsRef<std::path::Path>,
406 target_relative: impl AsRef<std::path::Path>,
407 contents: impl AsRef<[u8]>,
408 ) -> PathBuf {
409 let target = target_dir.as_ref().join(target_relative);
410 if let Some(parent) = target.parent() {
411 std::fs::create_dir_all(parent).unwrap();
412 }
413 std::fs::write(&target, contents.as_ref()).unwrap();
414 target
415 }
416
417 fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
418 use std::io::Write;
419
420 let mut target = vec![];
421 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
422
423 for entry in walkdir::WalkDir::new(&source_dir)
424 .min_depth(1)
425 .sort_by(|a, b| a.path().cmp(b.path()))
426 {
427 let entry = entry.unwrap();
428 let rela = entry
429 .path()
430 .strip_prefix(&source_dir)
431 .unwrap()
432 .to_str()
433 .unwrap();
434 if entry.path().is_dir() {
435 zip.add_directory(rela, SimpleFileOptions::default())
436 .unwrap();
437 } else if entry.path().is_file() {
438 zip.start_file(rela, SimpleFileOptions::default()).unwrap();
439 let bytes = std::fs::read(entry.path()).unwrap();
440 zip.write_all(&bytes).unwrap();
441 }
442 }
443
444 zip.finish().unwrap();
445 target
446 }
447
448 fn dir_to_temp(source_dir: impl AsRef<std::path::Path>) -> tempfile::TempDir {
449 let temp = tempfile::TempDir::new().unwrap();
450 for entry in walkdir::WalkDir::new(&source_dir).min_depth(1) {
451 let entry = entry.unwrap();
452 let rela = entry.path().strip_prefix(&source_dir).unwrap();
453 let target = temp.path().join(rela);
454 if entry.path().is_dir() {
455 std::fs::create_dir(target).unwrap();
456 } else if entry.path().is_file() {
457 std::fs::copy(entry.path(), target).unwrap();
458 }
459 }
460 temp
461 }
462
463 #[test]
464 fn extracts_runner_to_dir() {
465 init();
466
467 let temp = tempfile::TempDir::new().unwrap();
468 CSharpPlugin::extract_runner_to_dir(temp.path()).unwrap();
469 assert!(temp.path().join("TestMyCode.CSharp.Bootstrap.dll").exists());
470 }
471
472 #[test]
473 fn gets_bootstrap_path() {
474 init();
475
476 let path = CSharpPlugin::get_bootstrap_path().unwrap();
477 assert!(
478 path.to_string_lossy()
479 .contains("TestMyCode.CSharp.Bootstrap.dll")
480 );
481 }
482
483 #[test]
484 fn parses_test_results() {
485 init();
486
487 let temp = tempfile::TempDir::new().unwrap();
488 let json = file_to(
489 &temp,
490 ".tmc_test_results.json",
491 r#"
492[
493 {
494 "Name": "n1",
495 "Passed": true,
496 "Message": "m1",
497 "Points": ["1", "2"],
498 "ErrorStackTrace": []
499 },
500 {
501 "Name": "n2",
502 "Passed": false,
503 "Message": "m2",
504 "Points": [],
505 "ErrorStackTrace": ["err"]
506 }
507]
508"#,
509 );
510 let (status, test_results) = CSharpPlugin::parse_test_results(&json).unwrap();
511 assert_eq!(status, RunStatus::TestsFailed);
512 assert_eq!(test_results.len(), 2);
513 }
514
515 #[test]
516 fn exercise_type_is_correct() {
517 init();
518
519 let temp = TempDir::new().unwrap();
520 file_to(&temp, "src/dir/sample.csproj", "");
521 assert!(CSharpPlugin::is_exercise_type_correct(temp.path()));
522 }
523
524 #[test]
525 fn exercise_type_is_incorrect() {
526 init();
527
528 let temp = TempDir::new().unwrap();
529 file_to(&temp, "src/dir/dir/dir/sample.csproj", "");
530 file_to(&temp, "sample.csproj", "");
531 assert!(!CSharpPlugin::is_exercise_type_correct(temp.path()));
532 }
533
534 #[test]
535 fn finds_project_dir_in_zip() {
536 init();
537
538 let temp = TempDir::new().unwrap();
539 file_to(&temp, "dir1/dir2/dir3/src/dir4/sample.csproj", "");
540 let bytes = dir_to_zip(&temp);
541 let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
542 let dir = CSharpPlugin::find_project_dir_in_archive(&mut zip).unwrap();
543 assert_eq!(dir, Path::new("dir1/dir2/dir3"))
544 }
545
546 #[test]
547 fn finds_project_dir_in_zip_cs() {
548 init();
549
550 let temp = TempDir::new().unwrap();
551 file_to(&temp, "dir1/dir2/dir3/src/dir4/sample.cs", "");
552 let bytes = dir_to_zip(&temp);
553 let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
554 let dir = CSharpPlugin::find_project_dir_in_archive(&mut zip).unwrap();
555 assert_eq!(dir, Path::new("dir1/dir2/dir3"))
556 }
557
558 #[test]
559 fn no_project_dir_in_zip() {
560 init();
561
562 let temp = TempDir::new().unwrap();
563 file_to(&temp, "dir1/dir2/dir3/not src/directly in src.csproj", "");
564 file_to(&temp, "dir1/dir2/dir3/src/__MACOSX/under macosx.csproj", "");
565 file_to(&temp, "dir1/__MACOSX/dir3/src/dir/under macosx.csproj", "");
566 let bytes = dir_to_zip(&temp);
567 let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
568 let dir = CSharpPlugin::find_project_dir_in_archive(&mut zip);
569 assert!(dir.is_err())
570 }
571
572 #[test]
573 fn scans_exercise() {
574 init();
575 let _lock = MUTEX.lock().unwrap();
576
577 let temp = dir_to_temp("tests/data/passing-exercise");
578 let plugin = CSharpPlugin::new();
579 let scan = plugin
580 .scan_exercise(temp.path(), "name".to_string())
581 .unwrap();
582 assert_eq!(scan.name, "name");
583 assert_eq!(scan.tests.len(), 2);
584 }
585
586 #[test]
587 fn runs_tests_passing() {
588 init();
589 let _lock = MUTEX.lock().unwrap();
590
591 let temp = dir_to_temp("tests/data/passing-exercise");
592 let plugin = CSharpPlugin::new();
593 let res = plugin.run_tests(temp.path()).unwrap();
594 assert_eq!(res.status, RunStatus::Passed);
595 assert_eq!(res.test_results.len(), 2);
596 for tr in res.test_results {
597 assert!(tr.successful);
598 }
599 assert!(res.logs.get("stdout").unwrap().is_empty());
600 assert!(res.logs.get("stderr").unwrap().is_empty());
601 }
602
603 #[test]
604 fn runs_tests_failing() {
605 init();
606 let _lock = MUTEX.lock().unwrap();
607
608 let temp = dir_to_temp("tests/data/failing-exercise");
609 let plugin = CSharpPlugin::new();
610 let res = plugin.run_tests(temp.path()).unwrap();
611 assert_eq!(res.status, RunStatus::TestsFailed);
612 assert_eq!(res.test_results.len(), 1);
613 let test_result = &res.test_results[0];
614 assert!(!test_result.successful);
615 assert!(test_result.points.is_empty());
616 assert!(test_result.message.contains("Expected: False"));
617 assert!(!test_result.exception.is_empty());
618 assert!(res.logs.get("stdout").unwrap().is_empty());
619 assert!(res.logs.get("stderr").unwrap().is_empty());
620 }
621
622 #[test]
623 fn runs_tests_compile_err() {
624 init();
625 let _lock = MUTEX.lock().unwrap();
626
627 let temp = dir_to_temp("tests/data/non-compiling-exercise");
628 let plugin = CSharpPlugin::new();
629 let res = plugin.run_tests(temp.path()).unwrap();
630 assert_eq!(res.status, RunStatus::CompileFailed);
631 assert!(!res.logs.is_empty());
632 log::debug!("{:?}", res.logs.get("stdout"));
633 assert!(
634 res.logs
635 .get("stdout")
636 .unwrap()
637 .contains("This is a compile error")
638 );
639 }
640
641 #[test]
642 fn runs_tests_timeout() {
643 init();
644 let _lock = MUTEX.lock().unwrap();
645
646 let temp = dir_to_temp("tests/data/passing-exercise");
647 let plugin = CSharpPlugin::new();
648 let res = plugin
649 .run_tests_with_timeout(temp.path(), Some(std::time::Duration::from_nanos(1)))
650 .unwrap();
651 assert_eq!(res.status, RunStatus::TestsFailed);
652 }
653
654 #[test]
655 fn cleans() {
656 init();
657 let _lock = MUTEX.lock().unwrap();
658
659 let temp = dir_to_temp("tests/data/passing-exercise");
660 let plugin = CSharpPlugin::new();
661 let bin_path = temp.path().join("src").join("PassingSample").join("bin");
662 let obj_path_test = temp
663 .path()
664 .join("test")
665 .join("PassingSampleTests")
666 .join("obj");
667 assert!(!bin_path.exists());
668 assert!(!obj_path_test.exists());
669 plugin.run_tests(temp.path()).unwrap();
670 assert!(bin_path.exists());
671 assert!(obj_path_test.exists());
672 plugin.clean(temp.path()).unwrap();
673 assert!(!bin_path.exists());
674 assert!(!obj_path_test.exists());
675 }
676
677 #[test]
678 fn parses_points() {
679 let res = CSharpPlugin::points_parser("asd");
680 assert!(res.is_err());
681
682 let res = CSharpPlugin::points_parser("[Points(\"1\")]").unwrap();
683 assert_eq!(res.1, &["1"]);
684
685 let res = CSharpPlugin::points_parser("[ pOiNtS ( \" 1 \" ) ]").unwrap();
686 assert_eq!(res.1, &["1"]);
687
688 let res = CSharpPlugin::points_parser("[Points(\"1\", \"2\" , \"3\")]").unwrap();
689 assert_eq!(res.1, &["1", "2", "3"]);
690 }
691
692 #[test]
693 fn doesnt_give_points_unless_all_relevant_exercises_pass() {
694 init();
695 let _lock = MUTEX.lock().unwrap();
696
697 let temp = dir_to_temp("tests/data/partially-passing");
698 let plugin = CSharpPlugin::new();
699 let results = plugin.run_tests(temp.path()).unwrap();
700 assert_eq!(results.status, RunStatus::TestsFailed);
701 let mut got_point = false;
702 for test in results.test_results {
703 got_point = got_point || test.points.contains(&"1.2".to_string());
704 assert!(!test.points.contains(&"1".to_string()));
705 assert!(!test.points.contains(&"1.1".to_string()));
706 }
707 }
708}