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
49    match mode.as_str() {
50        "r" => crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?,
51        "w" | "a" => {
52            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?
53        }
54        "rw" => {
55            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
56            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
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    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
267    let path = args
268        .first()
269        .and_then(|a| a.as_str())
270        .ok_or_else(|| "io.exists() requires a string path".to_string())?;
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    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
280    let path = args
281        .first()
282        .and_then(|a| a.as_str())
283        .ok_or_else(|| "io.stat() requires a string path".to_string())?;
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    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
317    let path = args
318        .first()
319        .and_then(|a| a.as_str())
320        .ok_or_else(|| "io.is_file() requires a string path".to_string())?;
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    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
330    let path = args
331        .first()
332        .and_then(|a| a.as_str())
333        .ok_or_else(|| "io.is_dir() requires a string path".to_string())?;
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    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
343    let path = args
344        .first()
345        .and_then(|a| a.as_str())
346        .ok_or_else(|| "io.mkdir() requires a string path".to_string())?;
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    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
364    let path = args
365        .first()
366        .and_then(|a| a.as_str())
367        .ok_or_else(|| "io.remove() requires a string path".to_string())?;
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    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
384    let old = args
385        .first()
386        .and_then(|a| a.as_str())
387        .ok_or_else(|| "io.rename() requires old path as first argument".to_string())?;
388    let new = args
389        .get(1)
390        .and_then(|a| a.as_str())
391        .ok_or_else(|| "io.rename() requires new path as second argument".to_string())?;
392
393    std::fs::rename(old, new).map_err(|e| format!("io.rename(\"{}\", \"{}\"): {}", old, new, e))?;
394    Ok(ValueWord::unit())
395}
396
397/// io.read_dir(path) -> array of strings
398pub fn io_read_dir(
399    args: &[ValueWord],
400    ctx: &crate::module_exports::ModuleContext,
401) -> Result<ValueWord, String> {
402    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
403    let path = args
404        .first()
405        .and_then(|a| a.as_str())
406        .ok_or_else(|| "io.read_dir() requires a string path".to_string())?;
407
408    let entries: Vec<ValueWord> = std::fs::read_dir(path)
409        .map_err(|e| format!("io.read_dir(\"{}\"): {}", path, e))?
410        .filter_map(|entry| {
411            entry.ok().map(|e| {
412                ValueWord::from_string(std::sync::Arc::new(e.path().to_string_lossy().to_string()))
413            })
414        })
415        .collect();
416
417    Ok(ValueWord::from_array(std::sync::Arc::new(entries)))
418}
419
420/// io.read_gzip(path: string) -> string
421///
422/// Read a gzip-compressed file and return the decompressed content as a string.
423pub fn io_read_gzip(
424    args: &[ValueWord],
425    ctx: &crate::module_exports::ModuleContext,
426) -> Result<ValueWord, String> {
427    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
428    let path = args
429        .first()
430        .and_then(|a| a.as_str())
431        .ok_or_else(|| "io.read_gzip() requires a string path argument".to_string())?;
432
433    let file =
434        std::fs::File::open(path).map_err(|e| format!("io.read_gzip(\"{}\"): {}", path, e))?;
435
436    let mut decoder = flate2::read::GzDecoder::new(file);
437    let mut output = String::new();
438    decoder
439        .read_to_string(&mut output)
440        .map_err(|e| format!("io.read_gzip(\"{}\"): decompression failed: {}", path, e))?;
441
442    Ok(ValueWord::from_string(std::sync::Arc::new(output)))
443}
444
445/// io.write_gzip(path: string, data: string, level?: int) -> null
446///
447/// Compress a string with gzip and write it to a file.
448pub fn io_write_gzip(
449    args: &[ValueWord],
450    ctx: &crate::module_exports::ModuleContext,
451) -> Result<ValueWord, String> {
452    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
453    let path = args
454        .first()
455        .and_then(|a| a.as_str())
456        .ok_or_else(|| "io.write_gzip() requires a string path argument".to_string())?;
457
458    let data = args
459        .get(1)
460        .and_then(|a| a.as_str())
461        .ok_or_else(|| "io.write_gzip() requires a string data argument".to_string())?;
462
463    let level = args
464        .get(2)
465        .and_then(|a| a.as_i64().or_else(|| a.as_f64().map(|n| n as i64)))
466        .unwrap_or(6) as u32;
467
468    let file =
469        std::fs::File::create(path).map_err(|e| format!("io.write_gzip(\"{}\"): {}", path, e))?;
470
471    let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::new(level));
472    encoder
473        .write_all(data.as_bytes())
474        .map_err(|e| format!("io.write_gzip(\"{}\"): compression failed: {}", path, e))?;
475    encoder
476        .finish()
477        .map_err(|e| format!("io.write_gzip(\"{}\"): finalize failed: {}", path, e))?;
478
479    Ok(ValueWord::unit())
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
487        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
488        crate::module_exports::ModuleContext {
489            schemas: registry,
490            invoke_callable: None,
491            raw_invoker: None,
492            function_hashes: None,
493            vm_state: None,
494            granted_permissions: None,
495            scope_constraints: None,
496            set_pending_resume: None,
497            set_pending_frame_resume: None,
498        }
499    }
500
501    #[test]
502    fn test_io_open_write_read_close() {
503        let ctx = test_ctx();
504        let dir = std::env::temp_dir().join("shape_io_test");
505        let _ = std::fs::create_dir_all(&dir);
506        let path = dir.join("test_file.txt");
507        let path_str = path.to_string_lossy().to_string();
508
509        // Write
510        let handle = io_open(
511            &[
512                ValueWord::from_string(std::sync::Arc::new(path_str.clone())),
513                ValueWord::from_string(std::sync::Arc::new("w".to_string())),
514            ],
515            &ctx,
516        )
517        .unwrap();
518        assert_eq!(handle.type_name(), "io_handle");
519
520        io_write(
521            &[
522                handle.clone(),
523                ValueWord::from_string(std::sync::Arc::new("hello world".to_string())),
524            ],
525            &ctx,
526        )
527        .unwrap();
528        io_close(&[handle], &ctx).unwrap();
529
530        // Read
531        let handle2 = io_open(
532            &[ValueWord::from_string(std::sync::Arc::new(
533                path_str.clone(),
534            ))],
535            &ctx,
536        )
537        .unwrap();
538        let content = io_read_to_string(&[handle2.clone()], &ctx).unwrap();
539        assert_eq!(content.as_str().unwrap(), "hello world");
540        io_close(&[handle2], &ctx).unwrap();
541
542        // Cleanup
543        let _ = std::fs::remove_file(&path);
544        let _ = std::fs::remove_dir(&dir);
545    }
546
547    #[test]
548    fn test_io_exists() {
549        let ctx = test_ctx();
550        let result = io_exists(
551            &[ValueWord::from_string(std::sync::Arc::new(
552                "/tmp".to_string(),
553            ))],
554            &ctx,
555        )
556        .unwrap();
557        assert_eq!(result.as_bool(), Some(true));
558
559        let result = io_exists(
560            &[ValueWord::from_string(std::sync::Arc::new(
561                "/nonexistent_path_xyz".to_string(),
562            ))],
563            &ctx,
564        )
565        .unwrap();
566        assert_eq!(result.as_bool(), Some(false));
567    }
568
569    #[test]
570    fn test_io_is_file_is_dir() {
571        let ctx = test_ctx();
572        let result = io_is_dir(
573            &[ValueWord::from_string(std::sync::Arc::new(
574                "/tmp".to_string(),
575            ))],
576            &ctx,
577        )
578        .unwrap();
579        assert_eq!(result.as_bool(), Some(true));
580
581        let result = io_is_file(
582            &[ValueWord::from_string(std::sync::Arc::new(
583                "/tmp".to_string(),
584            ))],
585            &ctx,
586        )
587        .unwrap();
588        assert_eq!(result.as_bool(), Some(false));
589    }
590
591    #[test]
592    fn test_io_stat() {
593        let ctx = test_ctx();
594        let result = io_stat(
595            &[ValueWord::from_string(std::sync::Arc::new(
596                "/tmp".to_string(),
597            ))],
598            &ctx,
599        )
600        .unwrap();
601        assert_eq!(result.type_name(), "object");
602    }
603
604    #[test]
605    fn test_io_mkdir_remove() {
606        let ctx = test_ctx();
607        let dir = std::env::temp_dir().join("shape_io_mkdir_test");
608        let path_str = dir.to_string_lossy().to_string();
609
610        let _ = std::fs::remove_dir_all(&dir);
611
612        io_mkdir(
613            &[ValueWord::from_string(std::sync::Arc::new(
614                path_str.clone(),
615            ))],
616            &ctx,
617        )
618        .unwrap();
619        assert!(dir.is_dir());
620
621        io_remove(
622            &[ValueWord::from_string(std::sync::Arc::new(
623                path_str.clone(),
624            ))],
625            &ctx,
626        )
627        .unwrap();
628        assert!(!dir.exists());
629    }
630
631    #[test]
632    fn test_io_read_dir() {
633        let ctx = test_ctx();
634        let result = io_read_dir(
635            &[ValueWord::from_string(std::sync::Arc::new(
636                "/tmp".to_string(),
637            ))],
638            &ctx,
639        )
640        .unwrap();
641        assert_eq!(result.type_name(), "array");
642    }
643
644    #[test]
645    fn test_io_rename() {
646        let ctx = test_ctx();
647        let dir = std::env::temp_dir().join("shape_io_rename_test");
648        let _ = std::fs::create_dir_all(&dir);
649        let old = dir.join("old.txt");
650        let new = dir.join("new.txt");
651        std::fs::write(&old, "data").unwrap();
652
653        io_rename(
654            &[
655                ValueWord::from_string(std::sync::Arc::new(old.to_string_lossy().to_string())),
656                ValueWord::from_string(std::sync::Arc::new(new.to_string_lossy().to_string())),
657            ],
658            &ctx,
659        )
660        .unwrap();
661
662        assert!(!old.exists());
663        assert!(new.exists());
664
665        let _ = std::fs::remove_dir_all(&dir);
666    }
667
668    #[test]
669    fn test_io_read_bytes() {
670        let ctx = test_ctx();
671        let dir = std::env::temp_dir().join("shape_io_bytes_test");
672        let _ = std::fs::create_dir_all(&dir);
673        let path = dir.join("bytes.bin");
674        std::fs::write(&path, &[1u8, 2, 3, 255]).unwrap();
675
676        let handle = io_open(
677            &[ValueWord::from_string(std::sync::Arc::new(
678                path.to_string_lossy().to_string(),
679            ))],
680            &ctx,
681        )
682        .unwrap();
683
684        let result = io_read_bytes(&[handle.clone()], &ctx).unwrap();
685        let arr = result.as_any_array().unwrap().to_generic();
686        assert_eq!(arr.len(), 4);
687
688        io_close(&[handle], &ctx).unwrap();
689        let _ = std::fs::remove_dir_all(&dir);
690    }
691
692    #[test]
693    fn test_io_close_returns_false_on_double_close() {
694        let ctx = test_ctx();
695        let dir = std::env::temp_dir().join("shape_io_double_close");
696        let _ = std::fs::create_dir_all(&dir);
697        let path = dir.join("double.txt");
698        std::fs::write(&path, "x").unwrap();
699
700        let handle = io_open(
701            &[ValueWord::from_string(std::sync::Arc::new(
702                path.to_string_lossy().to_string(),
703            ))],
704            &ctx,
705        )
706        .unwrap();
707
708        let first = io_close(&[handle.clone()], &ctx).unwrap();
709        assert_eq!(first.as_bool(), Some(true));
710
711        let second = io_close(&[handle], &ctx).unwrap();
712        assert_eq!(second.as_bool(), Some(false));
713
714        let _ = std::fs::remove_dir_all(&dir);
715    }
716
717    #[test]
718    fn test_io_open_invalid_mode() {
719        let ctx = test_ctx();
720        let result = io_open(
721            &[
722                ValueWord::from_string(std::sync::Arc::new("/tmp/test.txt".to_string())),
723                ValueWord::from_string(std::sync::Arc::new("x".to_string())),
724            ],
725            &ctx,
726        );
727        assert!(result.is_err());
728    }
729
730    #[test]
731    fn test_io_flush() {
732        let ctx = test_ctx();
733        let dir = std::env::temp_dir().join("shape_io_flush_test");
734        let _ = std::fs::create_dir_all(&dir);
735        let path = dir.join("flush.txt");
736
737        let handle = io_open(
738            &[
739                ValueWord::from_string(std::sync::Arc::new(path.to_string_lossy().to_string())),
740                ValueWord::from_string(std::sync::Arc::new("w".to_string())),
741            ],
742            &ctx,
743        )
744        .unwrap();
745
746        io_write(
747            &[
748                handle.clone(),
749                ValueWord::from_string(std::sync::Arc::new("data".to_string())),
750            ],
751            &ctx,
752        )
753        .unwrap();
754
755        let result = io_flush(&[handle.clone()], &ctx).unwrap();
756        assert!(result.is_unit());
757
758        io_close(&[handle], &ctx).unwrap();
759        let _ = std::fs::remove_dir_all(&dir);
760    }
761
762    #[test]
763    fn test_io_write_gzip_read_gzip_roundtrip() {
764        let ctx = test_ctx();
765        let dir = std::env::temp_dir().join("shape_io_gzip_test");
766        let _ = std::fs::create_dir_all(&dir);
767        let path = dir.join("test.gz");
768        let path_str = path.to_string_lossy().to_string();
769
770        // Write gzip
771        io_write_gzip(
772            &[
773                ValueWord::from_string(std::sync::Arc::new(path_str.clone())),
774                ValueWord::from_string(std::sync::Arc::new("hello gzip world".to_string())),
775            ],
776            &ctx,
777        )
778        .unwrap();
779
780        assert!(path.exists());
781
782        // Read gzip
783        let result = io_read_gzip(
784            &[ValueWord::from_string(std::sync::Arc::new(path_str))],
785            &ctx,
786        )
787        .unwrap();
788        assert_eq!(result.as_str(), Some("hello gzip world"));
789
790        let _ = std::fs::remove_dir_all(&dir);
791    }
792
793    #[test]
794    fn test_io_read_gzip_nonexistent() {
795        let ctx = test_ctx();
796        let result = io_read_gzip(
797            &[ValueWord::from_string(std::sync::Arc::new(
798                "/nonexistent/file.gz".to_string(),
799            ))],
800            &ctx,
801        );
802        assert!(result.is_err());
803    }
804
805    #[test]
806    fn test_io_write_gzip_with_level() {
807        let ctx = test_ctx();
808        let dir = std::env::temp_dir().join("shape_io_gzip_level_test");
809        let _ = std::fs::create_dir_all(&dir);
810        let path = dir.join("test_level.gz");
811        let path_str = path.to_string_lossy().to_string();
812
813        io_write_gzip(
814            &[
815                ValueWord::from_string(std::sync::Arc::new(path_str.clone())),
816                ValueWord::from_string(std::sync::Arc::new("level test".to_string())),
817                ValueWord::from_i64(1),
818            ],
819            &ctx,
820        )
821        .unwrap();
822
823        let result = io_read_gzip(
824            &[ValueWord::from_string(std::sync::Arc::new(path_str))],
825            &ctx,
826        )
827        .unwrap();
828        assert_eq!(result.as_str(), Some("level test"));
829
830        let _ = std::fs::remove_dir_all(&dir);
831    }
832}