1use async_trait::async_trait;
18use std::io;
19use std::path::Path;
20use std::sync::Arc;
21
22use super::{DirEntry, DirEntryKind, Filesystem};
23use crate::scheduler::{JobId, JobManager};
24
25pub struct JobFs {
34 jobs: Arc<JobManager>,
35}
36
37impl JobFs {
38 pub fn new(jobs: Arc<JobManager>) -> Self {
40 Self { jobs }
41 }
42
43 fn parse_path(path: &Path) -> Option<(Option<JobId>, Option<&str>)> {
50 let path_str = path.to_str()?;
51 let path_str = path_str.trim_start_matches('/');
52
53 if path_str.is_empty() {
54 return Some((None, None)); }
56
57 let parts: Vec<&str> = path_str.split('/').collect();
58
59 match parts.as_slice() {
60 [id_str] => {
61 let id: u64 = id_str.parse().ok()?;
63 Some((Some(JobId(id)), None))
64 }
65 [id_str, file] => {
66 let id: u64 = id_str.parse().ok()?;
68 Some((Some(JobId(id)), Some(*file)))
69 }
70 _ => None,
71 }
72 }
73}
74
75impl std::fmt::Debug for JobFs {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("JobFs").finish()
78 }
79}
80
81#[async_trait]
82impl Filesystem for JobFs {
83 async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
84 let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
85 io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
86 })?;
87
88 let job_id = job_id.ok_or_else(|| {
89 io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
90 })?;
91
92 let file = file.ok_or_else(|| {
93 io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
94 })?;
95
96 if !self.jobs.exists(job_id).await {
98 return Err(io::Error::new(
99 io::ErrorKind::NotFound,
100 format!("job {} not found", job_id),
101 ));
102 }
103
104 match file {
105 "stdout" => {
106 let content = self.jobs.read_stdout(job_id).await.unwrap_or_default();
108 Ok(content)
109 }
110 "stderr" => {
111 let content = self.jobs.read_stderr(job_id).await.unwrap_or_default();
112 Ok(content)
113 }
114 "status" => {
115 let status = self
116 .jobs
117 .get_status_string(job_id)
118 .await
119 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
120 Ok(format!("{}\n", status).into_bytes())
121 }
122 "command" => {
123 let command = self
124 .jobs
125 .get_command(job_id)
126 .await
127 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
128 Ok(format!("{}\n", command).into_bytes())
129 }
130 "latch" => {
131 match self.jobs.get_latch(job_id).await {
136 Some(latch) => {
137 let json = serde_json::to_string_pretty(&latch)
138 .map_err(|e| io::Error::other(format!("latch serialize: {e}")))?;
139 Ok(format!("{json}\n").into_bytes())
140 }
141 None => Ok(Vec::new()),
142 }
143 }
144 _ => Err(io::Error::new(
145 io::ErrorKind::NotFound,
146 format!("unknown file: {}", file),
147 )),
148 }
149 }
150
151 async fn write(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
152 Err(io::Error::new(
153 io::ErrorKind::PermissionDenied,
154 "jobfs is read-only",
155 ))
156 }
157
158 async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
159 let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
160 io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
161 })?;
162
163 if file.is_some() {
165 return Err(io::Error::new(
166 io::ErrorKind::NotADirectory,
167 "not a directory",
168 ));
169 }
170
171 match job_id {
172 None => {
173 let job_ids = self.jobs.list_ids().await;
175 let entries = job_ids
176 .into_iter()
177 .map(|id| DirEntry {
178 name: id.0.to_string(),
179 kind: DirEntryKind::Directory,
180 modified: None,
181 permissions: None,
182 size: 0,
183 symlink_target: None,
184 })
185 .collect();
186 Ok(entries)
187 }
188 Some(id) => {
189 if !self.jobs.exists(id).await {
191 return Err(io::Error::new(
192 io::ErrorKind::NotFound,
193 format!("job {} not found", id),
194 ));
195 }
196
197 Ok(vec![
198 DirEntry {
199 name: "stdout".to_string(),
200 kind: DirEntryKind::File,
201 modified: None,
202 permissions: None,
203 size: 0, symlink_target: None,
205 },
206 DirEntry {
207 name: "stderr".to_string(),
208 kind: DirEntryKind::File,
209 modified: None,
210 permissions: None,
211 size: 0,
212 symlink_target: None,
213 },
214 DirEntry {
215 name: "status".to_string(),
216 kind: DirEntryKind::File,
217 modified: None,
218 permissions: None,
219 size: 0,
220 symlink_target: None,
221 },
222 DirEntry {
223 name: "command".to_string(),
224 kind: DirEntryKind::File,
225 modified: None,
226 permissions: None,
227 size: 0,
228 symlink_target: None,
229 },
230 DirEntry {
231 name: "latch".to_string(),
232 kind: DirEntryKind::File,
233 modified: None,
234 permissions: None,
235 size: 0, symlink_target: None,
237 },
238 ])
239 }
240 }
241 }
242
243 async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
244 let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
245 io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
246 })?;
247
248 let name = path
249 .file_name()
250 .map(|n| n.to_string_lossy().into_owned())
251 .unwrap_or_else(|| "/".to_string());
252
253 match (job_id, file) {
254 (None, None) => {
255 Ok(DirEntry::directory(name))
257 }
258 (Some(id), None) => {
259 if !self.jobs.exists(id).await {
261 return Err(io::Error::new(
262 io::ErrorKind::NotFound,
263 format!("job {} not found", id),
264 ));
265 }
266 Ok(DirEntry::directory(name))
267 }
268 (Some(id), Some(file)) => {
269 if !self.jobs.exists(id).await {
271 return Err(io::Error::new(
272 io::ErrorKind::NotFound,
273 format!("job {} not found", id),
274 ));
275 }
276
277 if !["stdout", "stderr", "status", "command", "latch"].contains(&file) {
279 return Err(io::Error::new(
280 io::ErrorKind::NotFound,
281 format!("unknown file: {}", file),
282 ));
283 }
284
285 Ok(DirEntry::file(name, 0))
286 }
287 (None, Some(_)) => {
288 Err(io::Error::new(
290 io::ErrorKind::NotFound,
291 "invalid path",
292 ))
293 }
294 }
295 }
296
297 async fn mkdir(&self, _path: &Path) -> io::Result<()> {
298 Err(io::Error::new(
299 io::ErrorKind::PermissionDenied,
300 "jobfs is read-only",
301 ))
302 }
303
304 async fn remove(&self, _path: &Path) -> io::Result<()> {
305 Err(io::Error::new(
306 io::ErrorKind::PermissionDenied,
307 "jobfs is read-only",
308 ))
309 }
310
311 fn read_only(&self) -> bool {
312 true
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::interpreter::ExecResult;
320 use crate::scheduler::BoundedStream;
321 use tokio::sync::oneshot;
322
323 async fn make_job_manager_with_job() -> (Arc<JobManager>, JobId) {
324 let manager = Arc::new(JobManager::new());
325
326 let stdout = Arc::new(BoundedStream::new(1024));
328 let stderr = Arc::new(BoundedStream::new(1024));
329
330 stdout.write(b"hello from stdout\n").await;
332 stderr.write(b"error message\n").await;
333
334 let (tx, rx) = oneshot::channel();
336
337 let id = manager
339 .register_with_streams("echo test".to_string(), rx, stdout, stderr)
340 .await;
341
342 let _ = tx.send(ExecResult::success("done"));
344
345 (manager, id)
346 }
347
348 #[tokio::test]
349 async fn test_list_root_empty() {
350 let manager = Arc::new(JobManager::new());
351 let fs = JobFs::new(manager);
352
353 let entries = fs.list(Path::new("")).await.unwrap();
354 assert!(entries.is_empty());
355 }
356
357 #[tokio::test]
358 async fn test_list_root_with_jobs() {
359 let (manager, id) = make_job_manager_with_job().await;
360 let fs = JobFs::new(manager);
361
362 let entries = fs.list(Path::new("")).await.unwrap();
363 assert_eq!(entries.len(), 1);
364 assert_eq!(entries[0].name, id.0.to_string());
365 assert_eq!(entries[0].kind, DirEntryKind::Directory);
366 }
367
368 #[tokio::test]
369 async fn test_list_job_directory() {
370 let (manager, id) = make_job_manager_with_job().await;
371 let fs = JobFs::new(manager);
372
373 let path = format!("{}", id);
374 let entries = fs.list(Path::new(&path)).await.unwrap();
375
376 let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
377 assert!(names.contains(&&"stdout".to_string()));
378 assert!(names.contains(&&"stderr".to_string()));
379 assert!(names.contains(&&"status".to_string()));
380 assert!(names.contains(&&"command".to_string()));
381 }
382
383 #[tokio::test]
384 async fn test_read_stdout() {
385 let (manager, id) = make_job_manager_with_job().await;
386 let fs = JobFs::new(manager);
387
388 let path = format!("{}/stdout", id);
389 let data = fs.read(Path::new(&path)).await.unwrap();
390 assert_eq!(data, b"hello from stdout\n");
391 }
392
393 #[tokio::test]
394 async fn test_read_stderr() {
395 let (manager, id) = make_job_manager_with_job().await;
396 let fs = JobFs::new(manager);
397
398 let path = format!("{}/stderr", id);
399 let data = fs.read(Path::new(&path)).await.unwrap();
400 assert_eq!(data, b"error message\n");
401 }
402
403 #[tokio::test]
404 async fn test_read_status_running() {
405 let manager = Arc::new(JobManager::new());
406
407 let stdout = Arc::new(BoundedStream::new(1024));
409 let stderr = Arc::new(BoundedStream::new(1024));
410 let (_tx, rx) = oneshot::channel();
411 let id = manager
412 .register_with_streams("sleep 100".to_string(), rx, stdout, stderr)
413 .await;
414
415 let fs = JobFs::new(manager);
416
417 let path = format!("{}/status", id);
418 let data = fs.read(Path::new(&path)).await.unwrap();
419 assert_eq!(String::from_utf8_lossy(&data), "running\n");
420 }
421
422 #[tokio::test]
423 async fn test_read_status_done() {
424 let (manager, id) = make_job_manager_with_job().await;
425
426 manager.wait(id).await;
428
429 let fs = JobFs::new(manager);
430
431 let path = format!("{}/status", id);
432 let data = fs.read(Path::new(&path)).await.unwrap();
433 assert_eq!(String::from_utf8_lossy(&data), "done:0\n");
434 }
435
436 #[tokio::test]
437 async fn test_read_command() {
438 let (manager, id) = make_job_manager_with_job().await;
439 let fs = JobFs::new(manager);
440
441 let path = format!("{}/command", id);
442 let data = fs.read(Path::new(&path)).await.unwrap();
443 assert_eq!(String::from_utf8_lossy(&data), "echo test\n");
444 }
445
446 #[tokio::test]
447 async fn test_stat_root() {
448 let manager = Arc::new(JobManager::new());
449 let fs = JobFs::new(manager);
450
451 let entry = fs.stat(Path::new("")).await.unwrap();
452 assert_eq!(entry.kind, DirEntryKind::Directory);
453 }
454
455 #[tokio::test]
456 async fn test_stat_job_dir() {
457 let (manager, id) = make_job_manager_with_job().await;
458 let fs = JobFs::new(manager);
459
460 let path = format!("{}", id);
461 let entry = fs.stat(Path::new(&path)).await.unwrap();
462 assert_eq!(entry.kind, DirEntryKind::Directory);
463 }
464
465 #[tokio::test]
466 async fn test_stat_file() {
467 let (manager, id) = make_job_manager_with_job().await;
468 let fs = JobFs::new(manager);
469
470 let path = format!("{}/stdout", id);
471 let entry = fs.stat(Path::new(&path)).await.unwrap();
472 assert_eq!(entry.kind, DirEntryKind::File);
473 }
474
475 #[tokio::test]
476 async fn test_read_only() {
477 let manager = Arc::new(JobManager::new());
478 let fs = JobFs::new(manager);
479
480 assert!(fs.read_only());
481
482 let write_result = fs.write(Path::new("1/stdout"), b"data").await;
483 assert!(write_result.is_err());
484
485 let mkdir_result = fs.mkdir(Path::new("1")).await;
486 assert!(mkdir_result.is_err());
487
488 let remove_result = fs.remove(Path::new("1")).await;
489 assert!(remove_result.is_err());
490 }
491
492 #[tokio::test]
493 async fn test_nonexistent_job() {
494 let manager = Arc::new(JobManager::new());
495 let fs = JobFs::new(manager);
496
497 let result = fs.read(Path::new("999/stdout")).await;
498 assert!(result.is_err());
499 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
500 }
501
502 #[tokio::test]
503 async fn test_unknown_file() {
504 let (manager, id) = make_job_manager_with_job().await;
505 let fs = JobFs::new(manager);
506
507 let path = format!("{}/unknown", id);
508 let result = fs.read(Path::new(&path)).await;
509 assert!(result.is_err());
510 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
511 }
512
513 #[tokio::test]
514 async fn test_read_directory_error() {
515 let (manager, id) = make_job_manager_with_job().await;
516 let fs = JobFs::new(manager);
517
518 let path = format!("{}", id);
519 let result = fs.read(Path::new(&path)).await;
520 assert!(result.is_err());
521 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::IsADirectory);
522 }
523}