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