Skip to main content

tokf_common/
hash.rs

1use std::fmt::Write as _;
2
3use sha2::{Digest, Sha256};
4
5use crate::config::types::FilterConfig;
6
7/// Error returned when a [`FilterConfig`] cannot be hashed.
8///
9/// Wraps the underlying serialization error without exposing `serde_json` as
10/// a public dependency of this crate.
11#[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
32/// Compute a deterministic SHA-256 content hash for a [`FilterConfig`].
33///
34/// Two configs that are logically identical (same fields, same values) produce
35/// the same hash regardless of TOML whitespace or key ordering, because the
36/// hash is derived from canonical JSON serialization.
37///
38/// # Errors
39///
40/// Returns a [`HashError`] if `config` cannot be serialized to JSON (should
41/// not happen for well-formed `FilterConfig` values, but callers must handle
42/// it).
43pub 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! to String is infallible; .ok() silences the unused-Result lint.
49        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        // GroupConfig lives at FilterConfig.parse.group — use the correct TOML
85        // path. The key field is ExtractRule { pattern, output } (no `line`).
86        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        // A minimal config relies on serde defaults for all optional fields.
137        // A config that explicitly sets every default value to its zero/empty
138        // equivalent must produce the same hash — proving that #[serde(default)]
139        // fields are handled consistently.
140        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
156inject_path = false
157"#,
158        );
159        assert_eq!(
160            canonical_hash(&implicit).unwrap(),
161            canonical_hash(&explicit).unwrap(),
162            "explicit defaults must hash identically to implicit defaults"
163        );
164    }
165}