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 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());
32 let ceiling = clocks(crate::hooks::common::check_timeout());
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. Give both commands the\n\
63longest timeout your tooling allows, never its default: here a check is\n\
64killed only after {idle} of silence or {ceiling} in total\n\
65(`amont.idleTimeout` / `amont.timeout`), and a test suite may legitimately\n\
66run for most of that. If your tooling caps a foreground command below it,\n\
67run the command in the background and read its result when it exits —\n\
68while it runs, a line a minute on stderr says which check is alive and\n\
69when it last printed. A push killed mid-suite pushed nothing; a commit\n\
70killed mid-check committed nothing, and your unstaged work stays parked\n\
71until the next run says how to recover it. Neither is the checks failing —\n\
72it is the timeout. Run both bare and check the effect (`git log --oneline\n\
73-1`, `git ls-remote origin <branch>`): trimming their output with `| tail`\n\
74reports the pipe's exit status, so a killed or rejected run reads as\n\
75success.\n\
76\n\
77Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
78intentionally instead:\n\
79\n\
80```sh\n\
81git config amont.severity.<check-id> warn\n\
82```\n\
83\n\
84`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
85message it asks for, or change what it asks for — `amont setup`, or\n\
86`amont.commit.*` directly.\n\
87\n\
88{END}\n"
89 )
90}
91
92pub fn generate_pointer() -> String {
101 format!(
102 "{START}\n\
103\n\
104## Git hooks (amont)\n\
105\n\
106This repository enforces pre-commit / pre-push checks that can REJECT a\n\
107commit or a push. What runs, the branch-name rule, and why `git commit`\n\
108and `git push` both need a long timeout — or a background run — are in\n\
109[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
110generated: run `amont agents-md` after changing either.\n\
111\n\
112{END}\n"
113 )
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum MarkerState {
118 Malformed,
121}
122
123fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
132 let start = text.find(START);
133 let end = text.find(END);
134
135 match (start, end) {
136 (None, None) => Ok(None),
137 (Some(s), Some(e)) if e >= s + START.len() => {
138 let mut range_end = e + END.len();
139 if text[range_end..].starts_with('\n') {
140 range_end += 1; }
142 Ok(Some(s..range_end))
143 }
144 _ => Err(MarkerState::Malformed),
146 }
147}
148
149pub fn desired_file_content(existing: &str) -> Result<String, MarkerState> {
157 desired_with(existing, &generate_block())
158}
159
160pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
162 desired_with(existing, &generate_pointer())
163}
164
165fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
170 match marker_range(existing)? {
171 Some(range) => Ok(format!(
172 "{}{}{}",
173 &existing[..range.start],
174 block,
175 &existing[range.end..]
176 )),
177 None if existing.is_empty() => Ok(block.to_string()),
178 None => {
179 let sep = if existing.ends_with("\n\n") {
180 ""
181 } else if existing.ends_with('\n') {
182 "\n"
183 } else {
184 "\n\n"
185 };
186 Ok(format!("{existing}{sep}{block}"))
187 }
188 }
189}
190
191pub fn write(path: &Path) -> Result<(), String> {
192 write_with(path, &generate_block())
193}
194
195pub fn write_pointer(path: &Path) -> Result<(), String> {
197 write_with(path, &generate_pointer())
198}
199
200fn write_with(path: &Path, block: &str) -> Result<(), String> {
201 let existing = std::fs::read_to_string(path).unwrap_or_default();
202 let content = desired_with(&existing, block).map_err(|_| {
203 format!(
204 "{}: has an unpaired amont marker — fix or remove it by hand, \
205 then re-run `amont agents-md`",
206 path.display()
207 )
208 })?;
209 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
210 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
211 }
212 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum CheckResult {
217 NotPresent,
220 MatchesGenerated,
221 Drifted,
222}
223
224pub fn check(path: &Path) -> Result<CheckResult, String> {
225 check_with(path, &generate_block())
226}
227
228pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
230 check_with(path, &generate_pointer())
231}
232
233fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
234 let existing = std::fs::read_to_string(path).unwrap_or_default();
235 match marker_range(&existing) {
236 Err(_) => Err(format!(
237 "{}: has an unpaired amont marker — fix or remove it by hand",
238 path.display()
239 )),
240 Ok(None) => Ok(CheckResult::NotPresent),
241 Ok(Some(range)) => {
242 if &existing[range] == block {
243 Ok(CheckResult::MatchesGenerated)
244 } else {
245 Ok(CheckResult::Drifted)
246 }
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn no_file_produces_just_the_block() {
257 assert_eq!(desired_file_content("").unwrap(), generate_block());
258 }
259
260 #[test]
269 fn the_generated_markdown_satisfies_prettier() {
270 for (what, text) in [("block", generate_block()), ("pointer", generate_pointer())] {
271 assert!(
272 text.starts_with(&format!("{START}\n\n")),
273 "{what}: prettier wants a blank line after the opening comment"
274 );
275 assert!(
276 text.ends_with(&format!("\n\n{END}\n")),
277 "{what}: prettier wants a blank line before the closing comment"
278 );
279 let opener = text
284 .as_bytes()
285 .windows(2)
286 .any(|w| w[0] == b'*' && w[1].is_ascii_alphanumeric());
287 assert!(
288 !opener,
289 "{what}: prettier rewrites *emphasis* into _emphasis_, and a \
290 formatted file then reads as drift — emit underscores"
291 );
292 }
293 }
294
295 #[test]
299 fn the_pointer_is_generated_and_therefore_checkable() {
300 let p = generate_pointer();
301 assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
302 assert!(p.contains("AGENTS.md"), "it has to name where to look");
303 assert_ne!(p, generate_block(), "a signpost, not a second copy");
304 assert!(
305 !p.contains("amont list --json"),
306 "the registry command lives in ONE place; duplicating it here is \
307 the drift this design avoids"
308 );
309 let existing = "# Notes\n\nsomething the repo wrote\n";
312 let once = desired_pointer_content(existing).unwrap();
313 assert!(once.starts_with(existing), "never touches what was there");
314 assert!(once.ends_with(&p));
315 assert_eq!(
316 desired_pointer_content(&once).unwrap(),
317 once,
318 "re-running is a no-op, so `--check` can be trusted"
319 );
320 }
321
322 #[test]
323 fn no_markers_appends_with_one_blank_line() {
324 let got = desired_file_content("# My Project\n\nSome docs.\n").unwrap();
325 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
326 assert!(got.ends_with(&generate_block()));
327 }
328
329 #[test]
330 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
331 let got = desired_file_content("# My Project").unwrap();
332 assert!(got.starts_with("# My Project\n\n"));
333 }
334
335 #[test]
336 fn existing_markers_are_replaced_and_everything_else_survives() {
337 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
338 let got = desired_file_content(&before).unwrap();
339 assert!(got.starts_with("before\n\n"));
340 assert!(got.ends_with("\n\nafter\n"));
341 assert!(got.contains(&generate_block()));
342 assert!(!got.contains("stale"));
343 }
344
345 #[test]
346 fn applying_twice_is_idempotent() {
347 let once = desired_file_content("preamble\n").unwrap();
348 let twice = desired_file_content(&once).unwrap();
349 assert_eq!(once, twice);
350 }
351
352 #[test]
353 fn an_unpaired_marker_is_refused_not_guessed_at() {
354 assert_eq!(
355 desired_file_content(&format!("{START}\nno end here\n")),
356 Err(MarkerState::Malformed)
357 );
358 assert_eq!(
359 desired_file_content(&format!("no start\n{END}\n")),
360 Err(MarkerState::Malformed)
361 );
362 }
363
364 #[test]
369 fn end_appearing_before_start_is_malformed_not_reordered() {
370 assert_eq!(
371 desired_file_content(&format!("{END}\n...\n{START}\n...\n")),
372 Err(MarkerState::Malformed)
373 );
374 }
375
376 #[test]
377 fn check_reports_not_present_for_a_missing_file() {
378 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
379 let _ = std::fs::remove_file(&tmp);
380 assert_eq!(check(&tmp).unwrap(), CheckResult::NotPresent);
381 }
382
383 #[test]
384 fn check_reports_matches_generated_after_a_write() {
385 let tmp =
386 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
387 std::fs::write(&tmp, generate_block()).unwrap();
388 assert_eq!(check(&tmp).unwrap(), CheckResult::MatchesGenerated);
389 let _ = std::fs::remove_file(&tmp);
390 }
391
392 #[test]
393 fn check_reports_drifted_for_a_stale_block() {
394 let tmp =
395 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
396 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
397 assert_eq!(check(&tmp).unwrap(), CheckResult::Drifted);
398 let _ = std::fs::remove_file(&tmp);
399 }
400}