locode_host/
session_dirs.rs1use std::path::{Path, PathBuf};
22
23const MAX_DIRNAME_BYTES: usize = 255;
25const FALLBACK_TAIL_BYTES: usize = 64;
28
29#[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#[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)] let byte = (hi * 16 + lo) as u8;
73 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
86fn fallback_dirname(raw: &str, encoded: &str) -> String {
90 let hash = fnv1a64(raw.as_bytes());
91 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
99fn 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 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 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 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 assert_eq!(name, encode_cwd_dirname(cwd));
183 assert_eq!(decode_cwd_dirname(&name), None);
184 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}