fs::Path, the platform’s own bytesOpen and answer this issue on GitHub.
language, libraries, compiler, ready · opened 2026-10-05 · updated 2026-11-03 · 9 comments
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.
std already treats names as text. The fs entries take path: ? (Rust impl AsRef<Path>), fs::Root::Dir holds a String (fs.rs: “text in this language is one type”), and io::IoError’s variants all hold a String.String is UTF-8 by construction, because it lowers to Rust’s String/&str. Every entry point refuses bytes that are not UTF-8 with NotText. Part I only said so indirectly.fs::walk stops at the first such name (fs.rs:537).Path/OsStr, camino, Haskell OsPath, Swift FilePath, Python surrogateescape, Go/Zig bytes, .NET/Java lossy.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.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.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.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.
fs::Path holds the platform’s bytes (Unix raw, Windows WTF-8, UTF-8 where names are Unicode). It is made only from text, by the OS, or by its own operations, so no call fails for its encoding.Path is asked, as a view without a copy.== against a path or text, starts_with, ends_with, file_name, extension, with_extension, parent, join. Each returns a Path.f"…" builds a Path where its use asks for one. print/println write its bytes as they are. A Path hole in a text f"…" is refused.to_text() throws NotText only for a path whose source is the OS, on every target.display() shows � and is not tracked.IoError’s name variants and Root::Dir carry a Path, and walk no longer fails on names.String is always valid UTF-8.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.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.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.p == "x.csv", p.ends_with(".csv"), p.with_extension("bak") on a non-UTF-8 name, keeping the bytes.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.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.display() (D6). Test: the non-UTF-8 name shows as caf�.txt.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().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.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.Steps 1 and 2 are built in 0.0.536 (9855afc).
nikaia_std::fs::Path is a PathBuf, the platform’s bytes. std.contracts describes it with pub, crosses and compares. ref fs::Path lowers to &std::path::Path, the same way ref String lowers to &str.let with a declared type, an argument, a field, a variant’s part, a return and an assignment. The checker records the conversion in the table the nullable wrap uses (Wrap::Path, Wrap::PathView).
std::path::PathBuf::from("a.txt").std::path::Path::new(&(name)).NotFound(fs::Path), now compiles. Something that is not text is still NK1103.tests/file_paths.rs.Next:
std::fs signatures become path: fs::Path; walk returns Vec[fs::Path] without NotText; the test with a name that is not UTF-8.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
14417e4): std::fs takes path: ref fs::Pathcreate, map, read, read_to_string, walk, exists and write take a view of a name instead of path: ?.std::path::Path::new(&(name))), and an fs::Path is lent.ref fs::Path, not as an owned fs::Path. With an owned one, every literal or String argument would allocate a PathBuf; step 2’s test asks for no allocation, and Part III 17.1 says a view.walk still returns Vec[String]. std’s own tools/sources.nika calls ends_with on each walked name and puts it into an f"…". So returning Vec[fs::Path] waits for step 5 (f"…" by use) as well as step 4, and is the second half of step 3.b76881c): the operations (D3) and display() (D6)starts_with, ends_with, file_name, extension, with_extension, parent, join, display, and ==/!= against a name or text.std’s functions (nikaia_std::fs::path), not Rust’s Path methods, because those compare whole components: "data.csv".ends_with(".csv") is false in Rust, and dir//data.csv == dir/data.csv is true. The checker records which method calls resolved to fs::Path::… and which comparisons have a name on one side, and the emitter writes those as calls into nikaia_std::fs::path.file_name, extension and parent are fs::Path?: the last part, the extension and the parent can be absent.== byte for byte, in line with starts_with/ends_with and D1’s the platform’s own bytes. So dir//data.csv and dir/data.csv are two names. D3 does not spell this out. If component-wise equality is meant, it is a one-line change in same.tests/file_paths.rs (lowering and a run), and fs.rs’s not_text tests on Unix. Those use a name that is not UTF-8 (caf\xe9.txt): ends_with(".txt"), with_extension("bak") keeps the bytes, == byte for byte, and display() shows caf�.txt.f"…" by use).walk returns names, without NotText), with the test on a name that is not UTF-8 from the OS.to_text() by source), which waits for #431.IoError and Root::Dir carry a Path).Generated by Claude Code
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.
let bak: fs::Path = f"{p}.{n}.bak" and fs::exists(f"{p}.bak", root) compile and run.caf\xe9.txt).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:
NK1103 for a let, NK1102 for an argument, and so on. The message would say the f"…" is a fs::Path here, because a hole is one, with D5’s two ways as help.f"…" used as text, the same at every position.NK1201’s family (a value in a hole with no text form), widened to fs::Path.(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):
print/println write a name’s bytes as they are;let without a type takes Path from its first use.Generated by Claude Code
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:
http1.rs: Other("431 a request head over the cap"), Other("400 a request body that is not text");net.rs: Other("a connection closed while writing");ADR-319 D7 says the name variants. Options:
Other stays a String, and only the three name variants change.Other(fs::Path) as listed. The http and net messages would then have to go through a Path, or move to a variant of their own.Other becoming a name.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
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:
NK1201 already describes.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.
let s: String = f"{p}" is NK1201. Tests: that line and f"no file: {p}" into a String, each refused at the hole with both helps; println(f"found: {p}") still writes the raw bytes.NotFound(fs::Path), PermissionDenied(fs::Path), Outside(fs::Path); Other(String) unchanged; Root::Dir(fs::Path).io::IoError::NotFound(p) => f"no file: {p}" is NK1201, and {p.display()} compiles and prints the name.tests/error_types.rs:415 and any program that writes the old shape.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
07dd104)Step 5, NK1201 for a name in a text f"…":
f"…" with an fs::Path in a hole is now typed as a name.NK1201 at the hole, with {p.to_text()} and {p.display()} in the help. That covers a let, an argument, an assignment, a return, a field, and a match or if arm whose sibling arms are text.let bak: fs::Path = f"{p}.bak", fs::exists(f"{p}.bak", root)) and printing are unchanged.let s: String = f"name {p}" passed the checker and failed in rustc.Step 8:
io::IoError::NotFound, PermissionDenied and Outside carry fs::Path; NotText and Other stay String.fs::Root::Dir holds fs::Path, and fs::Root::Dir("site") is written as before.std now hands the OS failure’s name over as it was asked, not as its display(), so a name that is not UTF-8 keeps its bytes in the error.IoError::what() returns the name shown as text.Tests in file_paths.rs:
a_name_in_a_text_interpolation_is_refused_at_the_hole, one case per position;a_name_in_an_interpolation_that_builds_a_name_is_not_refused;an_io_errors_name_is_a_file_name.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:
walk returns Vec[fs::Path]);to_text() by source);Generated by Claude Code
d27c678)Step 3: fs::walk returns Vec[fs::Path].
NotText.fs.rs with a file named by the bytes caf\xe9.txt.Step 6: to_text() by source (D5).
p.to_text() resolves to an entry that cannot fail (fs::Path::text_of, not pub), and the function needs no throws. Text-made means: a literal, a String/str, an f"…" whose name holes are text-made, or join/with_extension over such names.io::IoError (NotText), the same on every target, with NK2605 if the function does not say throws. That covers a parameter, a field, and what walk listed.file_paths.rs::to_text_throws_by_the_names_source, which covers the literal, text, f"…" and join cases, a parameter, and a walked name with and without throws. Also a_name_made_from_text_lowers_to_the_call_that_cannot_fail.Also changed:
tools/sources.nika writes {name.to_text()} where a walked name becomes a BTreeMap[String, …] key; it is relowered.walk example passed ref name, which is NK1137 for a Path. It now passes name.Still to build:
fs::Path.Generated by Claude Code
3c852fd); step 9 needs a decision, with a proposal belowStep 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.
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.
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.
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.
cli::paths() beside cli::args(). It returns Vec[fs::Path], every argument as the platform’s bytes. Its to_text() throws, because the source is the OS (D5). cli::args() stays text but stops panicking:
throws io::IoError (NotText) when an argument is not text. Every one of the 17 callers then needs throws or a catch;�, which is display()’s rule. The text then names another file, which D6 accepts only for showing.cli::args() itself returns fs::Paths. Every ?? "200000" and every numeric parse of an argument would need .to_text(), which throws. All 17 callers change.cli::arg(i) returns text and cli::path(i) returns a name. This changes the surface the existing Args already has.(a) with (a1).
cli::paths() is where a name comes from, and it is consistent with walk.cli::args() failing with NotText matches what to_text() does with a name from the OS.throws in each caller. Most of them already throw for other reasons; that is not counted yet.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
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