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(settings: &crate::config::Settings) -> String {
27 let clocks = |secs: u64| match secs {
28 0 => "no limit".to_string(),
29 s => crate::hooks::common::human_secs(s),
30 };
31 let idle = clocks(crate::hooks::common::idle_timeout(settings));
32 let ceiling = clocks(crate::hooks::common::check_timeout(settings));
33 let prefixes = crate::vocabulary::BRANCH_PREFIXES
34 .iter()
35 .map(|p| p.name)
36 .collect::<Vec<_>>()
37 .join(", ");
38 format!(
39 "{START}\n\
40\n\
41## Git hooks (amont)\n\
42\n\
43This repository enforces pre-commit / pre-push checks. Ask the registry\n\
44rather than guessing before assuming a change is safe:\n\
45\n\
46```sh\n\
47amont list --json\n\
48amont list --json --stage pre-push --pushed # exactly what pushing next gates\n\
49```\n\
50\n\
51Each check reports its _effective_ severity (`block`/`warn`, including any\n\
52`amont.severity.*` override) and whether it fires here. The same output\n\
53carries `commit_style`: the subject and description limits `commit-msg`\n\
54enforces, and where the type's gitmoji is placed. It also carries\n\
55`branch_style`: name a branch `<prefix>/<name>` BEFORE creating it —\n\
56prefixes are {prefixes} — because `pre-push` refuses a\n\
57new branch that breaks the pattern, at the end of the work instead of the\n\
58start.\n\
59\n\
60`git commit` and `git push` both run their checks first, and neither is\n\
61instant: pre-commit can invoke formatters, linters or clippy (a workspace\n\
62build), and pre-push can run the test suite. Run `amont rehearse --wait`\n\
63BEFORE `git push`: it runs the same push gate on a snapshot of HEAD with no\n\
64connection open and stamps the tree, so the push itself skips the suite and\n\
65holds the remote for seconds instead of minutes (git connects before\n\
66pre-push runs, and a remote may drop the idle session while a suite runs).\n\
67Where `amont.rehearseOnCommit` is on, every commit already starts that\n\
68rehearsal in the background and `--wait` only follows it. Give both commands the\n\
69longest timeout your tooling allows, never its default: here a check is\n\
70killed only after {idle} of silence or {ceiling} in total\n\
71(`amont.idleTimeout` / `amont.timeout`), and a test suite may legitimately\n\
72run for most of that. If your tooling caps a foreground command below it,\n\
73run the command in the background and read its result when it exits —\n\
74while it runs, a line a minute on stderr says which check is alive and\n\
75when it last printed. A push killed mid-suite pushed nothing; a commit\n\
76killed mid-check committed nothing, and your unstaged work stays parked\n\
77until the next run says how to recover it. Neither is the checks failing —\n\
78it is the timeout. Run both bare and check the effect (`git log --oneline\n\
79-1`, `git ls-remote origin <branch>`): trimming their output with `| tail`\n\
80reports the pipe's exit status, so a killed or rejected run reads as\n\
81success.\n\
82\n\
83Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
84intentionally instead:\n\
85\n\
86```sh\n\
87git config amont.severity.<check-id> warn\n\
88```\n\
89\n\
90`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
91message it asks for, or change what it asks for — `amont setup`, or\n\
92`amont.commit.*` directly.\n\
93\n\
94{END}\n"
95 )
96}
97
98pub fn generate_pointer() -> String {
107 format!(
108 "{START}\n\
109\n\
110## Git hooks (amont)\n\
111\n\
112This repository enforces pre-commit / pre-push checks that can REJECT a\n\
113commit or a push. What runs, the branch-name rule, and why `git commit`\n\
114and `git push` both need a long timeout — or a background run — are in\n\
115[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
116generated: run `amont agents-md` after changing either.\n\
117\n\
118{END}\n"
119 )
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum MarkerState {
124 Malformed,
127}
128
129fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
138 let start = text.find(START);
139 let end = text.find(END);
140
141 match (start, end) {
142 (None, None) => Ok(None),
143 (Some(s), Some(e)) if e >= s + START.len() => {
144 let mut range_end = e + END.len();
145 if text[range_end..].starts_with('\n') {
146 range_end += 1; }
148 Ok(Some(s..range_end))
149 }
150 _ => Err(MarkerState::Malformed),
152 }
153}
154
155pub fn desired_file_content(
163 settings: &crate::config::Settings,
164 existing: &str,
165) -> Result<String, MarkerState> {
166 desired_with(existing, &generate_block(settings))
167}
168
169pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
171 desired_with(existing, &generate_pointer())
172}
173
174fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
179 match marker_range(existing)? {
180 Some(range) => Ok(format!(
181 "{}{}{}",
182 &existing[..range.start],
183 block,
184 &existing[range.end..]
185 )),
186 None if existing.is_empty() => Ok(block.to_string()),
187 None => {
188 let sep = if existing.ends_with("\n\n") {
189 ""
190 } else if existing.ends_with('\n') {
191 "\n"
192 } else {
193 "\n\n"
194 };
195 Ok(format!("{existing}{sep}{block}"))
196 }
197 }
198}
199
200pub fn write(settings: &crate::config::Settings, path: &Path) -> Result<(), String> {
201 write_with(path, &generate_block(settings))
202}
203
204pub fn write_pointer(path: &Path) -> Result<(), String> {
206 write_with(path, &generate_pointer())
207}
208
209fn write_with(path: &Path, block: &str) -> Result<(), String> {
210 let existing = std::fs::read_to_string(path).unwrap_or_default();
211 let content = desired_with(&existing, block).map_err(|_| {
212 format!(
213 "{}: has an unpaired amont marker — fix or remove it by hand, \
214 then re-run `amont agents-md`",
215 path.display()
216 )
217 })?;
218 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
219 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
220 }
221 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum CheckResult {
226 NotPresent,
229 MatchesGenerated,
230 Drifted,
231}
232
233pub fn check(settings: &crate::config::Settings, path: &Path) -> Result<CheckResult, String> {
234 check_with(path, &generate_block(settings))
235}
236
237pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
239 check_with(path, &generate_pointer())
240}
241
242fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
243 let existing = std::fs::read_to_string(path).unwrap_or_default();
244 match marker_range(&existing) {
245 Err(_) => Err(format!(
246 "{}: has an unpaired amont marker — fix or remove it by hand",
247 path.display()
248 )),
249 Ok(None) => Ok(CheckResult::NotPresent),
250 Ok(Some(range)) => {
251 if &existing[range] == block {
252 Ok(CheckResult::MatchesGenerated)
253 } else {
254 Ok(CheckResult::Drifted)
255 }
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn test_settings() -> crate::config::Settings {
267 crate::config::Settings::default()
268 }
269
270 #[test]
271 fn no_file_produces_just_the_block() {
272 assert_eq!(
273 desired_file_content(&test_settings(), "").unwrap(),
274 generate_block(&test_settings())
275 );
276 }
277
278 #[test]
287 fn the_generated_markdown_satisfies_prettier() {
288 for (what, text) in [
289 ("block", generate_block(&test_settings())),
290 ("pointer", generate_pointer()),
291 ] {
292 assert!(
293 text.starts_with(&format!("{START}\n\n")),
294 "{what}: prettier wants a blank line after the opening comment"
295 );
296 assert!(
297 text.ends_with(&format!("\n\n{END}\n")),
298 "{what}: prettier wants a blank line before the closing comment"
299 );
300 let opener = text
305 .as_bytes()
306 .windows(2)
307 .any(|w| w[0] == b'*' && w[1].is_ascii_alphanumeric());
308 assert!(
309 !opener,
310 "{what}: prettier rewrites *emphasis* into _emphasis_, and a \
311 formatted file then reads as drift — emit underscores"
312 );
313 }
314 }
315
316 #[test]
320 fn the_pointer_is_generated_and_therefore_checkable() {
321 let p = generate_pointer();
322 assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
323 assert!(p.contains("AGENTS.md"), "it has to name where to look");
324 assert_ne!(
325 p,
326 generate_block(&test_settings()),
327 "a signpost, not a second copy"
328 );
329 assert!(
330 !p.contains("amont list --json"),
331 "the registry command lives in ONE place; duplicating it here is \
332 the drift this design avoids"
333 );
334 let existing = "# Notes\n\nsomething the repo wrote\n";
337 let once = desired_pointer_content(existing).unwrap();
338 assert!(once.starts_with(existing), "never touches what was there");
339 assert!(once.ends_with(&p));
340 assert_eq!(
341 desired_pointer_content(&once).unwrap(),
342 once,
343 "re-running is a no-op, so `--check` can be trusted"
344 );
345 }
346
347 #[test]
348 fn no_markers_appends_with_one_blank_line() {
349 let got = desired_file_content(&test_settings(), "# My Project\n\nSome docs.\n").unwrap();
350 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
351 assert!(got.ends_with(&generate_block(&test_settings())));
352 }
353
354 #[test]
355 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
356 let got = desired_file_content(&test_settings(), "# My Project").unwrap();
357 assert!(got.starts_with("# My Project\n\n"));
358 }
359
360 #[test]
361 fn existing_markers_are_replaced_and_everything_else_survives() {
362 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
363 let got = desired_file_content(&test_settings(), &before).unwrap();
364 assert!(got.starts_with("before\n\n"));
365 assert!(got.ends_with("\n\nafter\n"));
366 assert!(got.contains(&generate_block(&test_settings())));
367 assert!(!got.contains("stale"));
368 }
369
370 #[test]
371 fn applying_twice_is_idempotent() {
372 let once = desired_file_content(&test_settings(), "preamble\n").unwrap();
373 let twice = desired_file_content(&test_settings(), &once).unwrap();
374 assert_eq!(once, twice);
375 }
376
377 #[test]
378 fn an_unpaired_marker_is_refused_not_guessed_at() {
379 assert_eq!(
380 desired_file_content(&test_settings(), &format!("{START}\nno end here\n")),
381 Err(MarkerState::Malformed)
382 );
383 assert_eq!(
384 desired_file_content(&test_settings(), &format!("no start\n{END}\n")),
385 Err(MarkerState::Malformed)
386 );
387 }
388
389 #[test]
394 fn end_appearing_before_start_is_malformed_not_reordered() {
395 assert_eq!(
396 desired_file_content(&test_settings(), &format!("{END}\n...\n{START}\n...\n")),
397 Err(MarkerState::Malformed)
398 );
399 }
400
401 #[test]
402 fn check_reports_not_present_for_a_missing_file() {
403 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
404 let _ = std::fs::remove_file(&tmp);
405 assert_eq!(
406 check(&test_settings(), &tmp).unwrap(),
407 CheckResult::NotPresent
408 );
409 }
410
411 #[test]
412 fn check_reports_matches_generated_after_a_write() {
413 let tmp =
414 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
415 std::fs::write(&tmp, generate_block(&test_settings())).unwrap();
416 assert_eq!(
417 check(&test_settings(), &tmp).unwrap(),
418 CheckResult::MatchesGenerated
419 );
420 let _ = std::fs::remove_file(&tmp);
421 }
422
423 #[test]
424 fn check_reports_drifted_for_a_stale_block() {
425 let tmp =
426 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
427 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
428 assert_eq!(check(&test_settings(), &tmp).unwrap(), CheckResult::Drifted);
429 let _ = std::fs::remove_file(&tmp);
430 }
431}