Skip to main content

locode_host/
session_dirs.rs

1//! Session-directory naming: the **bijective** cwd → dirname encoding (ADR-0024 §2.1).
2//!
3//! The trace store groups sessions **per cwd** — `~/.locode/sessions/<encoded-cwd>/…`
4//! (Claude Code's structure, making `--continue` an O(1) one-directory listing).
5//! Claude's own encoding (`[^a-zA-Z0-9]` → `-`) is lossy — `foo-bar`/`foo_bar`/
6//! `foo.bar`/`foo/bar` all collide, and every non-ASCII char collapses to `-` — so
7//! ours is a bijection instead:
8//!
9//! - `/` → `+` (the readable separator swap);
10//! - literal `+` → `%2B` and literal `%` → `%25` (the self-escape that upgrades
11//!   "less likely to collide" into "cannot collide");
12//! - every other character (including non-ASCII) is preserved verbatim.
13//!
14//! Decoding inverts it exactly, so a resume picker can show real cwds with zero file
15//! reads. Over the 255-byte filename limit a stable `<fnv1a64-hex16>-<tail>` fallback
16//! is emitted — never decodable (starts with a hex digit, never `+`); the store writes
17//! a `.cwd` sidecar inside the directory to recover the original path (grok's scheme).
18//!
19//! Callers pass a **canonicalized** cwd; this module never touches the filesystem.
20
21use std::path::{Path, PathBuf};
22
23/// The filesystem's single-component limit (bytes) — beyond it the hash fallback kicks in.
24const MAX_DIRNAME_BYTES: usize = 255;
25/// Bytes of the encoded tail kept in the fallback name (the leaf dirs are the
26/// distinctive part).
27const FALLBACK_TAIL_BYTES: usize = 64;
28
29/// Encode a (canonicalized, absolute) `cwd` into its session-directory name.
30///
31/// Bijective for every path whose encoding fits the filename limit; longer paths get
32/// the stable `<hash16>-<tail>` fallback (decode returns `None`; the store's `.cwd`
33/// sidecar recovers the path).
34#[must_use]
35pub fn encode_cwd_dirname(cwd: &Path) -> String {
36    let raw = cwd.to_string_lossy();
37    let mut out = String::with_capacity(raw.len());
38    for ch in raw.chars() {
39        match ch {
40            '/' => out.push('+'),
41            '+' => out.push_str("%2B"),
42            '%' => out.push_str("%25"),
43            other => out.push(other),
44        }
45    }
46    if out.len() > MAX_DIRNAME_BYTES {
47        return fallback_dirname(&raw, &out);
48    }
49    out
50}
51
52/// Decode a session-directory name back to the cwd it encodes, or `None` for a
53/// hash-fallback name (recover via the `.cwd` sidecar) or a malformed escape.
54///
55/// Only names starting with `+` decode — an encoded absolute path always starts with
56/// `+` (the leading `/`), and a fallback name starts with a hex digit, so the first
57/// byte disambiguates the two forms.
58#[must_use]
59pub fn decode_cwd_dirname(name: &str) -> Option<PathBuf> {
60    if !name.starts_with('+') {
61        return None;
62    }
63    let mut out = String::with_capacity(name.len());
64    let mut chars = name.chars();
65    while let Some(ch) = chars.next() {
66        match ch {
67            '+' => out.push('/'),
68            '%' => {
69                let hi = chars.next()?.to_digit(16)?;
70                let lo = chars.next()?.to_digit(16)?;
71                #[allow(clippy::cast_possible_truncation)] // two hex digits ≤ 0xFF
72                let byte = (hi * 16 + lo) as u8;
73                // The encoder only escapes ASCII '+' and '%'; anything else is
74                // malformed.
75                if !byte.is_ascii() {
76                    return None;
77                }
78                out.push(char::from(byte));
79            }
80            other => out.push(other),
81        }
82    }
83    Some(PathBuf::from(out))
84}
85
86/// The over-limit fallback: `<fnv1a64 of the ORIGINAL path, 16 hex>-<encoded tail>`,
87/// truncated to fit. Deterministic and collision-resistant via the full-path hash;
88/// starts with a hex digit so it is never mistaken for a decodable `+…` name.
89fn fallback_dirname(raw: &str, encoded: &str) -> String {
90    let hash = fnv1a64(raw.as_bytes());
91    // Keep the tail of the encoded form — the deepest (most distinctive) directories.
92    let mut start = encoded.len().saturating_sub(FALLBACK_TAIL_BYTES);
93    while start < encoded.len() && !encoded.is_char_boundary(start) {
94        start += 1;
95    }
96    format!("{hash:016x}-{}", &encoded[start..])
97}
98
99/// FNV-1a 64-bit — stable and dependency-free (`DefaultHasher` is not stable across
100/// Rust releases, and a hashing dependency is an ask-first item).
101fn fnv1a64(bytes: &[u8]) -> u64 {
102    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
103    for &byte in bytes {
104        hash ^= u64::from(byte);
105        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
106    }
107    hash
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn round_trips_a_typical_path() {
116        let cwd = Path::new("/Users/me/dev/locode-core");
117        let name = encode_cwd_dirname(cwd);
118        assert_eq!(name, "+Users+me+dev+locode-core");
119        assert_eq!(decode_cwd_dirname(&name), Some(cwd.to_path_buf()));
120    }
121
122    #[test]
123    fn claudes_collision_quartet_stays_distinct() {
124        // Under Claude's `[^a-zA-Z0-9]` → `-` these four all collide; here they must
125        // encode to four distinct names, each round-tripping.
126        let paths = ["/d/foo-bar", "/d/foo_bar", "/d/foo.bar", "/d/foo/bar"];
127        let names: Vec<String> = paths
128            .iter()
129            .map(|p| encode_cwd_dirname(Path::new(p)))
130            .collect();
131        for (i, a) in names.iter().enumerate() {
132            for b in &names[i + 1..] {
133                assert_ne!(a, b);
134            }
135        }
136        for (path, name) in paths.iter().zip(&names) {
137            assert_eq!(
138                decode_cwd_dirname(name),
139                Some(PathBuf::from(path)),
140                "{name}"
141            );
142        }
143    }
144
145    #[test]
146    fn self_escapes_literal_plus_and_percent() {
147        let cwd = Path::new("/a+b/c%d");
148        let name = encode_cwd_dirname(cwd);
149        assert_eq!(name, "+a%2Bb+c%25d");
150        assert_eq!(decode_cwd_dirname(&name), Some(cwd.to_path_buf()));
151        // The dedicated ambiguity case: a dir literally named `a+b` vs nested `a/b`.
152        assert_ne!(
153            encode_cwd_dirname(Path::new("/a+b")),
154            encode_cwd_dirname(Path::new("/a/b"))
155        );
156    }
157
158    #[test]
159    fn non_ascii_components_are_preserved() {
160        let cwd = Path::new("/Users/me/dev/项目");
161        let name = encode_cwd_dirname(cwd);
162        assert_eq!(name, "+Users+me+dev+项目");
163        assert_eq!(decode_cwd_dirname(&name), Some(cwd.to_path_buf()));
164        // Same-length CJK names — Claude's scheme collides these; ours must not.
165        assert_ne!(
166            encode_cwd_dirname(Path::new("/dev/项目")),
167            encode_cwd_dirname(Path::new("/dev/工作"))
168        );
169    }
170
171    #[test]
172    fn over_limit_falls_back_to_stable_hash_name() {
173        let deep = format!("/{}", "very-long-directory-name/".repeat(20));
174        let cwd = Path::new(&deep);
175        let name = encode_cwd_dirname(cwd);
176        assert!(name.len() <= MAX_DIRNAME_BYTES, "fits the component limit");
177        assert!(
178            name.as_bytes()[0].is_ascii_hexdigit() && !name.starts_with('+'),
179            "fallback never looks decodable: {name}"
180        );
181        // Deterministic, and undecodable (the `.cwd` sidecar recovers it).
182        assert_eq!(name, encode_cwd_dirname(cwd));
183        assert_eq!(decode_cwd_dirname(&name), None);
184        // A different long path gets a different name (full-path hash).
185        let other = format!("/{}x", "very-long-directory-name/".repeat(20));
186        assert_ne!(name, encode_cwd_dirname(Path::new(&other)));
187    }
188
189    #[test]
190    fn malformed_names_do_not_decode() {
191        assert_eq!(decode_cwd_dirname("no-leading-plus"), None);
192        assert_eq!(decode_cwd_dirname("+bad%zz"), None, "non-hex escape");
193        assert_eq!(decode_cwd_dirname("+truncated%2"), None, "short escape");
194        assert_eq!(
195            decode_cwd_dirname("+high%FF"),
196            None,
197            "non-ASCII escape byte"
198        );
199    }
200}