use crate::{PlanError, PlanErrorCode};
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
pub fn validate_plan_path(path: &str, max_bytes: usize) -> Result<(), PlanError> {
weavatrix_edit::validate_plan_path(path, max_bytes).map_err(|error| {
PlanError::from_edit(&error)
.with_code(PlanErrorCode::UnsafePath)
.at_path(path)
})?;
if path
.split('/')
.any(|segment| segment.eq_ignore_ascii_case(".weavatrix"))
{
return Err(PlanError::new(
PlanErrorCode::UnsafePath,
"file path may not target the transaction state namespace",
)
.at_path(path));
}
Ok(())
}
#[must_use]
pub fn portable_path_key(path: &str) -> String {
let mut key = String::with_capacity(path.len());
let mut ascii_only = true;
for (index, segment) in path.split('/').enumerate() {
if index != 0 {
key.push('/');
}
let trimmed = segment.trim_end_matches(['.', ' ']);
if trimmed.is_ascii() {
for character in trimmed.chars() {
key.push(character.to_ascii_lowercase());
}
} else {
ascii_only = false;
key.push_str(&trimmed.to_lowercase());
}
}
if ascii_only || is_nfc_quick(key.chars()) == IsNormalized::Yes {
return key;
}
key.nfc().collect()
}
#[cfg(test)]
mod tests {
use unicode_normalization::UnicodeNormalization;
#[test]
fn folds_unicode_aliases_and_rejects_state_paths() {
assert_eq!(
super::portable_path_key("caf\u{e9}.rs"),
super::portable_path_key("cafe\u{301}.rs")
);
assert!(super::validate_plan_path(".WEAVATRIX/journal", 4_096).is_err());
}
#[test]
fn matches_the_legacy_edit_key_with_nfc_folding() {
for path in [
"src/lib.rs",
"Src/Foo.RS",
"src./Foo ",
"src/模块_0001/файл_0001.ts",
"src/CAF\u{c9}\u{301}/greek\u{3a3}/mixed.RS",
"a/b./c ",
"\u{212b}ngstr\u{f6}m/UNIT.rs",
] {
let expected: String = weavatrix_edit::portable_path_key(path).nfc().collect();
assert_eq!(super::portable_path_key(path), expected, "{path:?}");
}
}
}