Skip to main content

pwr_core/
transaction.rs

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/// Types of operations that can be logged.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "lowercase")]
14pub enum Operation {
15    Archive,
16    Restore,
17}
18
19/// Status of a logged transaction.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "lowercase")]
22pub enum TransactionStatus {
23    Started,
24    Completed,
25    Failed,
26}
27
28/// A single transaction entry in the log.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Transaction {
31    /// Unique transaction ID.
32    pub id: Uuid,
33
34    /// When the transaction was recorded.
35    pub timestamp: DateTime<Utc>,
36
37    /// What operation was performed.
38    pub operation: Operation,
39
40    /// Project UUID (from .project.toml).
41    pub project_uuid: Uuid,
42
43    /// Project name.
44    pub project_name: String,
45
46    /// Local path.
47    pub local_path: String,
48
49    /// Remote path.
50    pub remote_path: String,
51
52    /// Size in bytes transferred.
53    pub size_bytes: u64,
54
55    /// Current status.
56    pub status: TransactionStatus,
57
58    /// Error message if failed.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub error: Option<String>,
61
62    /// Duration in seconds.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub duration_secs: Option<f64>,
65}
66
67impl Transaction {
68    /// Create a new "started" transaction.
69    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    /// Mark as completed with timing.
93    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    /// Mark as failed with an error.
100    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
108/// Path to the transaction log file.
109pub fn log_path() -> PathBuf {
110    config::transaction_log_path()
111}
112
113/// Append a transaction entry to the log file (JSONL format).
114pub 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
137/// Read all transactions from the log file.
138pub 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
163/// Find any incomplete (Started) transactions — these represent interrupted
164/// operations that may need recovery.
165pub 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
173/// Get recent transactions for a project.
174pub 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}