1use std::ops::Range;
15use std::path::Path;
16
17pub const START: &str = "<!-- amont:start -->";
18pub const END: &str = "<!-- amont:end -->";
19
20pub fn generate_block() -> String {
27 let prefixes = crate::vocabulary::BRANCH_PREFIXES
28 .iter()
29 .map(|p| p.name)
30 .collect::<Vec<_>>()
31 .join(", ");
32 format!(
33 "{START}\n\
34\n\
35## Git hooks (amont)\n\
36\n\
37This repository enforces pre-commit / pre-push checks. Ask the registry\n\
38rather than guessing before assuming a change is safe:\n\
39\n\
40```sh\n\
41amont list --json\n\
42amont list --json --stage pre-push --pushed # exactly what pushing next gates\n\
43```\n\
44\n\
45Each check reports its _effective_ severity (`block`/`warn`, including any\n\
46`amont.severity.*` override) and whether it fires here. The same output\n\
47carries `commit_style`: the subject and description limits `commit-msg`\n\
48enforces, and where the type's gitmoji is placed. It also carries\n\
49`branch_style`: name a branch `<prefix>/<name>` BEFORE creating it —\n\
50prefixes are {prefixes} — because `pre-push` refuses a\n\
51new branch that breaks the pattern, at the end of the work instead of the\n\
52start.\n\
53\n\
54`git commit` and `git push` both run their checks first, and neither is\n\
55instant: pre-commit can invoke formatters, linters or clippy (a workspace\n\
56build), and pre-push can run the test suite. Give both commands a timeout\n\
57of at least 10 minutes instead of your tooling's default. A push killed\n\
58mid-suite pushed nothing; a commit killed mid-check committed nothing, and\n\
59your unstaged work stays parked until the next run says how to recover it.\n\
60Neither is the checks failing — it is the timeout. Run both bare and check\n\
61the effect (`git log --oneline -1`, `git ls-remote origin <branch>`):\n\
62trimming their output with `| tail` reports the pipe's exit status, so a\n\
63killed or rejected run reads as success.\n\
64\n\
65Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
66intentionally instead:\n\
67\n\
68```sh\n\
69git config amont.severity.<check-id> warn\n\
70```\n\
71\n\
72`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
73message it asks for, or change what it asks for — `amont setup`, or\n\
74`amont.commit.*` directly.\n\
75\n\
76{END}\n"
77 )
78}
79
80pub fn generate_pointer() -> String {
89 format!(
90 "{START}\n\
91\n\
92## Git hooks (amont)\n\
93\n\
94This repository enforces pre-commit / pre-push checks that can REJECT a\n\
95commit or a push. What runs, the branch-name rule, and why `git commit`\n\
96and `git push` both need a timeout of at least 10 minutes are in\n\
97[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
98generated: run `amont agents-md` after changing either.\n\
99\n\
100{END}\n"
101 )
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum MarkerState {
106 Malformed,
109}
110
111fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
120 let start = text.find(START);
121 let end = text.find(END);
122
123 match (start, end) {
124 (None, None) => Ok(None),
125 (Some(s), Some(e)) if e >= s + START.len() => {
126 let mut range_end = e + END.len();
127 if text[range_end..].starts_with('\n') {
128 range_end += 1; }
130 Ok(Some(s..range_end))
131 }
132 _ => Err(MarkerState::Malformed),
134 }
135}
136
137pub fn desired_file_content(existing: &str) -> Result<String, MarkerState> {
145 desired_with(existing, &generate_block())
146}
147
148pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
150 desired_with(existing, &generate_pointer())
151}
152
153fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
158 match marker_range(existing)? {
159 Some(range) => Ok(format!(
160 "{}{}{}",
161 &existing[..range.start],
162 block,
163 &existing[range.end..]
164 )),
165 None if existing.is_empty() => Ok(block.to_string()),
166 None => {
167 let sep = if existing.ends_with("\n\n") {
168 ""
169 } else if existing.ends_with('\n') {
170 "\n"
171 } else {
172 "\n\n"
173 };
174 Ok(format!("{existing}{sep}{block}"))
175 }
176 }
177}
178
179pub fn write(path: &Path) -> Result<(), String> {
180 write_with(path, &generate_block())
181}
182
183pub fn write_pointer(path: &Path) -> Result<(), String> {
185 write_with(path, &generate_pointer())
186}
187
188fn write_with(path: &Path, block: &str) -> Result<(), String> {
189 let existing = std::fs::read_to_string(path).unwrap_or_default();
190 let content = desired_with(&existing, block).map_err(|_| {
191 format!(
192 "{}: has an unpaired amont marker — fix or remove it by hand, \
193 then re-run `amont agents-md`",
194 path.display()
195 )
196 })?;
197 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
198 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
199 }
200 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum CheckResult {
205 NotPresent,
208 MatchesGenerated,
209 Drifted,
210}
211
212pub fn check(path: &Path) -> Result<CheckResult, String> {
213 check_with(path, &generate_block())
214}
215
216pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
218 check_with(path, &generate_pointer())
219}
220
221fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
222 let existing = std::fs::read_to_string(path).unwrap_or_default();
223 match marker_range(&existing) {
224 Err(_) => Err(format!(
225 "{}: has an unpaired amont marker — fix or remove it by hand",
226 path.display()
227 )),
228 Ok(None) => Ok(CheckResult::NotPresent),
229 Ok(Some(range)) => {
230 if &existing[range] == block {
231 Ok(CheckResult::MatchesGenerated)
232 } else {
233 Ok(CheckResult::Drifted)
234 }
235 }
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn no_file_produces_just_the_block() {
245 assert_eq!(desired_file_content("").unwrap(), generate_block());
246 }
247
248 #[test]
257 fn the_generated_markdown_satisfies_prettier() {
258 for (what, text) in [("block", generate_block()), ("pointer", generate_pointer())] {
259 assert!(
260 text.starts_with(&format!("{START}\n\n")),
261 "{what}: prettier wants a blank line after the opening comment"
262 );
263 assert!(
264 text.ends_with(&format!("\n\n{END}\n")),
265 "{what}: prettier wants a blank line before the closing comment"
266 );
267 let opener = text
272 .as_bytes()
273 .windows(2)
274 .any(|w| w[0] == b'*' && w[1].is_ascii_alphanumeric());
275 assert!(
276 !opener,
277 "{what}: prettier rewrites *emphasis* into _emphasis_, and a \
278 formatted file then reads as drift — emit underscores"
279 );
280 }
281 }
282
283 #[test]
287 fn the_pointer_is_generated_and_therefore_checkable() {
288 let p = generate_pointer();
289 assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
290 assert!(p.contains("AGENTS.md"), "it has to name where to look");
291 assert_ne!(p, generate_block(), "a signpost, not a second copy");
292 assert!(
293 !p.contains("amont list --json"),
294 "the registry command lives in ONE place; duplicating it here is \
295 the drift this design avoids"
296 );
297 let existing = "# Notes\n\nsomething the repo wrote\n";
300 let once = desired_pointer_content(existing).unwrap();
301 assert!(once.starts_with(existing), "never touches what was there");
302 assert!(once.ends_with(&p));
303 assert_eq!(
304 desired_pointer_content(&once).unwrap(),
305 once,
306 "re-running is a no-op, so `--check` can be trusted"
307 );
308 }
309
310 #[test]
311 fn no_markers_appends_with_one_blank_line() {
312 let got = desired_file_content("# My Project\n\nSome docs.\n").unwrap();
313 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
314 assert!(got.ends_with(&generate_block()));
315 }
316
317 #[test]
318 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
319 let got = desired_file_content("# My Project").unwrap();
320 assert!(got.starts_with("# My Project\n\n"));
321 }
322
323 #[test]
324 fn existing_markers_are_replaced_and_everything_else_survives() {
325 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
326 let got = desired_file_content(&before).unwrap();
327 assert!(got.starts_with("before\n\n"));
328 assert!(got.ends_with("\n\nafter\n"));
329 assert!(got.contains(&generate_block()));
330 assert!(!got.contains("stale"));
331 }
332
333 #[test]
334 fn applying_twice_is_idempotent() {
335 let once = desired_file_content("preamble\n").unwrap();
336 let twice = desired_file_content(&once).unwrap();
337 assert_eq!(once, twice);
338 }
339
340 #[test]
341 fn an_unpaired_marker_is_refused_not_guessed_at() {
342 assert_eq!(
343 desired_file_content(&format!("{START}\nno end here\n")),
344 Err(MarkerState::Malformed)
345 );
346 assert_eq!(
347 desired_file_content(&format!("no start\n{END}\n")),
348 Err(MarkerState::Malformed)
349 );
350 }
351
352 #[test]
357 fn end_appearing_before_start_is_malformed_not_reordered() {
358 assert_eq!(
359 desired_file_content(&format!("{END}\n...\n{START}\n...\n")),
360 Err(MarkerState::Malformed)
361 );
362 }
363
364 #[test]
365 fn check_reports_not_present_for_a_missing_file() {
366 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
367 let _ = std::fs::remove_file(&tmp);
368 assert_eq!(check(&tmp).unwrap(), CheckResult::NotPresent);
369 }
370
371 #[test]
372 fn check_reports_matches_generated_after_a_write() {
373 let tmp =
374 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
375 std::fs::write(&tmp, generate_block()).unwrap();
376 assert_eq!(check(&tmp).unwrap(), CheckResult::MatchesGenerated);
377 let _ = std::fs::remove_file(&tmp);
378 }
379
380 #[test]
381 fn check_reports_drifted_for_a_stale_block() {
382 let tmp =
383 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
384 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
385 assert_eq!(check(&tmp).unwrap(), CheckResult::Drifted);
386 let _ = std::fs::remove_file(&tmp);
387 }
388}