Skip to main content

sc/sync/
file.rs

1//! Atomic file operations for sync.
2//!
3//! This module provides safe file operations that prevent data corruption:
4//! - Atomic writes: write to temp file, sync to disk, then rename
5//! - JSONL appending with fsync for durability
6
7use std::fs::{self, File, OpenOptions};
8use std::io::{BufRead, BufReader, BufWriter, Write};
9use std::path::Path;
10
11use crate::sync::types::{SyncError, SyncRecord, SyncResult};
12
13/// Write content to a file atomically.
14///
15/// This function:
16/// 1. Writes content to a temporary file (same path with `.tmp` extension)
17/// 2. Calls `fsync` to ensure data is on disk
18/// 3. Atomically renames the temp file to the target path
19///
20/// If any step fails, the original file (if any) remains untouched.
21///
22/// # Errors
23///
24/// Returns an error if any file operation fails.
25pub fn atomic_write(path: &Path, content: &str) -> SyncResult<()> {
26    let temp_path = path.with_extension("jsonl.tmp");
27
28    // Ensure parent directory exists
29    if let Some(parent) = path.parent() {
30        fs::create_dir_all(parent)?;
31    }
32
33    // Write to temp file
34    {
35        let file = File::create(&temp_path)?;
36        let mut writer = BufWriter::new(file);
37        writer.write_all(content.as_bytes())?;
38        writer.flush()?;
39        // Sync to disk before rename
40        writer.get_ref().sync_all()?;
41    }
42
43    // Atomic rename
44    fs::rename(&temp_path, path)?;
45
46    Ok(())
47}
48
49/// Append a sync record to a JSONL file.
50///
51/// Each record is serialized as a single JSON line and appended to the file.
52/// The file is synced after each append for durability.
53///
54/// # Errors
55///
56/// Returns an error if the file cannot be opened or written.
57pub fn append_jsonl(path: &Path, record: &SyncRecord) -> SyncResult<()> {
58    // Ensure parent directory exists
59    if let Some(parent) = path.parent() {
60        fs::create_dir_all(parent)?;
61    }
62
63    let mut file = OpenOptions::new()
64        .create(true)
65        .append(true)
66        .open(path)?;
67
68    let line = serde_json::to_string(record)?;
69    writeln!(file, "{line}")?;
70    file.sync_all()?;
71
72    Ok(())
73}
74
75/// Write multiple sync records to a JSONL file atomically.
76///
77/// This is more efficient than calling `append_jsonl` repeatedly when
78/// exporting multiple records, as it writes all records in one operation.
79///
80/// # Errors
81///
82/// Returns an error if the file cannot be written.
83pub fn write_jsonl(path: &Path, records: &[SyncRecord]) -> SyncResult<()> {
84    let mut content = String::new();
85    for record in records {
86        let line = serde_json::to_string(record)?;
87        content.push_str(&line);
88        content.push('\n');
89    }
90    atomic_write(path, &content)
91}
92
93/// Read all sync records from a JSONL file.
94///
95/// Each line is parsed as a `SyncRecord`. Invalid lines cause an error
96/// with the line number for debugging.
97///
98/// # Errors
99///
100/// Returns an error if:
101/// - The file cannot be opened
102/// - Any line cannot be parsed as a valid `SyncRecord`
103pub fn read_jsonl(path: &Path) -> SyncResult<Vec<SyncRecord>> {
104    if !path.exists() {
105        return Err(SyncError::FileNotFound(path.display().to_string()));
106    }
107
108    let file = File::open(path)?;
109    let reader = BufReader::new(file);
110    let mut records = Vec::new();
111
112    for (line_num, line_result) in reader.lines().enumerate() {
113        let line = line_result?;
114        if line.trim().is_empty() {
115            continue;
116        }
117
118        let record: SyncRecord = serde_json::from_str(&line).map_err(|e| {
119            SyncError::InvalidRecord {
120                line: line_num + 1,
121                message: e.to_string(),
122            }
123        })?;
124        records.push(record);
125    }
126
127    Ok(records)
128}
129
130/// Count the number of lines in a JSONL file.
131///
132/// This is useful for showing statistics without loading all records into memory.
133///
134/// # Errors
135///
136/// Returns an error if the file cannot be read.
137pub fn count_lines(path: &Path) -> SyncResult<usize> {
138    if !path.exists() {
139        return Ok(0);
140    }
141
142    let file = File::open(path)?;
143    let reader = BufReader::new(file);
144    let count = reader.lines().filter(|l| l.is_ok()).count();
145    Ok(count)
146}
147
148/// Get the size of a file in bytes.
149///
150/// Returns 0 if the file doesn't exist.
151pub fn file_size(path: &Path) -> u64 {
152    fs::metadata(path).map(|m| m.len()).unwrap_or(0)
153}
154
155/// Generate .gitignore content for the .savecontext directory.
156///
157/// Uses a whitelist pattern: ignore everything by default, then explicitly
158/// include only the JSONL sync files that should be tracked in git.
159///
160/// This prevents accidentally committing:
161/// - The SQLite database
162/// - Temporary files
163/// - Any future files that shouldn't be tracked
164#[must_use]
165pub fn gitignore_content() -> &'static str {
166    r#"# SaveContext sync directory
167# Whitelist pattern: ignore everything except JSONL export files
168
169# Ignore everything by default
170*
171
172# Allow .gitignore itself
173!.gitignore
174
175# Allow JSONL sync files (git-friendly format)
176!*.jsonl
177"#
178}
179
180/// Ensure .gitignore exists in the export directory.
181///
182/// Creates a .gitignore file with whitelist pattern if it doesn't exist.
183/// If the file already exists, it is not modified (user may have customized it).
184///
185/// # Errors
186///
187/// Returns an error if the file cannot be written.
188pub fn ensure_gitignore(export_dir: &Path) -> SyncResult<()> {
189    let gitignore_path = export_dir.join(".gitignore");
190
191    if gitignore_path.exists() {
192        return Ok(());
193    }
194
195    // Ensure directory exists
196    fs::create_dir_all(export_dir)?;
197
198    // Write .gitignore
199    let mut file = File::create(&gitignore_path)?;
200    file.write_all(gitignore_content().as_bytes())?;
201    file.sync_all()?;
202
203    Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::storage::sqlite::Session;
210    use crate::sync::types::SessionRecord;
211    use tempfile::TempDir;
212
213    fn make_test_session(id: &str) -> Session {
214        Session {
215            id: id.to_string(),
216            name: "Test Session".to_string(),
217            description: None,
218            branch: None,
219            channel: None,
220            project_path: Some("/test".to_string()),
221            status: "active".to_string(),
222            ended_at: None,
223            created_at: 1000,
224            updated_at: 1000,
225        }
226    }
227
228    #[test]
229    fn test_atomic_write() {
230        let temp_dir = TempDir::new().unwrap();
231        let path = temp_dir.path().join("test.jsonl");
232
233        atomic_write(&path, "line 1\nline 2\n").unwrap();
234
235        let content = fs::read_to_string(&path).unwrap();
236        assert_eq!(content, "line 1\nline 2\n");
237    }
238
239    #[test]
240    fn test_append_jsonl() {
241        let temp_dir = TempDir::new().unwrap();
242        let path = temp_dir.path().join("sessions.jsonl");
243
244        let record = SyncRecord::Session(SessionRecord {
245            data: make_test_session("sess_1"),
246            content_hash: "abc123".to_string(),
247            exported_at: "2025-01-20T00:00:00Z".to_string(),
248        });
249
250        append_jsonl(&path, &record).unwrap();
251        append_jsonl(&path, &record).unwrap();
252
253        let content = fs::read_to_string(&path).unwrap();
254        let lines: Vec<_> = content.lines().filter(|l| !l.is_empty()).collect();
255        assert_eq!(lines.len(), 2);
256    }
257
258    #[test]
259    fn test_read_jsonl() {
260        let temp_dir = TempDir::new().unwrap();
261        let path = temp_dir.path().join("sessions.jsonl");
262
263        let record1 = SyncRecord::Session(SessionRecord {
264            data: make_test_session("sess_1"),
265            content_hash: "abc123".to_string(),
266            exported_at: "2025-01-20T00:00:00Z".to_string(),
267        });
268        let record2 = SyncRecord::Session(SessionRecord {
269            data: make_test_session("sess_2"),
270            content_hash: "def456".to_string(),
271            exported_at: "2025-01-20T00:00:01Z".to_string(),
272        });
273
274        write_jsonl(&path, &[record1, record2]).unwrap();
275
276        let records = read_jsonl(&path).unwrap();
277        assert_eq!(records.len(), 2);
278    }
279
280    #[test]
281    fn test_count_lines() {
282        let temp_dir = TempDir::new().unwrap();
283        let path = temp_dir.path().join("test.jsonl");
284
285        // Non-existent file
286        assert_eq!(count_lines(&path).unwrap(), 0);
287
288        // File with content
289        fs::write(&path, "line1\nline2\nline3\n").unwrap();
290        assert_eq!(count_lines(&path).unwrap(), 3);
291    }
292
293    #[test]
294    fn test_file_not_found() {
295        let result = read_jsonl(Path::new("/nonexistent/file.jsonl"));
296        assert!(matches!(result, Err(SyncError::FileNotFound(_))));
297    }
298
299    #[test]
300    fn test_gitignore_content() {
301        let content = gitignore_content();
302
303        // Should contain whitelist pattern
304        assert!(content.contains("*"), "Should ignore everything by default");
305        assert!(content.contains("!*.jsonl"), "Should whitelist JSONL files");
306        assert!(content.contains("!.gitignore"), "Should whitelist itself");
307    }
308
309    #[test]
310    fn test_ensure_gitignore_creates_file() {
311        let temp_dir = TempDir::new().unwrap();
312        let gitignore_path = temp_dir.path().join(".gitignore");
313
314        // Should not exist yet
315        assert!(!gitignore_path.exists());
316
317        ensure_gitignore(temp_dir.path()).unwrap();
318
319        // Should now exist
320        assert!(gitignore_path.exists());
321
322        // Verify content
323        let content = fs::read_to_string(&gitignore_path).unwrap();
324        assert!(content.contains("!*.jsonl"));
325    }
326
327    #[test]
328    fn test_ensure_gitignore_does_not_overwrite() {
329        let temp_dir = TempDir::new().unwrap();
330        let gitignore_path = temp_dir.path().join(".gitignore");
331
332        // Create custom gitignore
333        fs::write(&gitignore_path, "# Custom content\n*.tmp\n").unwrap();
334
335        // Should not overwrite
336        ensure_gitignore(temp_dir.path()).unwrap();
337
338        let content = fs::read_to_string(&gitignore_path).unwrap();
339        assert!(content.contains("Custom content"));
340        assert!(!content.contains("!*.jsonl"));
341    }
342}