rdi_platform_windows/ids.rs
1//! Icon identifier encoding.
2//!
3//! Icons on the desktop are identified by a **hex-encoded UTF-16 string**
4//! representing either the desktop-relative path (e.g. `notes.txt`) or —
5//! for virtual items such as *This PC* — the display name. Each `u16`
6//! becomes four lowercase hex characters (`'A'` → `"0041"`).
7//!
8//! The encoding matches
9//! the legacy library's `escape_wchars` function
10//! exactly, so IDs stored by older versions of the Python extension keep
11//! working.
12//!
13//! # Why hex-encoded UTF-16?
14//!
15//! * It round-trips **any** shell path or display name (including
16//! non-BMP characters, control codes, whitespace, or filesystem-invalid
17//! sequences) into an opaque ASCII token — safe for JSON, filenames,
18//! command-line arguments, MCP tool arguments, etc.
19//! * The encoded string sorts stably and can be used as a dict/HashMap
20//! key without normalisation surprises.
21//! * Decoding is trivial and lossless.
22
23use rdi_core::IconId;
24
25/// Maximum number of UTF-16 code units scanned for the path prefix.
26/// Matches `MAX_PATH` on Windows (260). Kept as an untyped constant
27/// here so the module compiles on non-Windows hosts too (unit tests).
28const MAX_PATH_WCHARS: usize = 260;
29
30/// Lowercase hex table used by [`escape_wchars`].
31const CHARSET: &[u8; 16] = b"0123456789abcdef";
32
33/// Encode a NUL-terminated UTF-16 buffer as lowercase hex.
34///
35/// Encoding stops at the first `0` code unit if present; otherwise the
36/// whole slice is consumed. Each `u16` becomes exactly four hex
37/// characters, so the output length is always `4 * (code units consumed)`.
38pub fn escape_wchars(source: &[u16]) -> String {
39 let mut out = Vec::with_capacity(source.len() * 4);
40 for &wc in source {
41 if wc == 0 {
42 break;
43 }
44 out.push(CHARSET[((wc >> 12) & 0xF) as usize]);
45 out.push(CHARSET[((wc >> 8) & 0xF) as usize]);
46 out.push(CHARSET[((wc >> 4) & 0xF) as usize]);
47 out.push(CHARSET[(wc & 0xF) as usize]);
48 }
49 // The output is guaranteed to be ASCII hex, so UTF-8 conversion is safe.
50 String::from_utf8(out).expect("hex table contains only ASCII")
51}
52
53/// Skip `C:\\Users\\User\\Desktop\\` — return the index just past the
54/// **fourth** backslash within the first `MAX_PATH` code units, or
55/// `None` if fewer than four backslashes are found. Matches the C++
56/// `findDesktopPathPrefixLength` helper (returns `-1` there).
57pub fn find_desktop_prefix_len(path: &[u16]) -> Option<usize> {
58 let mut backslashes = 0;
59 for (i, &wc) in path.iter().take(MAX_PATH_WCHARS).enumerate() {
60 if wc == b'\\' as u16 {
61 backslashes += 1;
62 if backslashes == 4 {
63 return Some(i + 1);
64 }
65 }
66 }
67 None
68}
69
70/// Build an [`IconId`] the same way the legacy extension did.
71///
72/// * If `path` is empty (virtual icon), the display name is hex-encoded.
73/// * Otherwise the part *after* the desktop prefix is encoded (falling
74/// back to the whole path if the prefix cannot be located).
75///
76/// The caller is responsible for supplying the shell display name and the
77/// filesystem path (both as UTF-16 code units, **without** a trailing
78/// NUL). The internal `com` module provides the shell-side helpers that produce them.
79pub fn build_icon_id(path: &[u16], display_name: &[u16]) -> IconId {
80 let encoded = if path.is_empty() {
81 escape_wchars(display_name)
82 } else {
83 let start = find_desktop_prefix_len(path).unwrap_or(0);
84 escape_wchars(&path[start..])
85 };
86 IconId::from(encoded)
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 fn utf16(s: &str) -> Vec<u16> {
94 s.encode_utf16().collect()
95 }
96
97 #[test]
98 fn escape_ascii_letter() {
99 // 'A' == U+0041 → "0041"
100 assert_eq!(escape_wchars(&utf16("A")), "0041");
101 }
102
103 #[test]
104 fn escape_stops_at_nul() {
105 let mut buf = utf16("ab");
106 buf.push(0);
107 buf.push(b'c' as u16);
108 // Only "ab" should be encoded — 'c' is past the NUL.
109 assert_eq!(escape_wchars(&buf), "00610062");
110 }
111
112 #[test]
113 fn escape_full_word() {
114 // "Desktop" = D(0044) e(0065) s(0073) k(006b) t(0074) o(006f) p(0070)
115 let hex = escape_wchars(&utf16("Desktop"));
116 assert_eq!(hex, "004400650073006b0074006f0070");
117 }
118
119 #[test]
120 fn escape_bmp_and_supplementary() {
121 // U+1F600 is a surrogate pair U+D83D U+DE00 in UTF-16 → 8 hex chars.
122 let hex = escape_wchars(&utf16("\u{1F600}"));
123 assert_eq!(hex, "d83dde00");
124 }
125
126 #[test]
127 fn escape_empty() {
128 assert_eq!(escape_wchars(&[]), "");
129 }
130
131 #[test]
132 fn prefix_finds_fourth_backslash() {
133 // C:\Users\U\Desktop\notes.txt → index just past 4th backslash.
134 let path = utf16(r"C:\Users\U\Desktop\notes.txt");
135 let idx = find_desktop_prefix_len(&path).expect("should find prefix");
136 // The suffix at that index should be "notes.txt".
137 let suffix: String = String::from_utf16_lossy(&path[idx..]);
138 assert_eq!(suffix, "notes.txt");
139 }
140
141 #[test]
142 fn prefix_returns_none_when_short() {
143 let path = utf16(r"C:\Users\Desktop");
144 // Only 3 backslashes — never reaches four.
145 assert!(find_desktop_prefix_len(&path).is_none());
146 }
147
148 #[test]
149 fn prefix_stops_at_max_path() {
150 // A long path with 4 backslashes past MAX_PATH_WCHARS is not found.
151 let mut path = vec![b'a' as u16; MAX_PATH_WCHARS + 10];
152 // Place 4 backslashes right at the end (past the scan window).
153 for i in 0..4 {
154 path[MAX_PATH_WCHARS + i] = b'\\' as u16;
155 }
156 assert!(find_desktop_prefix_len(&path).is_none());
157 }
158
159 #[test]
160 fn icon_id_uses_path_suffix_when_available() {
161 let path = utf16(r"C:\Users\U\Desktop\notes.txt");
162 let display = utf16("notes.txt");
163 let id = build_icon_id(&path, &display);
164 // "notes.txt" = U+006E U+006F U+0074 U+0065 U+0073 U+002E
165 // U+0074 U+0078 U+0074 → 9 code units × 4 hex chars.
166 assert_eq!(
167 id.as_str(),
168 "006e006f007400650073002e007400780074"
169 );
170 }
171
172 #[test]
173 fn icon_id_uses_display_name_for_virtual_icons() {
174 let display = utf16("This PC");
175 let id = build_icon_id(&[], &display);
176 // "This PC" is 7 code units → 28 hex chars.
177 assert_eq!(id.as_str().len(), 28);
178 assert!(id.as_str().starts_with("00540068"));
179 }
180
181 #[test]
182 fn icon_id_falls_back_to_full_path_when_prefix_missing() {
183 // Path without four backslashes → whole path is encoded.
184 let path = utf16(r"D:\loose\file.txt");
185 let display = utf16("file.txt");
186 let id = build_icon_id(&path, &display);
187 // Expect the encoding to be that of the WHOLE path (17 chars).
188 assert_eq!(id.as_str().len(), path.len() * 4);
189 }
190}