Skip to content

Commit 9bb7bec

Browse files
committed
Add safe_find_project_dir_in_archive method and test helpers. So that we can process submissions optimize by lang.
1 parent e66ea86 commit 9bb7bec

File tree

3 files changed

+431
-128
lines changed

3 files changed

+431
-128
lines changed

crates/tmc-langs-framework/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ mod plugin;
1111
mod policy;
1212
mod tmc_project_yml;
1313

14+
#[cfg(test)]
15+
mod test_helpers;
16+
1417
pub use self::{
1518
archive::{Archive, ArchiveBuilder, Compression},
1619
command::{ExitStatus, Output, TmcCommand},

crates/tmc-langs-framework/src/plugin.rs

Lines changed: 180 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use nom::{IResult, Parser, branch, bytes, character, combinator, multi, sequence
1414
use nom_language::error::VerboseError;
1515
use std::{
1616
collections::HashSet,
17+
ffi::OsStr,
1718
io::{Read, Seek},
1819
ops::ControlFlow::{Break, Continue},
1920
path::{Path, PathBuf},
@@ -228,7 +229,7 @@ pub trait LanguagePlugin {
228229
let mut archive = Archive::new(compressed_project, compression)?;
229230

230231
// find the exercise root directory inside the archive
231-
let project_dir = Self::find_project_dir_in_archive(&mut archive)?;
232+
let project_dir = Self::safe_find_project_dir_in_archive(&mut archive);
232233
log::debug!("Project directory in archive: {}", project_dir.display());
233234

234235
let policy = Self::StudentFilePolicy::new(target_location)?;
@@ -281,6 +282,81 @@ pub trait LanguagePlugin {
281282
archive: &mut Archive<R>,
282283
) -> Result<PathBuf, TmcError>;
283284

285+
/// A safer variant of `find_project_dir_in_archive` used by default extraction helpers.
286+
///
287+
/// Fallback order:
288+
/// 1) Delegate to `find_project_dir_in_archive` implemented by the language plugin
289+
/// 2) First directory containing a `.tmcproject.yml`
290+
/// 3) If archive root has only one folder, use that folder
291+
/// 4) Archive root (empty path)
292+
fn safe_find_project_dir_in_archive<R: Read + Seek>(archive: &mut Archive<R>) -> PathBuf {
293+
// 1) Try plugin-specific project dir detection first
294+
if let Ok(dir) = Self::find_project_dir_in_archive(archive) {
295+
return dir;
296+
}
297+
298+
// 2) Try to find the first directory that contains a .tmcproject.yml
299+
if let Ok(mut iter) = archive.iter() {
300+
loop {
301+
let next = iter.with_next(|file| {
302+
let file_path = file.path()?;
303+
if file.is_file()
304+
&& file_path
305+
.file_name()
306+
.map(|name| name == OsStr::new(".tmcproject.yml"))
307+
.unwrap_or(false)
308+
{
309+
let parent = file_path
310+
.parent()
311+
.map(PathBuf::from)
312+
.unwrap_or_else(|| PathBuf::from(""));
313+
return Ok(Break(Some(parent)));
314+
}
315+
Ok(Continue(()))
316+
});
317+
match next {
318+
Ok(Continue(_)) => continue,
319+
Ok(Break(Some(dir))) => return dir,
320+
Ok(Break(None)) => break,
321+
Err(_) => break,
322+
}
323+
}
324+
}
325+
326+
// 3) Check if archive root has only one folder. This is the format tmc-langs-cli sends submissions, so all official clients should use this format.
327+
if let Ok(mut iter) = archive.iter() {
328+
let mut folders = Vec::new();
329+
let mut root_file_count: usize = 0;
330+
loop {
331+
let next = iter.with_next::<(), _>(|file| {
332+
let file_path = file.path()?;
333+
// Only consider entries at the archive root
334+
if file_path.components().count() == 1 {
335+
if file.is_dir() {
336+
folders.push(file_path.to_path_buf());
337+
} else if file.is_file() {
338+
root_file_count += 1;
339+
}
340+
}
341+
Ok(Continue(()))
342+
});
343+
match next {
344+
Ok(Continue(_)) => continue,
345+
Ok(Break(_)) => break,
346+
Err(_) => break,
347+
}
348+
}
349+
350+
// If there's exactly one folder at the root and no files, use it
351+
if folders.len() == 1 && root_file_count == 0 {
352+
return folders[0].clone();
353+
}
354+
}
355+
356+
// 4) Default to archive root
357+
PathBuf::from("")
358+
}
359+
284360
/// Tells if there's a valid exercise in this archive.
285361
/// Unlike `is_exercise_type_correct`, searches the entire archive.
286362
fn is_archive_type_correct<R: Read + Seek>(archive: &mut Archive<R>) -> bool {
@@ -438,15 +514,14 @@ enum Parse {
438514
Other,
439515
}
440516

517+
441518
#[cfg(test)]
442519
#[allow(clippy::unwrap_used)]
443520
mod test {
444521
use super::*;
445-
use crate::TmcProjectYml;
446-
use nom::character;
447522
use std::io::Write;
448-
use tmc_langs_util::path_util;
449523
use zip::{ZipWriter, write::SimpleFileOptions};
524+
use crate::test_helpers::{MockPlugin, SimpleMockPlugin};
450525

451526
fn init() {
452527
use log::*;
@@ -505,130 +580,6 @@ mod test {
505580
target
506581
}
507582

508-
struct MockPlugin {}
509-
510-
struct MockPolicy {
511-
project_config: TmcProjectYml,
512-
}
513-
514-
impl StudentFilePolicy for MockPolicy {
515-
fn new_with_project_config(project_config: TmcProjectYml) -> Self
516-
where
517-
Self: Sized,
518-
{
519-
Self { project_config }
520-
}
521-
fn get_project_config(&self) -> &TmcProjectYml {
522-
&self.project_config
523-
}
524-
fn is_non_extra_student_file(&self, path: &Path) -> bool {
525-
path.starts_with("src")
526-
}
527-
}
528-
529-
impl LanguagePlugin for MockPlugin {
530-
const PLUGIN_NAME: &'static str = "mock_plugin";
531-
const DEFAULT_SANDBOX_IMAGE: &'static str = "mock_image";
532-
const LINE_COMMENT: &'static str = "//";
533-
const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("/*", "*/"));
534-
type StudentFilePolicy = MockPolicy;
535-
536-
fn scan_exercise(
537-
&self,
538-
_path: &Path,
539-
_exercise_name: String,
540-
) -> Result<ExerciseDesc, TmcError> {
541-
unimplemented!()
542-
}
543-
544-
fn run_tests_with_timeout(
545-
&self,
546-
_path: &Path,
547-
_timeout: Option<Duration>,
548-
) -> Result<RunResult, TmcError> {
549-
Ok(RunResult {
550-
status: RunStatus::Passed,
551-
test_results: vec![],
552-
logs: std::collections::HashMap::new(),
553-
})
554-
}
555-
556-
fn find_project_dir_in_archive<R: Read + Seek>(
557-
archive: &mut Archive<R>,
558-
) -> Result<PathBuf, TmcError> {
559-
let mut iter = archive.iter()?;
560-
let project_dir = loop {
561-
let next = iter.with_next(|file| {
562-
let file_path = file.path()?;
563-
564-
if let Some(parent) =
565-
path_util::get_parent_of_component_in_path(&file_path, "src")
566-
{
567-
return Ok(Break(Some(parent)));
568-
}
569-
Ok(Continue(()))
570-
});
571-
match next? {
572-
Continue(_) => continue,
573-
Break(project_dir) => break project_dir,
574-
}
575-
};
576-
577-
match project_dir {
578-
Some(project_dir) => Ok(project_dir),
579-
None => Err(TmcError::NoProjectDirInArchive),
580-
}
581-
}
582-
583-
fn is_exercise_type_correct(_path: &Path) -> bool {
584-
unimplemented!()
585-
}
586-
587-
fn clean(&self, _path: &Path) -> Result<(), TmcError> {
588-
Ok(())
589-
}
590-
591-
fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
592-
combinator::map(
593-
sequence::delimited(
594-
(
595-
bytes::complete::tag("@"),
596-
character::complete::multispace0,
597-
bytes::complete::tag_no_case("points"),
598-
character::complete::multispace0,
599-
character::complete::char('('),
600-
character::complete::multispace0,
601-
),
602-
branch::alt((
603-
sequence::delimited(
604-
character::complete::char('"'),
605-
bytes::complete::is_not("\""),
606-
character::complete::char('"'),
607-
),
608-
sequence::delimited(
609-
character::complete::char('\''),
610-
bytes::complete::is_not("'"),
611-
character::complete::char('\''),
612-
),
613-
)),
614-
(
615-
character::complete::multispace0,
616-
character::complete::char(')'),
617-
),
618-
),
619-
|s: &str| vec![s.trim()],
620-
)
621-
.parse(i)
622-
}
623-
624-
fn get_default_student_file_paths() -> Vec<PathBuf> {
625-
vec![PathBuf::from("src")]
626-
}
627-
628-
fn get_default_exercise_file_paths() -> Vec<PathBuf> {
629-
vec![PathBuf::from("test")]
630-
}
631-
}
632583

633584
#[test]
634585
fn gets_exercise_packaging_configuration() {
@@ -1127,4 +1078,105 @@ force_update:
11271078
// ensure .tmcproject.yml is NOT extracted
11281079
assert!(!temp.path().join("extracted/.tmcproject.yml").exists());
11291080
}
1081+
1082+
#[test]
1083+
fn safe_find_project_dir_fallback_to_tmcproject_yml() {
1084+
init();
1085+
1086+
let temp = tempfile::tempdir().unwrap();
1087+
// create an archive without src directory (which would normally fail)
1088+
// but with a .tmcproject.yml in a subdirectory
1089+
file_to(&temp, "some/deep/path/.tmcproject.yml", "some: yaml");
1090+
file_to(&temp, "some/deep/path/src/student_file", "content");
1091+
let zip = dir_to_zip(&temp);
1092+
1093+
// extract student files - should use fallback to .tmcproject.yml parent
1094+
MockPlugin::extract_student_files(
1095+
std::io::Cursor::new(zip),
1096+
Compression::Zip,
1097+
&temp.path().join("extracted"),
1098+
)
1099+
.unwrap();
1100+
1101+
// ensure student files are extracted from the fallback directory
1102+
assert!(temp.path().join("extracted/src/student_file").exists());
1103+
let content = std::fs::read_to_string(temp.path().join("extracted/src/student_file")).unwrap();
1104+
assert_eq!(content, "content");
1105+
}
1106+
1107+
#[test]
1108+
/** Matches the format tmc-langs-cli sends submissions. This makes sure submissions created by official clients are supported. */
1109+
fn safe_find_project_dir_fallback_to_single_folder() {
1110+
init();
1111+
1112+
let temp = tempfile::tempdir().unwrap();
1113+
// create an archive with only one folder at root level
1114+
file_to(&temp, "project_folder/src/student_file", "content");
1115+
let zip = dir_to_zip(&temp);
1116+
1117+
// extract student files - should use fallback to the single folder
1118+
MockPlugin::extract_student_files(
1119+
std::io::Cursor::new(zip),
1120+
Compression::Zip,
1121+
&temp.path().join("extracted"),
1122+
)
1123+
.unwrap();
1124+
1125+
// ensure student files are extracted from the single folder
1126+
assert!(temp.path().join("extracted/src/student_file").exists());
1127+
let content = std::fs::read_to_string(temp.path().join("extracted/src/student_file")).unwrap();
1128+
assert_eq!(content, "content");
1129+
}
1130+
1131+
#[test]
1132+
fn safe_find_project_dir_fallback_to_root() {
1133+
init();
1134+
1135+
let temp = tempfile::tempdir().unwrap();
1136+
// create an archive without src directory and without .tmcproject.yml
1137+
// should fallback to root
1138+
file_to(&temp, "src/student_file", "content");
1139+
let zip = dir_to_zip(&temp);
1140+
1141+
// extract student files - should use fallback to root
1142+
MockPlugin::extract_student_files(
1143+
std::io::Cursor::new(zip),
1144+
Compression::Zip,
1145+
&temp.path().join("extracted"),
1146+
)
1147+
.unwrap();
1148+
1149+
// ensure student files are extracted from the root
1150+
assert!(temp.path().join("extracted/src/student_file").exists());
1151+
let content = std::fs::read_to_string(temp.path().join("extracted/src/student_file")).unwrap();
1152+
assert_eq!(content, "content");
1153+
}
1154+
1155+
1156+
#[test]
1157+
fn safe_find_project_dir_single_folder_not_used_when_root_has_files() {
1158+
init();
1159+
1160+
let temp = tempfile::tempdir().unwrap();
1161+
// root has a single folder and also a file at root
1162+
// SimpleMockPlugin will never find project directory, so fallback logic will be used
1163+
file_to(&temp, "project_folder/src/student_file", "content");
1164+
file_to(&temp, "root_file.txt", "x");
1165+
let zip = dir_to_zip(&temp);
1166+
1167+
// extract student files - should NOT use the single-folder fallback because there's a root file
1168+
// so it should fallback to root, which extracts project_folder/src/student_file
1169+
SimpleMockPlugin::extract_student_files(
1170+
std::io::Cursor::new(zip),
1171+
Compression::Zip,
1172+
&temp.path().join("extracted"),
1173+
)
1174+
.unwrap();
1175+
1176+
// The file should be extracted as project_folder/src/student_file (preserving full path from root)
1177+
// This test verifies that the single folder fallback is not used when there's a root file
1178+
assert!(temp.path().join("extracted/project_folder/src/student_file").exists());
1179+
let content = std::fs::read_to_string(temp.path().join("extracted/project_folder/src/student_file")).unwrap();
1180+
assert_eq!(content, "content");
1181+
}
11301182
}

0 commit comments

Comments
 (0)