1use thiserror::Error;
30
31#[derive(Debug, Clone, Copy)]
38pub struct Markers<'a> {
39 pub start: &'a str,
41 pub end: &'a str,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Span {
51 pub start: usize,
53 pub end: usize,
56}
57
58#[derive(Debug, Error, PartialEq, Eq)]
60pub enum BlockError {
61 #[error(
68 "managed-block markers are malformed (expected exactly one start followed by one end)"
69 )]
70 MalformedMarkers,
71}
72
73#[derive(Debug, Clone, Copy)]
80pub struct Prelude<'a> {
81 pub line: &'a str,
84}
85
86pub fn find_block(existing: &str, markers: &Markers<'_>) -> Result<Option<Span>, BlockError> {
92 let (Some(start), Some(end_marker_start)) =
93 (existing.find(markers.start), existing.find(markers.end))
94 else {
95 return if existing.contains(markers.start) || existing.contains(markers.end) {
97 Err(BlockError::MalformedMarkers)
98 } else {
99 Ok(None)
100 };
101 };
102
103 if existing.rfind(markers.start) != Some(start)
106 || existing.rfind(markers.end) != Some(end_marker_start)
107 || end_marker_start < start
108 {
109 return Err(BlockError::MalformedMarkers);
110 }
111
112 let after_marker = end_marker_start + markers.end.len();
115 let end = if existing.as_bytes().get(after_marker) == Some(&b'\n') {
116 after_marker + 1
117 } else {
118 after_marker
119 };
120 Ok(Some(Span { start, end }))
121}
122
123pub fn contains_block(existing: &str, markers: &Markers<'_>) -> Result<bool, BlockError> {
125 Ok(find_block(existing, markers)?.is_some())
126}
127
128pub fn render_block(markers: &Markers<'_>, body: &str) -> String {
136 let trimmed = body.trim_end_matches('\n');
137 format!("{}\n{}\n{}\n", markers.start, trimmed, markers.end)
138}
139
140pub fn install_block(
168 existing: &str,
169 markers: &Markers<'_>,
170 body: &str,
171 prelude: Option<Prelude<'_>>,
172) -> Result<String, BlockError> {
173 let block = render_block(markers, body);
174
175 if let Some(span) = find_block(existing, markers)? {
176 let mut out = String::with_capacity(existing.len() + block.len());
178 out.push_str(&existing[..span.start]);
179 out.push_str(&block);
180 out.push_str(&existing[span.end..]);
181 return Ok(out);
182 }
183
184 if existing.trim().is_empty() {
186 return Ok(match prelude {
188 None => block,
189 Some(p) => {
190 let mut out = String::with_capacity(p.line.len() + block.len() + 2);
191 out.push_str(p.line);
192 out.push_str("\n\n");
193 out.push_str(&block);
194 out
195 }
196 });
197 }
198
199 let needs_prelude = match prelude {
203 Some(_) => !has_shebang(existing),
204 None => false,
205 };
206 let prelude_line = prelude.map(|p| p.line).unwrap_or("");
207 let mut out = String::with_capacity(existing.len() + prelude_line.len() + block.len() + 4);
208 if needs_prelude {
209 out.push_str(prelude_line);
210 out.push_str("\n\n");
211 }
212 out.push_str(existing.trim_end_matches('\n'));
213 out.push_str("\n\n");
214 out.push_str(&block);
215 Ok(out)
216}
217
218pub fn uninstall_block(
226 existing: &str,
227 markers: &Markers<'_>,
228 prelude: Option<Prelude<'_>>,
229) -> Result<String, BlockError> {
230 let Some(span) = find_block(existing, markers)? else {
231 return Ok(existing.to_string());
232 };
233
234 let before = &existing[..span.start];
235 let after = &existing[span.end..];
236
237 let mut out = String::with_capacity(before.len() + after.len() + 1);
247 if before.is_empty() {
248 out.push_str(after);
249 } else if after.is_empty() && prelude.is_some() && is_only_shebang_or_whitespace(before) {
250 } else if after.is_empty() {
253 out.push_str(before.trim_end_matches('\n'));
254 out.push('\n');
255 } else {
256 out.push_str(before);
257 out.push_str(after);
258 }
259 Ok(out)
260}
261
262fn has_shebang(s: &str) -> bool {
263 s.starts_with("#!")
264}
265
266fn is_only_shebang_or_whitespace(s: &str) -> bool {
270 let trimmed = s.trim();
271 trimmed.is_empty() || (trimmed.starts_with("#!") && !trimmed.contains('\n'))
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 const MD: Markers<'static> = Markers {
279 start: "<!-- start -->",
280 end: "<!-- end -->",
281 };
282
283 const SH: Markers<'static> = Markers {
284 start: "# >>> start <<<",
285 end: "# >>> end <<<",
286 };
287
288 const SHEBANG: Prelude<'static> = Prelude {
289 line: "#!/usr/bin/env sh",
290 };
291
292 #[test]
293 fn render_block_wraps_body_in_markers() {
294 let s = render_block(&MD, "hello");
295 assert!(s.starts_with(MD.start));
296 assert!(s.contains("hello"));
297 assert!(s.trim_end().ends_with(MD.end));
298 assert!(s.ends_with('\n'));
299 }
300
301 #[test]
302 fn render_block_normalises_trailing_newlines_in_body() {
303 let s = render_block(&MD, "hello\n\n\n");
304 assert_eq!(s, format!("{}\nhello\n{}\n", MD.start, MD.end));
305 }
306
307 #[test]
308 fn find_block_none_when_absent() {
309 assert_eq!(find_block("# Project\nNotes.\n", &MD).unwrap(), None);
310 }
311
312 #[test]
313 fn find_block_rejects_lone_marker() {
314 let pre = format!("{}\nbody\n", MD.start);
315 assert_eq!(find_block(&pre, &MD), Err(BlockError::MalformedMarkers));
316 }
317
318 #[test]
319 fn find_block_rejects_end_before_start() {
320 let pre = format!("{}\nbody\n{}\n", MD.end, MD.start);
321 assert_eq!(find_block(&pre, &MD), Err(BlockError::MalformedMarkers));
322 }
323
324 #[test]
325 fn find_block_rejects_duplicates() {
326 let pre = format!("{s}\none\n{e}\n{s}\ntwo\n{e}\n", s = MD.start, e = MD.end);
327 assert_eq!(find_block(&pre, &MD), Err(BlockError::MalformedMarkers));
328 }
329
330 #[test]
333 fn install_no_prelude_into_empty_emits_just_the_block() {
334 let out = install_block("", &MD, "body", None).unwrap();
335 assert!(out.starts_with(MD.start));
336 assert!(out.trim_end().ends_with(MD.end));
337 }
338
339 #[test]
340 fn install_no_prelude_appends_with_blank_line_separator() {
341 let pre = "# Project\n\nNotes.\n";
342 let out = install_block(pre, &MD, "body", None).unwrap();
343 assert!(out.starts_with(pre));
344 let after_pre = &out[pre.len()..];
345 assert!(after_pre.starts_with('\n'));
346 assert!(after_pre[1..].starts_with(MD.start));
347 }
348
349 #[test]
350 fn install_no_prelude_replaces_in_place() {
351 let stale = render_block(&MD, "OLD");
352 let pre = format!("# Top\n\n{stale}\nbottom\n");
353 let out = install_block(&pre, &MD, "NEW", None).unwrap();
354 assert!(out.contains("NEW"));
355 assert!(!out.contains("OLD"));
356 assert!(out.starts_with("# Top\n\n"));
357 assert!(out.ends_with("bottom\n"));
358 }
359
360 #[test]
361 fn install_no_prelude_is_idempotent() {
362 let pre = "# Project\n\nNotes.\n";
363 let once = install_block(pre, &MD, "body", None).unwrap();
364 let twice = install_block(&once, &MD, "body", None).unwrap();
365 assert_eq!(once, twice);
366 }
367
368 #[test]
369 fn round_trip_no_prelude_restores_original() {
370 let pre = "# Project\n\nNotes.\n";
371 let installed = install_block(pre, &MD, "body", None).unwrap();
372 let restored = uninstall_block(&installed, &MD, None).unwrap();
373 assert_eq!(restored, pre);
374 }
375
376 #[test]
377 fn round_trip_no_prelude_on_empty_returns_empty() {
378 let installed = install_block("", &MD, "body", None).unwrap();
379 let restored = uninstall_block(&installed, &MD, None).unwrap();
380 assert_eq!(restored, "");
381 }
382
383 #[test]
386 fn install_prelude_into_empty_emits_shebang_and_block() {
387 let out = install_block("", &SH, "body", Some(SHEBANG)).unwrap();
388 assert!(out.starts_with(SHEBANG.line));
389 assert!(out.contains(SH.start));
390 assert!(out.trim_end().ends_with(SH.end));
391 }
392
393 #[test]
394 fn install_prelude_into_hook_with_shebang_appends() {
395 let pre = "#!/bin/bash\n\necho 'user lint'\n";
396 let out = install_block(pre, &SH, "body", Some(SHEBANG)).unwrap();
397 assert!(out.starts_with(pre));
398 let after_pre = &out[pre.len()..];
399 assert!(after_pre.starts_with('\n'));
400 assert!(after_pre[1..].starts_with(SH.start));
401 }
402
403 #[test]
404 fn install_prelude_into_hook_without_shebang_prepends_one() {
405 let pre = "echo lint\n";
406 let out = install_block(pre, &SH, "body", Some(SHEBANG)).unwrap();
407 assert!(out.starts_with(SHEBANG.line));
408 assert!(out.contains("echo lint"));
409 assert!(out.contains(SH.start));
410 }
411
412 #[test]
413 fn install_prelude_is_idempotent() {
414 let pre = "#!/bin/bash\n\necho 'user lint'\n";
415 let once = install_block(pre, &SH, "body", Some(SHEBANG)).unwrap();
416 let twice = install_block(&once, &SH, "body", Some(SHEBANG)).unwrap();
417 assert_eq!(once, twice);
418 }
419
420 #[test]
421 fn round_trip_prelude_on_user_hook_restores_input() {
422 let pre = "#!/bin/bash\n\necho 'user lint'\n";
423 let installed = install_block(pre, &SH, "body", Some(SHEBANG)).unwrap();
424 let restored = uninstall_block(&installed, &SH, Some(SHEBANG)).unwrap();
425 assert_eq!(restored, pre);
426 }
427
428 #[test]
429 fn round_trip_prelude_on_empty_collapses_to_empty() {
430 let installed = install_block("", &SH, "body", Some(SHEBANG)).unwrap();
431 let restored = uninstall_block(&installed, &SH, Some(SHEBANG)).unwrap();
432 assert_eq!(restored, "");
433 }
434
435 #[test]
436 fn uninstall_is_noop_when_no_block_present() {
437 let pre = "#!/bin/sh\necho lint\n";
438 assert_eq!(uninstall_block(pre, &SH, Some(SHEBANG)).unwrap(), pre);
439 }
440
441 #[test]
442 fn contains_block_true_after_install() {
443 let installed = install_block("", &MD, "body", None).unwrap();
444 assert!(contains_block(&installed, &MD).unwrap());
445 }
446
447 #[test]
448 fn contains_block_false_for_unrelated_text() {
449 assert!(!contains_block("<!-- some other tool -->\n", &MD).unwrap());
450 }
451}