Skip to main content

qtcloud_devops_cli/model/
release.rs

1use 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}