oxicode_hashline/
format.rs1use crate::types::Cursor;
7
8pub const HL_FILE_PREFIX: &str = "[";
12pub const HL_FILE_SUFFIX: &str = "]";
14pub const HL_PAYLOAD_REPLACE: char = '+';
16
17pub const HL_REPLACE_KEYWORD: &str = "SWAP";
19pub const HL_DELETE_KEYWORD: &str = "DEL";
21pub const HL_INSERT_KEYWORD: &str = "INS";
23pub const HL_INSERT_BEFORE: &str = "PRE";
25pub const HL_INSERT_AFTER: &str = "POST";
27pub const HL_INSERT_HEAD: &str = "HEAD";
29pub const HL_INSERT_TAIL: &str = "TAIL";
31
32pub const HL_HEADER_COLON: char = ':';
34pub const HL_FILE_HASH_SEP: char = '#';
36pub const HL_RANGE_SEP: &str = ".=";
38pub const HL_LINE_BODY_SEP: char = ':';
40
41pub const HL_FILE_HASH_LENGTH: usize = 4;
43
44fn normalize_file_hash_text(text: &str) -> String {
51 let bytes = text.as_bytes();
53 let mut out = Vec::with_capacity(bytes.len());
54 let mut i = 0;
55 let len = bytes.len();
56 while i < len {
57 if matches!(bytes[i], b' ' | b'\t' | b'\r') {
59 let start = i;
61 while i < len && matches!(bytes[i], b' ' | b'\t' | b'\r') {
62 i += 1;
63 }
64 if i >= len || bytes[i] == b'\n' {
66 } else {
68 out.extend_from_slice(&bytes[start..i]);
69 }
70 } else {
71 out.push(bytes[i]);
72 i += 1;
73 }
74 }
75 String::from_utf8(out).expect("normalization preserves UTF-8 validity")
77}
78
79pub fn compute_file_hash(text: &str) -> String {
84 let normalized = normalize_file_hash_text(text);
85 let low16 = xxhash_rust::xxh32::xxh32(normalized.as_bytes(), 0) & 0xFFFF;
86 format!("{:04X}", low16)
87}
88
89pub fn format_replace_header(start: u32, end: u32) -> String {
93 format!("{HL_REPLACE_KEYWORD} {start}{HL_RANGE_SEP}{end}{HL_HEADER_COLON}")
94}
95
96pub fn format_delete_header(start: u32, end: u32) -> String {
98 if start == end {
99 format!("{HL_DELETE_KEYWORD} {start}")
100 } else {
101 format!("{HL_DELETE_KEYWORD} {start}{HL_RANGE_SEP}{end}")
102 }
103}
104
105pub fn format_insert_header(cursor: &Cursor) -> String {
107 match cursor {
108 Cursor::BeforeAnchor(anchor) => {
109 format!(
110 "{HL_INSERT_KEYWORD}.{HL_INSERT_BEFORE} {}{HL_HEADER_COLON}",
111 anchor.line
112 )
113 }
114 Cursor::AfterAnchor(anchor) => {
115 format!(
116 "{HL_INSERT_KEYWORD}.{HL_INSERT_AFTER} {}{HL_HEADER_COLON}",
117 anchor.line
118 )
119 }
120 Cursor::Bof => {
121 format!("{HL_INSERT_KEYWORD}.{HL_INSERT_HEAD}{HL_HEADER_COLON}")
122 }
123 Cursor::Eof => {
124 format!("{HL_INSERT_KEYWORD}.{HL_INSERT_TAIL}{HL_HEADER_COLON}")
125 }
126 }
127}
128
129pub fn format_hashline_header(file_path: &str, file_hash: &str) -> String {
131 format!("{HL_FILE_PREFIX}{file_path}{HL_FILE_HASH_SEP}{file_hash}{HL_FILE_SUFFIX}")
132}
133
134pub fn format_numbered_line(line_number: u32, line: &str) -> String {
136 format!("{line_number}{HL_LINE_BODY_SEP}{line}")
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::types::Anchor;
143
144 #[test]
145 fn hash_stable_and_uppercase() {
146 let tag = compute_file_hash("hello world\n");
147 assert_eq!(tag.len(), 4);
148 assert!(
149 tag.chars()
150 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
151 );
152 }
153
154 #[test]
155 fn hash_trailing_whitespace_invariant() {
156 let a = compute_file_hash("line one\nline two\n");
157 let b = compute_file_hash("line one \nline two\t\r\n");
158 assert_eq!(a, b, "trailing whitespace must not change hash");
159 }
160
161 #[test]
162 fn hash_crlf_invariant() {
163 let a = compute_file_hash("alpha\nbeta\n");
164 let b = compute_file_hash("alpha\r\nbeta\r\n");
165 assert_eq!(a, b, "CRLF must not change hash");
166 }
167
168 #[test]
169 fn hash_empty_string() {
170 let tag = compute_file_hash("");
171 assert_eq!(tag.len(), 4);
172 }
173
174 #[test]
175 fn format_headers() {
176 assert_eq!(format_replace_header(5, 10), "SWAP 5.=10:");
177 assert_eq!(format_delete_header(7, 7), "DEL 7");
178 assert_eq!(format_delete_header(7, 12), "DEL 7.=12");
179 assert_eq!(
180 format_insert_header(&Cursor::BeforeAnchor(Anchor { line: 3 })),
181 "INS.PRE 3:"
182 );
183 assert_eq!(
184 format_hashline_header("src/foo.rs", "1A2B"),
185 "[src/foo.rs#1A2B]"
186 );
187 assert_eq!(format_numbered_line(42, "let x = 1;"), "42:let x = 1;");
188 }
189}