Skip to main content

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}