1use std::fmt::Write as _;
2
3use sha2::{Digest, Sha256};
4
5use crate::config::types::FilterConfig;
6
7#[derive(Debug)]
12pub struct HashError(serde_json::Error);
13
14impl std::fmt::Display for HashError {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 self.0.fmt(f)
17 }
18}
19
20impl std::error::Error for HashError {
21 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
22 Some(&self.0)
23 }
24}
25
26impl From<serde_json::Error> for HashError {
27 fn from(e: serde_json::Error) -> Self {
28 Self(e)
29 }
30}
31
32pub fn canonical_hash(config: &FilterConfig) -> Result<String, HashError> {
44 let json = serde_json::to_vec(config)?;
45 let digest = Sha256::digest(&json);
46 let mut hex = String::with_capacity(64);
47 for b in &digest {
48 write!(hex, "{b:02x}").ok();
50 }
51 Ok(hex)
52}
53
54#[cfg(test)]
55#[allow(clippy::unwrap_used)]
56mod tests {
57 use super::*;
58
59 fn parse(toml: &str) -> FilterConfig {
60 toml::from_str(toml).unwrap()
61 }
62
63 #[test]
64 fn output_is_64_lowercase_hex_chars() {
65 let cfg = parse(r#"command = "git push""#);
66 let hash = canonical_hash(&cfg).unwrap();
67 assert_eq!(hash.len(), 64, "hash must be 64 chars");
68 assert!(
69 hash.chars()
70 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
71 "hash must be lowercase hex: {hash}"
72 );
73 }
74
75 #[test]
76 fn whitespace_invariance() {
77 let a = parse(r#"command = "git push""#);
78 let b = parse("command = \"git push\"\n\n");
79 assert_eq!(canonical_hash(&a).unwrap(), canonical_hash(&b).unwrap());
80 }
81
82 #[test]
83 fn label_key_order_invariance() {
84 let a = parse(
87 r#"
88command = "git status"
89
90[parse.group.key]
91pattern = "^(.{2}) "
92output = "{1}"
93
94[parse.group.labels]
95M = "modified"
96A = "added"
97"#,
98 );
99 let b = parse(
100 r#"
101command = "git status"
102
103[parse.group.key]
104pattern = "^(.{2}) "
105output = "{1}"
106
107[parse.group.labels]
108A = "added"
109M = "modified"
110"#,
111 );
112 assert_eq!(
113 canonical_hash(&a).unwrap(),
114 canonical_hash(&b).unwrap(),
115 "label key ordering must not affect hash"
116 );
117 }
118
119 #[test]
120 fn different_configs_produce_different_hashes() {
121 let a = parse(r#"command = "git push""#);
122 let b = parse(r#"command = "git pull""#);
123 assert_ne!(canonical_hash(&a).unwrap(), canonical_hash(&b).unwrap());
124 }
125
126 #[test]
127 fn hash_is_stable_across_calls() {
128 let cfg = parse(r#"command = "cargo build""#);
129 let h1 = canonical_hash(&cfg).unwrap();
130 let h2 = canonical_hash(&cfg).unwrap();
131 assert_eq!(h1, h2);
132 }
133
134 #[test]
135 fn explicit_defaults_same_as_implicit() {
136 let implicit = parse(r#"command = "git push""#);
141 let explicit = parse(
142 r#"
143command = "git push"
144skip = []
145keep = []
146step = []
147match_output = []
148section = []
149replace = []
150variant = []
151dedup = false
152strip_ansi = false
153trim_lines = false
154strip_empty_lines = false
155collapse_empty_lines = false
156"#,
157 );
158 assert_eq!(
159 canonical_hash(&implicit).unwrap(),
160 canonical_hash(&explicit).unwrap(),
161 "explicit defaults must hash identically to implicit defaults"
162 );
163 }
164}