Skip to content

0.26.0-rc2

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 13 Oct 07:52
Immutable release. Only release title and notes can be modified.
392b1df

basic-cli 0.26.0-rc2

Release candidate for 0.26.0, for trying the new APIs before they are final.
It follows 0.26.0-rc1,
which tested the Windows MSVC host, and adds the API conventions work from
PR #528. The notes below are
the draft notes for 0.26.0; please report anything that does not behave as
they say.

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc2/Gj3zVDr3qrQa6aTh7Z1zgUfe9NeBE6R76T5ho3xrc8af.tar.zst" }

Requires the Roc new-compiler nightly nightly-2026-10-12-2664a27 or later.

One way to do each thing

Until now each basic-cli module had grown its own habits. A timeout was a bare U64 of milliseconds in one place and a tagged value in another, and zero meant "fail at once" for TCP but "no deadline" for a command. "No more input" was Err(EndOfFile) from stdin, an empty list from a file and an empty string from a socket. Errors arrived in a different shape from every module, and some could not be compared with == at all.

0.26.0 settles these questions once and applies the answers everywhere:

  • A timeout is a Duration, and Duration.zero always expires immediately.
  • The end of input is Err(EndOfInput), on stdin, files and sockets alike. An empty Ok is real data.
  • Anything you can read, you can loop over: lines() and chunks() return a Stream on stdin, file readers, TCP connections and HTTP responses, and rows does the same for SQLite.
  • A module's failures arrive under one tag (TcpErr(IOErr), CmdErr(Cmd.Err), SqliteErr(Sqlite.Err)), and every error can be compared with ==.

The result is that code written against one module reads like code written against another:

conn = Tcp.connect!("example.org", 7, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?

for! item in conn.lines() {
    match item {
        Ok(line) => Stdout.line!(line)?
        Err(BadUtf8(_)) => {}
        Err(ReadLimitExceeded(_)) => {}
        Err(TcpErr(err)) => { return Err(TcpErr(err)) }
    }
}

This is a large release with many breaking changes. The upgrade guide below lists every one with its replacement. The rules themselves are written down in CONTRIBUTING.md, so future modules follow them too.

Time has types

start = Monotonic.now!()
build!()?
Stdout.line!("Completed in ${start.elapsed!().to_str()}")?   # "Completed in 1.5s"

stale = Utc.now!().since(path.time_modified!()?) > Duration.minutes(5)
  • Duration is a non-negative length of time with nanosecond resolution: Duration.millis(250), Duration.seconds(5), Duration.minutes(2). It supports +, -, ==, the ordering operators and List.sort, and prints as "250ms" or "1.5s". Nothing overflows or goes negative: results are clamped, so budget - start.elapsed!() is simply zero once the budget is spent.
  • Utc is now a real type holding a signed instant. Utc.now!() no longer returns 1970 when the clock cannot be read, and times before 1970 work, including in to_iso_8601.
  • Monotonic is an opaque clock reading. start.elapsed!() and later.since(earlier) return a Duration, so a reading can no longer be mistaken for a length of time.
  • File times (time_accessed!, time_modified!, time_created!) return Utc and succeed for files older than 1970.
  • Sleep.millis!(250) is unchanged. For a wait you computed, call duration.sleep!().

Loop over input

Reading lines used to mean hand-written recursion. Now it is a loop, and the same loop works on every source:

var $count = 0
for! item in reader.lines() {
    match item {
        Ok(_) => { $count = $count + 1 }
        Err(BadUtf8(_)) => {}
        Err(FileErr(err)) => { return Err(FileErr(err)) }
    }
}
  • Stdin.lines(), Stdin.chunks(), reader.lines() and reader.chunks() are new. Lines never include the \n, and one preceding \r is removed. A final line without a terminator is still a line.
  • File.Reader.read_until!(byte) returns bytes through and including a delimiter.
  • A line that is not valid UTF-8 is one Err(BadUtf8(_)) item and the loop carries on. A failure of the source itself is the last item.
  • Streams buffer nothing in Roc. You can break out of Stdin.lines() after a header and hand the rest to Stdin.read_to_end!() without losing a byte, and mix reader.lines() with seek! and read_exactly!.

Write files piece by piece

Path.write_utf8! replaces a file with one payload, so output had to be built in memory first. File.Writer opens a file once and writes to it as you go:

File.with_writer!("report.csv", Truncate, |writer| {
    writer.write_utf8!("name,total\n")?
    for row in rows {
        writer.write_utf8!("${row.name},${row.total.to_str()}\n")?
    }
    Ok({})
})?
  • The mode is Truncate, Append or RequireNew (fails with AlreadyExists if anything is at the path).
  • writer.write_stream!(stream) writes any chunk stream as it arrives, so a file can be copied or transformed in bounded memory: writer.write_stream!(reader.chunks()).
  • File.with_writer! flushes when your callback returns and reports a failed flush. With File.open_writer!, call flush! yourself after the last write.
  • Path.append_bytes! and Path.append_utf8! are also new, for adding to a file in one call (#516).

Download without holding the body in memory

Http.send_streaming! returns as soon as the status and headers arrive, and hands you the body as a stream:

request = Request.from_method(GET).with_uri(url.to_str())
response = Http.send_streaming!(request, {})?
if response.status() != 200 {
    return Err(UnexpectedStatus(response.status()))
}
File.with_writer!(destination, Truncate, |writer| writer.write_stream!(response.chunks()))?
  • You see the status before any of the body is read, so you can decline a 404's body.
  • idle_timeout (30 seconds unless you set it) bounds each single wait, not the whole download. A slow but healthy download keeps going; a stalled one stops. After an idle timeout the response is still usable.
  • response.read_to_end!(MaxBytes(1_048_576)) reads the rest with a bound, and response.content_length() is Known(n) or Unknown.
  • Http.send! and the functions built on it are unchanged.

TCP: typed errors and a timeout the connection remembers

# 0.25
stream = Tcp.connect!("127.0.0.1", 8085, 1_000) ? |err| ConnectFailed(err)
stream.write_utf8!("${out_msg}\n", 5_000) ? |err| TcpWriteFailed(err)
in_msg = stream.read_line!(1_048_576, 5_000) ? |err| TcpReadFailed(err)

# 0.26
conn = Tcp.connect!("127.0.0.1", 8085, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?
conn.write_utf8!("${out_msg}\n")?
in_msg = conn.read_line!()?
  • Tcp.Stream is now Tcp.Connection. It holds its I/O timeout and line limit; conn.with_timeout(d) and conn.with_line_limit(n) return the same connection with another setting.
  • Every failure is TcpErr(IOErr), so result == Err(TcpErr(TimedOut)) and Err(TcpErr(ConnectionRefused)) can be matched directly. Windows takes about two seconds to report a refused connection, so a shorter connect_timeout gives TimedOut there. IOErr gains TimedOut, ConnectionRefused, ConnectionReset, AddrInUse and AddrNotAvailable.
  • A read that times out consumes nothing. The bytes that had arrived are returned by the next read, so a client that tolerates idle periods just calls conn.lines() again.
  • A line longer than the limit is Err(ReadLimitExceeded(n)); the rest of it is discarded and the next read starts at the next line.
  • listener.accept!({ accept_timeout: NoTimeout, io_timeout: Duration.seconds(30) }) separates how long to wait for a client from the timeout that client's connection gets.

Commands: four ways to run, one result, one error

Cmd had seven ways to run a command, which disagreed on whether a non-zero exit was an error. There are now four:

Cmd.new("cargo").args(["build", "--release"]).exec!()?              # inherit the terminal; must succeed
sha = Cmd.new("git").args(["rev-parse", "HEAD"]).capture!()?.trim()  # stdout as text; must succeed
output = Cmd.new("cat").arg("notes.txt").run!()?                     # status and output as data
child = Cmd.new("server").stdout(Pipe).spawn!()?                     # run in the background
  • run! returns a Cmd.Output with status, stdout_bytes and stderr_bytes, plus require_success(), stdout_utf8(), stdout_utf8_lossy() and stderr_utf8_lossy(). It prints its output as text, not as a list of byte numbers.
  • Every failure is CmdErr(Cmd.Err): StartFailed(IOErr, program), TimedOut(partial_output), OutputLimitExceeded(partial_output) or IOErr(IOErr). A missing program is Err(CmdErr(StartFailed(NotFound, _))).
  • .timeout(Duration.minutes(5)) bounds the whole run, including a spawned child, which is killed at the deadline.
  • Errors and to_inspect no longer contain environment values, so a secret passed with .env cannot leak into a log.
  • .stderr(MergeWithStdout) and .output_limit(NoLimit) say directly what used to be encoded in a Bool or a zero.

SQLite: a connection you hold, rows you can stream

decode_todo : Decoder({ id : I64, task : Str }, Sqlite.DecodeErr)
decode_todo = { id: Decoder.i64("id"), task: Decoder.str("task") }.Decoder

db = Sqlite.open!("app.db", {})?
todo = db.query_one!("SELECT id, task FROM todos WHERE id = ${Integer(id)}", decode_todo)?
  • Sqlite.open! returns a Db that you pass around and can close!. Open read-only with { mode: ReadOnly }. A locked database is now waited on (five seconds unless you set busy_timeout) instead of failing at once. Sqlite.open_in_memory!() gives a private database on every call.
  • ${…} in a query is a bound parameter, never pasted into the SQL. Each value names its storage class (Integer(id), String(name)), and putting an interpolation inside quotes or a comment is a compile error.
  • Decoders are ordinary values built with record-builder syntax. You can annotate them, share them between queries, and name a column by name or by position (Decoder.i64(0)).
  • db.rows(query, decoder) is a lazy stream, so a large table can be read row by row and abandoned with break. query_one! and query_all! cover the common cases.
  • db.transaction!(|| { … }) commits if the body returns Ok and rolls back if it returns Err.
  • Errors say what went wrong. A UNIQUE violation is Err(SqliteErr(Failed({ code: Constraint(Unique), .. }))), with no message text to search. A wrong number of parameters is WrongParamCount instead of a silent NULL, and running two statements where one is expected is MultipleStatements instead of the second being ignored. execute_script! runs several statements on purpose.

Paths and OS strings compare by content

A path or OS string written as a literal never used to equal the same text returned by the platform, so these were silently always false:

verbose = args.contains("--verbose")
is_vim = Env.var!("EDITOR")? == "vim"
home = Dict.from_list(Env.dict!()).get("HOME")

They now work. Path and OsStr compare and hash by their content, whichever representation holds it. Paths are not normalised: a/b and ./a/b remain different.

Terminal

Tty.enable_raw_mode! and Tty.disable_raw_mode! now return Try({}, [TtyErr(IOErr)]), so a failure reaches your program. Enabling twice is harmless, and one disable undoes it. The host restores the terminal when your program returns, exits with a code, crashes, or is stopped by a signal such as Ctrl+C (#515).

Windows

The Windows x64 host keeps the MSVC ABI and now supplies the startup and runtime inputs that current Roc nightlies require, using the C runtime that ships with Windows 10 and 11 (#512). Windows execution is tested on Server 2025 in CI; reports from clean Windows 10 and 11 installations without the Visual C++ redistributables are still wanted.

Upgrading from 0.25

Update the platform URL and rebuild: the native host interface changed, and mixing hosts and platform sources from different versions is not supported. Then work through the modules you use.

Time

0.25 0.26
Utc.now!() returns U128 returns Utc; epoch integers are I128
Utc.delta_as_millis(a, b), delta_as_nanos later.since(earlier), a Duration. It is directional: zero if later is the earlier one
Monotonic.now!() returns U64 returns Monotonic; use start.elapsed!() or later.since(earlier)
path.time_modified!() returns U128 returns Utc

Stdin and files

0.25 0.26
Err(EndOfFile) Err(EndOfInput)
Err(FileUnexpectedEOF) Err(UnexpectedEndOfInput)
reader.read_line!() returns bytes with the \n, [] at the end returns a Str without it, Err(EndOfInput) at the end. For bytes, reader.read_until!('\n')
reader.read_up_to!(n) returns [] at the end Err(EndOfInput)
reader.chunks(n)? reader.chunks(); for a chosen size, call read_up_to!(n) in a loop
Stdin.line!() strips every trailing \r strips one, and reports invalid UTF-8 as Err(BadUtf8(_))

Tcp

0.25 0.26
Tcp.Stream Tcp.Connection
connect!(host, port, ms) connect!(host, port, { connect_timeout, io_timeout })
listen!(host, port, ms) listen!(host, port, Duration)
accept!(ms), ListenerClosed accept!({ accept_timeout, io_timeout }), Closed
read_up_to!(n, ms), Ok([]) at the end read_up_to!(n), Err(EndOfInput)
read_exactly!(n, ms), TcpUnexpectedEOF read_exactly!(n), UnexpectedEndOfInput
read_until!(byte, max, ms), TcpReadLimitExceeded(n) read_until!(byte), ReadLimitExceeded(n)
read_line!(max, ms) keeping \n, TcpReadBadUtf8(e) read_line!() without it, BadUtf8(e)
write!(bytes, ms), write_utf8!(str, ms) write_bytes!(bytes), write_utf8!(str)
ConnectErr, TcpReadErr(…), TcpWriteErr(…), TcpListenErr(…) TcpErr(IOErr)

Cmd

0.25 0.26
Cmd.exec!("cargo", ["build"]) Cmd.new("cargo").args(["build"]).exec!()
cmd.exec_cmd!() cmd.exec!()
cmd.exec_output!()?.stdout_utf8 cmd.capture!()?
cmd.exec_output_bytes!() cmd.run!()?.require_success()?.stdout_bytes
cmd.exec_exit_code!() cmd.inherit_stdio().run!()?.status
args_str, arg_str and the other _str builders args, arg: an OsStr literal interpolates, and a List(Str) is names.map(OsStr.from_str)
timeout_ms(ms), where 0 meant no deadline timeout(Duration); leave it out for no deadline, and zero expires immediately
output_limit(n) output_limit(MaxBytes(n)) or output_limit(NoLimit)
merge_stderr(True) stderr(MergeWithStdout)
child.pid!(), try_wait!(), write!(bytes, ms) child.pid(), poll!(), write_bytes!(bytes, Duration)
child.read!(max, ms), Ok(End) child.read!(Duration), Err(EndOfInput)
RunOutput, RunErr, and the per-function error tags Cmd.Output, CmdErr(Cmd.Err), BadExitStatus(Output)

Sqlite

0.25 0.26
Sqlite.query!({ path, query, bindings, row }) db = Sqlite.open!(path, {})? then db.query_one!(query, decoder)
Sqlite.query_many! db.query_all!, or db.rows to stream
Sqlite.execute!({ path, … }) db.execute!(query); it now rejects a statement that returns rows before running it
bindings: [{ name: ":id", value: Integer(id) }] ${Integer(id)} in the query, or Query.new(sql, values) with ? markers
Sqlite.prepare!, Stmt removed; each Db caches prepared statements by SQL text
Sqlite.i64("id")(cols)(stmt)? Decoder.i64("id") in a record builder
Sqlite.u8("n") and the other narrow widths `Decoder.i64("n").map_try(
[NotNull(x), Null] Try(x, [Null]) from decoder.nullable()
SqliteErr(code, message) SqliteErr(Sqlite.Err); decode failures are BadRow(err)
NoRowsReturned, TooManyRowsReturned NoRows, TooManyRows
one shared :memory: database a private one per open!

Everything else

  • IOErr has five new variants (TimedOut, ConnectionRefused, ConnectionReset, AddrInUse, AddrNotAvailable). A match that lists every IOErr variant needs new arms, even in code that never uses TCP.
  • Path and OsStr values that differed only in representation are now equal, and collapse to one Dict or Set key.
  • Tty.enable_raw_mode! and disable_raw_mode! return a Try; add ?.
  • Write True and False, not Bool.True and Bool.False, to match the platform's own docs and examples.

Fixes

  • Http.with_json_body and Http.decode_json_response are declared as the pure functions they are.
  • Locale.get! errors now combine with other errors when called through a function value.
  • File readers, writers, sockets, child processes, SQLite connections and HTTP responses are released as soon as your program drops its last reference to them. The test suite now asserts on Linux, macOS and Windows that none is left open at exit.

Documentation and tooling

  • CONTRIBUTING.md has a new API conventions section, the rule set every module in this release follows.
  • CI now formats, lints and unit-tests the Rust host on Linux, macOS and Windows (#514).
  • New examples: file-write-stream.roc and http-download.roc. The TCP, command, SQLite, stdin and file examples are rewritten for the new APIs.