Skip to main content

harmont_cli/commands/cache/
manifest.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
7pub struct Manifest {
8    pub version: u32,
9    pub images: BTreeMap<String, String>,
10}
11
12impl Manifest {
13    #[must_use]
14    pub const fn new() -> Self {
15        Self {
16            version: 1,
17            images: BTreeMap::new(),
18        }
19    }
20
21    /// SHA-256 content hash of the JSON-serialized manifest, truncated to 16
22    /// hex characters.
23    ///
24    /// # Panics
25    ///
26    /// Panics if the manifest cannot be serialized to JSON (should never
27    /// happen for this type).
28    #[must_use]
29    #[allow(clippy::expect_used)]
30    pub fn content_hash(&self) -> String {
31        let json = serde_json::to_string(self).expect("manifest serialization cannot fail");
32        let hash = Sha256::digest(json.as_bytes());
33        hex::encode(&hash[..8])
34    }
35}
36
37/// Convert a Docker image tag to the corresponding tar filename.
38///
39/// `"harmont-local/base:a1b2c3d4"` → `"base--a1b2c3d4.tar"`
40#[must_use]
41pub fn tar_name_for_tag(tag: &str) -> String {
42    let stripped = tag.strip_prefix("harmont-local/").unwrap_or(tag);
43    format!("{}.tar", stripped.replace(':', "--"))
44}
45
46/// Inverse of [`tar_name_for_tag`].
47///
48/// `"base--a1b2c3d4.tar"` → `Some("harmont-local/base:a1b2c3d4")`
49#[must_use]
50pub fn tag_from_tar_name(filename: &str) -> Option<String> {
51    let stem = filename.strip_suffix(".tar")?;
52    let (name, hash) = stem.split_once("--")?;
53    Some(format!("harmont-local/{name}:{hash}"))
54}
55
56#[cfg(test)]
57#[allow(clippy::unwrap_used, reason = "unit tests")]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn tar_filename_from_tag() {
63        assert_eq!(
64            tar_name_for_tag("harmont-local/base:a1b2c3d4"),
65            "base--a1b2c3d4.tar"
66        );
67    }
68
69    #[test]
70    fn tag_from_tar_filename() {
71        assert_eq!(
72            tag_from_tar_name("base--a1b2c3d4.tar"),
73            Some("harmont-local/base:a1b2c3d4".to_string())
74        );
75    }
76
77    #[test]
78    fn tag_from_bad_filename_returns_none() {
79        assert_eq!(tag_from_tar_name("random-file.tar"), None);
80        assert_eq!(tag_from_tar_name("no-extension"), None);
81    }
82
83    #[test]
84    fn manifest_round_trip() {
85        let mut m = Manifest::new();
86        m.images
87            .insert("base".to_string(), "harmont-local/base:abc123".to_string());
88
89        let json = serde_json::to_string(&m).unwrap();
90        let m2: Manifest = serde_json::from_str(&json).unwrap();
91        assert_eq!(m, m2);
92    }
93
94    #[test]
95    fn manifest_content_hash_is_deterministic() {
96        let mut m = Manifest::new();
97        m.images.insert(
98            "step1".to_string(),
99            "harmont-local/step1:deadbeef".to_string(),
100        );
101
102        let h1 = m.content_hash();
103        let h2 = m.content_hash();
104        assert_eq!(h1, h2);
105        assert_eq!(h1.len(), 16);
106        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
107    }
108}