1use std::collections::HashMap;
2use std::io::Read as _;
3use std::path::{Path, PathBuf};
4
5use crate::models::{StudentFile, SubmissionSet};
6
7fn 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
22fn 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
34fn 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 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 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; const MAX_TOTAL_SIZE: u64 = 50_000_000; 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 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, };
145
146 let filename = match name.file_name() {
148 Some(n) => n.to_owned(),
149 None => continue,
150 };
151
152 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; }
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
177pub 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 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 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 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 let is_extracted = dir_path
242 .components()
243 .any(|c| c.as_os_str() == ".scriptmark_extracted");
244
245 let sid = if is_extracted {
246 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 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 std::fs::write(dir.path().join("alice_Lab5.py"), "pass").unwrap();
313
314 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 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}