RunContext refactor + sync updates

This commit is contained in:
Joe 2026-01-30 08:12:04 -06:00
parent c7cddb72c2
commit d3cc11659a
79 changed files with 1415 additions and 626 deletions

View file

@ -1,15 +1,20 @@
//! Encoding and decoding utilities for Windows-invalid filename characters.
//! Encoding and decoding utilities for special filename characters.
//!
//! Windows does not allow the following characters in file names:
//! < > : " / \ | ? *
//! This module encodes characters that are problematic in file names:
//! - Windows-invalid characters: < > : " / \ | ? *
//! - Periods (.) to prevent conflicts with file extension parsing
//! - Leading/trailing spaces
//! - Literal percent signs (%) to allow round-tripping
//!
//! Windows also does not allow leading/trailing spaces in file names.
//!
//! This module provides functions to encode these characters using a %NAME%
//! format (e.g., `<` becomes `%LT%`) and decode them back.
//! Characters are encoded using a %NAME% format (e.g., `<` becomes `%LT%`,
//! `.` becomes `%DOT%`). Literal `%` is escaped as `%%` (like printf).
/// Mapping of Windows-invalid characters to their encoded representations.
use std::collections::HashMap;
use std::sync::OnceLock;
/// Mapping of special characters to their encoded representations.
const CHAR_ENCODINGS: &[(&str, &str)] = &[
(".", "%DOT%"),
("<", "%LT%"),
(">", "%GT%"),
(":", "%COLON%"),
@ -21,13 +26,27 @@ const CHAR_ENCODINGS: &[(&str, &str)] = &[
("*", "%STAR%"),
];
/// Returns a map from encoded pattern (e.g., "DOT") to decoded char (e.g., ".").
fn get_decode_map() -> &'static HashMap<&'static str, &'static str> {
static DECODE_MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
DECODE_MAP.get_or_init(|| {
let mut map = HashMap::new();
for (char, encoded) in CHAR_ENCODINGS {
// Strip the leading and trailing % from the pattern
let pattern = &encoded[1..encoded.len() - 1];
map.insert(pattern, *char);
}
map
})
}
const SPACE_ENCODING: &str = "%SPACE%";
/// Encodes Windows-invalid characters in a file name.
/// Encodes special characters in a file name.
///
/// This replaces characters like `<`, `>`, `:`, `"`, `/`, `\`, `|`, `?`, `*`
/// with their encoded representations like `%LT%`, `%GT%`, etc.
/// Leading and trailing spaces are encoded as `%SPACE%`.
/// This replaces characters like `.`, `<`, `>`, `:`, `"`, `/`, `\`, `|`, `?`, `*`
/// with their encoded representations like `%DOT%`, `%LT%`, `%GT%`, etc.
/// Literal `%` is escaped as `%%`. Leading and trailing spaces are encoded as `%SPACE%`.
pub fn encode_path_name(name: &str) -> String {
// Count leading and trailing spaces first (before any modifications)
let leading_spaces = name.len() - name.trim_start_matches(' ').len();
@ -36,8 +55,10 @@ pub fn encode_path_name(name: &str) -> String {
// Get the middle part (without leading/trailing spaces)
let middle = &name[leading_spaces..name.len() - trailing_spaces];
// Encode invalid characters in the middle part
let mut encoded_middle = middle.to_string();
// First: escape % as %% (before adding new % signs from encodings)
let mut encoded_middle = middle.replace('%', "%%");
// Then: encode special characters
for (char, encoded) in CHAR_ENCODINGS {
encoded_middle = encoded_middle.replace(char, encoded);
}
@ -49,11 +70,13 @@ pub fn encode_path_name(name: &str) -> String {
format!("{}{}{}", encoded_prefix, encoded_middle, encoded_suffix)
}
/// Decodes encoded Windows-invalid characters back to their original form.
/// Decodes encoded special characters back to their original form.
///
/// This replaces encoded representations like `%LT%`, `%GT%`, etc. back
/// to their original characters `<`, `>`, etc.
/// `%SPACE%` at the start/end is decoded back to spaces.
/// This replaces encoded representations like `%DOT%`, `%LT%`, `%GT%`, etc.
/// back to their original characters `.`, `<`, `>`, etc.
/// `%%` is decoded to literal `%`. `%SPACE%` at the start/end is decoded back to spaces.
///
/// Uses a proper left-to-right parser to correctly handle `%%` escaping.
pub fn decode_path_name(name: &str) -> String {
let mut result = name.to_string();
@ -71,15 +94,83 @@ pub fn decode_path_name(name: &str) -> String {
result = result[..result.len() - SPACE_ENCODING.len()].to_string();
}
// Decode invalid characters in the middle
for (char, encoded) in CHAR_ENCODINGS {
result = result.replace(encoded, char);
}
// Parse and decode the middle part
let decoded_middle = decode_patterns(&result);
// Rebuild with actual spaces
let prefix = " ".repeat(leading_spaces);
let suffix = " ".repeat(trailing_spaces);
format!("{}{}{}", prefix, result, suffix)
format!("{}{}{}", prefix, decoded_middle, suffix)
}
/// Parses an encoded string left-to-right, properly handling `%%` escapes and `%NAME%` patterns.
fn decode_patterns(input: &str) -> String {
let decode_map = get_decode_map();
let chars: Vec<char> = input.chars().collect();
let mut output = String::with_capacity(input.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == '%' {
// Check for %% (escaped percent)
if i + 1 < chars.len() && chars[i + 1] == '%' {
output.push('%');
i += 2;
continue;
}
// Try to match a %NAME% pattern
if let Some((decoded, consumed)) = try_decode_pattern(&chars, i, decode_map) {
output.push_str(&decoded);
i += consumed;
continue;
}
}
// Regular character, just copy it
output.push(chars[i]);
i += 1;
}
output
}
/// Tries to decode a %NAME% pattern starting at position `start`.
/// Returns Some((decoded_string, chars_consumed)) if successful, None otherwise.
fn try_decode_pattern(
chars: &[char],
start: usize,
decode_map: &HashMap<&str, &str>,
) -> Option<(String, usize)> {
// Must start with %
if chars.get(start) != Some(&'%') {
return None;
}
// Find the closing %
let mut end = start + 1;
while end < chars.len() && chars[end] != '%' {
// Pattern names are uppercase letters only
if !chars[end].is_ascii_uppercase() {
return None;
}
end += 1;
}
// Must have found a closing %
if end >= chars.len() || chars[end] != '%' {
return None;
}
// Extract the pattern name (without the % delimiters)
let pattern_name: String = chars[start + 1..end].iter().collect();
// Look up the pattern
if let Some(&decoded) = decode_map.get(pattern_name.as_str()) {
Some((decoded.to_string(), end - start + 1))
} else {
None
}
}
#[cfg(test)]
@ -88,15 +179,15 @@ mod tests {
#[test]
fn test_encode_all_chars() {
let input = r#"<>:"/\|?*"#;
let expected = "%LT%%GT%%COLON%%QUOTE%%SLASH%%BACKSLASH%%PIPE%%QUESTION%%STAR%";
let input = r#".<>:"/\|?*"#;
let expected = "%DOT%%LT%%GT%%COLON%%QUOTE%%SLASH%%BACKSLASH%%PIPE%%QUESTION%%STAR%";
assert_eq!(encode_path_name(input), expected);
}
#[test]
fn test_decode_all_chars() {
let input = "%LT%%GT%%COLON%%QUOTE%%SLASH%%BACKSLASH%%PIPE%%QUESTION%%STAR%";
let expected = r#"<>:"/\|?*"#;
let input = "%DOT%%LT%%GT%%COLON%%QUOTE%%SLASH%%BACKSLASH%%PIPE%%QUESTION%%STAR%";
let expected = r#".<>:"/\|?*"#;
assert_eq!(decode_path_name(input), expected);
}
@ -154,4 +245,67 @@ mod tests {
let decoded = decode_path_name(&encoded);
assert_eq!(decoded, original);
}
#[test]
fn test_encode_period() {
let input = "My.Script";
let expected = "My%DOT%Script";
assert_eq!(encode_path_name(input), expected);
}
#[test]
fn test_decode_period() {
let input = "My%DOT%Script";
let expected = "My.Script";
assert_eq!(decode_path_name(input), expected);
}
#[test]
fn test_roundtrip_with_period() {
let original = "My.Module.Name";
let encoded = encode_path_name(original);
let decoded = decode_path_name(&encoded);
assert_eq!(decoded, original);
}
#[test]
fn test_encode_percent() {
let input = "My%Thing";
let expected = "My%%Thing";
assert_eq!(encode_path_name(input), expected);
}
#[test]
fn test_decode_percent() {
let input = "My%%Thing";
let expected = "My%Thing";
assert_eq!(decode_path_name(input), expected);
}
#[test]
fn test_roundtrip_with_percent() {
let original = "My%Thing";
let encoded = encode_path_name(original);
let decoded = decode_path_name(&encoded);
assert_eq!(decoded, original);
}
#[test]
fn test_percent_encoding_pattern_roundtrip() {
// This tests that a name containing what looks like an encoding pattern
// round-trips correctly and doesn't get incorrectly decoded
let original = "My%DOT%Thing";
let encoded = encode_path_name(original);
assert_eq!(encoded, "My%%DOT%%Thing");
let decoded = decode_path_name(&encoded);
assert_eq!(decoded, original);
}
#[test]
fn test_combined_period_and_percent() {
let original = "My.%Thing.Other";
let encoded = encode_path_name(original);
let decoded = decode_path_name(&encoded);
assert_eq!(decoded, original);
}
}