Skip to main content

shine_core/
sentinel.rs

1//! Shared sentinel-block primitives used by `shells::profile` and
2//! `sys::profile` to find, extract, remove, and insert the
3//! `# >>> ... >>>` / `# <<< ... <<<` blocks shine writes into user-owned
4//! shell profile files.
5//!
6//! **Two removal styles are kept deliberately separate** ([`remove_block_bytewise`]
7//! vs [`remove_block_linewise`]): they differ in preceding-blank-line
8//! handling and CRLF behavior (see each function's docs), and canonicalizing
9//! them without golden-output proof that neither caller depends on the
10//! difference would risk a silent formatting regression in a file shine does
11//! not own. Do not merge them without characterization tests confirming both
12//! callers' current byte-for-byte output is preserved.
13
14/// A sentinel marker pair, e.g. `("# >>> shine >>>", "# <<< shine <<<")`.
15#[derive(Clone, Copy)]
16pub struct Sentinel<'a> {
17    pub start: &'a str,
18    pub end: &'a str,
19}
20
21/// Returns the substring from the start marker through the end marker
22/// (inclusive), or `None` if either marker is missing. Does not include any
23/// trailing newline after the end marker.
24pub fn find_block<'a>(content: &'a str, sentinel: &Sentinel) -> Option<&'a str> {
25    let start = content.find(sentinel.start)?;
26    let end = content[start..].find(sentinel.end)? + start + sentinel.end.len();
27    Some(&content[start..end])
28}
29
30/// Like [`find_block`], but also includes one trailing `'\n'` immediately
31/// after the end marker if present. The end marker is searched for only
32/// after the start marker, so an end marker that appears *before* the start
33/// marker is not matched.
34pub fn extract_block_with_newline<'a>(content: &'a str, sentinel: &Sentinel) -> Option<&'a str> {
35    let start = content.find(sentinel.start)?;
36    let after_start = &content[start..];
37    let end = after_start.find(sentinel.end)? + sentinel.end.len();
38    let end = if after_start[end..].starts_with('\n') {
39        end + 1
40    } else {
41        end
42    };
43    Some(&after_start[..end])
44}
45
46/// Byte-offset block removal (`shells::profile`'s semantics).
47///
48/// No-op if either marker is missing. When present, consumes one preceding
49/// blank line (a literal `"\n\n"` tail immediately before the start marker)
50/// and the trailing LF or CRLF immediately after the end marker, if present.
51///
52/// On CRLF input the preceding blank line is not consumed because the check
53/// deliberately looks for a literal `"\n\n"` tail. CRLF bytes elsewhere in
54/// `content` are left untouched (this function never rewrites line endings).
55pub fn remove_block_bytewise(content: &str, sentinel: &Sentinel) -> String {
56    let start = match content.find(sentinel.start) {
57        Some(i) => i,
58        None => return content.to_string(),
59    };
60    let end_marker = match content.find(sentinel.end) {
61        Some(i) => i + sentinel.end.len(),
62        None => return content.to_string(),
63    };
64    let end = if content[end_marker..].starts_with("\r\n") {
65        end_marker + 2
66    } else if content[end_marker..].starts_with('\n') {
67        end_marker + 1
68    } else {
69        end_marker
70    };
71    let block_start = if start > 0 && content[..start].ends_with("\n\n") {
72        start - 1
73    } else {
74        start
75    };
76    format!("{}{}", &content[..block_start], &content[end..])
77}
78
79/// Line-based block removal (`sys::profile`'s semantics).
80///
81/// Drops only the lines from the start marker through the end marker
82/// (inclusive); never consumes a preceding blank line separator, unlike
83/// [`remove_block_bytewise`].
84///
85/// Because this iterates via [`str::lines`], CRLF input is normalized to LF
86/// **unconditionally** — even when the sentinel isn't present at all, since
87/// `lines()` always strips a trailing `'\r'` from each line. The presence or
88/// absence of a trailing newline on the original `content` is preserved on
89/// the result.
90pub fn remove_block_linewise(content: &str, sentinel: &Sentinel) -> String {
91    let mut output = Vec::new();
92    let mut skip = false;
93    for line in content.lines() {
94        if line == sentinel.start {
95            skip = true;
96            continue;
97        }
98        if line == sentinel.end {
99            skip = false;
100            continue;
101        }
102        if !skip {
103            output.push(line);
104        }
105    }
106    let mut result = output.join("\n");
107    if content.ends_with('\n') && !result.is_empty() {
108        result.push('\n');
109    }
110    result
111}
112
113/// Where to insert a block relative to existing content, for [`insert_block`].
114#[derive(Clone, Copy, PartialEq, Eq, Debug)]
115pub enum InsertAt {
116    Start,
117    End,
118}
119
120/// Inserts `block` into `content`, separated from any existing content by
121/// exactly one blank line (regardless of whether `block` or `content`
122/// already end/start with a newline). When `content` is empty, no
123/// leading/trailing blank line is added — the result is just `block`.
124pub fn insert_block(content: &str, block: &str, at: InsertAt) -> String {
125    match at {
126        InsertAt::Start => {
127            let mut updated = String::new();
128            updated.push_str(block);
129            if !content.is_empty() {
130                if !block.ends_with('\n') {
131                    updated.push('\n');
132                }
133                updated.push('\n');
134                updated.push_str(content);
135            }
136            updated
137        }
138        InsertAt::End => {
139            let mut updated = content.to_string();
140            if !updated.ends_with('\n') && !updated.is_empty() {
141                updated.push('\n');
142            }
143            if !updated.is_empty() {
144                updated.push('\n');
145            }
146            updated.push_str(block);
147            updated
148        }
149    }
150}
151
152/// Trims all leading and trailing `'\n'` characters (not just one).
153pub fn trim_outer_blank_lines(content: &str) -> String {
154    content.trim_matches('\n').to_string()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    const SENTINEL: Sentinel<'static> = Sentinel {
162        start: "START",
163        end: "END",
164    };
165
166    #[test]
167    fn find_block_returns_start_through_end_inclusive() {
168        let content = "pre\nSTART\nbody\nEND\npost";
169        assert_eq!(find_block(content, &SENTINEL), Some("START\nbody\nEND"));
170    }
171
172    #[test]
173    fn find_block_none_when_either_marker_missing() {
174        assert_eq!(find_block("no markers", &SENTINEL), None);
175        assert_eq!(find_block("STARTonly", &SENTINEL), None);
176    }
177
178    #[test]
179    fn remove_block_bytewise_and_linewise_agree_on_the_simple_case() {
180        // Both styles remove an isolated block identically when there's no
181        // preceding blank line or CRLF involved — they only diverge on
182        // those specific edge cases (see each function's own module docs
183        // and the dedicated tests in shells::profile / sys::profile).
184        let content = "before\nSTART\nbody\nEND\nafter\n";
185        assert_eq!(
186            remove_block_bytewise(content, &SENTINEL),
187            remove_block_linewise(content, &SENTINEL)
188        );
189    }
190
191    #[test]
192    fn insert_block_start_and_end_are_symmetric_on_empty_content() {
193        assert_eq!(
194            insert_block("", "BLOCK\n", InsertAt::Start),
195            insert_block("", "BLOCK\n", InsertAt::End)
196        );
197    }
198
199    #[test]
200    fn trim_outer_blank_lines_is_idempotent() {
201        let once = trim_outer_blank_lines("\n\nfoo\n\n");
202        let twice = trim_outer_blank_lines(&once);
203        assert_eq!(once, twice);
204    }
205}