api_testing_core/
history.rs1use std::ffi::OsString;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6
7use crate::Result;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct RotationPolicy {
11 pub max_mb: u64,
12 pub keep: u32,
13}
14
15impl Default for RotationPolicy {
16 fn default() -> Self {
17 Self {
18 max_mb: 10,
19 keep: 5,
20 }
21 }
22}
23
24#[derive(Debug, Clone)]
25pub struct HistoryWriter {
26 history_file: PathBuf,
27 rotation: RotationPolicy,
28}
29
30impl HistoryWriter {
31 pub fn new(history_file: PathBuf, rotation: RotationPolicy) -> Self {
32 Self {
33 history_file,
34 rotation,
35 }
36 }
37
38 pub fn history_file(&self) -> &Path {
39 &self.history_file
40 }
41
42 pub fn append(&self, record: &str) -> Result<bool> {
43 append_record(&self.history_file, record, self.rotation)
44 }
45}
46
47fn lock_dir_for(history_file: &Path) -> PathBuf {
48 let mut os: OsString = history_file.as_os_str().to_os_string();
49 os.push(".lock");
50 PathBuf::from(os)
51}
52
53fn rotated_path(history_file: &Path, i: u32) -> PathBuf {
54 let mut os: OsString = history_file.as_os_str().to_os_string();
55 os.push(format!(".{i}"));
56 PathBuf::from(os)
57}
58
59fn rotate_file_keep_n(history_file: &Path, keep: u32) {
60 if keep == 0 || !history_file.is_file() {
61 return;
62 }
63
64 for i in (1..=keep).rev() {
65 let dst = rotated_path(history_file, i);
66 let src = if i == 1 {
67 history_file.to_path_buf()
68 } else {
69 rotated_path(history_file, i - 1)
70 };
71
72 if !src.exists() {
73 continue;
74 }
75
76 let _ = std::fs::remove_file(&dst);
77 let _ = std::fs::rename(&src, &dst);
78 }
79}
80
81pub fn resolve_history_file(
88 setup_dir: &Path,
89 override_path: Option<&Path>,
90 default_filename: &str,
91) -> PathBuf {
92 match override_path {
93 Some(p) if p.is_absolute() => p.to_path_buf(),
94 Some(p) => setup_dir.join(p),
95 None => setup_dir.join(default_filename),
96 }
97}
98
99pub fn append_record(history_file: &Path, record: &str, rotation: RotationPolicy) -> Result<bool> {
105 let Some(parent) = history_file.parent() else {
106 return Ok(false);
107 };
108
109 let _ = std::fs::create_dir_all(parent);
110
111 let lock_dir = lock_dir_for(history_file);
112 if std::fs::create_dir(&lock_dir).is_err() {
113 return Ok(false);
114 }
115 let _lock_guard = LockGuard { lock_dir };
116
117 if rotation.max_mb > 0 && history_file.is_file() {
118 let bytes = std::fs::metadata(history_file)
119 .map(|m| m.len())
120 .unwrap_or(0);
121 let max_bytes = rotation.max_mb * 1024 * 1024;
122 if bytes >= max_bytes {
123 rotate_file_keep_n(history_file, rotation.keep.max(1));
124 }
125 }
126
127 let mut f = std::fs::OpenOptions::new()
128 .create(true)
129 .append(true)
130 .open(history_file)
131 .with_context(|| format!("open history file for append: {}", history_file.display()))?;
132
133 f.write_all(record.as_bytes())
134 .context("write history record")?;
135
136 Ok(true)
137}
138
139#[derive(Debug)]
140struct LockGuard {
141 lock_dir: PathBuf,
142}
143
144impl Drop for LockGuard {
145 fn drop(&mut self) {
146 let _ = std::fs::remove_dir(&self.lock_dir);
147 }
148}
149
150pub fn read_records(history_file: &Path) -> Result<Vec<String>> {
152 let content = std::fs::read_to_string(history_file)
153 .with_context(|| format!("read history file: {}", history_file.display()))?;
154 let content = content.replace("\r\n", "\n");
155
156 Ok(content
157 .split("\n\n")
158 .map(str::trim)
159 .filter(|s| !s.trim().is_empty())
160 .map(|s| format!("{s}\n\n"))
161 .collect())
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use pretty_assertions::assert_eq;
168
169 use tempfile::TempDir;
170
171 #[test]
172 fn history_append_skips_when_lock_is_held() {
173 let tmp = TempDir::new().expect("tmp");
174 let setup_dir = tmp.path();
175 let history_file = setup_dir.join(".rest_history");
176
177 std::fs::create_dir_all(setup_dir).expect("mkdir");
178 std::fs::create_dir(lock_dir_for(&history_file)).expect("lock");
179
180 let appended =
181 append_record(&history_file, "# entry\n\n", RotationPolicy::default()).unwrap();
182 assert!(!appended);
183 assert!(!history_file.exists());
184 }
185
186 #[test]
187 fn history_rotation_happens_before_append() {
188 let tmp = TempDir::new().expect("tmp");
189 let setup_dir = tmp.path();
190 let history_file = setup_dir.join(".rest_history");
191
192 std::fs::create_dir_all(setup_dir).expect("mkdir");
193 std::fs::write(&history_file, vec![b'a'; 1024 * 1024]).expect("write big file");
194
195 let appended = append_record(
196 &history_file,
197 "# new\n\n",
198 RotationPolicy { max_mb: 1, keep: 2 },
199 )
200 .unwrap();
201 assert!(appended);
202
203 assert!(history_file.is_file());
204 assert_eq!(std::fs::read_to_string(&history_file).unwrap(), "# new\n\n");
205 assert!(setup_dir.join(".rest_history.1").is_file());
206 }
207
208 #[test]
209 fn history_read_records_splits_blank_lines_and_preserves_trailing_blank_line() {
210 let tmp = TempDir::new().expect("tmp");
211 let history_file = tmp.path().join(".rest_history");
212 std::fs::write(&history_file, "# a\ncmd\n\n# b\ncmd2\n\n").expect("write");
213
214 let records = read_records(&history_file).unwrap();
215 assert_eq!(records.len(), 2);
216 assert!(records[0].ends_with("\n\n"));
217 assert!(records[1].ends_with("\n\n"));
218 }
219}