1use crate::journal::{self, Journal, JournalEntry, JOURNAL_FILENAME, LOCK_FILENAME};
4use crate::platform;
5use similar::TextDiff;
6use std::fs::File;
7use std::io::{self, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use tempfile::NamedTempFile;
12
13pub fn read_file(path: &Path) -> io::Result<String> {
15 let mut file = File::open(path)?;
16 let mut content = String::new();
17 file.read_to_string(&mut content)?;
18 Ok(content)
19}
20
21pub fn read_file_encoded(path: &Path, encoding: &str) -> io::Result<String> {
25 if encoding.eq_ignore_ascii_case("utf-8") {
26 return read_file(path);
27 }
28 let bytes = std::fs::read(path)?;
29 let enc = resolve_encoding(encoding)?;
30 let (decoded, _, had_errors) = enc.decode(&bytes);
31 if had_errors {
32 return Err(io::Error::new(
33 io::ErrorKind::InvalidData,
34 format!("Failed to decode file as {}", encoding),
35 ));
36 }
37 Ok(decoded.into_owned())
38}
39
40fn resolve_encoding(label: &str) -> io::Result<&'static encoding_rs::Encoding> {
43 if let Some(enc) = encoding_rs::Encoding::for_label(label.as_bytes()) {
45 return Ok(enc);
46 }
47 let alias = match label.to_ascii_lowercase().as_str() {
49 "latin-1" | "latin1" => Some("iso-8859-1"),
50 "ascii" | "us-ascii" => Some("windows-1252"),
51 _ => None,
52 };
53 if let Some(alias_label) = alias {
54 if let Some(enc) = encoding_rs::Encoding::for_label(alias_label.as_bytes()) {
55 return Ok(enc);
56 }
57 }
58 Err(io::Error::new(
59 io::ErrorKind::InvalidInput,
60 format!("Unsupported encoding: {}", label),
61 ))
62}
63
64pub fn is_valid_encoding(label: &str) -> bool {
66 if label.eq_ignore_ascii_case("utf-8") {
67 return true;
68 }
69 resolve_encoding(label).is_ok()
70}
71
72fn encode_string(content: &str, encoding: &str) -> io::Result<Vec<u8>> {
74 if encoding.eq_ignore_ascii_case("utf-8") {
75 return Ok(content.as_bytes().to_vec());
76 }
77 let enc = resolve_encoding(encoding)?;
78 let (encoded, _, had_errors) = enc.encode(content);
79 if had_errors {
80 return Err(io::Error::new(
81 io::ErrorKind::InvalidData,
82 format!("Failed to encode content as {}", encoding),
83 ));
84 }
85 Ok(encoded.into_owned())
86}
87
88pub fn is_symlink(path: &Path) -> bool {
90 path.symlink_metadata()
91 .map(|m| m.file_type().is_symlink())
92 .unwrap_or(false)
93}
94
95fn resolve_symlink(path: &Path) -> io::Result<PathBuf> {
98 let target = std::fs::read_link(path)?;
99 if target.is_absolute() {
100 Ok(target)
101 } else {
102 let parent = path.parent().unwrap_or(Path::new("."));
103 Ok(parent.join(target))
104 }
105}
106
107pub fn write_file(path: &Path, content: &str, temp_suffix: Option<&str>) -> io::Result<()> {
113 write_bytes_impl(path, content.as_bytes(), temp_suffix, false)
114}
115
116pub fn write_file_no_deref(
118 path: &Path,
119 content: &str,
120 temp_suffix: Option<&str>,
121 no_dereference: bool,
122) -> io::Result<()> {
123 let bytes = content.as_bytes();
124 write_bytes_impl(path, bytes, temp_suffix, no_dereference)
125}
126
127pub fn write_file_encoded(
129 path: &Path,
130 content: &str,
131 temp_suffix: Option<&str>,
132 no_dereference: bool,
133 encoding: &str,
134) -> io::Result<()> {
135 let bytes = encode_string(content, encoding)?;
136 write_bytes_impl(path, &bytes, temp_suffix, no_dereference)
137}
138
139fn write_bytes_impl(
140 path: &Path,
141 bytes: &[u8],
142 temp_suffix: Option<&str>,
143 no_dereference: bool,
144) -> io::Result<()> {
145 let write_path = if no_dereference && is_symlink(path) {
146 resolve_symlink(path)?
147 } else {
148 path.to_path_buf()
149 };
150 let dir = write_path.parent().unwrap_or(Path::new("."));
151
152 if let Some(suffix) = temp_suffix {
153 let mut temp_name = write_path.as_os_str().to_os_string();
155 temp_name.push(".");
156 temp_name.push(suffix);
157 let temp_path = std::path::PathBuf::from(temp_name);
158 let mut file = File::create(&temp_path)?;
159 file.write_all(bytes)?;
160 file.sync_all()?;
161 std::fs::rename(&temp_path, &write_path)?;
162 } else {
163 let mut tmp = NamedTempFile::new_in(dir)?;
165 tmp.write_all(bytes)?;
166 tmp.as_file().sync_all()?;
167 tmp.persist(&write_path).map_err(|e| e.error)?;
168 }
169
170 Ok(())
171}
172
173pub fn print_diff(path: &Path, original: &str, modified: &str) {
176 if original == modified {
177 return;
178 }
179 let diff = TextDiff::from_lines(original, modified);
180 let path_str = path.display().to_string();
181 print!(
182 "{}",
183 diff.unified_diff()
184 .header(&format!("a/{}", path_str), &format!("b/{}", path_str))
185 );
186}
187
188pub fn create_backup(path: &Path, extension: &str) -> io::Result<()> {
191 let mut backup_path = path.as_os_str().to_os_string();
192 backup_path.push(extension);
193 std::fs::copy(path, PathBuf::from(backup_path))?;
194 Ok(())
195}
196
197pub fn normalize_eol(content: &str, eol: &str) -> String {
202 match eol {
203 "lf" => content.replace("\r\n", "\n").replace('\r', "\n"),
204 "crlf" => {
205 let lf = content.replace("\r\n", "\n").replace('\r', "\n");
207 lf.replace('\n', "\r\n")
208 }
209 _ => content.to_string(), }
211}
212
213pub fn has_utf8_bom(content: &[u8]) -> bool {
215 content.starts_with(&[0xEF, 0xBB, 0xBF])
216}
217
218pub fn detect_protected_lines(content: &str) -> Vec<usize> {
223 let mut protected = Vec::new();
224 let mut non_blank_seen = 0;
225
226 for (i, line) in content.lines().enumerate() {
227 let trimmed = line.trim();
228 if trimmed.is_empty() {
229 continue;
230 }
231
232 non_blank_seen += 1;
233 if non_blank_seen > 2 {
234 break;
235 }
236
237 if non_blank_seen == 1 && trimmed.starts_with("#!") {
239 protected.push(i);
240 }
241
242 if trimmed.starts_with('#')
244 && (trimmed.contains("coding:") || trimmed.contains("coding="))
245 && !protected.contains(&i)
246 {
247 protected.push(i);
248 }
249 }
250
251 protected
252}
253
254pub fn encode_for_atomic(content: &str, encoding: &str) -> io::Result<Vec<u8>> {
256 encode_string(content, encoding)
257}
258
259const BATCH_SIZE_WARNING_THRESHOLD: usize = 500;
263
264const ATOMIC_BACKUP_EXT: &str = ".toggle-atomic-backup";
266
267pub struct StagedWrite {
269 pub temp_path: PathBuf,
271 pub target_path: PathBuf,
273 pub content_sha256: String,
275 pub original_permissions: Option<std::fs::Permissions>,
277}
278
279pub struct AtomicBatch {
281 staged: Vec<StagedWrite>,
282 journal_path: PathBuf,
283 lock_path: PathBuf,
284 _lock: Option<fd_lock::RwLock<File>>,
285 backup_enabled: bool,
286 interrupted: Arc<AtomicBool>,
287}
288
289impl AtomicBatch {
290 pub fn new(
295 targets: &[PathBuf],
296 backup_enabled: bool,
297 interrupted: Arc<AtomicBool>,
298 ) -> io::Result<Self> {
299 let dir = journal::journal_dir(targets)?;
300 let lock_path = dir.join(LOCK_FILENAME);
301 let journal_path = dir.join(JOURNAL_FILENAME);
302
303 let lock_file = File::create(&lock_path)?;
307 let mut lock = fd_lock::RwLock::new(lock_file);
308 {
313 let _guard = lock.try_write().map_err(|_| {
314 io::Error::new(
315 io::ErrorKind::WouldBlock,
316 "Another atomic operation is already in progress in this directory. \
317 Wait for it to complete or remove .toggle-atomic.lock if the previous \
318 process crashed.",
319 )
320 })?;
321 }
323
324 Ok(Self {
325 staged: Vec::new(),
326 journal_path,
327 lock_path,
328 _lock: Some(lock),
329 backup_enabled,
330 interrupted,
331 })
332 }
333
334 pub fn stage(&mut self, target_path: &Path, content: &[u8], _encoding: &str) -> io::Result<()> {
337 let target_dir = target_path.parent().unwrap_or(Path::new("."));
338 let mut tmp = NamedTempFile::new_in(target_dir)?;
339 let encoded = content.to_vec();
340 tmp.write_all(&encoded)?;
341 platform::durable_sync(tmp.as_file())?;
342
343 let original_permissions = if target_path.exists() {
345 let meta = std::fs::metadata(target_path)?;
346 let perms = meta.permissions();
347 tmp.as_file().set_permissions(perms.clone()).ok();
348 Some(perms)
349 } else {
350 None
351 };
352
353 let content_sha256 = journal::sha256_hex(&encoded);
354
355 let temp_path_obj = tmp.into_temp_path();
357 let temp_path = temp_path_obj.to_path_buf();
358 temp_path_obj
360 .keep()
361 .map_err(|e| io::Error::other(format!("Failed to keep temp path: {}", e)))?;
362
363 self.staged.push(StagedWrite {
364 temp_path,
365 target_path: target_path.to_path_buf(),
366 content_sha256,
367 original_permissions,
368 });
369
370 Ok(())
371 }
372
373 pub fn warn_if_large_batch(&self) {
375 if self.staged.len() > BATCH_SIZE_WARNING_THRESHOLD {
376 eprintln!(
377 "Warning: Staging {} files in atomic mode. Large batches may be \
378 slow due to fsync overhead. Consider splitting into smaller \
379 batches if performance is critical.",
380 self.staged.len()
381 );
382 }
383 }
384
385 pub fn commit(self) -> io::Result<()> {
389 if self.staged.is_empty() {
390 self.cleanup_lock();
391 return Ok(());
392 }
393
394 self.warn_if_large_batch();
395
396 let mut journal_entries: Vec<JournalEntry> = Vec::with_capacity(self.staged.len());
398 for sw in &self.staged {
399 let backup_path = if self.backup_enabled {
400 let mut bp = sw.target_path.as_os_str().to_os_string();
401 bp.push(ATOMIC_BACKUP_EXT);
402 Some(PathBuf::from(bp))
403 } else {
404 None
405 };
406 journal_entries.push(JournalEntry {
407 target_path: sw.target_path.clone(),
408 temp_path: sw.temp_path.clone(),
409 backup_path,
410 content_sha256: sw.content_sha256.clone(),
411 rename_completed: false,
412 });
413 }
414
415 let mut j = Journal::new(journal_entries, self.backup_enabled);
416
417 journal::persist_journal(&j, &self.journal_path)?;
419
420 if self.backup_enabled {
422 for entry in &j.entries {
423 if let Some(ref backup_path) = entry.backup_path {
424 if entry.target_path.exists() {
425 if let Err(e) = std::fs::hard_link(&entry.target_path, backup_path) {
426 eprintln!(
427 "Error: failed to create backup for '{}': {}",
428 entry.target_path.display(),
429 e
430 );
431 self.rollback_staged(&j);
432 return Err(e);
433 }
434 }
435 }
436 }
437 }
438
439 j.transition_to_committing();
441 journal::persist_journal(&j, &self.journal_path)?;
442
443 if !self.backup_enabled {
444 eprintln!(
445 "Warning: Running without backups. If the rename phase fails, \
446 rollback is not possible."
447 );
448 }
449
450 let entry_count = j.entries.len();
452 for idx in 0..entry_count {
453 if self.interrupted.load(Ordering::Relaxed) {
455 eprintln!("Interrupted. Journal preserved for recovery.");
456 journal::persist_journal(&j, &self.journal_path)?;
457 return Err(io::Error::new(
458 io::ErrorKind::Interrupted,
459 "Atomic commit interrupted by signal. \
460 Run with --recover to clean up.",
461 ));
462 }
463
464 let temp_path = j.entries[idx].temp_path.clone();
465 let target_path = j.entries[idx].target_path.clone();
466
467 if let Some(ref perms) = self.staged[idx].original_permissions {
469 let _ = std::fs::set_permissions(&temp_path, perms.clone());
470 }
471
472 match platform::rename_with_retry(&temp_path, &target_path) {
473 Ok(()) => {
474 j.mark_entry_completed(idx);
475 journal::persist_journal_best_effort(&j, &self.journal_path);
476 }
477 Err(e) => {
478 eprintln!(
479 "Error: rename failed for '{}': {}",
480 target_path.display(),
481 e
482 );
483 if self.backup_enabled {
484 eprintln!("Attempting rollback...");
485 if let Err(rb_err) = journal::recover_rollback(&j, &self.journal_path) {
486 eprintln!("Rollback also failed: {}", rb_err);
487 }
488 } else {
489 let _ = journal::persist_journal(&j, &self.journal_path);
490 eprintln!(
491 "No backups available. Journal preserved at '{}' for manual recovery.",
492 self.journal_path.display()
493 );
494 }
495 return Err(e);
496 }
497 }
498 }
499
500 let mut synced_dirs = std::collections::HashSet::new();
502 for entry in &j.entries {
503 if let Some(parent) = entry.target_path.parent() {
504 if synced_dirs.insert(parent.to_path_buf()) {
505 let _ = platform::sync_dir(parent);
506 }
507 }
508 }
509
510 journal::delete_journal(&self.journal_path)?;
512
513 if self.backup_enabled {
515 for entry in &j.entries {
516 if let Some(ref backup_path) = entry.backup_path {
517 let _ = std::fs::remove_file(backup_path);
518 }
519 }
520 }
521
522 self.cleanup_lock();
523 Ok(())
524 }
525
526 fn rollback_staged(&self, journal: &Journal) {
528 for entry in &journal.entries {
529 if entry.temp_path.exists() {
530 let _ = std::fs::remove_file(&entry.temp_path);
531 }
532 if let Some(ref backup_path) = entry.backup_path {
533 if backup_path.exists() {
534 let _ = std::fs::remove_file(backup_path);
535 }
536 }
537 }
538 let _ = journal::delete_journal(&self.journal_path);
539 self.cleanup_lock();
540 }
541
542 fn cleanup_lock(&self) {
544 let _ = std::fs::remove_file(&self.lock_path);
545 }
546}
547
548impl Drop for AtomicBatch {
549 fn drop(&mut self) {
550 }
555}
556
557pub trait FileOps {
560 fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
561 fn hard_link(&self, src: &Path, dst: &Path) -> io::Result<()>;
562 fn remove_file(&self, path: &Path) -> io::Result<()>;
563 fn sync_dir(&self, path: &Path) -> io::Result<()>;
564}
565
566pub struct RealFileOps;
568
569impl FileOps for RealFileOps {
570 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
571 platform::rename_with_retry(from, to)
572 }
573
574 fn hard_link(&self, src: &Path, dst: &Path) -> io::Result<()> {
575 std::fs::hard_link(src, dst)
576 }
577
578 fn remove_file(&self, path: &Path) -> io::Result<()> {
579 std::fs::remove_file(path)
580 }
581
582 fn sync_dir(&self, path: &Path) -> io::Result<()> {
583 platform::sync_dir(path)
584 }
585}