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