Skip to main content

release_kit/plan/
operation.rs

1//! The closed set of things an apply may do.
2//!
3//! Every operation names digests and never bytes: the bytes live in the
4//! plan's blob store by digest, so the JSON view stays bounded and an
5//! apply reads exactly the bytes the plan was approved with. Every
6//! operation with a `before` names the digest the target must still hold
7//! at apply time. There is no operation that runs a command, and there
8//! will not be one.
9
10use serde::Serialize;
11
12use crate::digest::Digest;
13use crate::landing::Kind;
14
15/// One typed change an apply makes.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17#[serde(tag = "op", rename_all = "kebab-case")]
18pub enum Operation {
19    /// Write a whole file at `path`.
20    WriteFile {
21        /// The destination, relative to the target.
22        path: String,
23        /// The declared ownership kind of the destination.
24        kind: Kind,
25        /// The digest the destination holds now, or absent.
26        #[serde(skip_serializing_if = "Option::is_none")]
27        before: Option<Digest>,
28        /// The digest of the bytes written.
29        after: Digest,
30    },
31    /// Splice a marked block into the document at `path`.
32    SpliceBlock {
33        /// The document, relative to the target.
34        path: String,
35        /// The begin marker that names the block.
36        marker: String,
37        /// The digest of the block the document holds now, or absent.
38        #[serde(skip_serializing_if = "Option::is_none")]
39        before: Option<Digest>,
40        /// The digest of the block written.
41        after: Digest,
42    },
43    /// Remove a file release-kit owns.
44    RemoveOwnedFile {
45        /// The destination, relative to the target.
46        path: String,
47        /// The digest the destination must still hold.
48        before: Digest,
49    },
50    /// Write the landing record, last.
51    WriteRecord {
52        /// The digest of the record now, or absent.
53        #[serde(skip_serializing_if = "Option::is_none")]
54        before: Option<Digest>,
55        /// The digest of the record written.
56        after: Digest,
57    },
58    /// Move the `rk` pin through the wired manager.
59    UpdatePin {
60        /// The manager that records the pin.
61        manager: String,
62        /// The version the manager records now.
63        before: String,
64        /// The version the manager records after.
65        after: String,
66    },
67}
68
69impl Operation {
70    /// The kind word, for summaries and the fingerprint.
71    #[must_use]
72    pub const fn kind(&self) -> &'static str {
73        match self {
74            Self::WriteFile { .. } => "write-file",
75            Self::SpliceBlock { .. } => "splice-block",
76            Self::RemoveOwnedFile { .. } => "remove-owned-file",
77            Self::WriteRecord { .. } => "write-record",
78            Self::UpdatePin { .. } => "update-pin",
79        }
80    }
81
82    /// The path the operation touches, where it touches one.
83    #[must_use]
84    pub fn path(&self) -> Option<&str> {
85        match self {
86            Self::WriteFile { path, .. }
87            | Self::SpliceBlock { path, .. }
88            | Self::RemoveOwnedFile { path, .. } => Some(path),
89            Self::WriteRecord { .. } | Self::UpdatePin { .. } => None,
90        }
91    }
92
93    /// The canonical line the fingerprint hashes: the kind, the path, the
94    /// declared kind where there is one, and the two digests.
95    #[must_use]
96    pub fn canonical(&self) -> String {
97        match self {
98            Self::WriteFile {
99                path,
100                kind,
101                before,
102                after,
103            } => format!(
104                "write-file\t{path}\t{}\t{}\t{after}",
105                kind.as_str(),
106                digest_or_absent(before.as_ref())
107            ),
108            Self::SpliceBlock {
109                path,
110                marker,
111                before,
112                after,
113            } => format!(
114                "splice-block\t{path}\t{marker}\t{}\t{after}",
115                digest_or_absent(before.as_ref())
116            ),
117            Self::RemoveOwnedFile { path, before } => {
118                format!("remove-owned-file\t{path}\t{before}")
119            }
120            Self::WriteRecord { before, after } => {
121                format!(
122                    "write-record\t{}\t{after}",
123                    digest_or_absent(before.as_ref())
124                )
125            }
126            Self::UpdatePin {
127                manager,
128                before,
129                after,
130            } => format!("update-pin\t{manager}\t{before}\t{after}"),
131        }
132    }
133}
134
135/// The word an absent digest takes in a canonical line.
136fn digest_or_absent(digest: Option<&Digest>) -> String {
137    digest.map_or_else(|| "absent".to_owned(), ToString::to_string)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::Operation;
143    use crate::digest::Digest;
144    use crate::landing::Kind;
145
146    /// The wire shape of each variant, held by snapshot; the tag word is
147    /// what an agent branches on.
148    #[test]
149    fn every_operation_serializes_under_its_tag() {
150        let a = Digest::of(b"a");
151        let b = Digest::of(b"b");
152        let cases = [
153            (
154                Operation::WriteFile {
155                    path: "SECURITY.md".into(),
156                    kind: Kind::Rendered,
157                    before: None,
158                    after: b.clone(),
159                },
160                format!(
161                    r#"{{"op":"write-file","path":"SECURITY.md","kind":"rendered","after":"{b}"}}"#
162                ),
163            ),
164            (
165                Operation::SpliceBlock {
166                    path: "AGENTS.md".into(),
167                    marker: "<!-- BEGIN release-kit -->".into(),
168                    before: Some(a.clone()),
169                    after: b.clone(),
170                },
171                format!(
172                    r#"{{"op":"splice-block","path":"AGENTS.md","marker":"<!-- BEGIN release-kit -->","before":"{a}","after":"{b}"}}"#
173                ),
174            ),
175            (
176                Operation::RemoveOwnedFile {
177                    path: "old.yml".into(),
178                    before: a.clone(),
179                },
180                format!(r#"{{"op":"remove-owned-file","path":"old.yml","before":"{a}"}}"#),
181            ),
182            (
183                Operation::WriteRecord {
184                    before: Some(a.clone()),
185                    after: b.clone(),
186                },
187                format!(r#"{{"op":"write-record","before":"{a}","after":"{b}"}}"#),
188            ),
189            (
190                Operation::UpdatePin {
191                    manager: "mise".into(),
192                    before: "0.1.0".into(),
193                    after: "0.2.0".into(),
194                },
195                r#"{"op":"update-pin","manager":"mise","before":"0.1.0","after":"0.2.0"}"#
196                    .to_owned(),
197            ),
198        ];
199        for (operation, expected) in cases {
200            assert_eq!(
201                serde_json::to_string(&operation).expect("an operation serializes"),
202                expected
203            );
204            assert!(operation.canonical().starts_with(operation.kind()));
205        }
206    }
207}