Skip to main content

shape_runtime/stdlib_io/
file_ops.rs

1//! File operation implementations for the io module.
2//!
3//! Phase 2c migration: ported to the typed marshal layer (cluster #2 option γ
4//! for IoHandle-touching functions, stdlib_io path-mass for path-only ones).
5//! `register_file_io_handle_ops` registers the 8 IoHandle-touching functions;
6//! `register_file_path_ops` registers the 9 path-only ones. Tests deferred —
7//! ValueWord-based test helpers can't compile and aren't reconstructed until
8//! the shape-vm cascade provides a typed test harness.
9
10use crate::marshal::{
11    register_typed_fn_1, register_typed_fn_2, register_typed_fn_2_full,
12    register_typed_fn_3_full,
13};
14use crate::module_exports::{ModuleExports, ModuleParam};
15use crate::typed_module_exports::{ConcreteReturn, ConcreteType, TypedReturn};
16use shape_value::heap_value::{IoHandleData, IoResource};
17use std::io::{Read, Seek, Write};
18use std::sync::Arc;
19
20/// Helper: lock an IoHandleData, verify it's open and a File, return mutable guard.
21fn lock_as_file<'a>(
22    handle: &'a IoHandleData,
23    fn_name: &str,
24) -> Result<std::sync::MutexGuard<'a, Option<IoResource>>, String> {
25    let guard = handle
26        .resource
27        .lock()
28        .map_err(|_| format!("{}: lock poisoned", fn_name))?;
29    match guard.as_ref() {
30        None => Err(format!("{}: handle is closed", fn_name)),
31        Some(IoResource::File(_)) => Ok(guard),
32        Some(_) => Err(format!("{}: handle is not a file", fn_name)),
33    }
34}
35
36/// Extract `&mut File` from a locked IoResource guard. Caller must ensure it's a File.
37fn as_file_mut(resource: &mut Option<IoResource>) -> &mut std::fs::File {
38    match resource.as_mut().unwrap() {
39        IoResource::File(f) => f,
40        _ => unreachable!(),
41    }
42}
43
44/// Register the IoHandle-touching file operations on the io module.
45/// Cluster #2 (option γ) per docs/defections.md 2026-05-06.
46pub fn register_file_io_handle_ops(module: &mut ModuleExports) {
47    // io.open(path: string, mode?: string) -> IoHandle
48    register_typed_fn_2_full::<_, Arc<String>, Arc<String>>(
49        module,
50        "open",
51        "Open a file and return a handle",
52        [
53            ModuleParam {
54                name: "path".to_string(),
55                type_name: "string".to_string(),
56                required: true,
57                description: "File path to open".to_string(),
58                ..Default::default()
59            },
60            ModuleParam {
61                name: "mode".to_string(),
62                type_name: "string".to_string(),
63                required: false,
64                description: "Open mode: \"r\" (default), \"w\", \"a\", \"rw\"".to_string(),
65                default_snippet: Some("\"r\"".to_string()),
66                allowed_values: Some(vec![
67                    "r".to_string(),
68                    "w".to_string(),
69                    "a".to_string(),
70                    "rw".to_string(),
71                ]),
72                ..Default::default()
73            },
74        ],
75        ConcreteType::IoHandle,
76        |path, mode, ctx| {
77            let path = path.as_str();
78            let mode = mode.as_str();
79            match mode {
80                "r" => crate::module_exports::check_fs_permission(
81                    ctx,
82                    shape_abi_v1::Permission::FsRead,
83                    path,
84                )?,
85                "w" | "a" => crate::module_exports::check_fs_permission(
86                    ctx,
87                    shape_abi_v1::Permission::FsWrite,
88                    path,
89                )?,
90                "rw" => {
91                    crate::module_exports::check_fs_permission(
92                        ctx,
93                        shape_abi_v1::Permission::FsRead,
94                        path,
95                    )?;
96                    crate::module_exports::check_fs_permission(
97                        ctx,
98                        shape_abi_v1::Permission::FsWrite,
99                        path,
100                    )?;
101                }
102                _ => {}
103            }
104
105            let file = match mode {
106                "r" => std::fs::OpenOptions::new()
107                    .read(true)
108                    .open(path)
109                    .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
110                "w" => std::fs::OpenOptions::new()
111                    .write(true)
112                    .create(true)
113                    .truncate(true)
114                    .open(path)
115                    .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
116                "a" => std::fs::OpenOptions::new()
117                    .append(true)
118                    .create(true)
119                    .open(path)
120                    .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
121                "rw" => std::fs::OpenOptions::new()
122                    .read(true)
123                    .write(true)
124                    .create(true)
125                    .open(path)
126                    .map_err(|e| format!("io.open(\"{}\"): {}", path, e))?,
127                _ => {
128                    return Err(format!(
129                        "io.open(): invalid mode '{}'. Use \"r\", \"w\", \"a\", or \"rw\"",
130                        mode
131                    ));
132                }
133            };
134
135            let handle = IoHandleData::new_file(file, path.to_string(), mode.to_string());
136            Ok(TypedReturn::Concrete(ConcreteReturn::IoHandle(Arc::new(handle))))
137        },
138    );
139
140    // io.read_to_string(handle: IoHandle) -> string
141    register_typed_fn_1::<_, Arc<IoHandleData>>(
142        module,
143        "read_to_string",
144        "Read the entire file as a UTF-8 string",
145        "handle",
146        "IoHandle",
147        ConcreteType::String,
148        |handle, ctx| {
149            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
150            let mut guard = lock_as_file(&handle, "io.read_to_string()")?;
151            let file = as_file_mut(&mut guard);
152            file.seek(std::io::SeekFrom::Start(0))
153                .map_err(|e| format!("io.read_to_string(): seek failed: {}", e))?;
154            let mut contents = String::new();
155            file.read_to_string(&mut contents)
156                .map_err(|e| format!("io.read_to_string(): {}", e))?;
157            Ok(TypedReturn::Concrete(ConcreteReturn::String(contents)))
158        },
159    );
160
161    // io.read(handle: IoHandle, n?: int) -> string
162    register_typed_fn_2_full::<_, Arc<IoHandleData>, i64>(
163        module,
164        "read",
165        "Read from a file handle (n bytes or all)",
166        [
167            ModuleParam {
168                name: "handle".to_string(),
169                type_name: "IoHandle".to_string(),
170                required: true,
171                description: "File handle from io.open()".to_string(),
172                ..Default::default()
173            },
174            ModuleParam {
175                name: "n".to_string(),
176                type_name: "int".to_string(),
177                required: false,
178                description: "Number of bytes to read (omit for all)".to_string(),
179                default_snippet: Some("-1".to_string()),
180                ..Default::default()
181            },
182        ],
183        ConcreteType::String,
184        |handle, n, ctx| {
185            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
186            let mut guard = lock_as_file(&handle, "io.read()")?;
187            let file = as_file_mut(&mut guard);
188            let contents = if n >= 0 {
189                let n = n as usize;
190                let mut buf = vec![0u8; n];
191                let bytes_read = file.read(&mut buf).map_err(|e| format!("io.read(): {}", e))?;
192                buf.truncate(bytes_read);
193                String::from_utf8(buf).map_err(|e| format!("io.read(): invalid UTF-8: {}", e))?
194            } else {
195                let mut s = String::new();
196                file.read_to_string(&mut s)
197                    .map_err(|e| format!("io.read(): {}", e))?;
198                s
199            };
200            Ok(TypedReturn::Concrete(ConcreteReturn::String(contents)))
201        },
202    );
203
204    // io.read_bytes(handle: IoHandle, n?: int) -> Array<int>
205    register_typed_fn_2_full::<_, Arc<IoHandleData>, i64>(
206        module,
207        "read_bytes",
208        "Read bytes from a file handle into an Array<int>",
209        [
210            ModuleParam {
211                name: "handle".to_string(),
212                type_name: "IoHandle".to_string(),
213                required: true,
214                description: "File handle from io.open()".to_string(),
215                ..Default::default()
216            },
217            ModuleParam {
218                name: "n".to_string(),
219                type_name: "int".to_string(),
220                required: false,
221                description: "Number of bytes to read (omit for all)".to_string(),
222                default_snippet: Some("-1".to_string()),
223                ..Default::default()
224            },
225        ],
226        ConcreteType::Bytes,
227        |handle, n, ctx| {
228            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
229            let mut guard = lock_as_file(&handle, "io.read_bytes()")?;
230            let file = as_file_mut(&mut guard);
231            let bytes = if n >= 0 {
232                let n = n as usize;
233                let mut buf = vec![0u8; n];
234                let bytes_read = file
235                    .read(&mut buf)
236                    .map_err(|e| format!("io.read_bytes(): {}", e))?;
237                buf.truncate(bytes_read);
238                buf
239            } else {
240                let mut buf = Vec::new();
241                file.read_to_end(&mut buf)
242                    .map_err(|e| format!("io.read_bytes(): {}", e))?;
243                buf
244            };
245            Ok(TypedReturn::Concrete(ConcreteReturn::Bytes(bytes)))
246        },
247    );
248
249    // io.write(handle: IoHandle, data: string) -> int
250    register_typed_fn_2::<_, Arc<IoHandleData>, Arc<String>>(
251        module,
252        "write",
253        "Write a string to a file handle, returning bytes written",
254        [("handle", "IoHandle"), ("data", "string")],
255        ConcreteType::Int,
256        |handle, data, ctx| {
257            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
258            let mut guard = lock_as_file(&handle, "io.write()")?;
259            let file = as_file_mut(&mut guard);
260            let bytes_written = file
261                .write(data.as_bytes())
262                .map_err(|e| format!("io.write(): {}", e))?;
263            Ok(TypedReturn::Concrete(ConcreteReturn::I64(bytes_written as i64)))
264        },
265    );
266
267    // io.close(handle: IoHandle) -> bool
268    register_typed_fn_1::<_, Arc<IoHandleData>>(
269        module,
270        "close",
271        "Close a file handle, returning whether it was open",
272        "handle",
273        "IoHandle",
274        ConcreteType::Bool,
275        |handle, ctx| {
276            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsRead)?;
277            Ok(TypedReturn::Concrete(ConcreteReturn::Bool(handle.close())))
278        },
279    );
280
281    // io.flush(handle: IoHandle) -> unit
282    register_typed_fn_1::<_, Arc<IoHandleData>>(
283        module,
284        "flush",
285        "Flush pending writes to disk",
286        "handle",
287        "IoHandle",
288        ConcreteType::Unit,
289        |handle, ctx| {
290            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::FsWrite)?;
291            let mut guard = lock_as_file(&handle, "io.flush()")?;
292            let file = as_file_mut(&mut guard);
293            file.flush().map_err(|e| format!("io.flush(): {}", e))?;
294            Ok(TypedReturn::Concrete(ConcreteReturn::Unit))
295        },
296    );
297}
298
299/// Register the path-only file operations on the io module.
300/// stdlib_io path-mass cluster (group 2) per docs/defections.md 2026-05-06
301/// cluster #2 sibling re-classification.
302pub fn register_file_path_ops(module: &mut ModuleExports) {
303    // io.exists(path: string) -> bool
304    register_typed_fn_1::<_, Arc<String>>(
305        module,
306        "exists",
307        "Check if a file or directory exists",
308        "path",
309        "string",
310        ConcreteType::Bool,
311        |path, ctx| {
312            let path = path.as_str();
313            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
314            Ok(TypedReturn::Concrete(ConcreteReturn::Bool(
315                std::path::Path::new(path).exists(),
316            )))
317        },
318    );
319
320    // io.stat(path: string) -> object
321    register_typed_fn_1::<_, Arc<String>>(
322        module,
323        "stat",
324        "Return file metadata as an object",
325        "path",
326        "string",
327        ConcreteType::TypedObject,
328        |path, ctx| {
329            let path = path.as_str();
330            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
331            let metadata = std::fs::metadata(path)
332                .map_err(|e| format!("io.stat(\"{}\"): {}", path, e))?;
333            let modified_ms = metadata
334                .modified()
335                .ok()
336                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
337                .map(|d| d.as_millis() as f64)
338                .unwrap_or(0.0);
339            let created_ms = metadata
340                .created()
341                .ok()
342                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
343                .map(|d| d.as_millis() as f64)
344                .unwrap_or(0.0);
345            Ok(TypedReturn::TypedObject(vec![
346                ("size".to_string(), ConcreteReturn::I64(metadata.len() as i64)),
347                ("modified".to_string(), ConcreteReturn::F64(modified_ms)),
348                ("created".to_string(), ConcreteReturn::F64(created_ms)),
349                ("is_file".to_string(), ConcreteReturn::Bool(metadata.is_file())),
350                ("is_dir".to_string(), ConcreteReturn::Bool(metadata.is_dir())),
351            ]))
352        },
353    );
354
355    // io.is_file(path: string) -> bool
356    register_typed_fn_1::<_, Arc<String>>(
357        module,
358        "is_file",
359        "Check if a path refers to a regular file",
360        "path",
361        "string",
362        ConcreteType::Bool,
363        |path, ctx| {
364            let path = path.as_str();
365            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
366            Ok(TypedReturn::Concrete(ConcreteReturn::Bool(
367                std::path::Path::new(path).is_file(),
368            )))
369        },
370    );
371
372    // io.is_dir(path: string) -> bool
373    register_typed_fn_1::<_, Arc<String>>(
374        module,
375        "is_dir",
376        "Check if a path refers to a directory",
377        "path",
378        "string",
379        ConcreteType::Bool,
380        |path, ctx| {
381            let path = path.as_str();
382            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
383            Ok(TypedReturn::Concrete(ConcreteReturn::Bool(
384                std::path::Path::new(path).is_dir(),
385            )))
386        },
387    );
388
389    // io.mkdir(path: string, recursive?: bool) -> unit
390    register_typed_fn_2_full::<_, Arc<String>, bool>(
391        module,
392        "mkdir",
393        "Create a directory",
394        [
395            ModuleParam {
396                name: "path".to_string(),
397                type_name: "string".to_string(),
398                required: true,
399                description: "Directory path to create".to_string(),
400                ..Default::default()
401            },
402            ModuleParam {
403                name: "recursive".to_string(),
404                type_name: "bool".to_string(),
405                required: false,
406                description: "Create parent directories if needed".to_string(),
407                default_snippet: Some("false".to_string()),
408                ..Default::default()
409            },
410        ],
411        ConcreteType::Unit,
412        |path, recursive, ctx| {
413            let path = path.as_str();
414            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, path)?;
415            if recursive {
416                std::fs::create_dir_all(path)
417                    .map_err(|e| format!("io.mkdir(\"{}\"): {}", path, e))?;
418            } else {
419                std::fs::create_dir(path)
420                    .map_err(|e| format!("io.mkdir(\"{}\"): {}", path, e))?;
421            }
422            Ok(TypedReturn::Concrete(ConcreteReturn::Unit))
423        },
424    );
425
426    // io.remove(path: string) -> unit
427    register_typed_fn_1::<_, Arc<String>>(
428        module,
429        "remove",
430        "Remove a file or directory (recursive for directories)",
431        "path",
432        "string",
433        ConcreteType::Unit,
434        |path, ctx| {
435            let path = path.as_str();
436            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, path)?;
437            let p = std::path::Path::new(path);
438            if p.is_dir() {
439                std::fs::remove_dir_all(path)
440                    .map_err(|e| format!("io.remove(\"{}\"): {}", path, e))?;
441            } else {
442                std::fs::remove_file(path)
443                    .map_err(|e| format!("io.remove(\"{}\"): {}", path, e))?;
444            }
445            Ok(TypedReturn::Concrete(ConcreteReturn::Unit))
446        },
447    );
448
449    // io.rename(old: string, new: string) -> unit
450    register_typed_fn_2::<_, Arc<String>, Arc<String>>(
451        module,
452        "rename",
453        "Rename a file or directory",
454        [("old", "string"), ("new", "string")],
455        ConcreteType::Unit,
456        |old, new, ctx| {
457            let old = old.as_str();
458            let new = new.as_str();
459            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, old)?;
460            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, new)?;
461            std::fs::rename(old, new)
462                .map_err(|e| format!("io.rename(\"{}\", \"{}\"): {}", old, new, e))?;
463            Ok(TypedReturn::Concrete(ConcreteReturn::Unit))
464        },
465    );
466
467    // io.read_dir(path: string) -> Array<string>
468    register_typed_fn_1::<_, Arc<String>>(
469        module,
470        "read_dir",
471        "List entries in a directory",
472        "path",
473        "string",
474        ConcreteType::ArrayString,
475        |path, ctx| {
476            let path = path.as_str();
477            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
478            let entries: Vec<String> = std::fs::read_dir(path)
479                .map_err(|e| format!("io.read_dir(\"{}\"): {}", path, e))?
480                .filter_map(|entry| entry.ok().map(|e| e.path().to_string_lossy().to_string()))
481                .collect();
482            Ok(TypedReturn::Concrete(ConcreteReturn::ArrayString(entries)))
483        },
484    );
485
486    // io.read_gzip(path: string) -> string
487    register_typed_fn_1::<_, Arc<String>>(
488        module,
489        "read_gzip",
490        "Read and decompress a gzip-compressed file",
491        "path",
492        "string",
493        ConcreteType::String,
494        |path, ctx| {
495            let path = path.as_str();
496            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsRead, path)?;
497            let file = std::fs::File::open(path)
498                .map_err(|e| format!("io.read_gzip(\"{}\"): {}", path, e))?;
499            let mut decoder = flate2::read::GzDecoder::new(file);
500            let mut output = String::new();
501            decoder
502                .read_to_string(&mut output)
503                .map_err(|e| format!("io.read_gzip(\"{}\"): decompression failed: {}", path, e))?;
504            Ok(TypedReturn::Concrete(ConcreteReturn::String(output)))
505        },
506    );
507
508    // io.write_gzip(path: string, data: string, level?: int) -> unit
509    register_typed_fn_3_full::<_, Arc<String>, Arc<String>, i64>(
510        module,
511        "write_gzip",
512        "Compress and write a string to a file with gzip",
513        [
514            ModuleParam {
515                name: "path".to_string(),
516                type_name: "string".to_string(),
517                required: true,
518                description: "Destination file path".to_string(),
519                ..Default::default()
520            },
521            ModuleParam {
522                name: "data".to_string(),
523                type_name: "string".to_string(),
524                required: true,
525                description: "String content to compress and write".to_string(),
526                ..Default::default()
527            },
528            ModuleParam {
529                name: "level".to_string(),
530                type_name: "int".to_string(),
531                required: false,
532                description: "Compression level 0-9 (default: 6)".to_string(),
533                default_snippet: Some("6".to_string()),
534                ..Default::default()
535            },
536        ],
537        ConcreteType::Unit,
538        |path, data, level, ctx| {
539            let path = path.as_str();
540            crate::module_exports::check_fs_permission(ctx, shape_abi_v1::Permission::FsWrite, path)?;
541            let level = level as u32;
542            let file = std::fs::File::create(path)
543                .map_err(|e| format!("io.write_gzip(\"{}\"): {}", path, e))?;
544            let mut encoder =
545                flate2::write::GzEncoder::new(file, flate2::Compression::new(level));
546            encoder
547                .write_all(data.as_bytes())
548                .map_err(|e| format!("io.write_gzip(\"{}\"): compression failed: {}", path, e))?;
549            encoder
550                .finish()
551                .map_err(|e| format!("io.write_gzip(\"{}\"): finalize failed: {}", path, e))?;
552            Ok(TypedReturn::Concrete(ConcreteReturn::Unit))
553        },
554    );
555}