Write a JSON value to a file via atomic rename. The caller is responsible for creating the backup via [`backup_config_file`] before loading the config. Pass the backup path here so that it can be mentioned in error messages and used for restore if the rename somehow leaves the target in a bad state. # Strategy 1. Serialize → validate → write to a **new** sibling file (`.new`). The original file
(
path: &Path,
value: &serde_json::Value,
backup: Option<&Path>,
)
| 589 | /// |
| 590 | /// In every error case the original file remains intact. |
| 591 | pub fn safe_write_json_file( |
| 592 | path: &Path, |
| 593 | value: &serde_json::Value, |
| 594 | backup: Option<&Path>, |
| 595 | ) -> Result<()> { |
| 596 | // 1. Serialize |
| 597 | let pretty = serde_json::to_string_pretty(value).map_err(|e| TraceDecayError::Config { |
| 598 | message: format!("failed to serialize JSON for {}: {e}", path.display()), |
| 599 | })?; |
| 600 | |
| 601 | // 2. Re-parse to verify the serialized output is valid JSON |
| 602 | if serde_json::from_str::<serde_json::Value>(&pretty).is_err() { |
| 603 | return Err(TraceDecayError::Config { |
| 604 | message: format!( |
| 605 | "internal error: serialized JSON for {} failed re-parse validation.\n \ |
| 606 | This is a bug in tracedecay — please report it.", |
| 607 | path.display() |
| 608 | ), |
| 609 | }); |
| 610 | } |
| 611 | |
| 612 | // 3. Ensure parent dir |
| 613 | if let Some(parent) = path.parent() { |
| 614 | std::fs::create_dir_all(parent).map_err(|e| TraceDecayError::Config { |
| 615 | message: format!("cannot create directory {}: {e}", parent.display()), |
| 616 | })?; |
| 617 | } |
| 618 | |
| 619 | // 4. Write to a NEW sibling file — the original is never opened for |
| 620 | // writing, so an interrupted write or crash only affects the .new file. |
| 621 | let content = format!("{pretty}\n"); |
| 622 | let new_path = PathBuf::from(format!("{}.new", path.display())); |
| 623 | if let Err(e) = std::fs::write(&new_path, &content) { |
| 624 | std::fs::remove_file(&new_path).ok(); // clean up partial write |
| 625 | return Err(TraceDecayError::Config { |
| 626 | message: format!( |
| 627 | "failed to write new config file {}: {e}", |
| 628 | new_path.display() |
| 629 | ), |
| 630 | }); |
| 631 | } |
| 632 | |
| 633 | // 5. Atomic rename: new → original. |
| 634 | // On POSIX, rename(2) atomically replaces the target. |
| 635 | // If this fails the original file is still intact. |
| 636 | if let Err(e) = std::fs::rename(&new_path, path) { |
| 637 | std::fs::remove_file(&new_path).ok(); // clean up |
| 638 | let hint = if let Some(b) = backup { |
| 639 | format!( |
| 640 | "\n Backup is at: {}\n \ |
| 641 | The original file was NOT modified.", |
| 642 | b.display() |
| 643 | ) |
| 644 | } else { |
| 645 | "\n The original file was NOT modified.".to_string() |
| 646 | }; |
| 647 | return Err(TraceDecayError::Config { |
| 648 | message: format!( |