Skip to main content

klasp_core/text/
managed_block.rs

1//! Generic managed-block writer: insert/update/remove a delimited region
2//! inside an existing text file, idempotently, while preserving sibling
3//! content.
4//!
5//! Multiple klasp surfaces write a "managed block" — a region bracketed by
6//! a [`Markers::start`] / [`Markers::end`] line pair that other tools must
7//! leave alone. The AGENTS.md writer ([`crate`]'s `klasp-agents-codex`
8//! sibling, markdown), the git-hook writer (shell, with a shebang prelude),
9//! and future YAML config writers all share the same insert/update/remove
10//! algorithm; only their marker constants and an optional file-format
11//! prelude differ. This module is that shared algorithm; callers map
12//! [`BlockError`] onto their own crate-local error type and supply the
13//! file-format framing via [`Prelude`].
14//!
15//! ## Contract
16//!
17//! - **Idempotency.** `install(install(input))` == `install(input)`. The
18//!   block contents are anchored by the marker lines; re-running install
19//!   replaces only what's between them.
20//! - **Preservation.** Bytes outside the managed block are returned
21//!   unchanged, with one tolerated normalisation: trailing-newline state is
22//!   canonicalised to a single `\n` when install appended (or uninstall
23//!   stripped) the block.
24//! - **Round-trip.** `uninstall(install(input))` is `input` after
25//!   normalising the trailing-newline state to a single `\n` (or the empty
26//!   string when `input` was empty or whitespace-only, modulo a prelude
27//!   that owned the whole file).
28
29use thiserror::Error;
30
31/// The marker line pair that brackets a managed block.
32///
33/// `start` and `end` are matched as exact substrings (the writer greps for
34/// them verbatim), so callers pass their stable, namespaced marker
35/// constants — e.g. `<!-- klasp:managed:start -->` for markdown or
36/// `# >>> klasp managed start <<<` for shell.
37#[derive(Debug, Clone, Copy)]
38pub struct Markers<'a> {
39    /// Opening marker line.
40    pub start: &'a str,
41    /// Closing marker line.
42    pub end: &'a str,
43}
44
45/// Byte span of the managed block within the host string, including both
46/// markers and the trailing `\n` after the end marker (when present). The
47/// span is a clean cut: `text[..span.start] + new_block + text[span.end..]`
48/// replaces the block while preserving everything around it.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Span {
51    /// Byte offset of the first byte of the start marker.
52    pub start: usize,
53    /// Byte offset one past the block (after the end marker's trailing
54    /// newline, if any).
55    pub end: usize,
56}
57
58/// Errors the managed-block writer can raise.
59#[derive(Debug, Error, PartialEq, Eq)]
60pub enum BlockError {
61    /// The host text contains an unmatched marker pair (start without end,
62    /// end without start, duplicate markers, or end-before-start). The
63    /// writer refuses to coerce the file because the "safe" action —
64    /// overwriting from the first marker to EOF — could nuke hand-written
65    /// content the user intended to keep. Callers map this onto their own
66    /// error variant with a file-format-specific message.
67    #[error(
68        "managed-block markers are malformed (expected exactly one start followed by one end)"
69    )]
70    MalformedMarkers,
71}
72
73/// Optional file-format prelude prepended ahead of the block when install
74/// has to fresh-create a file (or when appending to a file that lacks it).
75///
76/// Markdown surfaces pass `None`. The git-hook surface passes
77/// `Some(Prelude { line: SHEBANG })` so a fresh hook starts with an
78/// interpreter line and a hook authored without one gains it.
79#[derive(Debug, Clone, Copy)]
80pub struct Prelude<'a> {
81    /// The prelude line (e.g. a shebang), inserted without a trailing
82    /// newline — [`install_block`] adds the separator.
83    pub line: &'a str,
84}
85
86/// Locate the managed block bracketed by `markers` within `existing`.
87///
88/// Returns `Ok(None)` when neither marker is present, `Ok(Some(span))` for
89/// a single well-formed pair, and `Err(BlockError::MalformedMarkers)` for a
90/// lone marker, duplicate markers, or an end-before-start pair.
91pub 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        // Either marker present without the other → malformed; both absent → no block.
96        return if existing.contains(markers.start) || existing.contains(markers.end) {
97            Err(BlockError::MalformedMarkers)
98        } else {
99            Ok(None)
100        };
101    };
102
103    // Reject duplicates and crossed pairs in one pass: a well-formed block
104    // has `find == rfind` for both markers, with the start before the end.
105    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    // Span end = end of the end-marker line, including the trailing newline
113    // if there is one. This makes the replace operation a clean cut.
114    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
123/// `true` when `existing` already contains a (well-formed) managed block.
124pub fn contains_block(existing: &str, markers: &Markers<'_>) -> Result<bool, BlockError> {
125    Ok(find_block(existing, markers)?.is_some())
126}
127
128/// Render the full managed block (markers + body) for embedding in a host
129/// file.
130///
131/// The output starts with `markers.start` on its own line, ends with
132/// `markers.end` on its own line, and the body is sandwiched with single
133/// newline separators. The body is normalised to end in a single `\n` so
134/// the closing marker sits on its own line regardless of caller hygiene.
135pub 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
140/// Insert (or update) the managed block in `existing`, returning the new
141/// file body.
142///
143/// Behaviour matrix (with `prelude = None`):
144///
145/// | Input shape                  | Output shape                                    |
146/// |------------------------------|-------------------------------------------------|
147/// | empty / all-whitespace       | the rendered block, no leading/trailing padding |
148/// | contains a managed block     | block contents replaced in-place                |
149/// | non-empty, no managed block  | original bytes + blank line + appended block    |
150///
151/// With `prelude = Some(p)` the fresh-create and no-shebang-append paths
152/// gain a `p.line\n\n` prefix so the file always opens with the prelude:
153///
154/// | Input shape                       | Output shape                                |
155/// |-----------------------------------|---------------------------------------------|
156/// | empty / all-whitespace            | `p.line\n\n<block>`                          |
157/// | non-empty, starts with `p.line`*  | `<existing>\n\n<block>`                      |
158/// | non-empty, missing prelude        | `p.line\n\n<existing>\n\n<block>`            |
159/// | contains a managed block          | block contents replaced in-place            |
160///
161/// *The "already has prelude" test is a generic `starts_with("#!")` shebang
162/// check — the git-hook caller's only prelude use today.
163///
164/// Idempotent: when the existing block already matches the rendered block
165/// byte-for-byte and no prelude prepending was needed, the input is
166/// returned unchanged.
167pub 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        // Replace in-place. Preserve everything outside [start, end).
177        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    // No existing block. Decide on the prelude prefix + spacing.
185    if existing.trim().is_empty() {
186        // Fresh-create / empty file.
187        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    // Existing user content, no block. Append after it with a blank-line
200    // separator, prepending the prelude first if one is required and the
201    // file doesn't already open with a shebang.
202    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
218/// Inverse of [`install_block`]: remove the managed block and the
219/// blank-line separator install inserted when it appended the block.
220///
221/// Idempotent: a file with no managed block is returned unchanged. A file
222/// that contained *only* the block (or, with a prelude, only the prelude +
223/// block) collapses to the empty string. When install *appended* the block
224/// to user content, uninstall restores the canonical `<content>\n` shape.
225pub 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    // Shapes possible after stripping the block:
238    //
239    // 1. `before` is empty (block at byte 0): collapse to `after`.
240    // 2. (prelude only) `before` is just the prelude/shebang + whitespace
241    //    and `after` is empty: the round-trip from a fresh-created file
242    //    klasp owned outright — collapse to empty so the caller can `rm`.
243    // 3. `before` has real content: strip the trailing `\n\n` install
244    //    inserted as a separator, restoring canonical `<content>\n`. If
245    //    `after` is non-empty, leave it verbatim.
246    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        // Prelude-only prefix means klasp was the sole content. Collapse
251        // to empty so the caller deletes the file.
252    } 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
266/// Returns `true` when `s` consists of only a shebang line plus whitespace
267/// (and nothing else). Used by [`uninstall_block`] to detect the
268/// round-trip-from-fresh-create case where klasp owns the entire file.
269fn 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    // --- No-prelude (markdown-shaped) path ---
331
332    #[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    // --- Prelude (shell-shaped) path ---
384
385    #[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}