subx_cli/core/matcher/
journal.rs1use crate::Result;
10use crate::error::SubXError;
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum JournalEntryStatus {
18 Pending,
20 Completed,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum JournalOperationType {
28 Renamed,
30 Copied,
32 Moved,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct JournalEntry {
39 pub operation_type: JournalOperationType,
41 pub source: PathBuf,
43 pub destination: PathBuf,
45 pub backup_path: Option<PathBuf>,
47 pub status: JournalEntryStatus,
49 pub file_size: u64,
51 pub file_mtime: u64,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct JournalData {
58 pub batch_id: String,
60 pub created_at: u64,
62 pub entries: Vec<JournalEntry>,
64}
65
66impl JournalData {
67 pub async fn save(&self, path: &Path) -> Result<()> {
74 let json = serde_json::to_string_pretty(self)?;
75 let path = path.to_path_buf();
76
77 tokio::task::spawn_blocking(move || -> std::io::Result<()> {
78 use std::io::Write;
79
80 if let Some(parent) = path.parent() {
81 std::fs::create_dir_all(parent)?;
82 }
83
84 let tmp_path = match path.file_name() {
85 Some(name) => {
86 let mut tmp_name = std::ffi::OsString::from(".");
87 tmp_name.push(name);
88 tmp_name.push(".tmp");
89 path.with_file_name(tmp_name)
90 }
91 None => {
92 return Err(std::io::Error::new(
93 std::io::ErrorKind::InvalidInput,
94 "journal path has no file name",
95 ));
96 }
97 };
98
99 {
100 let mut file = std::fs::File::create(&tmp_path)?;
101 file.write_all(json.as_bytes())?;
102 file.flush()?;
103 file.sync_all()?;
104 }
105
106 std::fs::rename(&tmp_path, &path)?;
107
108 if let Some(parent) = path.parent() {
109 if let Ok(dir) = std::fs::File::open(parent) {
110 let _ = dir.sync_all();
111 }
112 }
113
114 Ok(())
115 })
116 .await
117 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
118
119 Ok(())
120 }
121
122 pub async fn load(path: &Path) -> Result<Self> {
124 let path = path.to_path_buf();
125 let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
126 .await
127 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
128 let data: JournalData = serde_json::from_str(&content)?;
129 Ok(data)
130 }
131}
132
133pub fn journal_path() -> Result<PathBuf> {
138 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
139 PathBuf::from(xdg_config)
140 } else {
141 dirs::config_dir()
142 .ok_or_else(|| SubXError::config("Unable to determine config directory"))?
143 };
144 Ok(dir.join("subx").join("match_journal.json"))
145}
146
147pub fn lock_path() -> Result<PathBuf> {
151 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
152 PathBuf::from(xdg_config)
153 } else {
154 dirs::config_dir()
155 .ok_or_else(|| SubXError::config("Unable to determine config directory"))?
156 };
157 Ok(dir.join("subx").join("subx.lock"))
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use tempfile::TempDir;
164
165 fn sample_data() -> JournalData {
166 JournalData {
167 batch_id: "batch-123".to_string(),
168 created_at: 1_700_000_000,
169 entries: vec![
170 JournalEntry {
171 operation_type: JournalOperationType::Renamed,
172 source: PathBuf::from("/a/old.srt"),
173 destination: PathBuf::from("/a/new.srt"),
174 backup_path: None,
175 status: JournalEntryStatus::Pending,
176 file_size: 1024,
177 file_mtime: 1_699_999_000,
178 },
179 JournalEntry {
180 operation_type: JournalOperationType::Copied,
181 source: PathBuf::from("/a/src.srt"),
182 destination: PathBuf::from("/b/dst.srt"),
183 backup_path: Some(PathBuf::from("/a/src.srt.bak")),
184 status: JournalEntryStatus::Completed,
185 file_size: 2048,
186 file_mtime: 1_699_999_500,
187 },
188 ],
189 }
190 }
191
192 #[tokio::test]
193 async fn save_and_load_roundtrip() {
194 let temp = TempDir::new().unwrap();
195 let path = temp.path().join("journal.json");
196 let data = sample_data();
197
198 data.save(&path).await.expect("save");
199 assert!(path.exists());
200
201 let loaded = JournalData::load(&path).await.expect("load");
202 assert_eq!(loaded, data);
203 }
204
205 #[tokio::test]
206 async fn save_creates_parent_directories() {
207 let temp = TempDir::new().unwrap();
208 let path = temp.path().join("nested").join("deep").join("journal.json");
209 let data = sample_data();
210
211 data.save(&path).await.expect("save");
212 assert!(path.exists());
213 }
214
215 #[tokio::test]
216 async fn load_missing_file_returns_error() {
217 let temp = TempDir::new().unwrap();
218 let path = temp.path().join("does-not-exist.json");
219 let err = JournalData::load(&path).await.unwrap_err();
220 assert!(matches!(err, SubXError::Io(_)));
221 }
222
223 #[tokio::test]
224 async fn atomic_save_leaves_no_temp_file() {
225 let temp = TempDir::new().unwrap();
226 let path = temp.path().join("journal.json");
227 let data = sample_data();
228
229 data.save(&path).await.expect("save");
230
231 let entries: Vec<_> = std::fs::read_dir(temp.path())
232 .unwrap()
233 .filter_map(|e| e.ok())
234 .map(|e| e.file_name())
235 .collect();
236 assert_eq!(entries.len(), 1);
237 assert_eq!(entries[0], "journal.json");
238 }
239
240 #[test]
241 fn status_serializes_lowercase() {
242 let json = serde_json::to_string(&JournalEntryStatus::Pending).unwrap();
243 assert_eq!(json, "\"pending\"");
244 let json = serde_json::to_string(&JournalEntryStatus::Completed).unwrap();
245 assert_eq!(json, "\"completed\"");
246 }
247
248 #[test]
249 fn operation_type_serializes_lowercase() {
250 let json = serde_json::to_string(&JournalOperationType::Renamed).unwrap();
251 assert_eq!(json, "\"renamed\"");
252 let json = serde_json::to_string(&JournalOperationType::Copied).unwrap();
253 assert_eq!(json, "\"copied\"");
254 let json = serde_json::to_string(&JournalOperationType::Moved).unwrap();
255 assert_eq!(json, "\"moved\"");
256 }
257
258 #[test]
259 fn journal_and_lock_paths_end_with_expected_names() {
260 let p = journal_path().unwrap();
261 assert_eq!(p.file_name().unwrap(), "match_journal.json");
262 assert_eq!(p.parent().unwrap().file_name().unwrap(), "subx");
263
264 let l = lock_path().unwrap();
265 assert_eq!(l.file_name().unwrap(), "subx.lock");
266 assert_eq!(l.parent().unwrap().file_name().unwrap(), "subx");
267 }
268}