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 {
22 format!(
23 "{START}\n\
24## Git hooks (amont)\n\
25\n\
26This repository enforces pre-commit / pre-push checks. Ask the registry\n\
27rather than guessing before assuming a change is safe:\n\
28\n\
29```sh\n\
30amont list --json\n\
31amont list --json --stage pre-push --pushed # exactly what pushing next gates\n\
32```\n\
33\n\
34Each check reports its *effective* severity (`block`/`warn`, including any\n\
35`amont.severity.*` override) and whether it fires here. The same output\n\
36carries `commit_style`: the subject and description limits `commit-msg`\n\
37enforces, and where the type's gitmoji is placed.\n\
38\n\
39`git commit` and `git push` both run their checks first, and neither is\n\
40instant: pre-commit can invoke formatters, linters or clippy (a workspace\n\
41build), and pre-push can run the test suite. Give both commands a timeout\n\
42of at least 10 minutes instead of your tooling's default. A push killed\n\
43mid-suite pushed nothing; a commit killed mid-check committed nothing, and\n\
44your unstaged work stays parked until the next run says how to recover it.\n\
45Neither is the checks failing — it is the timeout.\n\
46\n\
47Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
48intentionally instead:\n\
49\n\
50```sh\n\
51git config amont.severity.<check-id> warn\n\
52```\n\
53\n\
54`commit-msg` takes neither `hook.skip` nor a severity override, and git\n\
55exempts it from `--no-verify`. Write the message it asks for, or change what\n\
56it asks for — `amont setup`, or `amont.commit.*` directly.\n\
57{END}\n"
58 )
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum MarkerState {
63 Malformed,
66}
67
68fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
77 let start = text.find(START);
78 let end = text.find(END);
79
80 match (start, end) {
81 (None, None) => Ok(None),
82 (Some(s), Some(e)) if e >= s + START.len() => {
83 let mut range_end = e + END.len();
84 if text[range_end..].starts_with('\n') {
85 range_end += 1; }
87 Ok(Some(s..range_end))
88 }
89 _ => Err(MarkerState::Malformed),
91 }
92}
93
94pub fn desired_file_content(existing: &str) -> Result<String, MarkerState> {
102 match marker_range(existing)? {
103 Some(range) => Ok(format!(
104 "{}{}{}",
105 &existing[..range.start],
106 generate_block(),
107 &existing[range.end..]
108 )),
109 None if existing.is_empty() => Ok(generate_block()),
110 None => {
111 let sep = if existing.ends_with("\n\n") {
112 ""
113 } else if existing.ends_with('\n') {
114 "\n"
115 } else {
116 "\n\n"
117 };
118 Ok(format!("{existing}{sep}{}", generate_block()))
119 }
120 }
121}
122
123pub fn write(path: &Path) -> Result<(), String> {
124 let existing = std::fs::read_to_string(path).unwrap_or_default();
125 let content = desired_file_content(&existing).map_err(|_| {
126 format!(
127 "{}: has an unpaired amont marker — fix or remove it by hand, \
128 then re-run `amont agents-md`",
129 path.display()
130 )
131 })?;
132 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
133 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
134 }
135 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum CheckResult {
140 NotPresent,
143 MatchesGenerated,
144 Drifted,
145}
146
147pub fn check(path: &Path) -> Result<CheckResult, String> {
148 let existing = std::fs::read_to_string(path).unwrap_or_default();
149 match marker_range(&existing) {
150 Err(_) => Err(format!(
151 "{}: has an unpaired amont marker — fix or remove it by hand",
152 path.display()
153 )),
154 Ok(None) => Ok(CheckResult::NotPresent),
155 Ok(Some(range)) => {
156 if existing[range] == generate_block() {
157 Ok(CheckResult::MatchesGenerated)
158 } else {
159 Ok(CheckResult::Drifted)
160 }
161 }
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn no_file_produces_just_the_block() {
171 assert_eq!(desired_file_content("").unwrap(), generate_block());
172 }
173
174 #[test]
175 fn no_markers_appends_with_one_blank_line() {
176 let got = desired_file_content("# My Project\n\nSome docs.\n").unwrap();
177 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
178 assert!(got.ends_with(&generate_block()));
179 }
180
181 #[test]
182 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
183 let got = desired_file_content("# My Project").unwrap();
184 assert!(got.starts_with("# My Project\n\n"));
185 }
186
187 #[test]
188 fn existing_markers_are_replaced_and_everything_else_survives() {
189 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
190 let got = desired_file_content(&before).unwrap();
191 assert!(got.starts_with("before\n\n"));
192 assert!(got.ends_with("\n\nafter\n"));
193 assert!(got.contains(&generate_block()));
194 assert!(!got.contains("stale"));
195 }
196
197 #[test]
198 fn applying_twice_is_idempotent() {
199 let once = desired_file_content("preamble\n").unwrap();
200 let twice = desired_file_content(&once).unwrap();
201 assert_eq!(once, twice);
202 }
203
204 #[test]
205 fn an_unpaired_marker_is_refused_not_guessed_at() {
206 assert_eq!(
207 desired_file_content(&format!("{START}\nno end here\n")),
208 Err(MarkerState::Malformed)
209 );
210 assert_eq!(
211 desired_file_content(&format!("no start\n{END}\n")),
212 Err(MarkerState::Malformed)
213 );
214 }
215
216 #[test]
221 fn end_appearing_before_start_is_malformed_not_reordered() {
222 assert_eq!(
223 desired_file_content(&format!("{END}\n...\n{START}\n...\n")),
224 Err(MarkerState::Malformed)
225 );
226 }
227
228 #[test]
229 fn check_reports_not_present_for_a_missing_file() {
230 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
231 let _ = std::fs::remove_file(&tmp);
232 assert_eq!(check(&tmp).unwrap(), CheckResult::NotPresent);
233 }
234
235 #[test]
236 fn check_reports_matches_generated_after_a_write() {
237 let tmp =
238 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
239 std::fs::write(&tmp, generate_block()).unwrap();
240 assert_eq!(check(&tmp).unwrap(), CheckResult::MatchesGenerated);
241 let _ = std::fs::remove_file(&tmp);
242 }
243
244 #[test]
245 fn check_reports_drifted_for_a_stale_block() {
246 let tmp =
247 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
248 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
249 assert_eq!(check(&tmp).unwrap(), CheckResult::Drifted);
250 let _ = std::fs::remove_file(&tmp);
251 }
252}