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}