Skip to main content

agent_doc_hash/
lib.rs

1//! SHA-256 hex hashing helpers shared across agent-doc crates.
2
3use sha2::{Digest, Sha256};
4use std::io;
5use std::path::Path;
6
7/// Compute the SHA-256 hex digest of UTF-8 text content.
8pub fn content_hash(content: &str) -> String {
9    bytes_hash(content.as_bytes())
10}
11
12/// Compute the 12-character SHA-256 hex prefix used in compact diagnostics.
13pub fn short_content_hash(content: &str) -> String {
14    let hash = content_hash(content);
15    hash[..hash.len().min(12)].to_string()
16}
17
18/// Compute the SHA-256 hex digest of arbitrary bytes.
19pub fn bytes_hash(bytes: &[u8]) -> String {
20    let mut hasher = Sha256::new();
21    hasher.update(bytes);
22    hex::encode(hasher.finalize())
23}
24
25/// Compute the SHA-256 hex digest of a canonical document path.
26pub fn path_hash(path: &Path) -> io::Result<String> {
27    let canonical = path.canonicalize()?;
28    Ok(path_string_hash(&canonical.to_string_lossy()))
29}
30
31/// Compute the SHA-256 hex digest of an already-resolved path string.
32pub fn path_string_hash(path: &str) -> String {
33    content_hash(path)
34}
35
36/// Compute the stable document id used by tracked-work and queue sidecars.
37///
38/// Existing documents are identified by the SHA-256 hex digest of their
39/// canonical path. Missing paths keep the historical fallback to the display
40/// string so callers that are already in best-effort cleanup paths do not panic.
41pub fn document_id_for_path(path: &Path) -> String {
42    path_hash(path).unwrap_or_else(|_| path.display().to_string())
43}
44
45#[cfg(test)]
46mod tests {
47    use super::{
48        bytes_hash, content_hash, document_id_for_path, path_hash, path_string_hash,
49        short_content_hash,
50    };
51    use std::path::Path;
52    use tempfile::TempDir;
53
54    #[test]
55    fn content_hash_matches_sha256_hex() {
56        assert_eq!(
57            content_hash("hello"),
58            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
59        );
60        assert_eq!(
61            content_hash(""),
62            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
63        );
64    }
65
66    #[test]
67    fn bytes_hash_matches_text_hash_for_utf8() {
68        assert_eq!(bytes_hash(b"hello"), content_hash("hello"));
69    }
70
71    #[test]
72    fn short_content_hash_uses_stable_sha256_prefix() {
73        assert_eq!(short_content_hash("hello"), &content_hash("hello")[..12]);
74        assert_eq!(short_content_hash(""), &content_hash("")[..12]);
75    }
76
77    #[test]
78    fn path_hash_uses_canonical_path_string() {
79        let dir = TempDir::new().unwrap();
80        let file = dir.path().join("doc.md");
81        std::fs::write(&file, "").unwrap();
82        let canonical = file.canonicalize().unwrap();
83
84        assert_eq!(
85            path_hash(&file).unwrap(),
86            path_string_hash(&canonical.to_string_lossy())
87        );
88        assert_eq!(document_id_for_path(&file), path_hash(&file).unwrap());
89    }
90
91    #[test]
92    fn document_id_for_path_preserves_missing_path_fallback() {
93        let missing = Path::new("definitely/missing.md");
94        assert_eq!(document_id_for_path(missing), missing.display().to_string());
95    }
96}