@@ -14,6 +14,7 @@ use nom::{IResult, Parser, branch, bytes, character, combinator, multi, sequence
1414use nom_language:: error:: VerboseError ;
1515use 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 {
@@ -442,10 +518,8 @@ enum Parse {
442518#[ allow( clippy:: unwrap_used) ]
443519mod test {
444520 use super :: * ;
445- use crate :: TmcProjectYml ;
446- use nom:: character;
521+ use crate :: test_helpers:: { MockPlugin , SimpleMockPlugin } ;
447522 use std:: io:: Write ;
448- use tmc_langs_util:: path_util;
449523 use zip:: { ZipWriter , write:: SimpleFileOptions } ;
450524
451525 fn init ( ) {
@@ -505,131 +579,6 @@ mod test {
505579 target
506580 }
507581
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- }
632-
633582 #[ test]
634583 fn gets_exercise_packaging_configuration ( ) {
635584 init ( ) ;
@@ -1127,4 +1076,115 @@ force_update:
11271076 // ensure .tmcproject.yml is NOT extracted
11281077 assert ! ( !temp. path( ) . join( "extracted/.tmcproject.yml" ) . exists( ) ) ;
11291078 }
1079+
1080+ #[ test]
1081+ fn safe_find_project_dir_fallback_to_tmcproject_yml ( ) {
1082+ init ( ) ;
1083+
1084+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
1085+ // create an archive without src directory (which would normally fail)
1086+ // but with a .tmcproject.yml in a subdirectory
1087+ file_to ( & temp, "some/deep/path/.tmcproject.yml" , "some: yaml" ) ;
1088+ file_to ( & temp, "some/deep/path/src/student_file" , "content" ) ;
1089+ let zip = dir_to_zip ( & temp) ;
1090+
1091+ // extract student files - should use fallback to .tmcproject.yml parent
1092+ MockPlugin :: extract_student_files (
1093+ std:: io:: Cursor :: new ( zip) ,
1094+ Compression :: Zip ,
1095+ & temp. path ( ) . join ( "extracted" ) ,
1096+ )
1097+ . unwrap ( ) ;
1098+
1099+ // ensure student files are extracted from the fallback directory
1100+ assert ! ( temp. path( ) . join( "extracted/src/student_file" ) . exists( ) ) ;
1101+ let content =
1102+ std:: fs:: read_to_string ( temp. path ( ) . join ( "extracted/src/student_file" ) ) . unwrap ( ) ;
1103+ assert_eq ! ( content, "content" ) ;
1104+ }
1105+
1106+ #[ test]
1107+ /** Matches the format tmc-langs-cli sends submissions. This makes sure submissions created by official clients are supported. */
1108+ fn safe_find_project_dir_fallback_to_single_folder ( ) {
1109+ init ( ) ;
1110+
1111+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
1112+ // create an archive with only one folder at root level
1113+ file_to ( & temp, "project_folder/src/student_file" , "content" ) ;
1114+ let zip = dir_to_zip ( & temp) ;
1115+
1116+ // extract student files - should use fallback to the single folder
1117+ MockPlugin :: extract_student_files (
1118+ std:: io:: Cursor :: new ( zip) ,
1119+ Compression :: Zip ,
1120+ & temp. path ( ) . join ( "extracted" ) ,
1121+ )
1122+ . unwrap ( ) ;
1123+
1124+ // ensure student files are extracted from the single folder
1125+ assert ! ( temp. path( ) . join( "extracted/src/student_file" ) . exists( ) ) ;
1126+ let content =
1127+ 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 =
1152+ std:: fs:: read_to_string ( temp. path ( ) . join ( "extracted/src/student_file" ) ) . unwrap ( ) ;
1153+ assert_eq ! ( content, "content" ) ;
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 ! (
1179+ temp. path( )
1180+ . join( "extracted/project_folder/src/student_file" )
1181+ . exists( )
1182+ ) ;
1183+ let content = std:: fs:: read_to_string (
1184+ temp. path ( )
1185+ . join ( "extracted/project_folder/src/student_file" ) ,
1186+ )
1187+ . unwrap ( ) ;
1188+ assert_eq ! ( content, "content" ) ;
1189+ }
11301190}
0 commit comments