Skip to main content

api_testing_core/
history.rs

1use 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        if let Err(err) = std::fs::remove_file(&dst)
77            && err.kind() != std::io::ErrorKind::NotFound
78        {
79            eprintln!(
80                "warning: history rotation failed to remove {}: {err}",
81                dst.display()
82            );
83            return;
84        }
85        if let Err(err) = std::fs::rename(&src, &dst) {
86            eprintln!(
87                "warning: history rotation failed to rename {} -> {}: {err}",
88                src.display(),
89                dst.display()
90            );
91            return;
92        }
93    }
94}
95
96/// Resolve a history file path from `<setup_dir>` and an optional override.
97///
98/// Parity:
99/// - if override is an absolute path, use it as-is
100/// - if override is relative, resolve it under `<setup_dir>`
101/// - otherwise use `<setup_dir>/<default_filename>`
102pub fn resolve_history_file(
103    setup_dir: &Path,
104    override_path: Option<&Path>,
105    default_filename: &str,
106) -> PathBuf {
107    match override_path {
108        Some(p) if p.is_absolute() => p.to_path_buf(),
109        Some(p) => setup_dir.join(p),
110        None => setup_dir.join(default_filename),
111    }
112}
113
114/// Append a record to the history file using a lock directory (`<history_file>.lock`).
115///
116/// Returns:
117/// - `Ok(true)` when a record was appended
118/// - `Ok(false)` when the lock could not be acquired (skip silently)
119pub fn append_record(history_file: &Path, record: &str, rotation: RotationPolicy) -> Result<bool> {
120    let Some(parent) = history_file.parent() else {
121        return Ok(false);
122    };
123
124    let _ = std::fs::create_dir_all(parent);
125
126    let lock_dir = lock_dir_for(history_file);
127    if std::fs::create_dir(&lock_dir).is_err() {
128        return Ok(false);
129    }
130    let _lock_guard = LockGuard { lock_dir };
131
132    if rotation.max_mb > 0 && history_file.is_file() {
133        let bytes = std::fs::metadata(history_file)
134            .map(|m| m.len())
135            .unwrap_or(0);
136        let max_bytes = rotation.max_mb * 1024 * 1024;
137        if bytes >= max_bytes {
138            rotate_file_keep_n(history_file, rotation.keep.max(1));
139        }
140    }
141
142    let mut f = std::fs::OpenOptions::new()
143        .create(true)
144        .append(true)
145        .open(history_file)
146        .with_context(|| format!("open history file for append: {}", history_file.display()))?;
147
148    f.write_all(record.as_bytes())
149        .context("write history record")?;
150
151    Ok(true)
152}
153
154#[derive(Debug)]
155struct LockGuard {
156    lock_dir: PathBuf,
157}
158
159impl Drop for LockGuard {
160    fn drop(&mut self) {
161        let _ = std::fs::remove_dir(&self.lock_dir);
162    }
163}
164
165/// Read blank-line-separated history records as raw strings.
166pub fn read_records(history_file: &Path) -> Result<Vec<String>> {
167    let content = std::fs::read_to_string(history_file)
168        .with_context(|| format!("read history file: {}", history_file.display()))?;
169    let content = content.replace("\r\n", "\n");
170
171    Ok(content
172        .split("\n\n")
173        .map(str::trim)
174        .filter(|s| !s.trim().is_empty())
175        .map(|s| format!("{s}\n\n"))
176        .collect())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use pretty_assertions::assert_eq;
183
184    use tempfile::TempDir;
185
186    #[test]
187    fn history_append_skips_when_lock_is_held() {
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::create_dir(lock_dir_for(&history_file)).expect("lock");
194
195        let appended =
196            append_record(&history_file, "# entry\n\n", RotationPolicy::default()).unwrap();
197        assert!(!appended);
198        assert!(!history_file.exists());
199    }
200
201    #[test]
202    fn history_rotation_happens_before_append() {
203        let tmp = TempDir::new().expect("tmp");
204        let setup_dir = tmp.path();
205        let history_file = setup_dir.join(".rest_history");
206
207        std::fs::create_dir_all(setup_dir).expect("mkdir");
208        std::fs::write(&history_file, vec![b'a'; 1024 * 1024]).expect("write big file");
209
210        let appended = append_record(
211            &history_file,
212            "# new\n\n",
213            RotationPolicy { max_mb: 1, keep: 2 },
214        )
215        .unwrap();
216        assert!(appended);
217
218        assert!(history_file.is_file());
219        assert_eq!(std::fs::read_to_string(&history_file).unwrap(), "# new\n\n");
220        assert!(setup_dir.join(".rest_history.1").is_file());
221    }
222
223    #[test]
224    fn history_read_records_splits_blank_lines_and_preserves_trailing_blank_line() {
225        let tmp = TempDir::new().expect("tmp");
226        let history_file = tmp.path().join(".rest_history");
227        std::fs::write(&history_file, "# a\ncmd\n\n# b\ncmd2\n\n").expect("write");
228
229        let records = read_records(&history_file).unwrap();
230        assert_eq!(records.len(), 2);
231        assert!(records[0].ends_with("\n\n"));
232        assert!(records[1].ends_with("\n\n"));
233    }
234}