Skip to main content

cli/
persist.rs

1//! Shared atomic-write primitives.
2//!
3//! Several modules (config, app/sys/task manifests, ssh transfer, workspace
4//! env files, self-install) each hand-rolled their own "write to a temp file,
5//! then rename over the destination" sequence. This module is the single
6//! place that logic lives.
7
8use anyhow::{Context, Result};
9use std::path::Path;
10use tokio::io::AsyncWriteExt;
11
12/// Durably writes `contents` to `path`.
13///
14/// Creates the parent directory if missing, writes to a uniquely-named temp
15/// file in the same directory, fsyncs it, then renames it over `path`. If
16/// anything fails after the temp file is created, the temp file is removed.
17pub async fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
18    let parent = path.parent().unwrap_or_else(|| Path::new("."));
19    tokio::fs::create_dir_all(parent)
20        .await
21        .with_context(|| format!("creating {}", parent.display()))?;
22    let temp = parent.join(format!(".shine-write-{}", uuid::Uuid::new_v4()));
23
24    if let Err(error) = write_temp(&temp, contents).await {
25        let _ = tokio::fs::remove_file(&temp).await;
26        return Err(error);
27    }
28
29    finalize_temp(&temp, path).await
30}
31
32async fn write_temp(temp: &Path, contents: &[u8]) -> Result<()> {
33    let mut file = tokio::fs::File::create(temp)
34        .await
35        .with_context(|| format!("creating {}", temp.display()))?;
36    file.write_all(contents)
37        .await
38        .with_context(|| format!("writing {}", temp.display()))?;
39    file.sync_all()
40        .await
41        .with_context(|| format!("syncing {}", temp.display()))?;
42    Ok(())
43}
44
45/// Renames `temp` over `dest`, for callers that already wrote/streamed their
46/// own temp file (e.g. large-file copies) and only need the finalize step.
47///
48/// On Windows, an existing `dest` is removed first (`rename` there doesn't
49/// replace an existing file). On any failure, `temp` is removed.
50pub async fn finalize_temp(temp: &Path, dest: &Path) -> Result<()> {
51    #[cfg(windows)]
52    if dest.exists() {
53        tokio::fs::remove_file(dest)
54            .await
55            .with_context(|| format!("removing {}", dest.display()))?;
56    }
57    if let Err(error) = tokio::fs::rename(temp, dest).await {
58        let _ = tokio::fs::remove_file(temp).await;
59        return Err(error).with_context(|| format!("replacing {}", dest.display()));
60    }
61    Ok(())
62}
63
64/// Loads and parses a TOML file at `path`, or returns `T::default()` if it
65/// doesn't exist yet. `what` is a human-readable label used in error
66/// messages (e.g. `"app manifest"`).
67pub async fn load_toml_or_default<T>(path: &Path, what: &str) -> Result<T>
68where
69    T: serde::de::DeserializeOwned + Default,
70{
71    match tokio::fs::read_to_string(path).await {
72        Ok(content) => toml::from_str(&content).with_context(|| format!("failed to parse {what}")),
73        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
74        Err(e) => Err(e).with_context(|| format!("failed to read {what}")),
75    }
76}
77
78/// Serializes `value` as pretty TOML and atomically writes it to `path`.
79/// `what` is a human-readable label used in error messages.
80pub async fn save_toml_atomic<T: serde::Serialize>(
81    value: &T,
82    path: &Path,
83    what: &str,
84) -> Result<()> {
85    let content =
86        toml::to_string_pretty(value).with_context(|| format!("failed to serialize {what}"))?;
87    atomic_write(path, content.as_bytes())
88        .await
89        .with_context(|| format!("failed to write {what}"))
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[tokio::test]
97    async fn atomic_write_creates_missing_parent_directories() {
98        let dir = crate::test_support::make_temp_dir("shine-persist").await;
99        let path = dir.join("nested/deep/file.txt");
100
101        atomic_write(&path, b"hello").await.unwrap();
102
103        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"hello");
104        tokio::fs::remove_dir_all(&dir).await.unwrap();
105    }
106
107    #[tokio::test]
108    async fn atomic_write_replaces_existing_file() {
109        let dir = crate::test_support::make_temp_dir("shine-persist").await;
110        let path = dir.join("file.txt");
111        tokio::fs::write(&path, b"old").await.unwrap();
112
113        atomic_write(&path, b"new").await.unwrap();
114
115        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"new");
116        tokio::fs::remove_dir_all(&dir).await.unwrap();
117    }
118
119    #[tokio::test]
120    async fn atomic_write_leaves_no_temp_file_behind_on_success() {
121        let dir = crate::test_support::make_temp_dir("shine-persist").await;
122        let path = dir.join("file.txt");
123
124        atomic_write(&path, b"content").await.unwrap();
125
126        let mut entries = tokio::fs::read_dir(&dir).await.unwrap();
127        let mut names = Vec::new();
128        while let Some(entry) = entries.next_entry().await.unwrap() {
129            names.push(entry.file_name());
130        }
131        assert_eq!(names, vec![std::ffi::OsString::from("file.txt")]);
132        tokio::fs::remove_dir_all(&dir).await.unwrap();
133    }
134
135    #[tokio::test]
136    async fn finalize_temp_removes_temp_on_rename_failure() {
137        let dir = crate::test_support::make_temp_dir("shine-persist").await;
138        let temp = dir.join(".shine-write-test");
139        tokio::fs::write(&temp, b"content").await.unwrap();
140        // A destination inside a nonexistent directory makes rename fail.
141        let dest = dir.join("missing-dir").join("dest.txt");
142
143        let result = finalize_temp(&temp, &dest).await;
144
145        assert!(result.is_err());
146        assert!(!temp.exists(), "temp file should be cleaned up on failure");
147        tokio::fs::remove_dir_all(&dir).await.unwrap();
148    }
149
150    #[derive(Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
151    struct SampleToml {
152        #[serde(default)]
153        name: String,
154        #[serde(default)]
155        count: u32,
156    }
157
158    #[tokio::test]
159    async fn load_toml_or_default_returns_default_when_file_missing() {
160        let dir = crate::test_support::make_temp_dir("shine-persist").await;
161        let path = dir.join("sample.toml");
162
163        let value: SampleToml = load_toml_or_default(&path, "sample").await.unwrap();
164
165        assert_eq!(value, SampleToml::default());
166        tokio::fs::remove_dir_all(&dir).await.unwrap();
167    }
168
169    #[tokio::test]
170    async fn save_then_load_toml_round_trips() {
171        let dir = crate::test_support::make_temp_dir("shine-persist").await;
172        let path = dir.join("sample.toml");
173        let value = SampleToml {
174            name: "hi".to_string(),
175            count: 3,
176        };
177
178        save_toml_atomic(&value, &path, "sample").await.unwrap();
179        let loaded: SampleToml = load_toml_or_default(&path, "sample").await.unwrap();
180
181        assert_eq!(loaded, value);
182        tokio::fs::remove_dir_all(&dir).await.unwrap();
183    }
184
185    #[tokio::test]
186    async fn save_toml_atomic_creates_missing_parent_directory() {
187        let dir = crate::test_support::make_temp_dir("shine-persist").await;
188        let path = dir.join("nested/sample.toml");
189        let value = SampleToml::default();
190
191        save_toml_atomic(&value, &path, "sample").await.unwrap();
192
193        assert!(path.exists());
194        tokio::fs::remove_dir_all(&dir).await.unwrap();
195    }
196}