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 pre-commit region.
16pub const BEGIN: &str = "# BEGIN spec-driven-docs managed";
17/// The line that closes the managed pre-commit region.
18pub const END: &str = "# END spec-driven-docs managed";
19/// The line that opens the managed documentation region in `AGENTS.md`.
20pub const AGENTS_BEGIN: &str = "<!-- BEGIN spec-driven-docs docs -->";
21/// The line that closes the managed documentation region in `AGENTS.md`.
22pub const AGENTS_END: &str = "<!-- END spec-driven-docs docs -->";
23
24/// A host file whose markers cannot be trusted, or that cannot host a block.
25#[derive(Debug, Clone, PartialEq, Eq, Error)]
26pub enum MarkerError {
27    /// Marker counts disagree, or more than one region is present.
28    #[error("malformed managed markers in .pre-commit-config.yaml; repair them and re-run")]
29    Malformed,
30    /// The END marker precedes the BEGIN marker.
31    #[error("managed markers are out of order in .pre-commit-config.yaml")]
32    OutOfOrder,
33    /// The host has no top-level `repos:` sequence to splice into.
34    #[error(".pre-commit-config.yaml has no top-level repos: key; add one and re-run")]
35    NoReposKey,
36}
37
38fn line_content(line: &str) -> &str {
39    line.strip_suffix('\n').unwrap_or(line)
40}
41
42/// Split a host text into its lines outside the managed region and the
43/// region itself, validating the markers first.
44///
45/// # Errors
46///
47/// [`MarkerError::Malformed`] when the marker counts disagree or a second
48/// region appears; [`MarkerError::OutOfOrder`] when END precedes BEGIN.
49pub fn split_block(text: &str) -> Result<(String, Option<String>), MarkerError> {
50    split_block_with(text, BEGIN, END)
51}
52
53/// Split a host text into its lines outside a marked region and the region.
54///
55/// The pre-commit `split_block` is this with the pre-commit markers; the
56/// `AGENTS.md` block uses its own pair.
57///
58/// # Errors
59///
60/// [`MarkerError::Malformed`] when the marker counts disagree or a second
61/// region appears; [`MarkerError::OutOfOrder`] when the end precedes the begin.
62pub fn split_block_with(
63    text: &str,
64    begin: &str,
65    end: &str,
66) -> Result<(String, Option<String>), MarkerError> {
67    let lines: Vec<&str> = text.split_inclusive('\n').collect();
68    let begins = lines.iter().filter(|l| line_content(l) == begin).count();
69    let ends = lines.iter().filter(|l| line_content(l) == end).count();
70    if begins != ends || begins > 1 {
71        return Err(MarkerError::Malformed);
72    }
73    if begins == 0 {
74        return Ok((text.to_string(), None));
75    }
76    let first_begin = lines.iter().position(|l| line_content(l) == begin);
77    let first_end = lines.iter().position(|l| line_content(l) == end);
78    let (Some(begin), Some(end)) = (first_begin, first_end) else {
79        return Err(MarkerError::Malformed);
80    };
81    if begin >= end {
82        return Err(MarkerError::OutOfOrder);
83    }
84    let base: String = lines[..begin].concat() + &lines[end + 1..].concat();
85    let block: String = lines[begin..=end].concat();
86    Ok((base, Some(block)))
87}
88
89fn is_top_level_key(line: &str) -> bool {
90    let content = line_content(line);
91    let Some(first) = content.bytes().next() else {
92        return false;
93    };
94    if !(first.is_ascii_alphabetic() || first == b'_') {
95        return false;
96    }
97    content
98        .bytes()
99        .position(|b| b == b':')
100        .is_some_and(|colon| {
101            content
102                .bytes()
103                .take(colon)
104                .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
105        })
106}
107
108fn is_repos_key(line: &str) -> bool {
109    let content = line_content(line);
110    content == "repos:"
111        || (content.starts_with("repos:") && content["repos:".len()..].trim().is_empty())
112}
113
114/// The indentation of the first sequence item after `repos:`, or two spaces.
115fn item_indent<'a>(lines: &[&'a str], repos_line: usize) -> &'a str {
116    lines[repos_line + 1..]
117        .iter()
118        .find_map(|line| {
119            let content = line_content(line);
120            let trimmed = content.trim_start();
121            trimmed
122                .starts_with("- ")
123                .then(|| &content[..content.len() - trimmed.len()])
124        })
125        .filter(|indent| !indent.is_empty())
126        .unwrap_or("  ")
127}
128
129/// Splice a rendered block (BEGIN through END, newline-terminated) into the
130/// `repos:` sequence of a marker-free base, returning the new host text and
131/// the indentation the block's entries should have used.
132///
133/// # Errors
134///
135/// [`MarkerError::NoReposKey`] when the base has no top-level `repos:` line.
136pub fn splice(base: &str, block: &str) -> Result<String, MarkerError> {
137    let lines: Vec<&str> = base.split_inclusive('\n').collect();
138    let repos_line = lines
139        .iter()
140        .position(|l| is_repos_key(l))
141        .ok_or(MarkerError::NoReposKey)?;
142    let end_line = lines[repos_line + 1..]
143        .iter()
144        .position(|l| is_top_level_key(l))
145        .map(|offset| repos_line + 1 + offset);
146
147    let mut out = String::with_capacity(base.len() + block.len());
148    if let Some(end) = end_line {
149        out.push_str(&lines[..end].concat());
150        out.push_str(block);
151        out.push_str(&lines[end..].concat());
152    } else {
153        out.push_str(base);
154        if !base.is_empty() && !base.ends_with('\n') {
155            out.push('\n');
156        }
157        out.push_str(block);
158    }
159    Ok(out)
160}
161
162/// The indentation a spliced block's sequence items should carry, measured
163/// from the base the block will join.
164///
165/// # Errors
166///
167/// [`MarkerError::NoReposKey`] when the base has no top-level `repos:` line.
168pub fn splice_indent(base: &str) -> Result<String, MarkerError> {
169    let lines: Vec<&str> = base.split_inclusive('\n').collect();
170    let repos_line = lines
171        .iter()
172        .position(|l| is_repos_key(l))
173        .ok_or(MarkerError::NoReposKey)?;
174    Ok(item_indent(&lines, repos_line).to_string())
175}
176
177/// The managed region — BEGIN through END inclusive — as present in a host
178/// text, or `None` when no complete region exists.
179#[must_use]
180pub fn block_region(text: &str) -> Option<String> {
181    block_region_with(text, BEGIN, END)
182}
183
184/// The marked region — begin through end inclusive — for the given marker
185/// pair, or `None` when no complete region exists.
186#[must_use]
187pub fn block_region_with(text: &str, begin: &str, end: &str) -> Option<String> {
188    let lines: Vec<&str> = text.split_inclusive('\n').collect();
189    let start = lines.iter().position(|l| line_content(l) == begin)?;
190    let stop = lines[start..].iter().position(|l| line_content(l) == end)? + start;
191    Some(lines[start..=stop].concat())
192}
193
194/// The hash the manifest records for a host file's managed pre-commit region.
195#[must_use]
196pub fn block_hash(text: &str) -> Option<Sha256> {
197    block_region(text).map(|region| Sha256::of(region.as_bytes()))
198}
199
200/// The hash the manifest records for a host file's region under a marker pair.
201#[must_use]
202pub fn block_hash_with(text: &str, begin: &str, end: &str) -> Option<Sha256> {
203    block_region_with(text, begin, end).map(|region| Sha256::of(region.as_bytes()))
204}
205
206/// Place an `AGENTS.md` block into a host, preserving every outside byte.
207///
208/// An existing managed region is replaced; otherwise the block is appended
209/// after the host content with one blank line. The block is newline-
210/// terminated. `host` is the current file, or empty when the file is absent.
211///
212/// # Errors
213///
214/// [`MarkerError::Malformed`] / [`MarkerError::OutOfOrder`] when the host's
215/// existing markers cannot be trusted.
216pub fn place_agents_block(host: &str, block: &str) -> Result<String, MarkerError> {
217    let (base, _) = split_block_with(host, AGENTS_BEGIN, AGENTS_END)?;
218    let trimmed = base.trim_end_matches('\n');
219    if trimmed.is_empty() {
220        return Ok(block.to_string());
221    }
222    Ok(format!("{trimmed}\n\n{block}"))
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    const BLOCK: &str =
230        "# BEGIN spec-driven-docs managed\n  - repo: local\n# END spec-driven-docs managed\n";
231
232    #[test]
233    fn splits_a_marked_host_and_keeps_outside_bytes() {
234        let host = format!("# lead comment\nrepos:\n{BLOCK}  - repo: other\n");
235        let (base, block) = split_block(&host).unwrap();
236        assert_eq!(base, "# lead comment\nrepos:\n  - repo: other\n");
237        assert_eq!(block.as_deref(), Some(BLOCK));
238    }
239
240    #[test]
241    fn split_without_markers_is_identity() {
242        let host = "repos:\n  - repo: other\n";
243        let (base, block) = split_block(host).unwrap();
244        assert_eq!(base, host);
245        assert!(block.is_none());
246    }
247
248    #[test]
249    fn lone_begin_is_malformed() {
250        let host = "repos:\n# BEGIN spec-driven-docs managed\n  - repo: local\n";
251        assert_eq!(split_block(host), Err(MarkerError::Malformed));
252    }
253
254    #[test]
255    fn lone_end_is_malformed() {
256        let host = "repos:\n# END spec-driven-docs managed\n";
257        assert_eq!(split_block(host), Err(MarkerError::Malformed));
258    }
259
260    #[test]
261    fn duplicate_regions_are_malformed() {
262        let host = format!("repos:\n{BLOCK}{BLOCK}");
263        assert_eq!(split_block(&host), Err(MarkerError::Malformed));
264    }
265
266    #[test]
267    fn reversed_markers_are_out_of_order() {
268        let host = "repos:\n# END spec-driven-docs managed\n# BEGIN spec-driven-docs managed\n";
269        assert_eq!(split_block(host), Err(MarkerError::OutOfOrder));
270    }
271
272    #[test]
273    fn splices_before_the_next_top_level_key() {
274        let base = "repos:\n  - repo: other\nci:\n  autofix: true\n";
275        let out = splice(base, BLOCK).unwrap();
276        assert_eq!(
277            out,
278            format!("repos:\n  - repo: other\n{BLOCK}ci:\n  autofix: true\n")
279        );
280    }
281
282    #[test]
283    fn splices_at_eof_when_repos_is_last() {
284        let base = "default_stages: [pre-commit]\nrepos:\n  - repo: other\n";
285        let out = splice(base, BLOCK).unwrap();
286        assert_eq!(out, format!("{base}{BLOCK}"));
287    }
288
289    #[test]
290    fn refuses_a_base_with_no_repos_key() {
291        assert_eq!(
292            splice("ci:\n  autofix: true\n", BLOCK),
293            Err(MarkerError::NoReposKey)
294        );
295        assert_eq!(splice("  repos:\n", BLOCK), Err(MarkerError::NoReposKey));
296    }
297
298    #[test]
299    fn measures_item_indent_from_the_first_entry() {
300        assert_eq!(
301            splice_indent("repos:\n    - repo: other\n").unwrap(),
302            "    "
303        );
304        assert_eq!(splice_indent("repos:\n").unwrap(), "  ");
305    }
306
307    #[test]
308    fn strip_then_splice_round_trips_outside_comments() {
309        let host = format!(
310            "# above\nrepos:\n  # inside, before ours\n  - repo: other # trailing\n{BLOCK}ci:\n  # below\n"
311        );
312        let (base, _) = split_block(&host).unwrap();
313        let out = splice(&base, BLOCK).unwrap();
314        assert_eq!(out, host);
315    }
316
317    #[test]
318    fn hashes_the_inclusive_region() {
319        let host = format!("repos:\n{BLOCK}");
320        assert_eq!(block_hash(&host), Some(Sha256::of(BLOCK.as_bytes())));
321        assert_eq!(block_hash("repos:\n"), None);
322    }
323}