1use std::path::PathBuf;
2use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
3use tokio::net::TcpStream;
4use zip::ZipArchive;
5use std::io::Cursor;
6use std::path::Path;
7
8const MOSS_SERVER: &str = "moss.stanford.edu";
9const MOSS_PORT: u16 = 7690;
10
11pub struct MossService {
13 user_id: String,
14}
15
16impl MossService {
17 pub fn new(user_id: &str) -> Self {
19 Self {
20 user_id: user_id.to_string(),
21 }
22 }
23
24 pub async fn run(
36 &self,
37 base_files: Vec<PathBuf>,
38 submission_files: Vec<(PathBuf, Option<String>, Option<i64>)>,
39 language: &str,
40 ) -> Result<String, String> {
41 if submission_files.len() < 2 {
42 return Err("MOSS requires at least 2 submission files to compare".to_string());
43 }
44
45 let mut stream = TcpStream::connect((MOSS_SERVER, MOSS_PORT))
46 .await
47 .map_err(|e| format!("Failed to connect to MOSS server: {}", e))?;
48
49 self.send_command(&mut stream, &format!("moss {}", self.user_id)).await?;
50 self.send_command(&mut stream, "directory 0").await?;
51 self.send_command(&mut stream, "X 0").await?;
52 self.send_command(&mut stream, "maxmatches 10").await?;
53 self.send_command(&mut stream, "show 250").await?;
54 self.send_command(&mut stream, &format!("language {}", language)).await?;
55
56 let mut response = String::new();
57 {
58 let mut reader = BufReader::new(&mut stream);
59 reader.read_line(&mut response).await.map_err(|e| {
60 format!("Failed to read language response: {}", e)
61 })?;
62 }
63
64 if response.trim() == "no" {
65 return Err(format!("Language '{}' not supported by MOSS", language));
66 }
67
68 for path in &base_files {
69 self.upload_file(&mut stream, path, 0, language, None, None).await?;
70 }
71
72 let mut file_id = 1u32;
73 for (path, username, submission_id) in &submission_files {
74 if path.extension().and_then(|s| s.to_str()) == Some("zip") {
75 self.send_command(&mut stream, "directory 1").await?;
76 file_id = self.upload_zip(
77 &mut stream,
78 path,
79 file_id,
80 language,
81 username.as_deref(),
82 *submission_id
83 ).await?;
84 self.send_command(&mut stream, "directory 0").await?;
85 } else {
86 file_id = self.upload_file(&mut stream, path, file_id, language, username.as_deref(), *submission_id).await?;
87 }
88 }
89
90 self.send_command(&mut stream, "query 0 ").await?;
91
92 let mut response = String::new();
93 {
94 let mut reader = BufReader::new(&mut stream);
95 reader.read_line(&mut response).await.map_err(|e| {
96 format!("Failed to read query response: {}", e)
97 })?;
98 }
99
100 let report_url = response.trim().to_string();
101
102 self.send_command(&mut stream, "end").await?;
103
104 if !report_url.starts_with("http") {
105 return Err(format!("Invalid response from MOSS server: '{}'", report_url));
106 }
107
108 Ok(report_url)
109 }
110
111 async fn send_command(&self, stream: &mut TcpStream, command: &str) -> Result<(), String> {
112 let command_with_newline = format!("{}\n", command);
113 stream.write_all(command_with_newline.as_bytes()).await.map_err(|e| {
114 format!("Failed to send command '{}': {}", command, e)
115 })?;
116 Ok(())
117 }
118
119 async fn upload_file(
122 &self,
123 stream: &mut TcpStream,
124 path: &Path,
125 file_id: u32,
126 language: &str,
127 username: Option<&str>,
128 submission_id: Option<i64>,
129 ) -> Result<u32, String> {
130 if !path.exists() {
131 return Err(format!("File does not exist: {}", path.display()));
132 }
133
134 let content = tokio::fs::read(path).await.map_err(|e| {
135 format!("Failed to read file {}: {}", path.display(), e)
136 })?;
137
138 let original_filename = path
139 .file_name()
140 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
141 .to_str()
142 .ok_or_else(|| format!("Non-UTF8 filename: {}", path.display()))?
143 .replace(' ', "_");
144
145 let filename = match (username, submission_id) {
146 (Some(username), Some(submission_id)) => {
147 format!("{}_{}_{}", username, submission_id, original_filename)
148 }
149 (Some(username), None) => {
150 format!("{}_{}", username, original_filename)
151 }
152 (None, Some(submission_id)) => {
153 format!("{}_{}", submission_id, original_filename)
154 }
155 (None, None) => original_filename,
156 };
157
158 let command = format!("file {} {} {} {}", file_id, language, content.len(), filename);
159 self.send_command(stream, &command).await?;
160
161 stream.write_all(&content).await.map_err(|e| {
162 format!("Failed to upload file content: {}", e)
163 })?;
164
165 Ok(file_id + 1)
166 }
167
168 async fn upload_zip(
171 &self,
172 stream: &mut TcpStream,
173 zip_path: &Path,
174 starting_file_id: u32,
175 language: &str,
176 username: Option<&str>,
177 submission_id: Option<i64>,
178 ) -> Result<u32, String> {
179 let zip_data = tokio::fs::read(zip_path).await.map_err(|e| {
180 format!("Failed to read ZIP file {}: {}", zip_path.display(), e)
181 })?;
182
183 let cursor = Cursor::new(zip_data);
184 let mut archive = ZipArchive::new(cursor).map_err(|e| {
185 format!("Failed to open ZIP file {}: {}", zip_path.display(), e)
186 })?;
187
188 let directory_name = match (username, submission_id) {
189 (Some(u), Some(sid)) => format!("{}_{}", u, sid),
190 (Some(u), None) => format!("{}", u),
191 (None, Some(sid)) => format!("{}", sid),
192 (None, None) => "".to_string(),
193 };
194
195 let sanitized_dir_name = directory_name
196 .replace('/', "_")
197 .replace('\\', "_")
198 .replace(' ', "_");
199
200 let mut current_file_id = starting_file_id;
201 let mut files_uploaded = 0;
202
203 for i in 0..archive.len() {
204 let mut file = archive.by_index(i).map_err(|e| {
205 format!("Failed to read file {} from ZIP: {}", i, e)
206 })?;
207
208 if file.is_dir() {
209 continue;
210 }
211
212 let mut contents = Vec::new();
213 std::io::copy(&mut file, &mut contents).map_err(|e| {
214 format!("Failed to read file contents: {}", e)
215 })?;
216
217 let internal_path = file.name()
218 .replace('\\', "/")
219 .trim_start_matches('/')
220 .to_string();
221
222 if internal_path.is_empty() {
223 continue;
224 }
225
226 let display_name = format!("{}/{}", sanitized_dir_name, internal_path)
227 .replace(' ', "_");
228
229 let command = format!(
230 "file {} {} {} {}",
231 current_file_id,
232 language,
233 contents.len(),
234 display_name
235 );
236
237 self.send_command(stream, &command).await?;
238 stream.write_all(&contents).await.map_err(|e| {
239 format!("Failed to upload file content: {}", e)
240 })?;
241
242 current_file_id += 1;
243 files_uploaded += 1;
244 }
245
246 if files_uploaded == 0 {
247 return Err(format!("ZIP contained no valid files: {}", zip_path.display()));
248 }
249
250 Ok(current_file_id)
251 }
252}