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";
19pub const AGENTS_BEGIN: &str = "<!-- BEGIN spec-driven-docs docs -->";
21pub const AGENTS_END: &str = "<!-- END spec-driven-docs docs -->";
23
24#[derive(Debug, Clone, PartialEq, Eq, Error)]
26pub enum MarkerError {
27 #[error("malformed managed markers in .pre-commit-config.yaml; repair them and re-run")]
29 Malformed,
30 #[error("managed markers are out of order in .pre-commit-config.yaml")]
32 OutOfOrder,
33 #[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
42pub fn split_block(text: &str) -> Result<(String, Option<String>), MarkerError> {
50 split_block_with(text, BEGIN, END)
51}
52
53pub 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
114fn 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
129pub 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
162pub 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#[must_use]
180pub fn block_region(text: &str) -> Option<String> {
181 block_region_with(text, BEGIN, END)
182}
183
184#[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#[must_use]
196pub fn block_hash(text: &str) -> Option<Sha256> {
197 block_region(text).map(|region| Sha256::of(region.as_bytes()))
198}
199
200#[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
206pub 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}