//! Files and directories: whole files read and written at once, files opened
//! to read and write a piece at a time, and directories made, listed and
//! removed.
//!
//! Paths are `String`s, and a relative path is resolved against the process's
//! current directory. `read_to_string` and `write_string` move a whole file
//! as text, and `read` and `write` as `Bytes`. `open` returns a `File`, which
//! reads and writes through `std/io`'s `Read` and `Write`. Failures are
//! `std/io`'s `IoError`s. Every module sees these names without an import,
//! and a module's own definition of one of them shadows the one here.
//!
//! Every function here that touches the filesystem is an `async fn`, but none
//! of them suspends: each blocks every task until the operation finishes.
//!
//! Writing a file in a directory of its own under `temp_dir()`, reading it
//! back, listing the directory, and removing both:
//!
//! ```
//! async fn main() {
//! let dir = "${temp_dir()}/nova-std-fs"
//! create_dir_all(dir).await.unwrap()
//! let path = "${dir}/notes.txt"
//! write_string(path, "first line\n").await.unwrap()
//! assert_eq(read_to_string(path).await.unwrap(), "first line\n")
//!
//! let entries = read_dir(dir).await.unwrap()
//! assert_eq(entries.len(), 1)
//! assert_eq(entries[0].name, "notes.txt")
//! assert(entries[0].is_file, "notes.txt is a file")
//!
//! remove_dir_all(dir).await.unwrap()
//! assert(!exists(dir).await, "the directory and its file are gone")
//! }
//! ```
// Nova standard library — filesystem.
//
// Compiled as an implicit module and glob-imported into every user module. Error
// types live in `std/io`.
//
// Paths are `String` throughout. Contents are not, uniformly: `read`/`write`
// take and return `Bytes` (`std/bytes`), so arbitrary file contents need no
// UTF-8 round-trip, while `read_to_string`/`write_string` stay on `String`
// for the common text case.
//
// Every function that touches the filesystem is an `async fn` per
// `nova-spec/20-STDLIB.md` §5, but **none of them suspends**: the operation
// runs synchronously inside the poll. This used to say "because there is no
// I/O poller yet"; amended 2026-08-16 (branch `io-poller-std-net`), because a
// real poller now exists (`crates/nova-runtime/src/poll.rs`,
// docs/adr/0013-io-poller.md) and `std/net` genuinely suspends on it. The
// reason this module still does not has *changed*, not merely aged: it is no
// longer a roadmap gap but a structural fact — a regular file is not
// readiness-pollable by `select`/`WSAPoll`, or anything like them, on any of
// this project's three CI platforms, so making `std/fs` suspend needs a
// completion-based interface (IOCP), which ADR 0013 declines on scope. See
// docs/adr/0009-async-execution-model.md §1's 2026-08-16 amendment. The
// signature is what matters — it did not change when the poller landed, and no
// call site broke, exactly as this comment predicted. The cost is that a call
// blocks the whole executor for its duration, so a sibling task makes no
// progress during a large read. See docs/adr/0011-io-error-kinds.md.
//
// `temp_dir` is the one of them that reaches outward and is still a plain
// `fn`: it only queries the environment and touches no filesystem, so there is
// nothing in it that could ever suspend, and no future reason to change its
// signature either. It is not the only plain `fn` in this module any more,
// only the only exception -- `OpenOptions`'s `default`/`reading`/`writing`/
// `appending` build a record of six `Bool`s and touch nothing at all, and
// `impl Read for File`/`impl Write for File`'s methods are plain `fn`s
// returning `Future`, which is how a suspending trait method is spelled in
// this language.
//
// # The shape of these wrappers is load-bearing
//
// Each one calls its intrinsic, then reads a slot, with **no `.await` between
// the two**. The runtime's payload and message slots are per-task, keyed on the
// polling task (`crates/nova-runtime/src/fs.rs`'s `SLOTS`/`slot_index`), so a
// sibling task cannot clobber them. The risk that remains is narrower but still
// real: **within one task**, a second operation overwrites that same task's own
// slot, so a wrapper that awaited mid-sequence could still read a payload its
// own later call replaced. A future function here that awaited mid-sequence
// would break that. Keep them straight-line.
// Read the whole file at `path` as a UTF-8 string.
//
// Non-UTF-8 contents are `InvalidData`, not a panic and not lossy replacement.
/// Reads the whole file at `path` and returns its contents as a `String`.
///
/// Returns an `IoError` of kind `NotFound` if nothing exists at `path`,
/// `PermissionDenied` if the system denies access to it, and `InvalidData` if
/// the contents are not valid UTF-8. Reading a directory fails too, with a
/// kind that depends on the system.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn read_to_string(path: String) -> Result<String, IoError> {
let status = fs_read_to_string(path)
if status == 0 { return Ok(fs_take_string()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// Write `content` to `path`, replacing any existing contents.
/// Writes `content` to the file at `path`, creating the file if it does not
/// exist and replacing its contents if it does.
///
/// Returns an `IoError` of kind `NotFound` if the directory to hold the file
/// does not exist, and `PermissionDenied` if the system denies access.
/// Writing to a directory fails too, with a kind that depends on the system.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn write_string(path: String, content: String) -> Result<(), IoError> {
let status = fs_write_string(path, content)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// Read the whole file at `path` as bytes.
//
// Unlike `read_to_string` this cannot fail with `InvalidData`: there is no
// encoding to violate.
/// Reads the whole file at `path` and returns its contents as `Bytes`.
///
/// Unlike `read_to_string`, it takes any contents. Returns an `IoError` of
/// kind `NotFound` if nothing exists at `path`, and `PermissionDenied` if the
/// system denies access to it. Reading a directory fails too, with a kind
/// that depends on the system.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn read(path: String) -> Result<Bytes, IoError> {
let status = fs_read(path)
if status == 0 { return Ok(fs_take_bytes()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// Write `content` to `path`, replacing any existing contents.
/// Writes `content` to the file at `path`, creating the file if it does not
/// exist and replacing its contents if it does.
///
/// Returns an `IoError` of kind `NotFound` if the directory to hold the file
/// does not exist, and `PermissionDenied` if the system denies access.
/// Writing to a directory fails too, with a kind that depends on the system.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn write(path: String, content: Bytes) -> Result<(), IoError> {
let status = fs_write(path, content)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// The OS's temporary-directory path, with no trailing separator guaranteed
// either way -- join a name onto it the same way `fs_roundtrip` does.
/// Returns the path of the operating system's directory for temporary files.
///
/// On Linux that is the `TMPDIR` environment variable when it is set, and
/// `/tmp` otherwise. The path may or may not end with a separator, and
/// nothing checks that the directory exists. Unlike most of this module, it
/// is not `async`: it touches no file.
pub fn temp_dir() -> String { fs_temp_dir() }
// Whether `path` exists.
//
// Returns `Bool`, not `Result`, per `nova-spec/20-STDLIB.md` §5 — so a path that
// exists but cannot be examined is indistinguishable from one that is absent.
/// Returns `true` if something exists at `path`: a file, a directory, or
/// anything else.
///
/// It also returns `false` when `path` cannot be examined, for lack of
/// permission say, so `false` does not prove that nothing is there. A
/// symbolic link counts only if what it points to exists.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn exists(path: String) -> Bool { fs_exists(path) }
// Create the directory `path`. The parent must already exist; use
// `create_dir_all` otherwise. An existing `path` is `AlreadyExists`.
/// Creates the directory `path`.
///
/// Its parent must already exist; `create_dir_all` creates missing parents
/// too. Returns an `IoError` of kind `AlreadyExists` if anything, a file
/// included, already exists at `path`, `NotFound` if the parent does not
/// exist, and `PermissionDenied` if the system denies access.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn create_dir(path: String) -> Result<(), IoError> {
let status = fs_create_dir(path)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// Create the directory `path`, and any missing parent directories along the
// way. Unlike `create_dir`, an existing `path` is not an error.
/// Creates the directory `path`, and every missing directory above it.
///
/// A directory already at `path` is not an error, and neither is an empty
/// `path`: both return `Ok(())`. Returns an `IoError` of kind `AlreadyExists`
/// if a file is at `path`, and `PermissionDenied` if the system denies
/// access. A file in the way above `path` gives an error whose kind depends
/// on the system.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn create_dir_all(path: String) -> Result<(), IoError> {
let status = fs_create_dir_all(path)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// Remove the file `path`.
/// Removes the file at `path`.
///
/// Returns an `IoError` of kind `NotFound` if nothing exists at `path`, and
/// `PermissionDenied` if the system denies the removal. A directory is not
/// removed: that fails with a kind that depends on the system, and
/// `remove_dir_all` removes one.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn remove_file(path: String) -> Result<(), IoError> {
let status = fs_remove_file(path)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// Remove the directory `path` and everything under it.
/// Removes the directory `path` and everything in it.
///
/// A symbolic link inside is removed, not followed. Returns an `IoError` of
/// kind `NotFound` if nothing exists at `path`, and `PermissionDenied` if the
/// system denies removing something; a file at `path` fails with a kind that
/// depends on the system. A failure part way can leave some of the contents
/// removed.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn remove_dir_all(path: String) -> Result<(), IoError> {
let status = fs_remove_dir_all(path)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
// One entry in a directory listing.
//
// `is_file` and `is_dir` come from a single `fs_kind` call, so they cannot
// disagree with each other. They CAN both be false, though: `fs_kind` returns
// `0` whenever it cannot read the entry's metadata at all, not only when the
// entry is absent (see `fs_kind`'s own doc comment), and this record does not
// special-case that. So "exactly one is true" holds only for an entry whose
// metadata `fs_kind` could actually read -- not for every entry that exists.
// INFERRED, not measured: the clean trigger is a dangling symlink (`read_dir`
// lists it, its metadata call fails), which this codebase has not measured
// against on any platform -- constructing one needs admin rights on Windows,
// where this was checked.
/// One entry of a directory, as `read_dir` lists it.
///
/// A symbolic link is followed: `is_file` and `is_dir` describe what it
/// points to. Both are `false` when the entry cannot be examined, as for a
/// link that points to nothing.
pub record DirEntry {
/// The entry's name in its directory, such as `notes.txt`.
pub name: String,
/// The path given to `read_dir`, then `/`, then `name`, so a path given
/// with a trailing `/` gives `//`.
pub path: String,
/// `true` if the entry is not a directory: a file, or another kind of
/// entry such as a pipe.
pub is_file: Bool,
/// `true` if the entry is a directory.
pub is_dir: Bool,
}
// List the entries in `path`, sorted by name.
//
// Sorted because directory order is unspecified by the operating system; the
// sort happens in the runtime.
//
// Builds each `DirEntry` here rather than in the runtime: a record built in Rust
// would put Nova's field layout in two places, which is the drift this module's
// boundary exists to avoid. The cost is one extra `fs_kind` call per entry.
/// Returns the entries of the directory `path`, sorted by name.
///
/// Names are compared byte by byte, so `B.txt` comes before `a.txt`. Names
/// beginning with `.` are listed, but `.` and `..` are not, and a name that
/// is not valid Unicode has U+FFFD in place of its bad bytes. Returns an
/// `IoError` of kind `NotFound` if nothing exists at `path`, and
/// `PermissionDenied` if the system denies access; a file at `path` fails
/// with a kind that depends on the system.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
pub async fn read_dir(path: String) -> Result<[DirEntry], IoError> {
let status = fs_read_dir(path)
if status != 0 {
return Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
let names = fs_take_string_array()
let n = names.len()
// A repeat array, then every slot overwritten. `[init; n]` evaluates `init`
// exactly ONCE, so all `n` slots start as the same heap object -- harmless
// only because the loop below replaces each one. Do not read a slot before
// assigning it.
let mut out = [DirEntry { name: "", path: "", is_file: false, is_dir: false }; n]
let mut i = 0
while i < n {
let name = names[i]
let full = "${path}/${name}"
let k = fs_kind(full)
out[i] = DirEntry { name: name, path: full, is_file: k == 1, is_dir: k == 2 }
i = i + 1
}
Ok(out)
}
// --- File, OpenOptions and open ---
//
// Unlike everything above, which opens, acts and closes inside one intrinsic
// call, a `File` is a handle that survives between calls -- Nova's first
// value holding an OS resource that way. Nova has no destructors, so
// explicit `close` is the only release mechanism; a `File` that is never
// closed leaks its descriptor until the process exits.
// Options for `open`. Each flag forwards onto `std::fs::OpenOptions`'s own
// method of the same name, in the order `open` below passes them to
// `file_open`: read, write, append, truncate, create, create_new.
//
// No chainable builder: a receiver-mutating method cannot be called on a
// temporary (`E0060`, measured), so `OpenOptions::reading().with_write()`
// does not compile in this language. Use one of the three constructors
// below, or build an exotic combination with `let mut o =
// OpenOptions::default()` followed by field assignment.
/// How `open` opens a file: to read, write or append, and whether to create
/// or truncate it.
///
/// `reading()`, `writing()` and `appending()` give the usual sets. For any
/// other, start from `OpenOptions::default()`, which sets every flag to
/// `false`, in a `let mut` binding, and assign `true` to the flags wanted:
/// `read`, `write`, `append`, `truncate`, `create`, and `create_new`, which
/// creates the file and fails if it already exists. `open` returns an
/// `IoError` of kind `Other` for a set it cannot honour, such as one with
/// none of `read`, `write` and `append`, or one with `create` or `truncate`
/// but neither `write` nor `append`.
pub record OpenOptions {
read: Bool,
write: Bool,
append: Bool,
truncate: Bool,
create: Bool,
create_new: Bool,
}
// Every flag false. Passed to `open` as-is this fails -- measured:
// `std::fs::OpenOptions` itself requires at least one of read/write/append
// -- so this exists as the base for field assignment building an exotic
// combination, not as an option set meant to be used directly.
impl Default for OpenOptions {
fn default() -> OpenOptions {
OpenOptions {
read: false,
write: false,
append: false,
truncate: false,
create: false,
create_new: false,
}
}
}
impl OpenOptions {
/// Returns options that open an existing file for reading only.
pub fn reading() -> OpenOptions {
OpenOptions {
read: true,
write: false,
append: false,
truncate: false,
create: false,
create_new: false,
}
}
// Write + create + truncate: start this file fresh, creating it if it is
// not there yet and discarding any content it already has.
/// Returns options that open a file for writing only, creating it if it
/// does not exist and emptying it if it does.
pub fn writing() -> OpenOptions {
OpenOptions {
read: false,
write: true,
append: false,
truncate: true,
create: true,
create_new: false,
}
}
// Append + create: add to this file, creating it if it is not there
// yet, and leaving any content it already has in place.
/// Returns options that open a file for appending only, creating it if it
/// does not exist and keeping what it holds: every write goes to the end.
pub fn appending() -> OpenOptions {
OpenOptions {
read: false,
write: false,
append: true,
truncate: false,
create: true,
create_new: false,
}
}
}
// A handle to an open file. `fd` keys
// `crates/nova-runtime/src/file.rs`'s thread-local table of open
// `std::fs::File`s -- it is not an OS file descriptor number, and nothing
// about its value means anything to a Nova program beyond identifying an
// entry in that table.
//
// `fd` is not privacy-enforced -- no record field in this language is -- so
// a program can build `File { fd: 9999 }` naming no file this module ever
// opened. That is safe by design: absence from the handle table is
// indistinguishable from closedness, so a forged, stale, or already-closed
// handle behaves exactly like the real thing does once closed (see `close`
// below).
/// A file opened by `open`, to read and write a piece at a time through
/// `std/io`'s `Read` and `Write`.
///
/// Copying a `File` copies the handle: every copy refers to the same open
/// file. Nothing closes a `File` for you: call `close` when done, or it stays
/// open until the program exits.
pub record File { fd: Int }
// Open `path` under `options`.
/// Opens the file at `path` as `options` say, and returns it.
///
/// Returns an `IoError` of kind `NotFound` if the file does not exist and
/// `options` does not create it, or if the directory to hold it does not
/// exist; `AlreadyExists` if `options` sets `create_new` and the file exists;
/// `PermissionDenied` if the system denies access; and `Other` for options it
/// cannot honour. A directory cannot be read as a file: depending on the
/// system, opening it or reading it fails.
///
/// An `async fn` that never suspends: it blocks every task until it
/// finishes.
///
/// Appending to a file, then reading it back:
///
/// ```
/// async fn main() {
/// let dir = "${temp_dir()}/nova-std-fs-open"
/// create_dir_all(dir).await.unwrap()
/// let path = "${dir}/log.txt"
/// write_string(path, "one\n").await.unwrap()
///
/// // `appending()` keeps what the file holds and adds each write at its end.
/// let log = open(path, OpenOptions::appending()).await.unwrap()
/// log.write(bytes_from_string("two\n")).await.unwrap()
/// log.close().await.unwrap() // nothing closes a File for you
///
/// let reader = open(path, OpenOptions::reading()).await.unwrap()
/// let text = reader.read_to_end().await.unwrap().to_string().unwrap()
/// reader.close().await.unwrap()
/// assert_eq(text, "one\ntwo\n")
/// # remove_dir_all(dir).await.unwrap()
/// }
/// ```
pub async fn open(path: String, options: OpenOptions) -> Result<File, IoError> {
let status = file_open(
path,
options.read,
options.write,
options.append,
options.truncate,
options.create,
options.create_new,
)
if status == 0 { return Ok(File { fd: decode_count(fs_take_bytes()) }) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
impl File {
// Closes the handle. Idempotent: a second or third call also returns
// `Ok(())`, because Nova has no move checking, so `self` by value cannot
// stop a second call the way it would in a language with real
// ownership. Closing a stale or forged handle is the same no-op: the
// table lookup a close performs simply removes nothing.
/// Closes the file.
///
/// It returns `Ok(())`, also when the file is already closed: closing
/// reports no error. Afterwards, reading, writing or flushing the file,
/// through any copy, returns an `IoError` of kind `Other`.
///
/// An `async fn` that never suspends.
pub async fn close(self) -> Result<(), IoError> {
let status = file_close(self.fd)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
}
// Any operation other than `close` against a handle this module's table does
// not recognise -- already closed, stale, or forged -- reaches this same
// `IoError { kind: Other }` by missing the same table lookup, whether the
// operation is this function or `write_file`/`flush_file` below.
async fn read_file(f: File, max: Int) -> Result<Bytes, IoError> {
let status = file_read(f.fd, max)
if status == 0 { return Ok(fs_take_bytes()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
impl Read for File {
fn read(self, max: Int) -> Future<Result<Bytes, IoError>> { read_file(self, max) }
}
// The write's byte count crosses the boundary the same way `std/io`'s
// `write_stdout` decodes its own byte count: 8 little-endian bytes in the
// payload slot, read back here by that same `decode_count` rather than a
// second decoder.
async fn write_file(f: File, buf: Bytes) -> Result<Int, IoError> {
let status = file_write(f.fd, buf)
if status == 0 { return Ok(decode_count(fs_take_bytes())) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
async fn flush_file(f: File) -> Result<(), IoError> {
let status = file_flush(f.fd)
if status == 0 { return Ok(()) }
Err(IoError { kind: io_error_kind_of(status), message: fs_last_error_message() })
}
impl Write for File {
fn write(self, buf: Bytes) -> Future<Result<Int, IoError>> { write_file(self, buf) }
fn flush(self) -> Future<Result<(), IoError>> { flush_file(self) }
}