fs::Path, the Platform’s Own Bytes — Text Stands Wherever One Is Asked, an f"…" Builds One Where the Use Asks, and Only a Name the System Handed Over May Fail to Be TextStatus: Accepted
Date: October 5, 2026
Answers: what Path is from the former open-decisions.md
(Part I 7.1 and Part III 17.1 wrote a type nothing declared, NK1135);
ADR-096 §4 left it to the owner
Related:
Part I 1.2 (one source, every target, the same bytes);
ADR-108 (every path names its fs::Root);
ADR-309 D2 (a literal’s type is read off its syntax);
ADR-152 D4 and ADR-135 (a literal takes the type its use asks for);
ADR-010 D1 (a value’s source is tracked along the program’s edges);
ADR-313 (the prelude is a closed list);
ADR-024 D1 (? in the ledger is the absence of a claim)
What is a file name in a program: text, bytes, or a type of its own — and what happens to a name the program cannot show as text?
A String is always valid UTF-8. A file name is not always text: on Linux it
is any bytes but / and zero, on Windows it is UTF-16 that may hold unpaired
surrogates, on macOS (APFS), WASI and the browser’s file system it is Unicode
— and a volume mounted over NFS or SMB can hand any of them other bytes. With a
name held as a String, fs::walk stops with NotText at the first such name,
and a file the system has is one no Nikaia program can open, rename or remove.
A language meant to be usable everywhere cannot leave files out.
| Language | A file name is | A name that is not text |
|---|---|---|
| Rust | Path over OsStr: Unix bytes, Windows WTF-8 |
to_str() is an Option; display() shows it lossily |
| camino (Rust) | Utf8Path |
refused — chosen by tools that prefer the convenience |
| Haskell | FilePath = String, since 2022 also OsPath over the system’s bytes |
the lossy String was the reason for OsPath |
| Swift | FilePath (System) over the platform’s bytes |
as Rust |
| Python 3 | str, with surrogateescape (PEP 383) |
smuggled into the str; printing it later fails. 3.0 silently dropped such names from listdir |
| Go, Zig | a string, which is bytes | nothing to fail; Zig uses WTF-8 on Windows |
| .NET, Java | text (UTF-16) | converted lossily; such names can be unreachable on Linux |
What transfers: Go’s and Zig’s exactness, and Rust’s separation of the system’s bytes from text. What does not: Zig’s answer as it stands — its string is bytes, and Nikaia’s is text.
| Option | Why it lost |
|---|---|
A name is a String |
a file the system has is a file the program cannot reach |
A name is a plain byte view, ref Array[u8] (Zig) |
on Windows not every byte run is a name, so a call could fail by target; showing and joining would hang on every byte array, binary data included |
Path → String fails only on the targets where it can |
the same source would compile on one target and not another (Part I 1.2) — and a Mac can mount a volume whose names are not text |
Path → String never fails, � for what is not text |
silent: the text names a different file or none |
f"…" is a Path when a hole is one |
the contents would decide a literal’s type, which ADR-309 D2 rules out |
| Track a shown name and refuse it where a path is asked | once f"…" builds a Path, only an explicit display() is left to track, at the cost of a second lattice and the precision #431 owes |
fs::Path is a type of std: a file name in the platform’s own encoding.Unix keeps the bytes as they are, Windows holds WTF-8, and a target whose names
are Unicode holds UTF-8. A Path is made from text, by the operating system
(walk, read_dir, a program’s arguments, the environment), or by D3’s and
D4’s operations on those; there is no constructor from arbitrary bytes. So
every Path is a valid name on its target, and no file system call fails for
its encoding on any target. Its bytes can be read; they are the platform’s, and
what does not hold as text on one target is not carried to another unchanged.
Path is asked, as a view, without a copy.UTF-8 is valid in every encoding of D1, so a String or a ref String is a
Path as it stands. The other direction is D5’s.
Path.== against a Path or text, starts_with, ends_with, file_name,
extension, with_extension, parent, join and strip_prefix (handing back
Path?). Each result is a Path, and
each keeps D1’s validity — joining two halves of a surrogate pair on Windows
makes one character, inside the type.
Amended 2026-10-28 (P3a): strip_prefix, and fs::canonical in Part III 17.1, for a name shown relative to a root.
f"…" builds a Path where its use asks for one.As a list literal takes the array type its use asks for
(ADR-152 D4). Its holes may be a Path, text or a number, and the
parts are joined in the platform’s encoding, so fs::write(f"{p}.bak", root,
data) writes next to a name that is not text without changing it. A let
without a type takes it from the binding’s first use, as an empty list does
(ADR-135). Where the use asks for text, an f"…" is a String
as before, and a Path in a hole is refused with NK1201, the code for a
hole whose value has no text form, at the hole and the same at every
position; the help names D5’s two ways. Rejected: each position’s own mismatch
(NK1103, NK1102, …), which names the line and not the hole; a code of
its own, for a case NK1201 already describes.
Where the use takes either — print, println, eprint, eprintln — the
bytes are written as they are, so a name reaches the output unchanged. The
contents never decide; the use does.
p.to_text() gives the String, and fails only for a name the system handed over.A Path the compiler knows was made from text converts without throws. One
whose source is the operating system throws io::IoError::NotText, on every
target — the source decides, not the target. The source is tracked along the
edges ADR-010 D1 tracks trust on.
p.display() is text for showing: � for what is not.It never fails. What it gives back is ordinary text: a program that hands it back to the file system names another file or none, and nothing tracks it. The word says what it is.
Path, and a root is one.io::IoError’s NotFound, PermissionDenied and Outside carry the name as
a Path, and fs::Root::Dir holds one. Other carries a message: most of
what it reports names no file (an HTTP head over its cap, a connection closed
while writing), and an operating system’s failure about a name is said in the
message, with the name shown.
Rejected: Other(fs::Path), which would put HTTP and network messages into
a file name; a new variant for failures without a name, which renames every
handler of Other for no case that needs it.
Amended October 6, 2026 (#449). fs::walk hands back
Vec[fs::Path] and no longer fails with NotText for a name under the
directory.
String is valid UTF-8, and Part I says so.Bytes that are not are refused where they enter, with NotText; bytes that
need not be text are Bytes, or a Path.
String is a path, and
f"{dir}/{name}.txt" handed to fs is one.to_text() of a name the system handed over, and
a program on a Mac writes its catch too.std::fs, io::IoError and fs::Root change their types;
an f"…" gains a second result; D5 needs the source tracking as precise as
ADR-010 D1 states it, not as built (#431); and display() can still be fed
back to the file system.to_text() or display().foreign), which has its own
crossing rules (Part III 15).fs::exists’s root, which is its own question in
the former open-decisions.md.fs::Metadata gains three readers, filled by the same fs::metadata call:
m.modified(), the last modification as a time::Duration since the Unix
epoch (zero where the platform does not say or before the epoch);
m.links(), the number of hard links (1 off Unix); and m.file_id(), the
device and inode as (u64, u64)? (null off Unix). They are readings of the
name, like len and is_file, and need no capability beyond the Root the
metadata call already took.
Path has an order== agrees with.Met by the move of proofs.rs: a set keyed by (fs::Path?, i64) was refused
NK1247, because the ledger row of fs::Path said compares and nothing more.
A name is a key often (files seen, files written, a cache by file), and every
language with a path type orders it: Rust’s PathBuf is Ord and Hash,
compared part by part; Python’s PurePath orders by its parts too. So
fs::Path orders and hashes: whole parts are compared one after the other,
each by its bytes, which is the order D3’s == agrees with (a/b and a//b
are equal and so sort together). The ledger row says equates, orders and
hashes (Part III 13.4’s columns). Nobody pays who does not key by a path; a
Path inside a tuple or an option orders as its parts do.
Decided by the architect under Language decisions (the obvious answer: the
order the type’s own == implies, the one every comparable path type has).
Path’s partsp.parts() gives the whole parts of the name, in order, each a Path.Met by the move of describe.rs: without_dots resolves . and .. in a
name the program built, before the name exists, so fs::canonical (which reads
the file system and throws NotFound) cannot do it, and D3 had no way to walk
a name part by part. The parts are the ones D9 already compares: p.parts()
is a Vec[Path], the root (and on Windows the prefix) as its own first part
where the name has one, then each whole part as written: .. always, a .
only where it leads the name; empty parts and a . inside are dropped, as D9’s == drops them (a/.//b has the parts a, b).
Joining the parts in order with join gives back a name == to p. It reads
no file system. Rust’s Path::components and Python’s PurePath.parts are the
same operation; a lexical ./.. resolution is a loop over them, so std
does not add that as a method of its own.
Decided by the supervisor under Language decisions (the obvious answer: the parts D9 already orders by, handed out the way every path type does).