Skip to main content

scriptmark/
discovery.rs

1use std::collections::HashMap;
2use std::io::Read as _;
3use std::path::{Path, PathBuf};
4
5use crate::models::{StudentFile, SubmissionSet};
6
7/// Map file extensions to language identifiers.
8fn detect_language(ext: &str) -> Option<&'static str> {
9	match ext {
10		"py" => Some("python"),
11		"cpp" | "cc" | "cxx" => Some("cpp"),
12		"c" => Some("c"),
13		"java" => Some("java"),
14		"js" => Some("javascript"),
15		"ts" => Some("typescript"),
16		"rs" => Some("rust"),
17		"go" => Some("go"),
18		_ => None,
19	}
20}
21
22/// Extract student ID from a filename.
23///
24/// Convention: `{student_id}_{rest}.ext` (e.g. `alice_Lab5_1.py` → `alice`)
25fn extract_sid(filename: &str) -> Option<String> {
26	let stem = Path::new(filename).file_stem()?.to_str()?;
27	let sid = stem.split('_').next()?;
28	if sid.is_empty() {
29		return None;
30	}
31	Some(sid.to_string())
32}
33
34/// Extract .zip archives in a directory to `.scriptmark_extracted/{archive_stem}/`.
35///
36/// Returns list of directories created. Skips archives that have already been extracted
37/// (directory exists and is non-empty). Silently skips corrupt/unreadable archives.
38fn extract_archives(dir: &Path) -> Vec<PathBuf> {
39	let extract_root = dir.join(".scriptmark_extracted");
40	let mut created = Vec::new();
41
42	let entries = match std::fs::read_dir(dir) {
43		Ok(e) => e,
44		Err(_) => return created,
45	};
46
47	for entry in entries.flatten() {
48		let path = entry.path();
49		if !path.is_file() {
50			continue;
51		}
52		let ext = path
53			.extension()
54			.and_then(|e| e.to_str())
55			.unwrap_or("")
56			.to_lowercase();
57		if ext != "zip" {
58			continue;
59		}
60
61		let stem = path
62			.file_stem()
63			.and_then(|s| s.to_str())
64			.unwrap_or("unknown");
65		let target = extract_root.join(stem);
66
67		// Skip if already extracted
68		if target.is_dir()
69			&& std::fs::read_dir(&target)
70				.map(|mut d| d.next().is_some())
71				.unwrap_or(false)
72		{
73			created.push(target);
74			continue;
75		}
76
77		// Extract
78		let file = match std::fs::File::open(&path) {
79			Ok(f) => f,
80			Err(_) => continue,
81		};
82		let mut archive = match zip::ZipArchive::new(file) {
83			Ok(a) => a,
84			Err(e) => {
85				eprintln!("[WARN] Skipping corrupt archive {}: {e}", path.display());
86				continue;
87			}
88		};
89
90		if let Err(e) = std::fs::create_dir_all(&target) {
91			eprintln!("[WARN] Cannot create extract dir {}: {e}", target.display());
92			continue;
93		}
94
95		const MAX_FILE_SIZE: u64 = 5_000_000; // 5 MB per file
96		const MAX_TOTAL_SIZE: u64 = 50_000_000; // 50 MB total per archive
97		const MAX_FILE_COUNT: usize = 100;
98
99		let mut total_bytes: u64 = 0;
100		let mut file_count: usize = 0;
101
102		for i in 0..archive.len() {
103			let mut entry = match archive.by_index(i) {
104				Ok(e) => e,
105				Err(_) => continue,
106			};
107
108			if entry.is_dir() {
109				continue;
110			}
111
112			// Check uncompressed size before reading
113			if entry.size() > MAX_FILE_SIZE {
114				eprintln!(
115					"[WARN] Skipping oversized entry in {}: {} ({} bytes)",
116					path.display(),
117					entry.name(),
118					entry.size()
119				);
120				continue;
121			}
122
123			if total_bytes + entry.size() > MAX_TOTAL_SIZE {
124				eprintln!(
125					"[WARN] Archive {} exceeds total extraction limit ({}B), stopping",
126					path.display(),
127					MAX_TOTAL_SIZE
128				);
129				break;
130			}
131
132			if file_count >= MAX_FILE_COUNT {
133				eprintln!(
134					"[WARN] Archive {} exceeds file count limit ({}), stopping",
135					path.display(),
136					MAX_FILE_COUNT
137				);
138				break;
139			}
140
141			let name = match entry.enclosed_name() {
142				Some(n) => n.to_owned(),
143				None => continue, // skip path traversal attempts
144			};
145
146			// Flatten: extract to target/{filename} regardless of subdirectories in archive
147			let filename = match name.file_name() {
148				Some(n) => n.to_owned(),
149				None => continue,
150			};
151
152			// Skip __pycache__, .DS_Store, etc.
153			let fname_str = filename.to_string_lossy();
154			if fname_str.starts_with('.') || fname_str.starts_with("__") {
155				continue;
156			}
157
158			let out_path = target.join(&filename);
159			if out_path.exists() {
160				continue; // don't overwrite
161			}
162
163			let mut buf = Vec::new();
164			if entry.read_to_end(&mut buf).is_ok() {
165				let _ = std::fs::write(&out_path, &buf);
166				total_bytes += buf.len() as u64;
167				file_count += 1;
168			}
169		}
170
171		created.push(target);
172	}
173
174	created
175}
176
177/// Scan directories for student submission files and group by student ID.
178///
179/// Only includes files with recognized language extensions.
180/// If `extensions` is provided, only includes files matching those extensions.
181pub fn discover_submissions(
182	paths: &[impl AsRef<Path>],
183	extensions: Option<&[&str]>,
184) -> Result<SubmissionSet, DiscoveryError> {
185	let mut by_student: HashMap<String, Vec<StudentFile>> = HashMap::new();
186
187	// Phase 0: Extract archives in each submission directory
188	let mut extra_dirs: Vec<PathBuf> = Vec::new();
189	for dir_path in paths {
190		let extracted = extract_archives(dir_path.as_ref());
191		extra_dirs.extend(extracted);
192	}
193
194	// Combine original paths + extracted subdirectories
195	let all_paths: Vec<&Path> = paths
196		.iter()
197		.map(|p| p.as_ref())
198		.chain(extra_dirs.iter().map(|p| p.as_path()))
199		.collect();
200
201	for dir_path in &all_paths {
202		if !dir_path.is_dir() {
203			return Err(DiscoveryError::NotADirectory(dir_path.to_path_buf()));
204		}
205
206		let entries = std::fs::read_dir(dir_path)
207			.map_err(|e| DiscoveryError::IoError(dir_path.to_path_buf(), e))?;
208
209		for entry in entries {
210			let entry = entry.map_err(|e| DiscoveryError::IoError(dir_path.to_path_buf(), e))?;
211			let path = entry.path();
212
213			if !path.is_file() {
214				continue;
215			}
216
217			let ext = match path.extension().and_then(|e| e.to_str()) {
218				Some(e) => e,
219				None => continue,
220			};
221
222			// Filter by allowed extensions if specified
223			if let Some(allowed) = extensions
224				&& !allowed.contains(&ext)
225			{
226				continue;
227			}
228
229			let language = match detect_language(ext) {
230				Some(lang) => lang.to_string(),
231				None => continue,
232			};
233
234			let filename = match path.file_name().and_then(|n| n.to_str()) {
235				Some(n) => n,
236				None => continue,
237			};
238
239			// For files inside .scriptmark_extracted/, prefer SID from the parent dir
240			// (which is the archive stem, e.g. "bob_12345_67890_Lab5")
241			let is_extracted = dir_path
242				.components()
243				.any(|c| c.as_os_str() == ".scriptmark_extracted");
244
245			let sid = if is_extracted {
246				// Try parent dir first (archive stem has SID), then file
247				dir_path
248					.file_name()
249					.and_then(|n| n.to_str())
250					.and_then(extract_sid)
251					.or_else(|| extract_sid(filename))
252			} else {
253				extract_sid(filename)
254			};
255
256			let sid = match sid {
257				Some(sid) => sid,
258				None => continue,
259			};
260
261			by_student
262				.entry(sid)
263				.or_default()
264				.push(StudentFile { path, language });
265		}
266	}
267
268	// Sort files within each student for deterministic ordering
269	for files in by_student.values_mut() {
270		files.sort_by(|a, b| a.path.cmp(&b.path));
271	}
272
273	Ok(SubmissionSet { by_student })
274}
275
276#[derive(Debug, thiserror::Error)]
277pub enum DiscoveryError {
278	#[error("not a directory: {0}")]
279	NotADirectory(std::path::PathBuf),
280	#[error("IO error reading {0}: {1}")]
281	IoError(std::path::PathBuf, std::io::Error),
282}
283
284#[cfg(test)]
285mod tests {
286	use super::*;
287
288	#[test]
289	fn test_extract_sid() {
290		assert_eq!(extract_sid("alice_Lab5_1.py"), Some("alice".to_string()));
291		assert_eq!(extract_sid("bob_hw3.py"), Some("bob".to_string()));
292		assert_eq!(
293			extract_sid("student123_final.cpp"),
294			Some("student123".to_string())
295		);
296		assert_eq!(extract_sid("_invalid.py"), None);
297	}
298
299	#[test]
300	fn test_detect_language() {
301		assert_eq!(detect_language("py"), Some("python"));
302		assert_eq!(detect_language("cpp"), Some("cpp"));
303		assert_eq!(detect_language("java"), Some("java"));
304		assert_eq!(detect_language("txt"), None);
305	}
306
307	#[test]
308	fn test_discover_extracts_zip_archives() {
309		let dir = tempfile::tempdir().unwrap();
310
311		// Normal .py file
312		std::fs::write(dir.path().join("alice_Lab5.py"), "pass").unwrap();
313
314		// Create a .zip containing a .py file
315		let zip_path = dir.path().join("bob_12345_67890_Lab5.zip");
316		let file = std::fs::File::create(&zip_path).unwrap();
317		let mut zip = zip::ZipWriter::new(file);
318		zip.start_file("Lab5.py", zip::write::SimpleFileOptions::default())
319			.unwrap();
320		use std::io::Write;
321		zip.write_all(b"def foo(): return 42").unwrap();
322		zip.finish().unwrap();
323
324		let result = discover_submissions(&[dir.path()], None).unwrap();
325
326		// alice from .py, bob from extracted .zip
327		assert_eq!(result.student_count(), 2);
328		assert!(result.by_student.contains_key("alice"));
329		assert!(result.by_student.contains_key("bob"));
330	}
331
332	#[test]
333	fn test_discover_submissions() {
334		let dir = tempfile::tempdir().unwrap();
335		std::fs::write(dir.path().join("alice_Lab5.py"), "pass").unwrap();
336		std::fs::write(dir.path().join("bob_Lab5.py"), "pass").unwrap();
337		std::fs::write(dir.path().join("alice_Lab6.py"), "pass").unwrap();
338		std::fs::write(dir.path().join("notes.txt"), "ignore me").unwrap();
339
340		let result = discover_submissions(&[dir.path()], None).unwrap();
341		assert_eq!(result.student_count(), 2);
342		assert_eq!(result.by_student["alice"].len(), 2);
343		assert_eq!(result.by_student["bob"].len(), 1);
344		assert_eq!(result.languages(), vec!["python"]);
345	}
346}