1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Prefix {
6 Default,
8 FailFast,
10 Advisory,
12 Fix,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct HookCommand {
27 pub prefix: Prefix,
29 pub command: String,
31 pub glob: Option<String>,
34}
35
36pub fn parse(content: &str) -> Vec<HookCommand> {
64 content
65 .lines()
66 .filter_map(|line| parse_line(line.trim()))
67 .collect()
68}
69
70fn parse_line(line: &str) -> Option<HookCommand> {
73 if line.is_empty() || line.starts_with('#') {
74 return None;
75 }
76
77 let (prefix, rest) = extract_prefix(line);
78 let rest = rest.trim();
79
80 let (command, glob) = extract_glob(rest);
81
82 Some(HookCommand {
83 prefix,
84 command: command.to_string(),
85 glob,
86 })
87}
88
89fn extract_prefix(line: &str) -> (Prefix, &str) {
91 if let Some(rest) = line.strip_prefix('!') {
92 (Prefix::FailFast, rest)
93 } else if let Some(rest) = line.strip_prefix('?') {
94 (Prefix::Advisory, rest)
95 } else if let Some(rest) = line.strip_prefix('~') {
96 (Prefix::Fix, rest)
97 } else {
98 (Prefix::Default, line)
99 }
100}
101
102fn extract_glob(text: &str) -> (&str, Option<String>) {
109 if let Some(pos) = text.rfind(|c: char| c.is_ascii_whitespace()) {
111 let last_token = &text[pos + 1..];
112 if !last_token.starts_with('"') && is_glob(last_token) {
113 let command = text[..pos].trim_end();
114 return (command, Some(last_token.to_string()));
115 }
116 }
117 (text, None)
118}
119
120fn is_glob(token: &str) -> bool {
126 if token.contains('*') || token.contains('[') {
127 return true;
128 }
129 if let Some(open) = token.find('{')
131 && let Some(close) = token[open..].find('}')
132 {
133 let inner = &token[open + 1..open + close];
134 return inner.contains(',');
135 }
136 false
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn blank_lines_are_skipped() {
145 let commands = parse("\n\n \n");
146 assert!(commands.is_empty());
147 }
148
149 #[test]
150 fn comment_lines_are_skipped() {
151 let commands = parse("# This is a comment\n # indented comment\n");
152 assert!(commands.is_empty());
153 }
154
155 #[test]
156 fn simple_command_no_prefix_no_glob() {
157 let commands = parse("dprint check\n");
158 assert_eq!(commands.len(), 1);
159 assert_eq!(commands[0].prefix, Prefix::Default);
160 assert_eq!(commands[0].command, "dprint check");
161 assert_eq!(commands[0].glob, None);
162 }
163
164 #[test]
165 fn fail_fast_prefix() {
166 let commands = parse("!cargo build --workspace\n");
167 assert_eq!(commands.len(), 1);
168 assert_eq!(commands[0].prefix, Prefix::FailFast);
169 assert_eq!(commands[0].command, "cargo build --workspace");
170 assert_eq!(commands[0].glob, None);
171 }
172
173 #[test]
174 fn advisory_prefix() {
175 let commands = parse("? detekt --input modules/ *.kt\n");
176 assert_eq!(commands.len(), 1);
177 assert_eq!(commands[0].prefix, Prefix::Advisory);
178 assert_eq!(commands[0].command, "detekt --input modules/");
179 assert_eq!(commands[0].glob, Some("*.kt".to_string()));
180 }
181
182 #[test]
183 fn advisory_prefix_no_space() {
184 let commands = parse("?detekt --input modules/ *.kt\n");
185 assert_eq!(commands.len(), 1);
186 assert_eq!(commands[0].prefix, Prefix::Advisory);
187 assert_eq!(commands[0].command, "detekt --input modules/");
188 assert_eq!(commands[0].glob, Some("*.kt".to_string()));
189 }
190
191 #[test]
192 fn trailing_glob_pattern() {
193 let commands = parse("cargo clippy --workspace -- -D warnings *.rs\n");
194 assert_eq!(commands.len(), 1);
195 assert_eq!(
196 commands[0].command,
197 "cargo clippy --workspace -- -D warnings"
198 );
199 assert_eq!(commands[0].glob, Some("*.rs".to_string()));
200 }
201
202 #[test]
203 fn command_with_arguments_no_glob() {
204 let commands = parse("prettier --check \"**/*.md\"\n");
205 assert_eq!(commands.len(), 1);
206 assert_eq!(commands[0].command, "prettier --check \"**/*.md\"");
207 }
208
209 #[test]
210 fn command_with_msg_substitution() {
211 let commands = parse("! git std check --file {msg}\n");
212 assert_eq!(commands.len(), 1);
213 assert_eq!(commands[0].prefix, Prefix::FailFast);
214 assert_eq!(commands[0].command, "git std check --file {msg}");
215 assert_eq!(commands[0].glob, None);
216 }
217
218 #[test]
219 fn mixed_content() {
220 let input = "\
221# ── Formatting ────────────────────────────
222dprint check
223prettier --check \"**/*.md\"
224
225# ── Rust ──────────────────────────────────
226cargo clippy --workspace -- -D warnings *.rs
227cargo test --workspace --lib *.rs
228
229# ── Android ───────────────────────────────
230? detekt --input modules/ *.kt
231";
232 let commands = parse(input);
233 assert_eq!(commands.len(), 5);
234
235 assert_eq!(commands[0].prefix, Prefix::Default);
236 assert_eq!(commands[0].command, "dprint check");
237 assert_eq!(commands[0].glob, None);
238
239 assert_eq!(commands[1].prefix, Prefix::Default);
240 assert_eq!(commands[1].command, "prettier --check \"**/*.md\"");
241 assert_eq!(commands[1].glob, None);
242
243 assert_eq!(commands[2].prefix, Prefix::Default);
244 assert_eq!(
245 commands[2].command,
246 "cargo clippy --workspace -- -D warnings"
247 );
248 assert_eq!(commands[2].glob, Some("*.rs".to_string()));
249
250 assert_eq!(commands[3].prefix, Prefix::Default);
251 assert_eq!(commands[3].command, "cargo test --workspace --lib");
252 assert_eq!(commands[3].glob, Some("*.rs".to_string()));
253
254 assert_eq!(commands[4].prefix, Prefix::Advisory);
255 assert_eq!(commands[4].command, "detekt --input modules/");
256 assert_eq!(commands[4].glob, Some("*.kt".to_string()));
257 }
258
259 #[test]
260 fn commit_msg_hooks_file() {
261 let input = "! git std check --file {msg}\n";
262 let commands = parse(input);
263 assert_eq!(commands.len(), 1);
264 assert_eq!(commands[0].prefix, Prefix::FailFast);
265 assert_eq!(commands[0].command, "git std check --file {msg}");
266 assert_eq!(commands[0].glob, None);
267 }
268
269 #[test]
270 fn glob_with_brackets() {
271 let commands = parse("lint src/[a-z]*.rs\n");
272 assert_eq!(commands.len(), 1);
273 assert_eq!(commands[0].command, "lint");
274 assert_eq!(commands[0].glob, Some("src/[a-z]*.rs".to_string()));
275 }
276
277 #[test]
278 fn glob_with_braces() {
279 let commands = parse("check *.{js,ts}\n");
280 assert_eq!(commands.len(), 1);
281 assert_eq!(commands[0].command, "check");
282 assert_eq!(commands[0].glob, Some("*.{js,ts}".to_string()));
283 }
284
285 #[test]
286 fn single_word_command() {
287 let commands = parse("lint\n");
288 assert_eq!(commands.len(), 1);
289 assert_eq!(commands[0].command, "lint");
290 assert_eq!(commands[0].glob, None);
291 }
292
293 #[test]
294 fn whitespace_handling() {
295 let commands = parse(" cargo test \n");
296 assert_eq!(commands.len(), 1);
297 assert_eq!(commands[0].command, "cargo test");
298 assert_eq!(commands[0].glob, None);
299 }
300
301 #[test]
302 fn empty_input() {
303 let commands = parse("");
304 assert!(commands.is_empty());
305 }
306
307 #[test]
308 fn prefix_display_coverage() {
309 assert_ne!(Prefix::Default, Prefix::FailFast);
310 assert_ne!(Prefix::Default, Prefix::Advisory);
311 assert_ne!(Prefix::FailFast, Prefix::Advisory);
312 }
313
314 #[test]
317 fn prefix_only_bang_produces_empty_command() {
318 let commands = parse("!\n");
319 assert_eq!(commands.len(), 1);
320 assert_eq!(commands[0].prefix, Prefix::FailFast);
321 assert_eq!(commands[0].command, "");
322 assert_eq!(commands[0].glob, None);
323 }
324
325 #[test]
326 fn prefix_only_question_mark_produces_empty_command() {
327 let commands = parse("?\n");
328 assert_eq!(commands.len(), 1);
329 assert_eq!(commands[0].prefix, Prefix::Advisory);
330 assert_eq!(commands[0].command, "");
331 assert_eq!(commands[0].glob, None);
332 }
333
334 #[test]
335 fn whitespace_only_lines_are_skipped() {
336 let commands = parse(" \n\t\n \t \n");
337 assert!(commands.is_empty());
338 }
339
340 #[test]
341 fn lines_with_only_tabs() {
342 let commands = parse("\t\t\t\n");
343 assert!(commands.is_empty());
344 }
345
346 #[test]
347 fn malformed_glob_no_star_or_bracket() {
348 let commands = parse("cargo test src/main.rs\n");
351 assert_eq!(commands.len(), 1);
352 assert_eq!(commands[0].command, "cargo test src/main.rs");
353 assert_eq!(commands[0].glob, None);
354 }
355
356 #[test]
357 fn braces_without_comma_are_not_glob() {
358 let commands = parse("echo {msg}\n");
360 assert_eq!(commands.len(), 1);
361 assert_eq!(commands[0].command, "echo {msg}");
362 assert_eq!(commands[0].glob, None);
363 }
364
365 #[test]
366 fn braces_with_comma_are_glob() {
367 let commands = parse("lint src/*.{js,ts}\n");
368 assert_eq!(commands.len(), 1);
369 assert_eq!(commands[0].command, "lint");
370 assert_eq!(commands[0].glob, Some("src/*.{js,ts}".to_string()));
371 }
372
373 #[test]
374 fn empty_braces_are_not_glob() {
375 let commands = parse("echo {}\n");
376 assert_eq!(commands.len(), 1);
377 assert_eq!(commands[0].command, "echo {}");
378 assert_eq!(commands[0].glob, None);
379 }
380
381 #[test]
382 fn prefix_with_space_before_command() {
383 let commands = parse("! cargo test\n");
385 assert_eq!(commands.len(), 1);
386 assert_eq!(commands[0].prefix, Prefix::FailFast);
387 assert_eq!(commands[0].command, "cargo test");
388 }
389
390 #[test]
391 fn comment_after_whitespace() {
392 let commands = parse(" # indented comment\n");
394 assert!(commands.is_empty());
395 }
396
397 #[test]
398 fn mixed_edge_cases() {
399 let input = "\n\
400 !\n\
401 ?\n\
402 #comment\n\
403 \n\
404 cargo test\n\
405 \t\n";
406 let commands = parse(input);
407 assert_eq!(commands.len(), 3);
408 assert_eq!(commands[0].prefix, Prefix::FailFast);
409 assert_eq!(commands[0].command, "");
410 assert_eq!(commands[1].prefix, Prefix::Advisory);
411 assert_eq!(commands[1].command, "");
412 assert_eq!(commands[2].prefix, Prefix::Default);
413 assert_eq!(commands[2].command, "cargo test");
414 }
415
416 #[test]
419 fn fix_prefix_with_space() {
420 let commands = parse("~ cargo fmt\n");
421 assert_eq!(commands.len(), 1);
422 assert_eq!(commands[0].prefix, Prefix::Fix);
423 assert_eq!(commands[0].command, "cargo fmt");
424 assert_eq!(commands[0].glob, None);
425 }
426
427 #[test]
428 fn fix_prefix_no_space() {
429 let commands = parse("~cargo fmt\n");
430 assert_eq!(commands.len(), 1);
431 assert_eq!(commands[0].prefix, Prefix::Fix);
432 assert_eq!(commands[0].command, "cargo fmt");
433 assert_eq!(commands[0].glob, None);
434 }
435
436 #[test]
437 fn fix_prefix_with_glob() {
438 let commands = parse("~ dprint fmt *.rs\n");
439 assert_eq!(commands.len(), 1);
440 assert_eq!(commands[0].prefix, Prefix::Fix);
441 assert_eq!(commands[0].command, "dprint fmt");
442 assert_eq!(commands[0].glob, Some("*.rs".to_string()));
443 }
444
445 #[test]
446 fn fix_prefix_only_produces_empty_command() {
447 let commands = parse("~\n");
448 assert_eq!(commands.len(), 1);
449 assert_eq!(commands[0].prefix, Prefix::Fix);
450 assert_eq!(commands[0].command, "");
451 assert_eq!(commands[0].glob, None);
452 }
453
454 #[test]
455 fn fix_prefix_distinct_from_others() {
456 assert_ne!(Prefix::Fix, Prefix::Default);
457 assert_ne!(Prefix::Fix, Prefix::FailFast);
458 assert_ne!(Prefix::Fix, Prefix::Advisory);
459 }
460
461 #[test]
462 fn fix_prefix_in_mixed_content() {
463 let input = "! cargo clippy\n~ cargo fmt\n? cargo test\n";
464 let commands = parse(input);
465 assert_eq!(commands.len(), 3);
466 assert_eq!(commands[0].prefix, Prefix::FailFast);
467 assert_eq!(commands[1].prefix, Prefix::Fix);
468 assert_eq!(commands[2].prefix, Prefix::Advisory);
469 }
470}