1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::fs::{self, OpenOptions};
4use std::io::Write;
5use std::path::PathBuf;
6use uuid::Uuid;
7
8use crate::config;
9use crate::error::Result;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "lowercase")]
14pub enum Operation {
15 Archive,
16 Restore,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "lowercase")]
22pub enum TransactionStatus {
23 Started,
24 Completed,
25 Failed,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Transaction {
31 pub id: Uuid,
33
34 pub timestamp: DateTime<Utc>,
36
37 pub operation: Operation,
39
40 pub project_uuid: Uuid,
42
43 pub project_name: String,
45
46 pub local_path: String,
48
49 pub remote_path: String,
51
52 pub size_bytes: u64,
54
55 pub status: TransactionStatus,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub error: Option<String>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub duration_secs: Option<f64>,
65}
66
67impl Transaction {
68 pub fn started(
70 operation: Operation,
71 project_uuid: Uuid,
72 project_name: &str,
73 local_path: &str,
74 remote_path: &str,
75 size_bytes: u64,
76 ) -> Self {
77 Self {
78 id: Uuid::new_v4(),
79 timestamp: Utc::now(),
80 operation,
81 project_uuid,
82 project_name: project_name.to_string(),
83 local_path: local_path.to_string(),
84 remote_path: remote_path.to_string(),
85 size_bytes,
86 status: TransactionStatus::Started,
87 error: None,
88 duration_secs: None,
89 }
90 }
91
92 pub fn completed(&mut self, start: DateTime<Utc>) {
94 self.status = TransactionStatus::Completed;
95 self.duration_secs =
96 Some((Utc::now() - start).num_milliseconds() as f64 / 1000.0);
97 }
98
99 pub fn failed(&mut self, error: &str, start: DateTime<Utc>) {
101 self.status = TransactionStatus::Failed;
102 self.error = Some(error.to_string());
103 self.duration_secs =
104 Some((Utc::now() - start).num_milliseconds() as f64 / 1000.0);
105 }
106}
107
108pub fn log_path() -> PathBuf {
110 config::transaction_log_path()
111}
112
113pub fn append_transaction(transaction: &Transaction) -> Result<()> {
115 let dir = log_path().parent().unwrap().to_path_buf();
116 fs::create_dir_all(&dir)?;
117
118 let mut file = OpenOptions::new()
119 .create(true)
120 .append(true)
121 .open(log_path())?;
122
123 let line = serde_json::to_string(transaction)?;
124 writeln!(file, "{}", line)?;
125
126 log::info!(
127 "Transaction {} logged: {} of '{}' ({})",
128 transaction.id,
129 serde_json::to_string(&transaction.operation).unwrap_or_default(),
130 transaction.project_name,
131 serde_json::to_string(&transaction.status).unwrap_or_default(),
132 );
133
134 Ok(())
135}
136
137pub fn read_transactions() -> Result<Vec<Transaction>> {
139 let path = log_path();
140 if !path.exists() {
141 return Ok(Vec::new());
142 }
143
144 let contents = fs::read_to_string(&path)?;
145 let mut transactions = Vec::new();
146
147 for line in contents.lines() {
148 let line = line.trim();
149 if line.is_empty() {
150 continue;
151 }
152 match serde_json::from_str::<Transaction>(line) {
153 Ok(tx) => transactions.push(tx),
154 Err(e) => {
155 log::warn!("Skipping malformed transaction line: {}", e);
156 }
157 }
158 }
159
160 Ok(transactions)
161}
162
163pub fn find_incomplete_transactions() -> Result<Vec<Transaction>> {
166 let all = read_transactions()?;
167 Ok(all
168 .into_iter()
169 .filter(|tx| tx.status == TransactionStatus::Started)
170 .collect())
171}
172
173pub fn recent_for_project(project_uuid: &Uuid, limit: usize) -> Result<Vec<Transaction>> {
175 let all = read_transactions()?;
176 let mut matching: Vec<_> = all
177 .into_iter()
178 .filter(|tx| tx.project_uuid == *project_uuid)
179 .collect();
180 matching.reverse();
181 matching.truncate(limit);
182 Ok(matching)
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_transaction_serialization() {
191 let tx = Transaction::started(
192 Operation::Archive,
193 Uuid::new_v4(),
194 "testproj",
195 "/home/jacob/Projects/testproj",
196 "nas:/srv/projects/testproj",
197 1_000_000,
198 );
199 let json = serde_json::to_string(&tx).unwrap();
200 let decoded: Transaction = serde_json::from_str(&json).unwrap();
201 assert_eq!(decoded.project_name, "testproj");
202 assert_eq!(decoded.status, TransactionStatus::Started);
203 assert_eq!(decoded.operation, Operation::Archive);
204 }
205}