Skip to main content

sui_eval/
path.rs

1//! Centralized path resolution for the Nix evaluator.
2//!
3//! All path operations (normalization, relative resolution, import resolution)
4//! go through this module to ensure consistent behavior.
5
6use std::cell::RefCell;
7use std::path::{Component, Path, PathBuf};
8
9thread_local! {
10    /// Registry of fetched flake-input source trees: each entry maps the
11    /// cppnix `/nix/store/<narhash>-source` STORE-PATH STRING (what an
12    /// input's `outPath`/`sourceInfo.outPath` exposes for byte-parity) to
13    /// the ACTUAL on-disk directory the tree lives at (the sui fetcher
14    /// cache `~/.cache/sui/inputs/…`, or the literal path for a
15    /// `type = "path"` input).
16    ///
17    /// sui does NOT copy fetched trees into `/nix/store` at that path, so a
18    /// flake's own Nix code that reads `${input.outPath}/<subpath>` (an
19    /// `import`, `readFile`, `pathExists`, `readDir`, …) would resolve
20    /// against a directory that does not exist. `materialize` redirects the
21    /// FILESYSTEM READ to the real tree while the store-path STRING flowing
22    /// through eval stays byte-correct — no hash, no derivation, no value
23    /// ever changes.
24    ///
25    /// The marquee darwin root (2026-07-11) set the store-path string; this
26    /// registry closes its sibling: arbitrary `${outPath}/subpath` reads
27    /// (the prior peel special-cased only reading `flake.nix`).
28    /// Tuple is `(store_path, read_dir, read_dir_canon)` — the third field is
29    /// `read_dir.canonicalize()` computed ONCE at registration (falling back to
30    /// `read_dir` on failure, the same semantics the per-call fallback had).
31    ///
32    /// WHY REGISTRATION-TIME (perf root, found live 2026-07-21): `dematerialize`
33    /// used to call `read_dir.canonicalize()` INSIDE its per-entry loop, on
34    /// every call — and its callers are the path-literal arms of `eval_expr`,
35    /// i.e. every `./x` in every .nix file. On the cid marquee eval (~60
36    /// registered inputs), sampling the live process showed **61% of the eval
37    /// thread's wall-clock inside `__getattrlist`**, the syscall macOS
38    /// `realpath` issues per path component. One canonicalize per registration
39    /// replaces N-per-dematerialize-call; the registered trees are immutable
40    /// fetcher caches, so the value cannot go stale.
41    static INPUT_SOURCE_MAP: RefCell<Vec<(PathBuf, PathBuf, PathBuf)>> = const { RefCell::new(Vec::new()) };
42
43    /// Registry of `builtins.fetch*` result trees: each entry maps the REAL
44    /// on-disk fetcher-cache directory (e.g. `$TMPDIR/sui-fetchGit/<cache-hash>`)
45    /// to the STORE-PATH NAME CppNix gives the tree when it is copied into the
46    /// store as a derivation `src` — `"source"` for `fetchGit`/`fetchTree`/
47    /// `fetchTarball` (or the explicit `name` arg where one is honored).
48    ///
49    /// sui's fetchers return a raw temp `Value::Path` whose basename is a
50    /// content-addressing cache-hash (a 64-char sha256 hex), NOT a store path.
51    /// When that path is later coerced-to-store as a derivation `src`
52    /// (`zshSynHlSrc = builtins.fetchGit {…}`), the copy-to-store code named
53    /// the store path after the temp basename (`<store-hash>-<cache-hash>`)
54    /// instead of CppNix's `<store-hash>-source`. This registry is the
55    /// fetcher-family sibling of `INPUT_SOURCE_MAP`: it records the correct
56    /// `-source` NAME for each fetcher result so `source_name_for_read_dir`
57    /// resolves the copy-to-store name to CppNix's convention. Only the NAME
58    /// changes — the bytes (→ NAR hash) are identical either way.
59    /// Tuple is `(read_dir, name, read_dir_canon)` — canon computed once at
60    /// registration, same rationale as `INPUT_SOURCE_MAP` above.
61    static FETCHED_SOURCE_NAMES: RefCell<Vec<(PathBuf, String, PathBuf)>> = const { RefCell::new(Vec::new()) };
62
63    /// Success-only memo for probe-side `canonicalize` calls.
64    ///
65    /// `dematerialize`/`source_name_for_read_dir` canonicalize their argument
66    /// on every call, and eval hands them the same paths over and over (every
67    /// path literal in a file, every position report). A SUCCESSFUL
68    /// canonicalization of a source path is stable for the life of an eval —
69    /// the same frozen-source assumption CppNix itself makes when it copies a
70    /// tree to the store. FAILURES are deliberately NOT memoized: a path that
71    /// does not exist yet (an IFD output, a store path about to be realized)
72    /// may exist later, and caching the failure would wrongly pin it.
73    static CANON_MEMO: RefCell<std::collections::HashMap<PathBuf, PathBuf>> =
74        RefCell::new(std::collections::HashMap::new());
75}
76
77/// `path.canonicalize()` through the success-only thread-local memo.
78fn canonicalize_memo(path: &Path) -> Option<PathBuf> {
79    if let Some(hit) = CANON_MEMO.with(|m| m.borrow().get(path).cloned()) {
80        return Some(hit);
81    }
82    match path.canonicalize() {
83        Ok(canon) => {
84            CANON_MEMO.with(|m| {
85                m.borrow_mut().insert(path.to_path_buf(), canon.clone());
86            });
87            Some(canon)
88        }
89        Err(_) => None,
90    }
91}
92
93/// Register a `builtins.fetch*` result directory with the store-path NAME
94/// CppNix would give its copied-to-store tree (`"source"` for
95/// `fetchGit`/`fetchTree`/`fetchTarball`). Idempotent per `read_dir`. Does
96/// not affect any string value — only the name a later copy-to-store
97/// coercion of this exact path assigns to its store path.
98pub fn register_fetched_source(read_dir: &Path, name: &str) {
99    FETCHED_SOURCE_NAMES.with(|m| {
100        let mut m = m.borrow_mut();
101        if m.iter().any(|(rd, _, _)| rd == read_dir) {
102            return;
103        }
104        // Canon once here instead of per lookup — the tree is an immutable
105        // fetcher cache, so this cannot go stale. Fallback mirrors the old
106        // per-call `unwrap_or_else`.
107        let canon = read_dir
108            .canonicalize()
109            .unwrap_or_else(|_| read_dir.to_path_buf());
110        m.push((read_dir.to_path_buf(), name.to_string(), canon));
111    });
112}
113
114/// Register a fetched flake-input source tree so subsequent filesystem
115/// reads under its `/nix/store/<narhash>-source` store path resolve to the
116/// real on-disk `read_dir`. Idempotent per `store_path`. Does not affect
117/// any string value — only where reads land on disk.
118pub fn register_input_source(store_path: &Path, read_dir: &Path) {
119    // Only store paths need remapping — a `type = "path"` input whose
120    // outPath already equals its real on-disk dir is a no-op (and would
121    // shadow nothing), so skip it.
122    if store_path == read_dir {
123        return;
124    }
125    INPUT_SOURCE_MAP.with(|m| {
126        let mut m = m.borrow_mut();
127        if m.iter().any(|(sp, _, _)| sp == store_path) {
128            return;
129        }
130        // Canon once at registration — see the INPUT_SOURCE_MAP doc for the
131        // measured 61%-of-eval-in-getattrlist root this replaces.
132        let canon = read_dir
133            .canonicalize()
134            .unwrap_or_else(|_| read_dir.to_path_buf());
135        m.push((store_path.to_path_buf(), read_dir.to_path_buf(), canon));
136    });
137}
138
139/// If `path` lies under a registered flake-input store-path prefix, rewrite
140/// that prefix to the input's real on-disk `read_dir`; otherwise return
141/// `path` unchanged. This is a FILESYSTEM-READ-ONLY redirect — callers use
142/// the result to touch disk, never to build a value the evaluator observes.
143#[must_use]
144pub fn materialize(path: &Path) -> PathBuf {
145    INPUT_SOURCE_MAP.with(|m| {
146        for (store_path, read_dir, _) in m.borrow().iter() {
147            if path == store_path {
148                return read_dir.clone();
149            }
150            if let Ok(suffix) = path.strip_prefix(store_path) {
151                return read_dir.join(suffix);
152            }
153        }
154        path.to_path_buf()
155    })
156}
157
158/// Convenience: `materialize` a `&str` path, returning an owned `String`.
159#[must_use]
160pub fn materialize_str(path: &str) -> String {
161    materialize(Path::new(path)).to_string_lossy().into_owned()
162}
163
164/// REVERSE of [`materialize`] for SOURCE POSITIONS: given a REAL on-disk
165/// path (a fetcher-cache `~/.cache/sui/inputs/…` file that eval actually
166/// read from), return the flake input's `/nix/store/<narhash>-source`
167/// STORE-PATH equivalent (with the same relative subpath appended); returns
168/// `path` unchanged when it is not under any registered input's cache dir.
169///
170/// This closes the position half of the store↔cache seam. `materialize`
171/// redirects a store-path READ down to the cache; `dematerialize` lifts a
172/// cache-path back up to the store path for REPORTING — so
173/// `builtins.unsafeGetAttrPos`/`__curPos` reports the store-source `.file`
174/// CppNix reports (`/nix/store/<h>-source/lib/foo.nix`), NOT the sui fetcher
175/// cache dir. nix-darwin's `doc/manual` `hasPrefix <nix-darwin>.outPath decl`
176/// rewrite only fires when `decl` carries the store prefix — the
177/// `options.json` dock-declarations root.
178///
179/// Both sides are compared after `canonicalize` on the cache side (a
180/// symlinked cache dir — macOS `/tmp` → `/private/tmp`, `~` expansion —
181/// still matches the registered `read_dir`, which is canonicalized here
182/// too). Only the reported STRING changes; no value the evaluator observes
183/// is mutated — the byte-parity invariant.
184#[must_use]
185pub fn dematerialize(path: &Path) -> PathBuf {
186    // Probe canon through the success-only memo; a failed canon falls back to
187    // the raw path exactly as before. The per-entry `read_dir` canon is now
188    // precomputed at registration — this loop used to re-realpath every
189    // registered input on every call, which sampling showed as 61% of the
190    // eval thread's wall-clock on the cid marquee (getattrlist per component,
191    // per input, per path literal).
192    let canon = canonicalize_memo(path);
193    let probe: &Path = canon.as_deref().unwrap_or(path);
194    INPUT_SOURCE_MAP.with(|m| {
195        for (store_path, read_dir, rd_canon) in m.borrow().iter() {
196            if probe == rd_canon.as_path() {
197                return store_path.clone();
198            }
199            if let Ok(suffix) = probe.strip_prefix(rd_canon) {
200                return store_path.join(suffix);
201            }
202            // Also try the un-canonicalized read_dir (registration may have
203            // stored a symlinked path); harmless when it already matched above.
204            if path == read_dir.as_path() {
205                return store_path.clone();
206            }
207            if let Ok(suffix) = path.strip_prefix(read_dir) {
208                return store_path.join(suffix);
209            }
210        }
211        path.to_path_buf()
212    })
213}
214
215/// Convenience: [`dematerialize`] a `&str` path, returning an owned `String`.
216#[must_use]
217pub fn dematerialize_str(path: &str) -> String {
218    dematerialize(Path::new(path)).to_string_lossy().into_owned()
219}
220
221/// Reverse of [`materialize`] for the copy-to-store NAMING rule: given a
222/// REAL on-disk directory (the fetcher-cache `read_dir` a `materialize`
223/// already resolved to, then `canonicalize`d), return the store-path
224/// BASENAME of the flake input that tree belongs to — i.e. CppNix's
225/// `-source` name — when `real` IS that input's whole tree root.
226///
227/// This closes the darwin `system-path` root: a fetched flake input's
228/// `src = ./.` copies the input's own tree back into the store; CppNix names
229/// that copy after the input's `/nix/store/<h>-source` basename
230/// (`<h>-source`), but sui reads the tree from `~/.cache/sui/inputs/<narhash>`
231/// whose basename is `<repo>-<rev>`. Only the NAME needs correcting — the
232/// bytes (→ NAR hash) are identical either way — so this maps the physical
233/// read location back to the logical `-source` name.
234///
235/// Both sides are `canonicalize`d before comparison so a symlinked cache dir
236/// (macOS `/tmp` → `/private/tmp`, `~` expansion) still matches. Returns
237/// `None` when `real` is not a registered input root (a normal local
238/// `src = ./.` keeps its own directory basename).
239#[must_use]
240pub fn source_name_for_read_dir(real: &Path) -> Option<String> {
241    // Probe via the success-only memo; per-entry canons are precomputed at
242    // registration (see the INPUT_SOURCE_MAP doc for the measured root).
243    let real_canon = canonicalize_memo(real)?;
244    // 1) Flake-input trees: a fetched input's `src = ./.` copies the input's
245    //    whole tree, named after the input's `/nix/store/<h>-source` basename.
246    let from_input = INPUT_SOURCE_MAP.with(|m| {
247        for (store_path, _read_dir, rd_canon) in m.borrow().iter() {
248            // Only the WHOLE tree root maps to the input's `-source` name; a
249            // subpath (`src = ./subdir`) copies a sub-tree CppNix names after
250            // that subdir, so require an exact root match.
251            if real_canon == *rd_canon {
252                return store_path
253                    .file_name()
254                    .map(|n| n.to_string_lossy().into_owned());
255            }
256        }
257        None
258    });
259    if from_input.is_some() {
260        return from_input;
261    }
262    // 2) `builtins.fetch*` result trees: `zshSynHlSrc = builtins.fetchGit {…}`
263    //    coerced-to-store must be named `source` (CppNix's convention), NOT the
264    //    fetcher-cache temp basename (a 64-char sha256 cache-hash). The fetcher
265    //    registered the correct name for this exact dir.
266    FETCHED_SOURCE_NAMES.with(|m| {
267        for (_read_dir, name, rd_canon) in m.borrow().iter() {
268            if real_canon == *rd_canon {
269                return Some(name.clone());
270            }
271        }
272        None
273    })
274}
275
276/// Normalize a path by removing `.` components and resolving `..` components.
277/// Unlike `canonicalize()`, this doesn't require the path to exist on disk.
278#[must_use]
279pub fn normalize(path: &Path) -> PathBuf {
280    let mut out = Vec::new();
281    for component in path.components() {
282        match component {
283            Component::CurDir => {}
284            Component::ParentDir => {
285                out.pop();
286            }
287            other => out.push(other),
288        }
289    }
290    if out.is_empty() {
291        PathBuf::from(".")
292    } else {
293        out.iter().collect()
294    }
295}
296
297/// Canonicalize an ABSOLUTE path string exactly the way CppNix's
298/// `canonPath` does — the byte-for-byte semantics of a Nix path *value*.
299///
300/// CppNix canonicalizes every path literal on evaluation: `.` components
301/// vanish, `..` pops the preceding component **but is clamped at the
302/// filesystem root** (`/..` → `/`, never below), and redundant separators
303/// collapse. The result always begins with a single `/` and never carries
304/// a trailing `/` (except the root itself, which is `/`).
305///
306/// This differs from [`normalize`] in the one load-bearing way the marquee
307/// cid root exposed: `normalize` uses `Path::components()`, whose
308/// `ParentDir` arm unconditionally `pop()`s — so `/..` collapses to `.`
309/// (root is popped, out empties) instead of clamping to `/`. A path VALUE
310/// must never escape its root, so absolute paths take this dedicated
311/// root-aware canonicalizer.
312///
313/// Only ABSOLUTE inputs (leading `/`) are canonicalized here; a
314/// non-absolute input is returned unchanged so callers can keep their own
315/// resolution semantics (relative-to-eval-dir, `~`-home, `<search>`).
316///
317/// Examples (all verified against CppNix):
318/// - `/.` → `/`            (the `lib.path.hasStorePathPrefix` root case)
319/// - `/foo/./bar` → `/foo/bar`
320/// - `/foo/../bar` → `/bar`
321/// - `/..` → `/`           (root clamp)
322/// - `/a/../..` → `/`      (root clamp after underflow)
323/// - `/nix/store` → `/nix/store`  (identity)
324#[must_use]
325pub fn canon_abs(raw: &str) -> String {
326    if !raw.starts_with('/') {
327        return raw.to_string();
328    }
329    let mut components: Vec<&str> = Vec::new();
330    for seg in raw.split('/') {
331        match seg {
332            // Empty (leading `/`, doubled `//`, trailing `/`) and `.` vanish.
333            "" | "." => {}
334            ".." => {
335                // Clamp at root: popping an empty stack is a no-op, so an
336                // absolute path can never escape below `/`.
337                components.pop();
338            }
339            other => components.push(other),
340        }
341    }
342    if components.is_empty() {
343        "/".to_string()
344    } else {
345        let mut out = String::with_capacity(raw.len());
346        for c in &components {
347            out.push('/');
348            out.push_str(c);
349        }
350        out
351    }
352}
353
354/// Resolve a relative path against a base directory, normalizing the result.
355#[must_use]
356pub fn resolve_relative(base: &Path, relative: &str) -> PathBuf {
357    normalize(&base.join(relative))
358}
359
360/// Resolve an import path.
361/// - Absolute paths are returned as-is (normalized).
362/// - Relative paths are resolved against `base_dir`.
363/// - If the result is a directory, append `/default.nix`.
364///
365/// # Errors
366///
367/// Returns an error if the path is relative but no `base_dir` is provided.
368pub fn resolve_import(base_dir: Option<&Path>, raw: &str) -> Result<PathBuf, String> {
369    let resolved = if Path::new(raw).is_absolute() {
370        normalize(Path::new(raw))
371    } else {
372        let base = base_dir.ok_or_else(|| {
373            format!("relative import '{raw}' with no base directory")
374        })?;
375        resolve_relative(base, raw)
376    };
377
378    // The directory-vs-file probe must consult the REAL tree (a fetched
379    // input's `/nix/store/<narhash>-source` prefix isn't materialized on
380    // disk), but the RETURNED path keeps the store-path prefix so relative
381    // imports inside the target re-enter this remap and eval-dir/string
382    // tracking stays byte-correct. Only the on-disk read (at the call site)
383    // is redirected via `materialize`.
384    if materialize(&resolved).is_dir() {
385        Ok(resolved.join("default.nix"))
386    } else {
387        Ok(resolved)
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn normalize_removes_dot() {
397        assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b"));
398    }
399
400    #[test]
401    fn normalize_resolves_dotdot() {
402        assert_eq!(normalize(Path::new("/a/b/../c")), PathBuf::from("/a/c"));
403    }
404
405    #[test]
406    fn normalize_multiple_dots() {
407        assert_eq!(
408            normalize(Path::new("/a/./b/./c")),
409            PathBuf::from("/a/b/c")
410        );
411    }
412
413    #[test]
414    fn normalize_preserves_absolute() {
415        assert_eq!(normalize(Path::new("/a/b/c")), PathBuf::from("/a/b/c"));
416    }
417
418    #[test]
419    fn normalize_empty_result_becomes_dot() {
420        assert_eq!(normalize(Path::new(".")), PathBuf::from("."));
421    }
422
423    #[test]
424    fn resolve_relative_basic() {
425        assert_eq!(
426            resolve_relative(Path::new("/base"), "sub/file.nix"),
427            PathBuf::from("/base/sub/file.nix")
428        );
429    }
430
431    #[test]
432    fn resolve_relative_with_dotdot() {
433        assert_eq!(
434            resolve_relative(Path::new("/base/sub"), "../file.nix"),
435            PathBuf::from("/base/file.nix")
436        );
437    }
438
439    #[test]
440    fn resolve_import_absolute() {
441        let r = resolve_import(None, "/absolute/path.nix").unwrap();
442        assert_eq!(r, PathBuf::from("/absolute/path.nix"));
443    }
444
445    #[test]
446    fn resolve_import_relative_needs_base() {
447        assert!(resolve_import(None, "./relative.nix").is_err());
448    }
449
450    // ── canon_abs: CppNix path-value canonicalization ──────────────
451    //
452    // Every case verified against `nix eval --raw --expr 'toString <p>'`.
453    // The marquee case is `/.` → `/` — the failing clause of
454    // `lib.path.hasStorePathPrefix`'s root assertion in the cid closure.
455
456    #[test]
457    fn canon_abs_root_dot() {
458        // THE marquee root: `/.` must canonicalize to `/`.
459        assert_eq!(canon_abs("/."), "/");
460    }
461
462    #[test]
463    fn canon_abs_root_identity() {
464        assert_eq!(canon_abs("/"), "/");
465    }
466
467    #[test]
468    fn canon_abs_removes_dot() {
469        assert_eq!(canon_abs("/foo/./bar"), "/foo/bar");
470    }
471
472    #[test]
473    fn canon_abs_resolves_dotdot() {
474        assert_eq!(canon_abs("/foo/../bar"), "/bar");
475    }
476
477    #[test]
478    fn canon_abs_dotdot_clamps_at_root() {
479        // CppNix never escapes root: `/..` → `/`, `/a/../..` → `/`.
480        assert_eq!(canon_abs("/.."), "/");
481        assert_eq!(canon_abs("/../.."), "/");
482        assert_eq!(canon_abs("/a/../.."), "/");
483    }
484
485    #[test]
486    fn canon_abs_collapses_redundant_slashes() {
487        assert_eq!(canon_abs("/foo//bar"), "/foo/bar");
488        assert_eq!(canon_abs("/nix/store/"), "/nix/store");
489    }
490
491    #[test]
492    fn canon_abs_store_path_identity() {
493        assert_eq!(canon_abs("/nix/store"), "/nix/store");
494        assert_eq!(
495            canon_abs("/nix/store/nvl9ic0pj1fpyln3zaqrf4cclbqdfn1j-foo"),
496            "/nix/store/nvl9ic0pj1fpyln3zaqrf4cclbqdfn1j-foo"
497        );
498    }
499
500    #[test]
501    fn canon_abs_leaves_relative_untouched() {
502        // Non-absolute inputs are the caller's concern (eval-dir / ~ / <search>).
503        assert_eq!(canon_abs("~/foo"), "~/foo");
504        assert_eq!(canon_abs("./foo"), "./foo");
505    }
506
507    #[test]
508    fn resolve_import_absolute_with_dotdot() {
509        let r = resolve_import(None, "/a/b/../c.nix").unwrap();
510        assert_eq!(r, PathBuf::from("/a/c.nix"));
511    }
512
513    #[test]
514    fn resolve_import_directory_appends_default_nix() {
515        // Use a known directory that exists on all systems.
516        let r = resolve_import(None, "/tmp").unwrap();
517        assert_eq!(r, PathBuf::from("/tmp/default.nix"));
518    }
519
520    #[test]
521    fn resolve_import_relative_with_base() {
522        // The relative path won't be a directory on disk, so no default.nix append.
523        let r = resolve_import(Some(Path::new("/base")), "sub/file.nix").unwrap();
524        assert_eq!(r, PathBuf::from("/base/sub/file.nix"));
525    }
526
527    // ── flake-input source materialization (marquee darwin root #3) ──
528    //
529    // A fetched flake input's `outPath` is a `/nix/store/<narhash>-source`
530    // STORE-PATH STRING that sui never copies to disk; the real tree lives
531    // in the fetcher cache. `register_input_source` + `materialize` redirect
532    // the FILESYSTEM READ of `${outPath}/subpath` to the cache while the
533    // store-path string is never mutated — the byte-parity invariant.
534
535    #[test]
536    fn materialize_remaps_registered_store_prefix() {
537        register_input_source(
538            Path::new("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-source"),
539            Path::new("/home/u/.cache/sui/inputs/dead"),
540        );
541        // A read of `${outPath}/lib/foo.nix` lands in the real cache tree.
542        assert_eq!(
543            materialize(Path::new(
544                "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-source/lib/foo.nix"
545            )),
546            PathBuf::from("/home/u/.cache/sui/inputs/dead/lib/foo.nix"),
547        );
548        // The bare store path itself remaps to the tree root.
549        assert_eq!(
550            materialize(Path::new(
551                "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-source"
552            )),
553            PathBuf::from("/home/u/.cache/sui/inputs/dead"),
554        );
555    }
556
557    #[test]
558    fn dematerialize_lifts_cache_path_to_store_source() {
559        // The options.json dock-declarations root (Layer B): a source
560        // position read from the fetcher-cache dir must be REPORTED as the
561        // input's `/nix/store/<h>-source` path so nix-darwin's `hasPrefix`
562        // rewrite fires. `dematerialize` is the reverse of `materialize`.
563        let cache = tempfile::tempdir().unwrap();
564        std::fs::create_dir_all(cache.path().join("modules/system")).unwrap();
565        let store = Path::new(
566            "/nix/store/npm9dap7j0i92l524y09x255zi9447qp-source",
567        );
568        register_input_source(store, cache.path());
569        // A subpath under the cache dir lifts to the store path + subpath.
570        let real = cache.path().join("modules/system/dock.nix");
571        assert_eq!(
572            dematerialize(&real),
573            store.join("modules/system/dock.nix"),
574        );
575        // The cache root itself lifts to the bare store path.
576        assert_eq!(dematerialize(cache.path()), store.to_path_buf());
577    }
578
579    #[test]
580    fn dematerialize_passes_unregistered_paths_through() {
581        // A local (unregistered) path is reported verbatim — only a
582        // registered input's cache subtree is lifted to its store path.
583        let unrelated = tempfile::tempdir().unwrap();
584        let p = unrelated.path().join("some/file.nix");
585        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
586        std::fs::write(&p, b"x").unwrap();
587        assert_eq!(dematerialize(&p), p);
588    }
589
590    #[test]
591    fn materialize_passes_unregistered_paths_through() {
592        // An unrelated store path (or any other path) is untouched — the
593        // remap only fires for a registered input source prefix.
594        let p = "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-hello-2.12/bin/hello";
595        assert_eq!(materialize(Path::new(p)), PathBuf::from(p));
596        assert_eq!(materialize(Path::new("/etc/nix/nix.conf")), PathBuf::from("/etc/nix/nix.conf"));
597    }
598
599    #[test]
600    fn register_input_source_ignores_identity_mapping() {
601        // A `type = "path"` input whose outPath already equals its real dir
602        // registers nothing (and cannot shadow the tree with itself).
603        let dir = Path::new("/some/local/path-input");
604        register_input_source(dir, dir);
605        assert_eq!(materialize(dir), PathBuf::from(dir));
606    }
607
608    #[test]
609    fn register_input_source_prefix_boundary_is_exact() {
610        // A sibling store path that merely SHARES a textual prefix with a
611        // registered one must NOT be remapped — `strip_prefix` is
612        // path-component-aware, so `…-source-extra` is not under `…-source`.
613        register_input_source(
614            Path::new("/nix/store/cccccccccccccccccccccccccccccccc-source"),
615            Path::new("/cache/c"),
616        );
617        let sibling = "/nix/store/cccccccccccccccccccccccccccccccc-source-extra/x";
618        assert_eq!(materialize(Path::new(sibling)), PathBuf::from(sibling));
619    }
620
621    #[test]
622    fn source_name_maps_read_dir_root_to_store_source_name() {
623        // The darwin `system-path` root: a fetched flake input's `src = ./.`
624        // copies the tree read from the fetcher cache `read_dir`, but the copy
625        // must carry the input's `/nix/store/<h>-source` BASENAME (CppNix's
626        // name), not the cache dir's `<repo>-<rev>` basename.
627        let cache = tempfile::tempdir().unwrap();
628        let store_path = Path::new(
629            "/nix/store/9qcaaxf4dyy09df0gv4ibfj93aplq3jk-source",
630        );
631        register_input_source(store_path, cache.path());
632        // The whole-tree root maps to the input's `-source` name.
633        assert_eq!(
634            source_name_for_read_dir(cache.path()).as_deref(),
635            Some("9qcaaxf4dyy09df0gv4ibfj93aplq3jk-source"),
636        );
637    }
638
639    #[test]
640    fn source_name_returns_none_for_unregistered_or_subpath() {
641        // A local `src = ./.` (unregistered) keeps its own basename → None,
642        // so the caller falls back to the real dir's file_name. A SUBpath of a
643        // registered root also returns None (only the whole-tree root is the
644        // `-source` copy — `src = ./subdir` is named after the subdir).
645        let unrelated = tempfile::tempdir().unwrap();
646        assert_eq!(source_name_for_read_dir(unrelated.path()), None);
647
648        let cache = tempfile::tempdir().unwrap();
649        std::fs::create_dir(cache.path().join("subdir")).unwrap();
650        register_input_source(
651            Path::new("/nix/store/dddddddddddddddddddddddddddddddd-source"),
652            cache.path(),
653        );
654        assert_eq!(source_name_for_read_dir(&cache.path().join("subdir")), None);
655    }
656
657    #[test]
658    fn fetched_source_dir_maps_to_source_name() {
659        // The zsh-syntax-highlighting-config root: `builtins.fetchGit` returns a
660        // temp dir whose basename is a 64-char sha256 cache-hash. Coerced-to-
661        // store as a derivation `src`, CppNix names it `-source`, not the
662        // cache-hash. `register_fetched_source` records that intended name.
663        let cache = tempfile::tempdir().unwrap();
664        register_fetched_source(cache.path(), "source");
665        assert_eq!(
666            source_name_for_read_dir(cache.path()).as_deref(),
667            Some("source"),
668        );
669    }
670
671    #[test]
672    fn fetched_source_registry_honors_explicit_name() {
673        // The registered name flows through verbatim (a fetcher that honors an
674        // explicit `name` arg would register that name instead of "source").
675        let cache = tempfile::tempdir().unwrap();
676        register_fetched_source(cache.path(), "my-thing");
677        assert_eq!(
678            source_name_for_read_dir(cache.path()).as_deref(),
679            Some("my-thing"),
680        );
681    }
682}