1use serde::{Deserialize, Serialize};
11
12use crate::digest::Digest;
13use crate::landing::Kind;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "op", rename_all = "kebab-case")]
18pub enum Operation {
19 WriteFile {
21 path: String,
23 kind: Kind,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 before: Option<Digest>,
28 after: Digest,
30 },
31 SpliceBlock {
33 path: String,
35 marker: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 before: Option<Digest>,
40 after: Digest,
42 },
43 RemoveOwnedFile {
45 path: String,
47 before: Digest,
49 },
50 WriteRecord {
52 #[serde(skip_serializing_if = "Option::is_none")]
54 before: Option<Digest>,
55 after: Digest,
57 },
58 UpdatePin {
60 manager: String,
62 before: String,
64 after: String,
66 },
67}
68
69impl Operation {
70 #[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 #[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 #[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
135fn 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 #[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}