weavatrix-refactor-plan 0.1.1

Evidence metadata, validation profiles, and canonical fingerprints for Weavatrix refactor plans
Documentation
use crate::{PlanError, PlanErrorCode};
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};

/// Validates a portable repository-relative refactor target.
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(())
}

/// Conservative portable identity with Unicode canonical-equivalence folding.
///
/// Produces exactly `weavatrix_edit::portable_path_key(path)` re-normalized to
/// NFC, but folds each segment in one pass and skips re-normalization when the
/// folded key is already NFC (always true for ASCII paths).
#[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;
            // `str::to_lowercase` is context-sensitive (Greek final sigma), so
            // fold the whole segment with it, matching the legacy key.
            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:?}");
        }
    }
}