Skip to main content

shape_runtime/stdlib_io/
file_ops.rs

1//! File operation implementations for the io module.
2
3use shape_value::ValueWord;
4use shape_value::heap_value::{IoHandleData, IoResource};
5use std::io::{Read, Seek, Write};
6
7/// Helper: lock an IoHandleData, verify it's open and a File, return mutable guard.
8fn lock_as_file<'a>(
9    handle: &'a IoHandleData,
10    fn_name: &str,
11) -> Result<std::sync::MutexGuard<'a, Option<IoResource>>, String> {
12    let guard = handle
13        .resource
14        .lock()
15        .map_err(|_| format!("{}: lock poisoned", fn_name))?;
16    match guard.as_ref() {
17        None => Err(format!("{}: handle is closed", fn_name)),
18        Some(IoResource::File(_)) => Ok(guard),
19        Some(_) => Err(format!("{}: handle is not a file", fn_name)),
20    }
21}
22
23/// Extract `&mut File` from a locked IoResource guard. Caller must ensure it's a File.
24fn as_file_mut(resource: &mut Option<IoResource>) -> &mut std::fs::File {
25    match resource.as_mut().unwrap() {
26        IoResource::File(f) => f,
27        _ => unreachable!(),
28    }
29}
30
31/// io.open(path, mode?) -> IoHandle
32pub fn io_open(
33    args: &[ValueWord],
34    ctx: &crate::module_exports::ModuleContext,
35) -> Result<ValueWord, String> {
36    let path = args
37        .first()
38        .and_then(|a| a.as_str())
39        .ok_or_else(|| "io.open() requires a string path argument".to_string())?
40        .to_string();
41
42    let mode = args
43        .get(1)
44        .and_then(|a| a.as_str())
45        .unwrap_or("r")
46        .to_string();
47
48    // Permission check depends on the mode (with scope constraints)
49    match mode.as_str() {
50        "r" => crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, &path)?,
51        "w" | "a" => {
52            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, &path)?
53        }
54        "rw" => {
55            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, &path)?;
56            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, &path)?;
57        }
58        _ => {} // invalid mode will be caught below
59    }
60
61    let file = match mode.as_str() {
62        "r" => std::fs::OpenOptions::new()
63            .read(true)
64            .open(&path)
65            .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
66        "w" => std::fs::OpenOptions::new()
67            .write(true)
68            .create(true)
69            .truncate(true)
70            .open(&path)
71            .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
72        "a" => std::fs::OpenOptions::new()
73            .append(true)
74            .create(true)
75            .open(&path)
76            .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
77        "rw" => std::fs::OpenOptions::new()
78            .read(true)
79            .write(true)
80            .create(true)
81            .open(&path)
82            .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
83        _ => {
84            return Err(format!(
85                "io.open(): invalid mode '{}'. Use \"r\", \"w\", \"a\", or \"rw\"",
86                mode
87            ));
88        }
89    };
90
91    let handle = IoHandleData::new_file(file, path, mode);
92    Ok(ValueWord::from_io_handle(handle))
93}
94
95/// io.read_to_string(handle) -> string
96pub fn io_read_to_string(
97    args: &[ValueWord],
98    ctx: &crate::module_exports::ModuleContext,
99) -> Result<ValueWord, String> {
100    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
101    let handle = args
102        .first()
103        .and_then(|a| a.as_io_handle())
104        .ok_or_else(|| "io.read_to_string() requires an IoHandle argument".to_string())?;
105
106    let mut guard = lock_as_file(handle, "io.read_to_string()")?;
107    let file = as_file_mut(&mut guard);
108
109    // Seek to beginning for a full read
110    file.seek(std::io::SeekFrom::Start(0))
111        .map_err(|e| format!("io.read_to_string(): seek failed: {}", e))?;
112
113    let mut contents = String::new();
114    file.read_to_string(&mut contents)
115        .map_err(|e| format!("io.read_to_string(): {}", e))?;
116    Ok(ValueWord::from_string(std::sync::Arc::new(contents)))
117}
118
119/// io.read(handle, n?) -> string (read n bytes or all)
120pub fn io_read(
121    args: &[ValueWord],
122    ctx: &crate::module_exports::ModuleContext,
123) -> Result<ValueWord, String> {
124    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
125    let handle = args
126        .first()
127        .and_then(|a| a.as_io_handle())
128        .ok_or_else(|| "io.read() requires an IoHandle argument".to_string())?;
129
130    let n = args.get(1).and_then(|a| a.as_number_coerce());
131
132    let mut guard = lock_as_file(handle, "io.read()")?;
133    let file = as_file_mut(&mut guard);
134
135    let contents = if let Some(n) = n {
136        let n = n as usize;
137        let mut buf = vec![0u8; n];
138        let bytes_read = file
139            .read(&mut buf)
140            .map_err(|e| format!("io.read(): {}", e))?;
141        buf.truncate(bytes_read);
142        String::from_utf8(buf).map_err(|e| format!("io.read(): invalid UTF-8: {}", e))?
143    } else {
144        let mut s = String::new();
145        file.read_to_string(&mut s)
146            .map_err(|e| format!("io.read(): {}", e))?;
147        s
148    };
149
150    Ok(ValueWord::from_string(std::sync::Arc::new(contents)))
151}
152
153/// io.read_bytes(handle, n?) -> array of ints
154pub fn io_read_bytes(
155    args: &[ValueWord],
156    ctx: &crate::module_exports::ModuleContext,
157) -> Result<ValueWord, String> {
158    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
159    let handle = args
160        .first()
161        .and_then(|a| a.as_io_handle())
162        .ok_or_else(|| "io.read_bytes() requires an IoHandle argument".to_string())?;
163
164    let n = args.get(1).and_then(|a| a.as_number_coerce());
165
166    let mut guard = lock_as_file(handle, "io.read_bytes()")?;
167    let file = as_file_mut(&mut guard);
168
169    let bytes = if let Some(n) = n {
170        let n = n as usize;
171        let mut buf = vec![0u8; n];
172        let bytes_read = file
173            .read(&mut buf)
174            .map_err(|e| format!("io.read_bytes(): {}", e))?;
175        buf.truncate(bytes_read);
176        buf
177    } else {
178        let mut buf = Vec::new();
179        file.read_to_end(&mut buf)
180            .map_err(|e| format!("io.read_bytes(): {}", e))?;
181        buf
182    };
183
184    let arr: Vec<ValueWord> = bytes
185        .iter()
186        .map(|&b| ValueWord::from_i64(b as i64))
187        .collect();
188    Ok(ValueWord::from_array(std::sync::Arc::new(arr)))
189}
190
191/// io.write(handle, data) -> int (bytes written)
192pub fn io_write(
193    args: &[ValueWord],
194    ctx: &crate::module_exports::ModuleContext,
195) -> Result<ValueWord, String> {
196    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
197    let handle = args
198        .first()
199        .and_then(|a| a.as_io_handle())
200        .ok_or_else(|| "io.write() requires an IoHandle as first argument".to_string())?;
201
202    let data = args
203        .get(1)
204        .ok_or_else(|| "io.write() requires data as second argument".to_string())?;
205
206    let mut guard = lock_as_file(handle, "io.write()")?;
207    let file = as_file_mut(&mut guard);
208
209    let bytes_written = if let Some(s) = data.as_str() {
210        file.write(s.as_bytes())
211            .map_err(|e| format!("io.write(): {}", e))?
212    } else if let Some(view) = data.as_any_array() {
213        let arr = view.to_generic();
214        let bytes: Vec<u8> = arr
215            .iter()
216            .map(|nb| nb.as_number_coerce().unwrap_or(0.0) as u8)
217            .collect();
218        file.write(&bytes)
219            .map_err(|e| format!("io.write(): {}", e))?
220    } else {
221        let s = format!("{}", data);
222        file.write(s.as_bytes())
223            .map_err(|e| format!("io.write(): {}", e))?
224    };
225
226    Ok(ValueWord::from_i64(bytes_written as i64))
227}
228
229/// io.close(handle) -> bool
230pub fn io_close(
231    args: &[ValueWord],
232    ctx: &crate::module_exports::ModuleContext,
233) -> Result<ValueWord, String> {
234    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
235    let handle = args
236        .first()
237        .and_then(|a| a.as_io_handle())
238        .ok_or_else(|| "io.close() requires an IoHandle argument".to_string())?;
239
240    Ok(ValueWord::from_bool(handle.close()))
241}
242
243/// io.flush(handle) -> unit
244pub fn io_flush(
245    args: &[ValueWord],
246    ctx: &crate::module_exports::ModuleContext,
247) -> Result<ValueWord, String> {
248    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
249    let handle = args
250        .first()
251        .and_then(|a| a.as_io_handle())
252        .ok_or_else(|| "io.flush() requires an IoHandle argument".to_string())?;
253
254    let mut guard = lock_as_file(handle, "io.flush()")?;
255    let file = as_file_mut(&mut guard);
256
257    file.flush().map_err(|e| format!("io.flush(): {}", e))?;
258    Ok(ValueWord::unit())
259}
260
261/// io.exists(path) -> bool
262pub fn io_exists(
263    args: &[ValueWord],
264    ctx: &crate::module_exports::ModuleContext,
265) -> Result<ValueWord, String> {
266    let path = args
267        .first()
268        .and_then(|a| a.as_str())
269        .ok_or_else(|| "io.exists() requires a string path".to_string())?;
270    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
271    Ok(ValueWord::from_bool(std::path::Path::new(path).exists()))
272}
273
274/// io.stat(path) -> TypedObject {size, modified, created, is_file, is_dir}
275pub fn io_stat(
276    args: &[ValueWord],
277    ctx: &crate::module_exports::ModuleContext,
278) -> Result<ValueWord, String> {
279    let path = args
280        .first()
281        .and_then(|a| a.as_str())
282        .ok_or_else(|| "io.stat() requires a string path".to_string())?;
283    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
284
285    let metadata = std::fs::metadata(path).map_err(|e| format!("io.stat(\"{}\"): {}", path, e))?;
286
287    let modified_ms = metadata
288        .modified()
289        .ok()
290        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
291        .map(|d| d.as_millis() as f64)
292        .unwrap_or(0.0);
293
294    let created_ms = metadata
295        .created()
296        .ok()
297        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
298        .map(|d| d.as_millis() as f64)
299        .unwrap_or(0.0);
300
301    let pairs: Vec<(&str, ValueWord)> = vec![
302        ("size", ValueWord::from_i64(metadata.len() as i64)),
303        ("modified", ValueWord::from_f64(modified_ms)),
304        ("created", ValueWord::from_f64(created_ms)),
305        ("is_file", ValueWord::from_bool(metadata.is_file())),
306        ("is_dir", ValueWord::from_bool(metadata.is_dir())),
307    ];
308    Ok(crate::type_schema::typed_object_from_pairs(&pairs))
309}
310
311/// io.is_file(path) -> bool
312pub fn io_is_file(
313    args: &[ValueWord],
314    ctx: &crate::module_exports::ModuleContext,
315) -> Result<ValueWord, String> {
316    let path = args
317        .first()
318        .and_then(|a| a.as_str())
319        .ok_or_else(|| "io.is_file() requires a string path".to_string())?;
320    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
321    Ok(ValueWord::from_bool(std::path::Path::new(path).is_file()))
322}
323
324/// io.is_dir(path) -> bool
325pub fn io_is_dir(
326    args: &[ValueWord],
327    ctx: &crate::module_exports::ModuleContext,
328) -> Result<ValueWord, String> {
329    let path = args
330        .first()
331        .and_then(|a| a.as_str())
332        .ok_or_else(|| "io.is_dir() requires a string path".to_string())?;
333    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
334    Ok(ValueWord::from_bool(std::path::Path::new(path).is_dir()))
335}
336
337/// io.mkdir(path, recursive?) -> unit
338pub fn io_mkdir(
339    args: &[ValueWord],
340    ctx: &crate::module_exports::ModuleContext,
341) -> Result<ValueWord, String> {
342    let path = args
343        .first()
344        .and_then(|a| a.as_str())
345        .ok_or_else(|| "io.mkdir() requires a string path".to_string())?;
346    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, path)?;
347
348    let recursive = args.get(1).and_then(|a| a.as_bool()).unwrap_or(false);
349
350    if recursive {
351        std::fs::create_dir_all(path).map_err(|e| format!("io.mkdir(\"{}\"): {}", path, e))?;
352    } else {
353        std::fs::create_dir(path).map_err(|e| format!("io.mkdir(\"{}\"): {}", path, e))?;
354    }
355    Ok(ValueWord::unit())
356}
357
358/// io.remove(path) -> unit
359pub fn io_remove(
360    args: &[ValueWord],
361    ctx: &crate::module_exports::ModuleContext,
362) -> Result<ValueWord, String> {
363    let path = args
364        .first()
365        .and_then(|a| a.as_str())
366        .ok_or_else(|| "io.remove() requires a string path".to_string())?;
367    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, path)?;
368
369    let p = std::path::Path::new(path);
370    if p.is_dir() {
371        std::fs::remove_dir_all(path).map_err(|e| format!("io.remove(\"{}\"): {}", path, e))?;
372    } else {
373        std::fs::remove_file(path).map_err(|e| format!("io.remove(\"{}\"): {}", path, e))?;
374    }
375    Ok(ValueWord::unit())
376}
377
378/// io.rename(old, new) -> unit
379pub fn io_rename(
380    args: &[ValueWord],
381    ctx: &crate::module_exports::ModuleContext,
382) -> Result<ValueWord, String> {
383    let old = args
384        .first()
385        .and_then(|a| a.as_str())
386        .ok_or_else(|| "io.rename() requires old path as first argument".to_string())?;
387    let new = args
388        .get(1)
389        .and_then(|a| a.as_str())
390        .ok_or_else(|| "io.rename() requires new path as second argument".to_string())?;
391    // Both old and new paths need write permission
392    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, old)?;
393    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, new)?;
394
395    std::fs::rename(old, new).map_err(|e| format!("io.rename(\"{}\", \"{}\"): {}", old, new, e))?;
396    Ok(ValueWord::unit())
397}
398
399/// io.read_dir(path) -> array of strings
400pub fn io_read_dir(
401    args: &[ValueWord],
402    ctx: &crate::module_exports::ModuleContext,
403) -> Result<ValueWord, String> {
404    let path = args
405        .first()
406        .and_then(|a| a.as_str())
407        .ok_or_else(|| "io.read_dir() requires a string path".to_string())?;
408    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
409
410    let entries: Vec<ValueWord> = std::fs::read_dir(path)
411        .map_err(|e| format!("io.read_dir(\"{}\"): {}", path, e))?
412        .filter_map(|entry| {
413            entry.ok().map(|e| {
414                ValueWord::from_string(std::sync::Arc::new(e.path().to_string_lossy().to_string()))
415            })
416        })
417        .collect();
418
419    Ok(ValueWord::from_array(std::sync::Arc::new(entries)))
420}
421
422/// io.read_gzip(path: string) -> string
423///
424/// Read a gzip-compressed file and return the decompressed content as a string.
425pub fn io_read_gzip(
426    args: &[ValueWord],
427    ctx: &crate::module_exports::ModuleContext,
428) -> Result<ValueWord, String> {
429    let path = args
430        .first()
431        .and_then(|a| a.as_str())
432        .ok_or_else(|| "io.read_gzip() requires a string path argument".to_string())?;
433    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
434
435    let file =
436        std::fs::File::open(path).map_err(|e| format!("io.read_gzip(\"{}\"): {}", path, e))?;
437
438    let mut decoder = flate2::read::GzDecoder::new(file);
439    let mut output = String::new();
440    decoder
441        .read_to_string(&mut output)
442        .map_err(|e| format!("io.read_gzip(\"{}\"): decompression failed: {}", path, e))?;
443
444    Ok(ValueWord::from_string(std::sync::Arc::new(output)))
445}
446
447/// io.write_gzip(path: string, data: string, level?: int) -> null
448///
449/// Compress a string with gzip and write it to a file.
450pub fn io_write_gzip(
451    args: &[ValueWord],
452    ctx: &crate::module_exports::ModuleContext,
453) -> Result<ValueWord, String> {
454    let path = args
455        .first()
456        .and_then(|a| a.as_str())
457        .ok_or_else(|| "io.write_gzip() requires a string path argument".to_string())?;
458    crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, path)?;
459
460    let data = args
461        .get(1)
462        .and_then(|a| a.as_str())
463        .ok_or_else(|| "io.write_gzip() requires a string data argument".to_string())?;
464
465    let level = args
466        .get(2)
467        .and_then(|a| a.as_i64().or_else(|| a.as_f64().map(|n| n as i64)))
468        .unwrap_or(6) as u32;
469
470    let file =
471        std::fs::File::create(path).map_err(|e| format!("io.write_gzip(\"{}\"): {}", path, e))?;
472
473    let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::new(level));
474    encoder
475        .write_all(data.as_bytes())
476        .map_err(|e| format!("io.write_gzip(\"{}\"): compression failed: {}", path, e))?;
477    encoder
478        .finish()
479        .map_err(|e| format!("io.write_gzip(\"{}\"): finalize failed: {}", path, e))?;
480
481    Ok(ValueWord::unit())
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
489        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
490        crate::module_exports::ModuleContext {
491            schemas: registry,
492            invoke_callable: None,
493            raw_invoker: None,
494            function_hashes: None,
495            vm_state: None,
496            granted_permissions: None,
497            scope_constraints: None,
498            set_pending_resume: None,
499            set_pending_frame_resume: None,
500        }
501    }
502
503    #[test]
504    fn test_io_open_write_read_close() {
505        let ctx = test_ctx();
506        let dir = std::env::temp_dir().join("shape_io_test");
507        let _ = std::fs::create_dir_all(&dir);
508        let path = dir.join("test_file.txt");
509        let path_str = path.to_string_lossy().to_string();
510
511        // Write
512        let handle = io_open(
513            &[
514                ValueWord::from_string(std::sync::Arc::new(path_str.clone())),
515                ValueWord::from_string(std::sync::Arc::new("w".to_string())),
516            ],
517            &ctx,
518        )
519        .unwrap();
520        assert_eq!(handle.type_name(), "io_handle");
521
522        io_write(
523            &[
524                handle.clone(),
525                ValueWord::from_string(std::sync::Arc::new("hello world".to_string())),
526            ],
527            &ctx,
528        )
529        .unwrap();
530        io_close(&[handle], &ctx).unwrap();
531
532        // Read
533        let handle2 = io_open(
534            &[ValueWord::from_string(std::sync::Arc::new(
535                path_str.clone(),
536            ))],
537            &ctx,
538        )
539        .unwrap();
540        let content = io_read_to_string(&[handle2.clone()], &ctx).unwrap();
541        assert_eq!(content.as_str().unwrap(), "hello world");
542        io_close(&[handle2], &ctx).unwrap();
543
544        // Cleanup
545        let _ = std::fs::remove_file(&path);
546        let _ = std::fs::remove_dir(&dir);
547    }
548
549    #[test]
550    fn test_io_exists() {
551        let ctx = test_ctx();
552        let result = io_exists(
553            &[ValueWord::from_string(std::sync::Arc::new(
554                "/tmp".to_string(),
555            ))],
556            &ctx,
557        )
558        .unwrap();
559        assert_eq!(result.as_bool(), Some(true));
560
561        let result = io_exists(
562            &[ValueWord::from_string(std::sync::Arc::new(
563                "/nonexistent_path_xyz".to_string(),
564            ))],
565            &ctx,
566        )
567        .unwrap();
568        assert_eq!(result.as_bool(), Some(false));
569    }
570
571    #[test]
572    fn test_io_is_file_is_dir() {
573        let ctx = test_ctx();
574        let result = io_is_dir(
575            &[ValueWord::from_string(std::sync::Arc::new(
576                "/tmp".to_string(),
577            ))],
578            &ctx,
579        )
580        .unwrap();
581        assert_eq!(result.as_bool(), Some(true));
582
583        let result = io_is_file(
584            &[ValueWord::from_string(std::sync::Arc::new(
585                "/tmp".to_string(),
586            ))],
587            &ctx,
588        )
589        .unwrap();
590        assert_eq!(result.as_bool(), Some(false));
591    }
592
593    #[test]
594    fn test_io_stat() {
595        let ctx = test_ctx();
596        let result = io_stat(
597            &[ValueWord::from_string(std::sync::Arc::new(
598                "/tmp".to_string(),
599            ))],
600            &ctx,
601        )
602        .unwrap();
603        assert_eq!(result.type_name(), "object");
604    }
605
606    #[test]
607    fn test_io_mkdir_remove() {
608        let ctx = test_ctx();
609        let dir = std::env::temp_dir().join("shape_io_mkdir_test");
610        let path_str = dir.to_string_lossy().to_string();
611
612        let _ = std::fs::remove_dir_all(&dir);
613
614        io_mkdir(
615            &[ValueWord::from_string(std::sync::Arc::new(
616                path_str.clone(),
617            ))],
618            &ctx,
619        )
620        .unwrap();
621        assert!(dir.is_dir());
622
623        io_remove(
624            &[ValueWord::from_string(std::sync::Arc::new(
625                path_str.clone(),
626            ))],
627            &ctx,
628        )
629        .unwrap();
630        assert!(!dir.exists());
631    }
632
633    #[test]
634    fn test_io_read_dir() {
635        let ctx = test_ctx();
636        let result = io_read_dir(
637            &[ValueWord::from_string(std::sync::Arc::new(
638                "/tmp".to_string(),
639            ))],
640            &ctx,
641        )
642        .unwrap();
643        assert_eq!(result.type_name(), "array");
644    }
645
646    #[test]
647    fn test_io_rename() {
648        let ctx = test_ctx();
649        let dir = std::env::temp_dir().join("shape_io_rename_test");
650        let _ = std::fs::create_dir_all(&dir);
651        let old = dir.join("old.txt");
652        let new = dir.join("new.txt");
653        std::fs::write(&old, "data").unwrap();
654
655        io_rename(
656            &[
657                ValueWord::from_string(std::sync::Arc::new(old.to_string_lossy().to_string())),
658                ValueWord::from_string(std::sync::Arc::new(new.to_string_lossy().to_string())),
659            ],
660            &ctx,
661        )
662        .unwrap();
663
664        assert!(!old.exists());
665        assert!(new.exists());
666
667        let _ = std::fs::remove_dir_all(&dir);
668    }
669
670    #[test]
671    fn test_io_read_bytes() {
672        let ctx = test_ctx();
673        let dir = std::env::temp_dir().join("shape_io_bytes_test");
674        let _ = std::fs::create_dir_all(&dir);
675        let path = dir.join("bytes.bin");
676        std::fs::write(&path, &[1u8, 2, 3, 255]).unwrap();
677
678        let handle = io_open(
679            &[ValueWord::from_string(std::sync::Arc::new(
680                path.to_string_lossy().to_string(),
681            ))],
682            &ctx,
683        )
684        .unwrap();
685
686        let result = io_read_bytes(&[handle.clone()], &ctx).unwrap();
687        let arr = result.as_any_array().unwrap().to_generic();
688        assert_eq!(arr.len(), 4);
689
690        io_close(&[handle], &ctx).unwrap();
691        let _ = std::fs::remove_dir_all(&dir);
692    }
693
694    #[test]
695    fn test_io_close_returns_false_on_double_close() {
696        let ctx = test_ctx();
697        let dir = std::env::temp_dir().join("shape_io_double_close");
698        let _ = std::fs::create_dir_all(&dir);
699        let path = dir.join("double.txt");
700        std::fs::write(&path, "x").unwrap();
701
702        let handle = io_open(
703            &[ValueWord::from_string(std::sync::Arc::new(
704                path.to_string_lossy().to_string(),
705            ))],
706            &ctx,
707        )
708        .unwrap();
709
710        let first = io_close(&[handle.clone()], &ctx).unwrap();
711        assert_eq!(first.as_bool(), Some(true));
712
713        let second = io_close(&[handle], &ctx).unwrap();
714        assert_eq!(second.as_bool(), Some(false));
715
716        let _ = std::fs::remove_dir_all(&dir);
717    }
718
719    #[test]
720    fn test_io_open_invalid_mode() {
721        let ctx = test_ctx();
722        let result = io_open(
723            &[
724                ValueWord::from_string(std::sync::Arc::new("/tmp/test.txt".to_string())),
725                ValueWord::from_string(std::sync::Arc::new("x".to_string())),
726            ],
727            &ctx,
728        );
729        assert!(result.is_err());
730    }
731
732    #[test]
733    fn test_io_flush() {
734        let ctx = test_ctx();
735        let dir = std::env::temp_dir().join("shape_io_flush_test");
736        let _ = std::fs::create_dir_all(&dir);
737        let path = dir.join("flush.txt");
738
739        let handle = io_open(
740            &[
741                ValueWord::from_string(std::sync::Arc::new(path.to_string_lossy().to_string())),
742                ValueWord::from_string(std::sync::Arc::new("w".to_string())),
743            ],
744            &ctx,
745        )
746        .unwrap();
747
748        io_write(
749            &[
750                handle.clone(),
751                ValueWord::from_string(std::sync::Arc::new("data".to_string())),
752            ],
753            &ctx,
754        )
755        .unwrap();
756
757        let result = io_flush(&[handle.clone()], &ctx).unwrap();
758        assert!(result.is_unit());
759
760        io_close(&[handle], &ctx).unwrap();
761        let _ = std::fs::remove_dir_all(&dir);
762    }
763
764    #[test]
765    fn test_io_write_gzip_read_gzip_roundtrip() {
766        let ctx = test_ctx();
767        let dir = std::env::temp_dir().join("shape_io_gzip_test");
768        let _ = std::fs::create_dir_all(&dir);
769        let path = dir.join("test.gz");
770        let path_str = path.to_string_lossy().to_string();
771
772        // Write gzip
773        io_write_gzip(
774            &[
775                ValueWord::from_string(std::sync::Arc::new(path_str.clone())),
776                ValueWord::from_string(std::sync::Arc::new("hello gzip world".to_string())),
777            ],
778            &ctx,
779        )
780        .unwrap();
781
782        assert!(path.exists());
783
784        // Read gzip
785        let result = io_read_gzip(
786            &[ValueWord::from_string(std::sync::Arc::new(path_str))],
787            &ctx,
788        )
789        .unwrap();
790        assert_eq!(result.as_str(), Some("hello gzip world"));
791
792        let _ = std::fs::remove_dir_all(&dir);
793    }
794
795    #[test]
796    fn test_io_read_gzip_nonexistent() {
797        let ctx = test_ctx();
798        let result = io_read_gzip(
799            &[ValueWord::from_string(std::sync::Arc::new(
800                "/nonexistent/file.gz".to_string(),
801            ))],
802            &ctx,
803        );
804        assert!(result.is_err());
805    }
806
807    #[test]
808    fn test_io_write_gzip_with_level() {
809        let ctx = test_ctx();
810        let dir = std::env::temp_dir().join("shape_io_gzip_level_test");
811        let _ = std::fs::create_dir_all(&dir);
812        let path = dir.join("test_level.gz");
813        let path_str = path.to_string_lossy().to_string();
814
815        io_write_gzip(
816            &[
817                ValueWord::from_string(std::sync::Arc::new(path_str.clone())),
818                ValueWord::from_string(std::sync::Arc::new("level test".to_string())),
819                ValueWord::from_i64(1),
820            ],
821            &ctx,
822        )
823        .unwrap();
824
825        let result = io_read_gzip(
826            &[ValueWord::from_string(std::sync::Arc::new(path_str))],
827            &ctx,
828        )
829        .unwrap();
830        assert_eq!(result.as_str(), Some("level test"));
831
832        let _ = std::fs::remove_dir_all(&dir);
833    }
834}