Skip to main content

subx_cli/core/matcher/
journal.rs

1//! Transactional journal for file relocation operations.
2//!
3//! The journal records every file operation (rename, copy, move) performed
4//! during a match batch so that the action can be reliably resumed or
5//! rolled back if the process is interrupted. Entries are persisted to
6//! disk using an atomic write-then-rename strategy to guarantee that the
7//! on-disk journal is never left in a partially written state.
8
9use crate::Result;
10use crate::error::SubXError;
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13
14/// Status of an individual journal entry.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum JournalEntryStatus {
18    /// The operation has been recorded but not yet executed on disk.
19    Pending,
20    /// The operation has been successfully executed on disk.
21    Completed,
22}
23
24/// The type of file system operation described by a journal entry.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum JournalOperationType {
28    /// A rename (in-place) of the source file to a new name.
29    Renamed,
30    /// A copy from source to destination preserving the source.
31    Copied,
32    /// A move of the source file to a new directory or name.
33    Moved,
34}
35
36/// A single recorded file operation within a journal batch.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct JournalEntry {
39    /// The type of file system operation performed.
40    pub operation_type: JournalOperationType,
41    /// Absolute path of the original source file.
42    pub source: PathBuf,
43    /// Absolute path of the destination file.
44    pub destination: PathBuf,
45    /// Optional path to a backup of the source file, if a backup was created.
46    pub backup_path: Option<PathBuf>,
47    /// Current execution status of this entry.
48    pub status: JournalEntryStatus,
49    /// Size of the source file in bytes at the time of recording.
50    pub file_size: u64,
51    /// Modification time of the source file as Unix epoch seconds.
52    pub file_mtime: u64,
53}
54
55/// Persistent journal data describing a single match batch.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct JournalData {
58    /// Unique identifier for this batch of operations.
59    pub batch_id: String,
60    /// Creation timestamp as Unix epoch seconds.
61    pub created_at: u64,
62    /// Recorded operations belonging to this batch.
63    pub entries: Vec<JournalEntry>,
64}
65
66impl JournalData {
67    /// Atomically persist this journal to `path`.
68    ///
69    /// The data is first serialized as pretty JSON, written to a
70    /// sibling temporary file, flushed and fsynced, and finally renamed
71    /// into place. Parent directories are created on demand. All
72    /// blocking I/O is executed inside [`tokio::task::spawn_blocking`].
73    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    /// Load and deserialize a journal from `path`.
123    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
133/// Resolve the canonical path to the match journal file.
134///
135/// Honors `XDG_CONFIG_HOME` when set (used in tests), otherwise falls back
136/// to the platform-specific user configuration directory.
137pub 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
147/// Resolve the canonical path to the match batch lock file.
148///
149/// Used to serialize concurrent invocations that modify the journal.
150pub 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}