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## Git hooks (amont)\n\
35\n\
36This repository enforces pre-commit / pre-push checks. Ask the registry\n\
37rather than guessing before assuming a change is safe:\n\
38\n\
39```sh\n\
40amont list --json\n\
41amont list --json --stage pre-push --pushed # exactly what pushing next gates\n\
42```\n\
43\n\
44Each check reports its *effective* severity (`block`/`warn`, including any\n\
45`amont.severity.*` override) and whether it fires here. The same output\n\
46carries `commit_style`: the subject and description limits `commit-msg`\n\
47enforces, and where the type's gitmoji is placed. It also carries\n\
48`branch_style`: name a branch `<prefix>/<name>` BEFORE creating it —\n\
49prefixes are {prefixes} — because `pre-push` refuses a\n\
50new branch that breaks the pattern, at the end of the work instead of the\n\
51start.\n\
52\n\
53`git commit` and `git push` both run their checks first, and neither is\n\
54instant: pre-commit can invoke formatters, linters or clippy (a workspace\n\
55build), and pre-push can run the test suite. Give both commands a timeout\n\
56of at least 10 minutes instead of your tooling's default. A push killed\n\
57mid-suite pushed nothing; a commit killed mid-check committed nothing, and\n\
58your unstaged work stays parked until the next run says how to recover it.\n\
59Neither is the checks failing — it is the timeout. Run both bare and check\n\
60the effect (`git log --oneline -1`, `git ls-remote origin <branch>`):\n\
61trimming their output with `| tail` reports the pipe's exit status, so a\n\
62killed or rejected run reads as success.\n\
63\n\
64Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
65intentionally instead:\n\
66\n\
67```sh\n\
68git config amont.severity.<check-id> warn\n\
69```\n\
70\n\
71`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
72message it asks for, or change what it asks for — `amont setup`, or\n\
73`amont.commit.*` directly.\n\
74{END}\n"
75 )
76}
77
78pub fn generate_pointer() -> String {
87 format!(
88 "{START}\n\
89## Git hooks (amont)\n\
90\n\
91This repository enforces pre-commit / pre-push checks that can REJECT a\n\
92commit or a push. What runs, the branch-name rule, and why `git commit`\n\
93and `git push` both need a timeout of at least 10 minutes are in\n\
94[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
95generated: run `amont agents-md` after changing either.\n\
96{END}\n"
97 )
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum MarkerState {
102 Malformed,
105}
106
107fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
116 let start = text.find(START);
117 let end = text.find(END);
118
119 match (start, end) {
120 (None, None) => Ok(None),
121 (Some(s), Some(e)) if e >= s + START.len() => {
122 let mut range_end = e + END.len();
123 if text[range_end..].starts_with('\n') {
124 range_end += 1; }
126 Ok(Some(s..range_end))
127 }
128 _ => Err(MarkerState::Malformed),
130 }
131}
132
133pub fn desired_file_content(existing: &str) -> Result<String, MarkerState> {
141 desired_with(existing, &generate_block())
142}
143
144pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
146 desired_with(existing, &generate_pointer())
147}
148
149fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
154 match marker_range(existing)? {
155 Some(range) => Ok(format!(
156 "{}{}{}",
157 &existing[..range.start],
158 block,
159 &existing[range.end..]
160 )),
161 None if existing.is_empty() => Ok(block.to_string()),
162 None => {
163 let sep = if existing.ends_with("\n\n") {
164 ""
165 } else if existing.ends_with('\n') {
166 "\n"
167 } else {
168 "\n\n"
169 };
170 Ok(format!("{existing}{sep}{block}"))
171 }
172 }
173}
174
175pub fn write(path: &Path) -> Result<(), String> {
176 write_with(path, &generate_block())
177}
178
179pub fn write_pointer(path: &Path) -> Result<(), String> {
181 write_with(path, &generate_pointer())
182}
183
184fn write_with(path: &Path, block: &str) -> Result<(), String> {
185 let existing = std::fs::read_to_string(path).unwrap_or_default();
186 let content = desired_with(&existing, block).map_err(|_| {
187 format!(
188 "{}: has an unpaired amont marker — fix or remove it by hand, \
189 then re-run `amont agents-md`",
190 path.display()
191 )
192 })?;
193 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
194 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
195 }
196 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum CheckResult {
201 NotPresent,
204 MatchesGenerated,
205 Drifted,
206}
207
208pub fn check(path: &Path) -> Result<CheckResult, String> {
209 check_with(path, &generate_block())
210}
211
212pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
214 check_with(path, &generate_pointer())
215}
216
217fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
218 let existing = std::fs::read_to_string(path).unwrap_or_default();
219 match marker_range(&existing) {
220 Err(_) => Err(format!(
221 "{}: has an unpaired amont marker — fix or remove it by hand",
222 path.display()
223 )),
224 Ok(None) => Ok(CheckResult::NotPresent),
225 Ok(Some(range)) => {
226 if &existing[range] == block {
227 Ok(CheckResult::MatchesGenerated)
228 } else {
229 Ok(CheckResult::Drifted)
230 }
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn no_file_produces_just_the_block() {
241 assert_eq!(desired_file_content("").unwrap(), generate_block());
242 }
243
244 #[test]
248 fn the_pointer_is_generated_and_therefore_checkable() {
249 let p = generate_pointer();
250 assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
251 assert!(p.contains("AGENTS.md"), "it has to name where to look");
252 assert_ne!(p, generate_block(), "a signpost, not a second copy");
253 assert!(
254 !p.contains("amont list --json"),
255 "the registry command lives in ONE place; duplicating it here is \
256 the drift this design avoids"
257 );
258 let existing = "# Notes\n\nsomething the repo wrote\n";
261 let once = desired_pointer_content(existing).unwrap();
262 assert!(once.starts_with(existing), "never touches what was there");
263 assert!(once.ends_with(&p));
264 assert_eq!(
265 desired_pointer_content(&once).unwrap(),
266 once,
267 "re-running is a no-op, so `--check` can be trusted"
268 );
269 }
270
271 #[test]
272 fn no_markers_appends_with_one_blank_line() {
273 let got = desired_file_content("# My Project\n\nSome docs.\n").unwrap();
274 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
275 assert!(got.ends_with(&generate_block()));
276 }
277
278 #[test]
279 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
280 let got = desired_file_content("# My Project").unwrap();
281 assert!(got.starts_with("# My Project\n\n"));
282 }
283
284 #[test]
285 fn existing_markers_are_replaced_and_everything_else_survives() {
286 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
287 let got = desired_file_content(&before).unwrap();
288 assert!(got.starts_with("before\n\n"));
289 assert!(got.ends_with("\n\nafter\n"));
290 assert!(got.contains(&generate_block()));
291 assert!(!got.contains("stale"));
292 }
293
294 #[test]
295 fn applying_twice_is_idempotent() {
296 let once = desired_file_content("preamble\n").unwrap();
297 let twice = desired_file_content(&once).unwrap();
298 assert_eq!(once, twice);
299 }
300
301 #[test]
302 fn an_unpaired_marker_is_refused_not_guessed_at() {
303 assert_eq!(
304 desired_file_content(&format!("{START}\nno end here\n")),
305 Err(MarkerState::Malformed)
306 );
307 assert_eq!(
308 desired_file_content(&format!("no start\n{END}\n")),
309 Err(MarkerState::Malformed)
310 );
311 }
312
313 #[test]
318 fn end_appearing_before_start_is_malformed_not_reordered() {
319 assert_eq!(
320 desired_file_content(&format!("{END}\n...\n{START}\n...\n")),
321 Err(MarkerState::Malformed)
322 );
323 }
324
325 #[test]
326 fn check_reports_not_present_for_a_missing_file() {
327 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
328 let _ = std::fs::remove_file(&tmp);
329 assert_eq!(check(&tmp).unwrap(), CheckResult::NotPresent);
330 }
331
332 #[test]
333 fn check_reports_matches_generated_after_a_write() {
334 let tmp =
335 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
336 std::fs::write(&tmp, generate_block()).unwrap();
337 assert_eq!(check(&tmp).unwrap(), CheckResult::MatchesGenerated);
338 let _ = std::fs::remove_file(&tmp);
339 }
340
341 #[test]
342 fn check_reports_drifted_for_a_stale_block() {
343 let tmp =
344 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
345 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
346 assert_eq!(check(&tmp).unwrap(), CheckResult::Drifted);
347 let _ = std::fs::remove_file(&tmp);
348 }
349}