Skip to main content

spec_driven_docs/domain/
marker.rs

1//! Marker-delimited managed block: string surgery over a host file.
2//!
3//! The canon owns exactly one region of a consumer's `.pre-commit-config.yaml`
4//! — the lines between its BEGIN and END markers — and everything here is a
5//! pure function over the host text: validate the markers, strip the region,
6//! splice a new one into the `repos:` sequence, and hash what is present.
7//! Every line outside the markers passes through byte-identical. What the
8//! block contains is the hook renderer's business, and reading or writing
9//! the file is the installer's.
10
11use thiserror::Error;
12
13use crate::domain::ownership::Sha256;
14
15/// The line that opens the managed region.
16pub const BEGIN: &str = "# BEGIN spec-driven-docs managed";
17/// The line that closes the managed region.
18pub const END: &str = "# END spec-driven-docs managed";
19
20/// A host file whose markers cannot be trusted, or that cannot host a block.
21#[derive(Debug, Clone, PartialEq, Eq, Error)]
22pub enum MarkerError {
23    /// Marker counts disagree, or more than one region is present.
24    #[error("malformed managed markers in .pre-commit-config.yaml; repair them and re-run")]
25    Malformed,
26    /// The END marker precedes the BEGIN marker.
27    #[error("managed markers are out of order in .pre-commit-config.yaml")]
28    OutOfOrder,
29    /// The host has no top-level `repos:` sequence to splice into.
30    #[error(".pre-commit-config.yaml has no top-level repos: key; add one and re-run")]
31    NoReposKey,
32}
33
34fn line_content(line: &str) -> &str {
35    line.strip_suffix('\n').unwrap_or(line)
36}
37
38/// Split a host text into its lines outside the managed region and the
39/// region itself, validating the markers first.
40///
41/// # Errors
42///
43/// [`MarkerError::Malformed`] when the marker counts disagree or a second
44/// region appears; [`MarkerError::OutOfOrder`] when END precedes BEGIN.
45pub fn split_block(text: &str) -> Result<(String, Option<String>), MarkerError> {
46    let lines: Vec<&str> = text.split_inclusive('\n').collect();
47    let begins = lines.iter().filter(|l| line_content(l) == BEGIN).count();
48    let ends = lines.iter().filter(|l| line_content(l) == END).count();
49    if begins != ends || begins > 1 {
50        return Err(MarkerError::Malformed);
51    }
52    if begins == 0 {
53        return Ok((text.to_string(), None));
54    }
55    let first_begin = lines.iter().position(|l| line_content(l) == BEGIN);
56    let first_end = lines.iter().position(|l| line_content(l) == END);
57    let (Some(begin), Some(end)) = (first_begin, first_end) else {
58        return Err(MarkerError::Malformed);
59    };
60    if begin >= end {
61        return Err(MarkerError::OutOfOrder);
62    }
63    let base: String = lines[..begin].concat() + &lines[end + 1..].concat();
64    let block: String = lines[begin..=end].concat();
65    Ok((base, Some(block)))
66}
67
68fn is_top_level_key(line: &str) -> bool {
69    let content = line_content(line);
70    let Some(first) = content.bytes().next() else {
71        return false;
72    };
73    if !(first.is_ascii_alphabetic() || first == b'_') {
74        return false;
75    }
76    content
77        .bytes()
78        .position(|b| b == b':')
79        .is_some_and(|colon| {
80            content
81                .bytes()
82                .take(colon)
83                .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
84        })
85}
86
87fn is_repos_key(line: &str) -> bool {
88    let content = line_content(line);
89    content == "repos:"
90        || (content.starts_with("repos:") && content["repos:".len()..].trim().is_empty())
91}
92
93/// The indentation of the first sequence item after `repos:`, or two spaces.
94fn item_indent<'a>(lines: &[&'a str], repos_line: usize) -> &'a str {
95    lines[repos_line + 1..]
96        .iter()
97        .find_map(|line| {
98            let content = line_content(line);
99            let trimmed = content.trim_start();
100            trimmed
101                .starts_with("- ")
102                .then(|| &content[..content.len() - trimmed.len()])
103        })
104        .filter(|indent| !indent.is_empty())
105        .unwrap_or("  ")
106}
107
108/// Splice a rendered block (BEGIN through END, newline-terminated) into the
109/// `repos:` sequence of a marker-free base, returning the new host text and
110/// the indentation the block's entries should have used.
111///
112/// # Errors
113///
114/// [`MarkerError::NoReposKey`] when the base has no top-level `repos:` line.
115pub fn splice(base: &str, block: &str) -> Result<String, MarkerError> {
116    let lines: Vec<&str> = base.split_inclusive('\n').collect();
117    let repos_line = lines
118        .iter()
119        .position(|l| is_repos_key(l))
120        .ok_or(MarkerError::NoReposKey)?;
121    let end_line = lines[repos_line + 1..]
122        .iter()
123        .position(|l| is_top_level_key(l))
124        .map(|offset| repos_line + 1 + offset);
125
126    let mut out = String::with_capacity(base.len() + block.len());
127    if let Some(end) = end_line {
128        out.push_str(&lines[..end].concat());
129        out.push_str(block);
130        out.push_str(&lines[end..].concat());
131    } else {
132        out.push_str(base);
133        if !base.is_empty() && !base.ends_with('\n') {
134            out.push('\n');
135        }
136        out.push_str(block);
137    }
138    Ok(out)
139}
140
141/// The indentation a spliced block's sequence items should carry, measured
142/// from the base the block will join.
143///
144/// # Errors
145///
146/// [`MarkerError::NoReposKey`] when the base has no top-level `repos:` line.
147pub fn splice_indent(base: &str) -> Result<String, MarkerError> {
148    let lines: Vec<&str> = base.split_inclusive('\n').collect();
149    let repos_line = lines
150        .iter()
151        .position(|l| is_repos_key(l))
152        .ok_or(MarkerError::NoReposKey)?;
153    Ok(item_indent(&lines, repos_line).to_string())
154}
155
156/// The managed region — BEGIN through END inclusive — as present in a host
157/// text, or `None` when no complete region exists.
158#[must_use]
159pub fn block_region(text: &str) -> Option<String> {
160    let lines: Vec<&str> = text.split_inclusive('\n').collect();
161    let begin = lines.iter().position(|l| line_content(l) == BEGIN)?;
162    let end = lines[begin..].iter().position(|l| line_content(l) == END)? + begin;
163    Some(lines[begin..=end].concat())
164}
165
166/// The hash the manifest records for a host file's managed region.
167#[must_use]
168pub fn block_hash(text: &str) -> Option<Sha256> {
169    block_region(text).map(|region| Sha256::of(region.as_bytes()))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    const BLOCK: &str =
177        "# BEGIN spec-driven-docs managed\n  - repo: local\n# END spec-driven-docs managed\n";
178
179    #[test]
180    fn splits_a_marked_host_and_keeps_outside_bytes() {
181        let host = format!("# lead comment\nrepos:\n{BLOCK}  - repo: other\n");
182        let (base, block) = split_block(&host).unwrap();
183        assert_eq!(base, "# lead comment\nrepos:\n  - repo: other\n");
184        assert_eq!(block.as_deref(), Some(BLOCK));
185    }
186
187    #[test]
188    fn split_without_markers_is_identity() {
189        let host = "repos:\n  - repo: other\n";
190        let (base, block) = split_block(host).unwrap();
191        assert_eq!(base, host);
192        assert!(block.is_none());
193    }
194
195    #[test]
196    fn lone_begin_is_malformed() {
197        let host = "repos:\n# BEGIN spec-driven-docs managed\n  - repo: local\n";
198        assert_eq!(split_block(host), Err(MarkerError::Malformed));
199    }
200
201    #[test]
202    fn lone_end_is_malformed() {
203        let host = "repos:\n# END spec-driven-docs managed\n";
204        assert_eq!(split_block(host), Err(MarkerError::Malformed));
205    }
206
207    #[test]
208    fn duplicate_regions_are_malformed() {
209        let host = format!("repos:\n{BLOCK}{BLOCK}");
210        assert_eq!(split_block(&host), Err(MarkerError::Malformed));
211    }
212
213    #[test]
214    fn reversed_markers_are_out_of_order() {
215        let host = "repos:\n# END spec-driven-docs managed\n# BEGIN spec-driven-docs managed\n";
216        assert_eq!(split_block(host), Err(MarkerError::OutOfOrder));
217    }
218
219    #[test]
220    fn splices_before_the_next_top_level_key() {
221        let base = "repos:\n  - repo: other\nci:\n  autofix: true\n";
222        let out = splice(base, BLOCK).unwrap();
223        assert_eq!(
224            out,
225            format!("repos:\n  - repo: other\n{BLOCK}ci:\n  autofix: true\n")
226        );
227    }
228
229    #[test]
230    fn splices_at_eof_when_repos_is_last() {
231        let base = "default_stages: [pre-commit]\nrepos:\n  - repo: other\n";
232        let out = splice(base, BLOCK).unwrap();
233        assert_eq!(out, format!("{base}{BLOCK}"));
234    }
235
236    #[test]
237    fn refuses_a_base_with_no_repos_key() {
238        assert_eq!(
239            splice("ci:\n  autofix: true\n", BLOCK),
240            Err(MarkerError::NoReposKey)
241        );
242        assert_eq!(splice("  repos:\n", BLOCK), Err(MarkerError::NoReposKey));
243    }
244
245    #[test]
246    fn measures_item_indent_from_the_first_entry() {
247        assert_eq!(
248            splice_indent("repos:\n    - repo: other\n").unwrap(),
249            "    "
250        );
251        assert_eq!(splice_indent("repos:\n").unwrap(), "  ");
252    }
253
254    #[test]
255    fn strip_then_splice_round_trips_outside_comments() {
256        let host = format!(
257            "# above\nrepos:\n  # inside, before ours\n  - repo: other # trailing\n{BLOCK}ci:\n  # below\n"
258        );
259        let (base, _) = split_block(&host).unwrap();
260        let out = splice(&base, BLOCK).unwrap();
261        assert_eq!(out, host);
262    }
263
264    #[test]
265    fn hashes_the_inclusive_region() {
266        let host = format!("repos:\n{BLOCK}");
267        assert_eq!(block_hash(&host), Some(Sha256::of(BLOCK.as_bytes())));
268        assert_eq!(block_hash("repos:\n"), None);
269    }
270}