Skip to main content

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}