use crate::types::Cursor;
pub const HL_FILE_PREFIX: &str = "[";
pub const HL_FILE_SUFFIX: &str = "]";
pub const HL_PAYLOAD_REPLACE: char = '+';
pub const HL_REPLACE_KEYWORD: &str = "SWAP";
pub const HL_DELETE_KEYWORD: &str = "DEL";
pub const HL_INSERT_KEYWORD: &str = "INS";
pub const HL_INSERT_BEFORE: &str = "PRE";
pub const HL_INSERT_AFTER: &str = "POST";
pub const HL_INSERT_HEAD: &str = "HEAD";
pub const HL_INSERT_TAIL: &str = "TAIL";
pub const HL_HEADER_COLON: char = ':';
pub const HL_FILE_HASH_SEP: char = '#';
pub const HL_RANGE_SEP: &str = ".=";
pub const HL_LINE_BODY_SEP: char = ':';
pub const HL_FILE_HASH_LENGTH: usize = 4;
fn normalize_file_hash_text(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
let len = bytes.len();
while i < len {
if matches!(bytes[i], b' ' | b'\t' | b'\r') {
let start = i;
while i < len && matches!(bytes[i], b' ' | b'\t' | b'\r') {
i += 1;
}
if i >= len || bytes[i] == b'\n' {
} else {
out.extend_from_slice(&bytes[start..i]);
}
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).expect("normalization preserves UTF-8 validity")
}
pub fn compute_file_hash(text: &str) -> String {
let normalized = normalize_file_hash_text(text);
let low16 = xxhash_rust::xxh32::xxh32(normalized.as_bytes(), 0) & 0xFFFF;
format!("{:04X}", low16)
}
pub fn format_replace_header(start: u32, end: u32) -> String {
format!("{HL_REPLACE_KEYWORD} {start}{HL_RANGE_SEP}{end}{HL_HEADER_COLON}")
}
pub fn format_delete_header(start: u32, end: u32) -> String {
if start == end {
format!("{HL_DELETE_KEYWORD} {start}")
} else {
format!("{HL_DELETE_KEYWORD} {start}{HL_RANGE_SEP}{end}")
}
}
pub fn format_insert_header(cursor: &Cursor) -> String {
match cursor {
Cursor::BeforeAnchor(anchor) => {
format!(
"{HL_INSERT_KEYWORD}.{HL_INSERT_BEFORE} {}{HL_HEADER_COLON}",
anchor.line
)
}
Cursor::AfterAnchor(anchor) => {
format!(
"{HL_INSERT_KEYWORD}.{HL_INSERT_AFTER} {}{HL_HEADER_COLON}",
anchor.line
)
}
Cursor::Bof => {
format!("{HL_INSERT_KEYWORD}.{HL_INSERT_HEAD}{HL_HEADER_COLON}")
}
Cursor::Eof => {
format!("{HL_INSERT_KEYWORD}.{HL_INSERT_TAIL}{HL_HEADER_COLON}")
}
}
}
pub fn format_hashline_header(file_path: &str, file_hash: &str) -> String {
format!("{HL_FILE_PREFIX}{file_path}{HL_FILE_HASH_SEP}{file_hash}{HL_FILE_SUFFIX}")
}
pub fn format_numbered_line(line_number: u32, line: &str) -> String {
format!("{line_number}{HL_LINE_BODY_SEP}{line}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Anchor;
#[test]
fn hash_stable_and_uppercase() {
let tag = compute_file_hash("hello world\n");
assert_eq!(tag.len(), 4);
assert!(
tag.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
);
}
#[test]
fn hash_trailing_whitespace_invariant() {
let a = compute_file_hash("line one\nline two\n");
let b = compute_file_hash("line one \nline two\t\r\n");
assert_eq!(a, b, "trailing whitespace must not change hash");
}
#[test]
fn hash_crlf_invariant() {
let a = compute_file_hash("alpha\nbeta\n");
let b = compute_file_hash("alpha\r\nbeta\r\n");
assert_eq!(a, b, "CRLF must not change hash");
}
#[test]
fn hash_empty_string() {
let tag = compute_file_hash("");
assert_eq!(tag.len(), 4);
}
#[test]
fn format_headers() {
assert_eq!(format_replace_header(5, 10), "SWAP 5.=10:");
assert_eq!(format_delete_header(7, 7), "DEL 7");
assert_eq!(format_delete_header(7, 12), "DEL 7.=12");
assert_eq!(
format_insert_header(&Cursor::BeforeAnchor(Anchor { line: 3 })),
"INS.PRE 3:"
);
assert_eq!(
format_hashline_header("src/foo.rs", "1A2B"),
"[src/foo.rs#1A2B]"
);
assert_eq!(format_numbered_line(42, "let x = 1;"), "42:let x = 1;");
}
}