Skip to main content

semifold_core/
file_edit.rs

1use camino::Utf8PathBuf;
2use serde::Serialize;
3use sha2::{Digest, Sha256};
4
5use crate::{PackageId, VersionSourceId};
6
7#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
8pub struct SharedVersionEdit {
9    pub source: VersionSourceId,
10    pub packages: Vec<PackageId>,
11}
12
13/// Hash of the source content used while planning an edit.
14#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
15#[serde(transparent)]
16pub struct FileHash(String);
17
18impl FileHash {
19    #[must_use]
20    pub fn from_bytes(bytes: &[u8]) -> Self {
21        Self(
22            Sha256::digest(bytes)
23                .iter()
24                .map(|byte| format!("{byte:02x}"))
25                .collect(),
26        )
27    }
28
29    /// Restores a canonical SHA-256 value received across a serialized boundary.
30    pub fn from_sha256(value: impl Into<String>) -> Result<Self, FileHashError> {
31        let value = value.into();
32        if value.len() == 64
33            && value
34                .bytes()
35                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
36        {
37            Ok(Self(value))
38        } else {
39            Err(FileHashError::InvalidSha256 { value })
40        }
41    }
42
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47}
48
49#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
50pub enum FileHashError {
51    #[error("file SHA-256 must contain exactly 64 lowercase hexadecimal characters: {value}")]
52    InvalidSha256 { value: String },
53}
54
55/// Domain operation that produced a planned file edit.
56#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
57#[serde(tag = "kind", rename_all = "snake_case")]
58pub enum EditSource {
59    PackageVersion {
60        package: PackageId,
61    },
62    DependencyVersion {
63        package: PackageId,
64        dependency: PackageId,
65    },
66    WorkspaceDependencies {
67        dependencies: Vec<PackageId>,
68    },
69    WorkspaceManifest {
70        shared_versions: Vec<SharedVersionEdit>,
71        dependencies: Vec<PackageId>,
72    },
73    Changelog {
74        package: PackageId,
75    },
76}
77
78/// Required target state when a planned edit is applied.
79#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
80#[serde(tag = "kind", rename_all = "snake_case")]
81pub enum FileEditExpectation {
82    Existing { hash: FileHash },
83    Missing,
84}
85
86/// A validated, not-yet-applied file content replacement.
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88pub struct FileEdit {
89    pub path: Utf8PathBuf,
90    pub expected: FileEditExpectation,
91    pub new_content: String,
92    pub source: EditSource,
93}
94
95#[cfg(test)]
96mod tests {
97    use super::{FileHash, FileHashError};
98
99    #[test]
100    fn hashes_source_bytes_with_sha256() {
101        assert_eq!(
102            FileHash::from_bytes(b"semifold").as_str(),
103            "acfa94237c0f2abcae06590ebe6bb12455e24f07a9608a2418d618b540aee4e0"
104        );
105    }
106
107    #[test]
108    fn restores_only_canonical_sha256_values() {
109        let value = "acfa94237c0f2abcae06590ebe6bb12455e24f07a9608a2418d618b540aee4e0";
110
111        assert_eq!(FileHash::from_sha256(value).unwrap().as_str(), value);
112        assert!(matches!(
113            FileHash::from_sha256(value.to_uppercase()),
114            Err(FileHashError::InvalidSha256 { .. })
115        ));
116        assert!(matches!(
117            FileHash::from_sha256("short"),
118            Err(FileHashError::InvalidSha256 { .. })
119        ));
120    }
121}