qtcloud_devops_cli/model/
release.rs1use std::collections::HashMap;
2use std::io::Write;
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub enum ReleaseStatus {
9 Staged,
10 Published,
11 Cancelled,
12 Retired,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ReleaseEntry {
17 pub id: String,
18 pub version: String,
19 pub status: ReleaseStatus,
20 pub created_at: String,
21}
22
23#[derive(Debug, Clone)]
24pub struct ReleaseRecord {
25 pub id: String,
26 pub version: String,
27 pub status: ReleaseStatus,
28 pub created_at: String,
29 pub updated_at: String,
30}
31
32impl ReleaseRecord {
33 pub fn new_staged(version: &str) -> Self {
34 let now = timestamp();
35 Self {
36 id: uuid::Uuid::new_v4().to_string(),
37 version: version.to_string(),
38 status: ReleaseStatus::Staged,
39 created_at: now.clone(),
40 updated_at: now,
41 }
42 }
43}
44
45fn timestamp() -> String {
46 let d = std::time::SystemTime::now()
47 .duration_since(std::time::UNIX_EPOCH)
48 .unwrap_or_default();
49 format!("{}", d.as_secs())
50}
51
52#[derive(Debug)]
53pub enum TransitionError {
54 NotStaged(String),
55 NotPublished(String),
56}
57
58impl std::fmt::Display for TransitionError {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Self::NotStaged(v) => write!(f, "版本 {} 不处于 Staged 状态", v),
62 Self::NotPublished(v) => write!(f, "版本 {} 不处于 Published 状态", v),
63 }
64 }
65}
66
67impl std::error::Error for TransitionError {}
68
69pub trait Storage {
70 fn save(&mut self, record: &ReleaseRecord) -> Result<(), Box<dyn std::error::Error>>;
71 fn load(&self, version: &str) -> Option<ReleaseRecord>;
72 fn list(&self) -> Vec<ReleaseRecord>;
73}
74
75fn replay_events(path: &Path) -> Vec<ReleaseRecord> {
76 if !path.exists() {
77 return Vec::new();
78 }
79 let mut records: HashMap<String, ReleaseRecord> = HashMap::new();
80 if let Ok(content) = std::fs::read_to_string(path) {
81 for line in content.lines() {
82 if let Ok(entry) = serde_json::from_str::<ReleaseEntry>(line) {
83 let first_created = records
84 .get(&entry.version)
85 .map(|r| r.created_at.clone())
86 .unwrap_or_else(|| entry.created_at.clone());
87 records.insert(
88 entry.version.clone(),
89 ReleaseRecord {
90 id: entry.id,
91 version: entry.version,
92 status: entry.status,
93 created_at: first_created,
94 updated_at: entry.created_at,
95 },
96 );
97 }
98 }
99 }
100 records.into_values().collect()
101}
102
103pub struct FileStorage {
104 events_path: std::path::PathBuf,
105 records: Vec<ReleaseRecord>,
106}
107
108impl FileStorage {
109 pub fn new(base_path: &Path) -> Self {
110 let events_path = base_path.join(".quanttide/devops/release-journal.jsonl");
111 let records = replay_events(&events_path);
112 Self {
113 events_path,
114 records,
115 }
116 }
117}
118
119impl Storage for FileStorage {
120 fn save(&mut self, record: &ReleaseRecord) -> Result<(), Box<dyn std::error::Error>> {
121 if let Some(existing) = self
122 .records
123 .iter_mut()
124 .find(|r| r.version == record.version)
125 {
126 *existing = record.clone();
127 } else {
128 self.records.push(record.clone());
129 }
130
131 let entry = ReleaseEntry {
132 id: record.id.clone(),
133 version: record.version.clone(),
134 status: record.status.clone(),
135 created_at: record.updated_at.clone(),
136 };
137
138 if let Some(parent) = self.events_path.parent() {
139 std::fs::create_dir_all(parent)?;
140 }
141 let json = serde_json::to_string(&entry)?;
142 let mut f = std::fs::OpenOptions::new()
143 .create(true)
144 .append(true)
145 .open(&self.events_path)?;
146 writeln!(f, "{}", json)?;
147
148 Ok(())
149 }
150
151 fn load(&self, version: &str) -> Option<ReleaseRecord> {
152 self.records.iter().find(|r| r.version == version).cloned()
153 }
154
155 fn list(&self) -> Vec<ReleaseRecord> {
156 self.records.clone()
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn make_record(version: &str, status: ReleaseStatus) -> ReleaseRecord {
165 let now = timestamp();
166 ReleaseRecord {
167 id: uuid::Uuid::new_v4().to_string(),
168 version: version.to_string(),
169 status,
170 created_at: now.clone(),
171 updated_at: now,
172 }
173 }
174
175 #[test]
176 fn test_status_debug() {
177 assert_eq!(format!("{:?}", ReleaseStatus::Staged), "Staged");
178 assert_eq!(format!("{:?}", ReleaseStatus::Published), "Published");
179 assert_eq!(format!("{:?}", ReleaseStatus::Cancelled), "Cancelled");
180 assert_eq!(format!("{:?}", ReleaseStatus::Retired), "Retired");
181 }
182
183 #[test]
184 fn test_status_clone_eq() {
185 assert_eq!(ReleaseStatus::Staged, ReleaseStatus::Staged);
186 }
187
188 #[test]
189 fn test_record_new_staged() {
190 let r = ReleaseRecord::new_staged("v1.0.0");
191 assert_eq!(r.version, "v1.0.0");
192 assert_eq!(r.status, ReleaseStatus::Staged);
193 assert!(!r.id.is_empty());
194 assert_eq!(r.created_at, r.updated_at);
195 }
196
197 #[test]
198 fn test_storage_save_and_load() {
199 let dir = tempfile::tempdir().unwrap();
200 let mut s = FileStorage::new(dir.path());
201 let r = make_record("v1.0.0", ReleaseStatus::Staged);
202 s.save(&r).unwrap();
203 assert!(s.load("v1.0.0").is_some());
204 }
205
206 #[test]
207 fn test_storage_update() {
208 let dir = tempfile::tempdir().unwrap();
209 let mut s = FileStorage::new(dir.path());
210 let mut r = make_record("v1.0.0", ReleaseStatus::Staged);
211 s.save(&r).unwrap();
212 r.status = ReleaseStatus::Published;
213 r.updated_at = "999".into();
214 s.save(&r).unwrap();
215 let loaded = s.load("v1.0.0").unwrap();
216 assert_eq!(loaded.status, ReleaseStatus::Published);
217 }
218
219 #[test]
220 fn test_storage_list() {
221 let dir = tempfile::tempdir().unwrap();
222 let mut s = FileStorage::new(dir.path());
223 s.save(&make_record("v1.0.0", ReleaseStatus::Staged))
224 .unwrap();
225 s.save(&make_record("v2.0.0", ReleaseStatus::Published))
226 .unwrap();
227 assert_eq!(s.list().len(), 2);
228 }
229
230 #[test]
231 fn test_storage_persists() {
232 let dir = tempfile::tempdir().unwrap();
233 {
234 let mut s = FileStorage::new(dir.path());
235 s.save(&make_record("v1.0.0", ReleaseStatus::Staged))
236 .unwrap();
237 }
238 {
239 let s = FileStorage::new(dir.path());
240 assert!(s.load("v1.0.0").is_some());
241 }
242 }
243
244 #[test]
245 fn test_journal_appended() {
246 let dir = tempfile::tempdir().unwrap();
247 let mut s = FileStorage::new(dir.path());
248 let r = make_record("v1.0.0", ReleaseStatus::Staged);
249 s.save(&r).unwrap();
250
251 let journal = dir.path().join(".quanttide/devops/release-journal.jsonl");
252 let content = std::fs::read_to_string(&journal).unwrap();
253 assert!(content.contains("v1.0.0"));
254
255 let mut r2 = r.clone();
256 r2.status = ReleaseStatus::Published;
257 s.save(&r2).unwrap();
258 let content = std::fs::read_to_string(&journal).unwrap();
259 assert_eq!(content.trim().lines().count(), 2);
260 }
261
262 #[test]
263 fn test_created_at_preserved() {
264 let dir = tempfile::tempdir().unwrap();
265 let first_ts;
266 {
267 let mut s = FileStorage::new(dir.path());
268 let r = make_record("v1.0.0", ReleaseStatus::Staged);
269 first_ts = r.created_at.clone();
270 s.save(&r).unwrap();
271 }
272 {
273 let mut s = FileStorage::new(dir.path());
274 let mut r = s.load("v1.0.0").unwrap();
275 r.status = ReleaseStatus::Published;
276 r.updated_at = timestamp();
277 s.save(&r).unwrap();
278 }
279 {
280 let s = FileStorage::new(dir.path());
281 let r = s.load("v1.0.0").unwrap();
282 assert_eq!(r.created_at, first_ts);
283 assert_eq!(r.status, ReleaseStatus::Published);
284 }
285 }
286
287 #[test]
288 fn test_transition_error_display() {
289 let e = TransitionError::NotStaged("v1.0.0".into());
290 assert!(e.to_string().contains("Staged"));
291
292 let e = TransitionError::NotPublished("v1.0.0".into());
293 assert!(e.to_string().contains("Published"));
294 }
295}