amont_runtime/
agents_md.rs1use 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.\n\
60\n\
61Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
62intentionally instead:\n\
63\n\
64```sh\n\
65git config amont.severity.<check-id> warn\n\
66```\n\
67\n\
68`commit-msg` takes neither `hook.skip` nor a severity override, and git\n\
69exempts it from `--no-verify`. Write the message it asks for, or change what\n\
70it asks for — `amont setup`, or `amont.commit.*` directly.\n\
71{END}\n"
72 )
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum MarkerState {
77 Malformed,
80}
81
82fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
91 let start = text.find(START);
92 let end = text.find(END);
93
94 match (start, end) {
95 (None, None) => Ok(None),
96 (Some(s), Some(e)) if e >= s + START.len() => {
97 let mut range_end = e + END.len();
98 if text[range_end..].starts_with('\n') {
99 range_end += 1; }
101 Ok(Some(s..range_end))
102 }
103 _ => Err(MarkerState::Malformed),
105 }
106}
107
108pub fn desired_file_content(existing: &str) -> Result<String, MarkerState> {
116 match marker_range(existing)? {
117 Some(range) => Ok(format!(
118 "{}{}{}",
119 &existing[..range.start],
120 generate_block(),
121 &existing[range.end..]
122 )),
123 None if existing.is_empty() => Ok(generate_block()),
124 None => {
125 let sep = if existing.ends_with("\n\n") {
126 ""
127 } else if existing.ends_with('\n') {
128 "\n"
129 } else {
130 "\n\n"
131 };
132 Ok(format!("{existing}{sep}{}", generate_block()))
133 }
134 }
135}
136
137pub fn write(path: &Path) -> Result<(), String> {
138 let existing = std::fs::read_to_string(path).unwrap_or_default();
139 let content = desired_file_content(&existing).map_err(|_| {
140 format!(
141 "{}: has an unpaired amont marker — fix or remove it by hand, \
142 then re-run `amont agents-md`",
143 path.display()
144 )
145 })?;
146 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
147 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
148 }
149 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum CheckResult {
154 NotPresent,
157 MatchesGenerated,
158 Drifted,
159}
160
161pub fn check(path: &Path) -> Result<CheckResult, String> {
162 let existing = std::fs::read_to_string(path).unwrap_or_default();
163 match marker_range(&existing) {
164 Err(_) => Err(format!(
165 "{}: has an unpaired amont marker — fix or remove it by hand",
166 path.display()
167 )),
168 Ok(None) => Ok(CheckResult::NotPresent),
169 Ok(Some(range)) => {
170 if existing[range] == generate_block() {
171 Ok(CheckResult::MatchesGenerated)
172 } else {
173 Ok(CheckResult::Drifted)
174 }
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn no_file_produces_just_the_block() {
185 assert_eq!(desired_file_content("").unwrap(), generate_block());
186 }
187
188 #[test]
189 fn no_markers_appends_with_one_blank_line() {
190 let got = desired_file_content("# My Project\n\nSome docs.\n").unwrap();
191 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
192 assert!(got.ends_with(&generate_block()));
193 }
194
195 #[test]
196 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
197 let got = desired_file_content("# My Project").unwrap();
198 assert!(got.starts_with("# My Project\n\n"));
199 }
200
201 #[test]
202 fn existing_markers_are_replaced_and_everything_else_survives() {
203 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
204 let got = desired_file_content(&before).unwrap();
205 assert!(got.starts_with("before\n\n"));
206 assert!(got.ends_with("\n\nafter\n"));
207 assert!(got.contains(&generate_block()));
208 assert!(!got.contains("stale"));
209 }
210
211 #[test]
212 fn applying_twice_is_idempotent() {
213 let once = desired_file_content("preamble\n").unwrap();
214 let twice = desired_file_content(&once).unwrap();
215 assert_eq!(once, twice);
216 }
217
218 #[test]
219 fn an_unpaired_marker_is_refused_not_guessed_at() {
220 assert_eq!(
221 desired_file_content(&format!("{START}\nno end here\n")),
222 Err(MarkerState::Malformed)
223 );
224 assert_eq!(
225 desired_file_content(&format!("no start\n{END}\n")),
226 Err(MarkerState::Malformed)
227 );
228 }
229
230 #[test]
235 fn end_appearing_before_start_is_malformed_not_reordered() {
236 assert_eq!(
237 desired_file_content(&format!("{END}\n...\n{START}\n...\n")),
238 Err(MarkerState::Malformed)
239 );
240 }
241
242 #[test]
243 fn check_reports_not_present_for_a_missing_file() {
244 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
245 let _ = std::fs::remove_file(&tmp);
246 assert_eq!(check(&tmp).unwrap(), CheckResult::NotPresent);
247 }
248
249 #[test]
250 fn check_reports_matches_generated_after_a_write() {
251 let tmp =
252 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
253 std::fs::write(&tmp, generate_block()).unwrap();
254 assert_eq!(check(&tmp).unwrap(), CheckResult::MatchesGenerated);
255 let _ = std::fs::remove_file(&tmp);
256 }
257
258 #[test]
259 fn check_reports_drifted_for_a_stale_block() {
260 let tmp =
261 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
262 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
263 assert_eq!(check(&tmp).unwrap(), CheckResult::Drifted);
264 let _ = std::fs::remove_file(&tmp);
265 }
266}