spec_driven_docs/domain/
marker.rs1use thiserror::Error;
12
13use crate::domain::ownership::Sha256;
14
15pub const BEGIN: &str = "# BEGIN spec-driven-docs managed";
17pub const END: &str = "# END spec-driven-docs managed";
19
20#[derive(Debug, Clone, PartialEq, Eq, Error)]
22pub enum MarkerError {
23 #[error("malformed managed markers in .pre-commit-config.yaml; repair them and re-run")]
25 Malformed,
26 #[error("managed markers are out of order in .pre-commit-config.yaml")]
28 OutOfOrder,
29 #[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
38pub 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
93fn 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
108pub 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
141pub 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#[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#[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}