Issue #449: Build ADR-319: a file name is fs::Path, the platform’s own bytes

Open and answer this issue on GitHub.

language, libraries, compiler, ready · opened 2026-10-05 · updated 2026-11-03 · 9 comments

How it came up

docs/open-decisions.md had What Path is. Part I 7.1 (enum ConfigError { NotFound(Path), … }) and every signature of Part III 17.1’s std::fs wrote Path, but nothing declared it. The Part I example is refused with NK1135 (There’s no type called Path). ADR-096 §4 had left the question to the owner.

What was checked

The course of the decision round

  1. The first recommendation was “a path is a String, a byte-capable type when someone needs it”. The owner rejected it: Nikaia is meant to be universally usable, and UTF-8-only contradicts that.
  2. The owner proposed bytes or a byte view, like Zig.
  3. The requirement all platforms then ruled out a plain byte view, because on Windows (WTF-8) not every byte run is a valid name. That leads to a type of its own with the platform’s bytes inside.
  4. Path → String can fail, and the failure looked target-dependent. Part I 1.2 (one source, every target) rules out a target-dependent contract. Besides, a Mac can mount NFS or SMB volumes. The answer was to let the source of the value decide, not the target.
  5. To protect against display() text flowing back into the file system, the round first considered taint tracking. The owner then proposed that f"{p}" give back a Path. Letting the hole’s contents decide would break ADR-309 D2, so the variant chosen is that the use decides (as in ADR-152 D4). display() is accepted and not tracked.

The decision in short

ADR-319; specification: Part I 2.2 (a String is always valid UTF-8), Part I 2.5 (f"…" builds a Path where its use asks for one), Part III 17.1 (fs::Path, walk -> Vec[Path]). Version 0.0.474.

To build

  1. The type. fs::Path in std (Rust half: a wrapper over OsStr/OsString) with a ledger entry (pub, crosses, compares). Test: let p: fs::Path = "a.txt" lowers, and Part I 7.1’s example, rewritten to fs::Path, compiles.
  2. Text as a path. ref String/String stands where fs::Path is asked, without a copy. Test: fs::read_to_string("a.txt", root) and fs::read_to_string(name, root) with name: String unchanged; no allocation in the emitted Rust.
  3. std::fs signatures. path: ? becomes path: fs::Path. walk returns Vec[fs::Path] and no longer throws NotText for names. read_dir returns paths. Test (Linux): a directory with a file named by the bytes b"caf\xe9.txt". walk lists it, read/rename/remove work on the listed Path.
  4. Operations (D3). Tests for each, including p == "x.csv", p.ends_with(".csv"), p.with_extension("bak") on a non-UTF-8 name, keeping the bytes.
  5. f"…" by use (D4). In the checker, a f"…" whose use asks for fs::Path is typed Path, and a let takes its type from the binding’s first use (as ADR-135). In the emitter, the parts are joined as OsString (Windows: surrogate halves at the seam become one character, inside the type). Tests: fs::write(f"{p}.bak", root, data) on the non-UTF-8 name writes caf\xe9.txt.bak; let s: String = f"{p}" is refused with help naming to_text()/display() (code to be chosen); println(f"found: {p}") writes the raw bytes.
  6. to_text() by source (D5). Requires ADR-010 D1’s tracking to be per value, not per program, so #431 first. Tests: let p: fs::Path = "a"; p.to_text() needs no throws; a walk result’s to_text() makes the function throws; the same verdict for every --target.
  7. display() (D6). Test: the non-UTF-8 name shows as caf�.txt.
  8. Errors and root (D7). io::IoError variants NotFound/PermissionDenied/Outside/Other carry fs::Path (ledger variants), and fs::Root::Dir(fs::Path). Test: a match over IoError still compiles total; f"no config at {p}" in an error message uses display().
  9. Program arguments and environment hand back a Path where they name files, or a way to get one. Decide the spelling with the build (e.g. cli::args() stays text and fails NotText, with cli::paths() beside it). This is the one surface ADR-319 names as a source without spelling it.
  10. Afterwards: Part I 7.1’s example writes fs::Path (it is left as Path until then, so that rustc does not refuse it, which would be Part III C.1’s class), and the spec baselines are regenerated.

Comments

keywan-ghadami, 2026-10-06

Steps 1 and 2 are built in 0.0.536 (9855afc).

Next:

Side finding #471, fixed in 0.0.537: a number literal where a non-number is declared (let s: String = 3) used to pass the checker and fail in rustc.


Generated by Claude Code

keywan-ghadami, 2026-10-06

Steps 3 (first half), 4 and 7 are built

0.0.538 (14417e4): std::fs takes path: ref fs::Path

0.0.542 (b76881c): the operations (D3) and display() (D6)

Still to build


Generated by Claude Code

keywan-ghadami, 2026-10-06

Step 5, first half: 0.0.543 (442b5fc)

An f"…" with an fs::Path in a hole is joined in the platform’s encoding (nikaia_std::fs::path::joined): the name by its bytes, text and numbers by their text.

Question for the deciding session: the code for a name in a text f"…"

Step 5’s text says “let s: String = f"{p}" is refused with help naming to_text()/display() (code to be chosen)”. Part III gives no code for it. Today such a line passes the checker and fails in rustc, as it did before this work.

Options:

(b) and (c) name the cause wherever it is. (a) adds no code but says it in the words of each position. I’ll build it once this is answered.

Also still open in step 5, buildable without a decision (next):


Generated by Claude Code

keywan-ghadami, 2026-10-06

Step 8: two questions before I build it

I looked at step 8 (D7: IoError’s name variants and Root::Dir carry fs::Path). Root::Dir and NotFound/PermissionDenied/Outside are clear. Two points are not, and I do not want to decide them in the build.

1. IoError::Other. The To build list puts Other among the variants that carry fs::Path. Today Other also carries messages that are not names:

ADR-319 D7 says the name variants. Options:

I would build (a) unless you say otherwise.

2. Step 8 depends on step 5’s open refusal code. IoError::NotFound(p) => f"no file: {p}" is the usual shape: tests/error_types.rs:415 and several programs write it. Once p is an fs::Path, that is a name in a text f"…". D4 refuses it, but the code is still the open question in my comment above (options a/b/c). Until a code is chosen, the checker passes such a line and rustc refuses it (Part III C.1). Building step 8 first would turn today’s working programs into rustc errors. I would build step 8 together with that refusal, so they get NK…, help: {p.display()}.

So step 8 waits for both answers. Meanwhile I am working on other issues.


Generated by Claude Code

keywan-ghadami, 2026-10-06

Both questions decided (owner, 6 Oct) - 0.0.580, ee5e967c

1. The code for a path in a text f"…": NK1201, widened (option c). NK1201 already reports a hole whose value has no text form (a list, a map, a set). A path has none where the f"…" is text. The refusal stands at the hole and reads the same at every position. The help names {p.to_text()} and {p.display()}.

Rejected:

Written in ADR-319 D4, Part I 2.5 and Part III’s NK1201.

2. IoError::Other stays a message (option a). NotFound, PermissionDenied and Outside carry the name as an fs::Path, and Root::Dir holds a Path. D7 had listed Other among the name variants, but most of what Other reports names no file (http1.rs, net.rs, 12 sites). An OS failure about a name says it in the message, with the name shown. D7 is amended. Part III 17.1 says so.

What this unblocks

Still waiting: step 6 on #431 (ready). Step 9’s spelling (cli::args() text with NotText and a cli::paths() beside it, or another) is an API spelling. Bring a proposal back before building it rather than deciding it in the build.

The commit that finishes the list says Fixes #449.


Generated by Claude Code

keywan-ghadami, 2026-10-07

Steps 5 (the refusal) and 8 built: 0.0.604 (07dd104)

Step 5, NK1201 for a name in a text f"…":

Step 8:

Tests in file_paths.rs:

error_types.rs:415 and error_sums.rs now write {p.display()}.

Note on the help: it names to_text(), as decided, but fs::Path::to_text() does not exist yet; that is step 6. #431, which step 6 waited for, is closed, so step 6 is next.

Still to build:


Generated by Claude Code

keywan-ghadami, 2026-10-07

Steps 3 (second half) and 6 built: 0.0.605 (d27c678)

Step 3: fs::walk returns Vec[fs::Path].

Step 6: to_text() by source (D5).

Also changed:

Still to build:


Generated by Claude Code

keywan-ghadami, 2026-10-07

Step 10 built (0.0.606, 3c852fd); step 9 needs a decision, with a proposal below

Step 10: Part I 7.1’s ConfigError now holds NotFound(fs::Path) and Unreadable(fs::Path), and message writes {p.display()}. The block used to be NK1135; it now lowers, and the spec baseline says so.

With that, every step except 9 is built.

Step 9: the program’s arguments

What it is

cli::args() hands back the words the program was started with, as text: let name = cli::args().nth(1) ?? "data.csv". 17 programs in the repository use it, all in this shape: nth(i) ?? default. There is no env module yet, so the environment is not a question today.

What happens today

cli::args() is Rust’s std::env::args(). That panics when an argument is not UTF-8. So a program started with a file name that is not text (./tool $'caf\xe9.txt') stops with a Rust panic, which is a raw internal error. This happens before any Nikaia code runs, and nothing in the program can catch it. ADR-319 names arguments as a source of names but does not spell how.

Why a decision is needed

A word typed on the command line is often a file name. Today there is no way to get it as an fs::Path with its bytes kept.

Options

Recommendation

(a) with (a1).

Until this is decided I will not change cli::args(). The panic stays a known defect, and this comment records it.


Generated by Claude Code

keywan-ghadami, 2026-10-09

no direct cli argument access, I like to see something like: Struct AppConfig { ///sytax help for input_file argument input_file: fs::Path, // Nimmt Pfad als OS-Bytes (kein UTF-8 Panic) name: String = “data.csv”, // UTF-8 Text mit Default-Wert count: u32 = 20000, // Automatisch geparste Zahl mit default }

fn main() { // Liest cli::paths()/args() intern, parsed sie in das Struct // und wirft saubere CLI-Fehler, falls Typen/UTF-8 nicht passen: let config = cli::parse(AppConfig::ty); }


whivh should allow declaring the cli arguments in a way that gives the program typed inputs

Back to Open Issues