1use std::path::{Path, PathBuf};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::{DeployError, Result};
7use crate::types::DeployPlan;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct HistoryEntry {
11 pub id: String,
12 pub status: String,
13 pub path: String,
14 pub timestamp: DateTime<Utc>,
15 pub env: String,
16 pub target: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20pub struct DeployHistoryIndex {
21 pub entries: Vec<HistoryEntry>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DeployHistoryRecord {
26 pub id: String,
27 pub timestamp: DateTime<Utc>,
28 pub env: String,
29 pub target: String,
30 pub target_kind: String,
31 pub status: String,
32 pub services: Vec<String>,
33 pub git_sha: Option<String>,
34 pub project_version: Option<String>,
35 pub digests: std::collections::BTreeMap<String, String>,
36 pub kubernetes_context: Option<String>,
37 pub namespace: Option<String>,
38 pub summary: String,
39 pub error: Option<String>,
40 pub plan: DeployPlan,
41}
42
43pub struct DeployHistoryStore {
44 pub dir: PathBuf,
45}
46
47impl DeployHistoryStore {
48 pub fn new(dir: impl Into<PathBuf>) -> Self {
49 Self { dir: dir.into() }
50 }
51
52 pub fn index_path(&self) -> PathBuf {
53 self.dir.join("index.json")
54 }
55
56 pub fn load_index(&self) -> Result<DeployHistoryIndex> {
57 let path = self.index_path();
58 if !path.exists() {
59 return Ok(DeployHistoryIndex::default());
60 }
61 let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
62 serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))
63 }
64
65 pub fn write_record(&self, record: &DeployHistoryRecord) -> Result<PathBuf> {
66 std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
67 let file_name = format!("{}.json", record.id);
68 let path = self.dir.join(&file_name);
69 let body =
70 serde_json::to_string_pretty(record).map_err(|e| DeployError::Io(e.to_string()))?;
71 std::fs::write(&path, body).map_err(|e| DeployError::Io(e.to_string()))?;
72
73 let mut index = self.load_index()?;
74 index.entries.retain(|e| e.id != record.id);
75 index.entries.insert(
76 0,
77 HistoryEntry {
78 id: record.id.clone(),
79 status: record.status.clone(),
80 path: file_name,
81 timestamp: record.timestamp,
82 env: record.env.clone(),
83 target: record.target.clone(),
84 },
85 );
86 index.entries.truncate(200);
88 let index_body =
89 serde_json::to_string_pretty(&index).map_err(|e| DeployError::Io(e.to_string()))?;
90 std::fs::write(self.index_path(), index_body)
91 .map_err(|e| DeployError::Io(e.to_string()))?;
92 Ok(path)
93 }
94
95 pub fn latest_status(&self, target: &str, env: &str) -> Result<Option<HistoryEntry>> {
96 let index = self.load_index()?;
97 Ok(index
98 .entries
99 .into_iter()
100 .find(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env)))
101 }
102
103 pub fn list(&self, target: &str, env: &str, limit: usize) -> Result<Vec<HistoryEntry>> {
104 let index = self.load_index()?;
105 Ok(index
106 .entries
107 .into_iter()
108 .filter(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env))
109 .take(limit)
110 .collect())
111 }
112}
113
114pub fn make_record_id(env: &str, target: &str, ts: DateTime<Utc>) -> String {
115 let safe_target: String = target
116 .chars()
117 .map(|c| {
118 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
119 c
120 } else {
121 '_'
122 }
123 })
124 .collect();
125 format!("{}-{}-{}", ts.format("%Y%m%dT%H%M%SZ"), env, safe_target)
126}
127
128pub fn record_from_plan(
129 plan: &DeployPlan,
130 ok: bool,
131 summary: String,
132 error: Option<String>,
133 kubernetes_context: Option<String>,
134) -> DeployHistoryRecord {
135 let ts = Utc::now();
136 let id = make_record_id(&plan.env, &plan.target.label(), ts);
137 let mut digests = std::collections::BTreeMap::new();
138 for svc in &plan.services {
139 if let Some(d) = &svc.digest {
140 digests.insert(svc.name.clone(), d.clone());
141 }
142 }
143 DeployHistoryRecord {
144 id,
145 timestamp: ts,
146 env: plan.env.clone(),
147 target: plan.target.label(),
148 target_kind: plan.target.kind_str().into(),
149 status: if ok { "success".into() } else { "failed".into() },
150 services: plan.order.clone(),
151 git_sha: plan.git_sha.clone(),
152 project_version: Some(plan.project_version.clone()),
153 digests,
154 kubernetes_context,
155 namespace: plan
156 .services
157 .first()
158 .and_then(|s| s.deploy.namespace.clone()),
159 summary,
160 error,
161 plan: plan.clone(),
162 }
163}
164
165#[allow(dead_code)]
167pub fn history_dir(root: &Path, rel: &Path) -> PathBuf {
168 root.join(rel)
169}