cli/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 newline immediately after the end marker, if present.
51///
52/// On CRLF input, neither the preceding blank line nor the trailing newline
53/// is consumed: the checks look for a literal `"\n\n"` tail / leading
54/// `'\n'`, which a `"\r\n"` sequence doesn't satisfy. CRLF bytes elsewhere in
55/// `content` are left untouched (this function never rewrites line endings).
56pub fn remove_block_bytewise(content: &str, sentinel: &Sentinel) -> String {
57 let start = match content.find(sentinel.start) {
58 Some(i) => i,
59 None => return content.to_string(),
60 };
61 let end_marker = match content.find(sentinel.end) {
62 Some(i) => i + sentinel.end.len(),
63 None => return content.to_string(),
64 };
65 let end = if content[end_marker..].starts_with('\n') {
66 end_marker + 1
67 } else {
68 end_marker
69 };
70 let block_start = if start > 0 && content[..start].ends_with("\n\n") {
71 start - 1
72 } else {
73 start
74 };
75 format!("{}{}", &content[..block_start], &content[end..])
76}
77
78/// Line-based block removal (`sys::profile`'s semantics).
79///
80/// Drops only the lines from the start marker through the end marker
81/// (inclusive); never consumes a preceding blank line separator, unlike
82/// [`remove_block_bytewise`].
83///
84/// Because this iterates via [`str::lines`], CRLF input is normalized to LF
85/// **unconditionally** — even when the sentinel isn't present at all, since
86/// `lines()` always strips a trailing `'\r'` from each line. The presence or
87/// absence of a trailing newline on the original `content` is preserved on
88/// the result.
89pub fn remove_block_linewise(content: &str, sentinel: &Sentinel) -> String {
90 let mut output = Vec::new();
91 let mut skip = false;
92 for line in content.lines() {
93 if line == sentinel.start {
94 skip = true;
95 continue;
96 }
97 if line == sentinel.end {
98 skip = false;
99 continue;
100 }
101 if !skip {
102 output.push(line);
103 }
104 }
105 let mut result = output.join("\n");
106 if content.ends_with('\n') && !result.is_empty() {
107 result.push('\n');
108 }
109 result
110}
111
112/// Where to insert a block relative to existing content, for [`insert_block`].
113#[derive(Clone, Copy, PartialEq, Eq, Debug)]
114pub enum InsertAt {
115 Start,
116 End,
117}
118
119/// Inserts `block` into `content`, separated from any existing content by
120/// exactly one blank line (regardless of whether `block` or `content`
121/// already end/start with a newline). When `content` is empty, no
122/// leading/trailing blank line is added — the result is just `block`.
123pub fn insert_block(content: &str, block: &str, at: InsertAt) -> String {
124 match at {
125 InsertAt::Start => {
126 let mut updated = String::new();
127 updated.push_str(block);
128 if !content.is_empty() {
129 if !block.ends_with('\n') {
130 updated.push('\n');
131 }
132 updated.push('\n');
133 updated.push_str(content);
134 }
135 updated
136 }
137 InsertAt::End => {
138 let mut updated = content.to_string();
139 if !updated.ends_with('\n') && !updated.is_empty() {
140 updated.push('\n');
141 }
142 if !updated.is_empty() {
143 updated.push('\n');
144 }
145 updated.push_str(block);
146 updated
147 }
148 }
149}
150
151/// Trims all leading and trailing `'\n'` characters (not just one).
152pub fn trim_outer_blank_lines(content: &str) -> String {
153 content.trim_matches('\n').to_string()
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 const SENTINEL: Sentinel<'static> = Sentinel {
161 start: "START",
162 end: "END",
163 };
164
165 #[test]
166 fn find_block_returns_start_through_end_inclusive() {
167 let content = "pre\nSTART\nbody\nEND\npost";
168 assert_eq!(find_block(content, &SENTINEL), Some("START\nbody\nEND"));
169 }
170
171 #[test]
172 fn find_block_none_when_either_marker_missing() {
173 assert_eq!(find_block("no markers", &SENTINEL), None);
174 assert_eq!(find_block("STARTonly", &SENTINEL), None);
175 }
176
177 #[test]
178 fn remove_block_bytewise_and_linewise_agree_on_the_simple_case() {
179 // Both styles remove an isolated block identically when there's no
180 // preceding blank line or CRLF involved — they only diverge on
181 // those specific edge cases (see each function's own module docs
182 // and the dedicated tests in shells::profile / sys::profile).
183 let content = "before\nSTART\nbody\nEND\nafter\n";
184 assert_eq!(
185 remove_block_bytewise(content, &SENTINEL),
186 remove_block_linewise(content, &SENTINEL)
187 );
188 }
189
190 #[test]
191 fn insert_block_start_and_end_are_symmetric_on_empty_content() {
192 assert_eq!(
193 insert_block("", "BLOCK\n", InsertAt::Start),
194 insert_block("", "BLOCK\n", InsertAt::End)
195 );
196 }
197
198 #[test]
199 fn trim_outer_blank_lines_is_idempotent() {
200 let once = trim_outer_blank_lines("\n\nfoo\n\n");
201 let twice = trim_outer_blank_lines(&once);
202 assert_eq!(once, twice);
203 }
204}