1use 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
13pub fn atomic_write(path: &Path, content: &str) -> SyncResult<()> {
26 let temp_path = path.with_extension("jsonl.tmp");
27
28 if let Some(parent) = path.parent() {
30 fs::create_dir_all(parent)?;
31 }
32
33 {
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 writer.get_ref().sync_all()?;
41 }
42
43 fs::rename(&temp_path, path)?;
45
46 Ok(())
47}
48
49pub fn append_jsonl(path: &Path, record: &SyncRecord) -> SyncResult<()> {
58 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
75pub 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
93pub 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
130pub 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
148pub fn file_size(path: &Path) -> u64 {
152 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
153}
154
155#[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
180pub 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 fs::create_dir_all(export_dir)?;
197
198 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 assert_eq!(count_lines(&path).unwrap(), 0);
287
288 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 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 assert!(!gitignore_path.exists());
316
317 ensure_gitignore(temp_dir.path()).unwrap();
318
319 assert!(gitignore_path.exists());
321
322 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 fs::write(&gitignore_path, "# Custom content\n*.tmp\n").unwrap();
334
335 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}