1pub const SAFE_OPEN: &str = "<lc_safe>";
20pub const SAFE_CLOSE: &str = "</lc_safe>";
22
23#[must_use]
26pub fn has_markers(s: &str) -> bool {
27 s.contains(SAFE_OPEN)
28}
29
30#[must_use]
39pub fn compress_preserving<F: Fn(&str) -> String>(input: &str, f: F) -> String {
40 if !has_markers(input) {
41 return f(input);
42 }
43 let mut out = String::with_capacity(input.len());
44 let mut rest = input;
45 while let Some(start) = rest.find(SAFE_OPEN) {
46 out.push_str(&f(&rest[..start]));
47 let after = &rest[start + SAFE_OPEN.len()..];
48 let Some(end) = after.find(SAFE_CLOSE) else {
49 out.push_str(after); return out;
51 };
52 out.push_str(&after[..end]); rest = &after[end + SAFE_CLOSE.len()..];
54 }
55 out.push_str(&f(rest));
56 out
57}
58
59#[must_use]
63pub fn line_is_protected(line: &str, needles: &[String]) -> bool {
64 needles
65 .iter()
66 .any(|n| !n.is_empty() && line.contains(n.as_str()))
67}
68
69#[must_use]
76pub fn protect_fragment(needles: &[String]) -> String {
77 let mut canon: Vec<&str> = needles
78 .iter()
79 .map(String::as_str)
80 .filter(|s| !s.is_empty())
81 .collect();
82 if canon.is_empty() {
83 return String::new();
84 }
85 canon.sort_unstable();
86 canon.dedup();
87 let joined = canon.join("\u{0}");
90 format!("p{}", &crate::core::hasher::hash_short(&joined)[..8])
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 fn drop_all(_: &str) -> String {
100 String::new()
101 }
102
103 #[test]
104 fn no_markers_passes_input_to_f() {
105 assert_eq!(
106 compress_preserving("plain text", str::to_uppercase),
107 "PLAIN TEXT"
108 );
109 assert!(!has_markers("plain text"));
110 }
111
112 #[test]
113 fn protect_spans_survive_compression() {
114 let input =
115 "noise before\n<lc_safe>CRITICAL = 42\nkeep me literally</lc_safe>\nnoise after";
116 let out = compress_preserving(input, drop_all);
117 assert_eq!(out, "CRITICAL = 42\nkeep me literally");
119 assert!(!out.contains(SAFE_OPEN));
120 assert!(!out.contains(SAFE_CLOSE));
121 }
122
123 #[test]
124 fn unprotected_regions_are_compressed_protected_are_not() {
125 let f = |s: &str| s.to_uppercase();
126 let out = compress_preserving("a<lc_safe>b</lc_safe>c", f);
127 assert_eq!(out, "AbC");
128 }
129
130 #[test]
131 fn multiple_spans_all_survive() {
132 let input = "x<lc_safe>one</lc_safe>y<lc_safe>two</lc_safe>z";
133 let out = compress_preserving(input, drop_all);
134 assert_eq!(out, "onetwo");
135 }
136
137 #[test]
138 fn unterminated_marker_keeps_remainder_verbatim() {
139 let input = "drop<lc_safe>tail without close\nstill kept";
140 let out = compress_preserving(input, drop_all);
141 assert_eq!(out, "tail without close\nstill kept");
142 }
143
144 #[test]
145 fn compress_preserving_is_deterministic() {
146 let input = "a<lc_safe>SAFE</lc_safe>b<lc_safe>X</lc_safe>c";
147 let a = compress_preserving(input, str::to_uppercase);
148 let b = compress_preserving(input, str::to_uppercase);
149 assert_eq!(a, b);
150 }
151
152 #[test]
153 fn line_is_protected_matches_token_and_ignores_empty() {
154 let needles = vec!["TODO".to_string(), String::new()];
155 assert!(line_is_protected(" // TODO: fix", &needles));
156 assert!(!line_is_protected("nothing here", &needles));
157 assert!(!line_is_protected("anything", &[String::new()]));
159 assert!(!line_is_protected("anything", &[]));
160 }
161
162 #[test]
163 fn protect_fragment_empty_is_blank() {
164 assert_eq!(protect_fragment(&[]), "");
165 assert_eq!(protect_fragment(&[String::new()]), "");
166 }
167
168 #[test]
169 fn protect_fragment_is_order_and_dup_independent() {
170 let a = protect_fragment(&["alpha".to_string(), "beta".to_string()]);
171 let b = protect_fragment(&["beta".to_string(), "alpha".to_string(), "alpha".to_string()]);
172 assert_eq!(a, b);
173 assert!(a.starts_with('p'));
174 assert_eq!(a.len(), 9); }
176
177 #[test]
178 fn protect_fragment_distinguishes_sets() {
179 let a = protect_fragment(&["alpha".to_string()]);
180 let b = protect_fragment(&["beta".to_string()]);
181 assert_ne!(a, b);
182 }
183}