• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MilesCranmer / rip2 / #797

02 Sep 2025 11:17PM UTC coverage: 58.14% (-5.0%) from 63.158%
#797

push

325 of 559 relevant lines covered (58.14%)

180557783.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.85
/src/record.rs
1
use chrono::{DateTime, Local};
2
use fs4::fs_std::FileExt;
3
use std::io::{BufRead, BufReader, Error, ErrorKind, Read, Write};
4
use std::path::{Path, PathBuf};
5
use std::{fs, io};
6

7
use crate::util;
8

9
pub const RECORD: &str = ".record";
10

11
#[derive(Debug)]
12
pub struct RecordItem {
13
    pub time: String,
14
    pub orig: PathBuf,
15
    pub dest: PathBuf,
16
}
17

18
impl RecordItem {
19
    /// Parse a line in the record into a `RecordItem`
20
    pub fn new(line: &str) -> Self {
10✔
21
        let mut tokens = line.split('\t');
30✔
22
        let time = tokens.next().expect("Bad format: column 1").to_string();
50✔
23
        let orig = tokens.next().expect("Bad format: column 2").to_string();
50✔
24
        let dest = tokens.next().expect("Bad format: column 3").to_string();
50✔
25
        Self {
26
            time,
27
            orig: PathBuf::from(orig),
30✔
28
            dest: PathBuf::from(dest),
10✔
29
        }
30
    }
31

32
    /// Parse the timestamp in this record, which could be in either RFC3339 format (from rip2)
33
    /// or the old rip format --- in which case we return a helpful error.
34
    fn parse_timestamp(&self) -> Result<DateTime<Local>, Error> {
12✔
35
        // Try parsing as RFC3339 first
36
        if let Ok(dt) = DateTime::parse_from_rfc3339(&self.time) {
14✔
37
            return Ok(dt.with_timezone(&Local));
×
38
        }
39

40
        // Roughly check if it matches the old rip format (e.g., "Sun Dec  1 02:15:56 2024")
41
        let is_old_format = self.time.split_whitespace().count() == 5
20✔
42
            && self
10✔
43
                .time
10✔
44
                .chars()
10✔
45
                .all(|c| c.is_ascii_alphanumeric() || c.is_whitespace() || c == ':');
29✔
46
        if is_old_format {
10✔
47
            Err(Error::new(
20✔
48
                ErrorKind::InvalidData,
20✔
49
                format!(
10✔
50
                    "Found timestamp '{}' from old rip format. \
10✔
51
                    You will need to delete the `.record` file \
10✔
52
                    and start over with rip2. \
10✔
53
                    You can see the path with `rip graveyard`.",
10✔
54
                    self.time
10✔
55
                ),
56
            ))
57
        } else {
58
            Err(Error::new(
×
59
                ErrorKind::InvalidData,
×
60
                format!("Failed to parse time '{}' as RFC3339 format", self.time),
×
61
            ))
62
        }
63
    }
64

65
    /// Format this record's timestamp for display in the seance output
66
    pub fn format_time_for_display(&self) -> Result<String, Error> {
10✔
67
        self.parse_timestamp()
20✔
68
            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
40✔
69
    }
70
}
71

72
/// A record of file operations maintained in the graveyard directory
73
///
74
/// # Type Parameters
75
///
76
/// * `FILE_LOCK` - When `true`, exclusive file locks are acquired when opening
77
///   the record file for reading or writing. This prevents concurrent access from multiple
78
///   processes. When `false`, no file locking is performed - which is used for testing.
79
#[derive(Debug)]
80
pub struct Record<const FILE_LOCK: bool> {
81
    path: PathBuf,
82
}
83

84
#[cfg(not(target_os = "windows"))]
85
pub const DEFAULT_FILE_LOCK: bool = true;
86

87
#[cfg(target_os = "windows")]
88
pub const DEFAULT_FILE_LOCK: bool = false;
89
// TODO: Investigate why this is needed. Does Windows not support file locks?
90

91
impl<const FILE_LOCK: bool> Record<FILE_LOCK> {
92
    const HEADER: &'static str = "Time\tOriginal\tDestination";
93

94
    pub fn new(graveyard: &Path) -> Self {
×
95
        let path = graveyard.join(RECORD);
×
96
        // Create the record file if it doesn't exist
97
        if !path.exists() {
×
98
            // Write a header to the record file
99
            let mut record_file = fs::OpenOptions::new()
×
100
                .truncate(true)
101
                .create(true)
102
                .write(true)
103
                .open(&path)
×
104
                .expect("Failed to open record file");
105
            if FILE_LOCK {
13✔
106
                record_file.lock_exclusive().unwrap();
26✔
107
            }
108
            writeln!(record_file, "{}", Self::HEADER)
×
109
                .expect("Failed to write header to record file");
110
        }
111
        Self { path }
112
    }
113

114
    pub fn open(&self) -> Result<fs::File, Error> {
11✔
115
        let file = fs::File::open(&self.path)
22✔
116
            .map_err(|_| Error::new(ErrorKind::NotFound, "Failed to read record!"))?;
56✔
117
        if FILE_LOCK {
11✔
118
            file.lock_exclusive().unwrap();
22✔
119
        }
120
        Ok(file)
×
121
    }
122

123
    /// Return the path in the graveyard of the last file to be buried.
124
    /// As a side effect, any valid last files that are found in the record but
125
    /// not on the filesystem are removed from the record.
126
    pub fn get_last_bury(&self) -> Result<PathBuf, Error> {
2,003✔
127
        // record: impl AsRef<Path>
128
        let record_file = self.open()?;
6,009✔
129
        let mut contents = String::new();
×
130
        {
131
            let mut reader = self.skip_header(BufReader::new(&record_file))?;
2,003✔
132
            reader.read_to_string(&mut contents)?;
×
133
        }
134

135
        // This will be None if there is nothing, or Some
136
        // if there is items in the vector
137
        let mut graves_to_exhume: Vec<PathBuf> = Vec::new();
2,002✔
138
        for entry in contents.lines().rev().map(RecordItem::new) {
2,000✔
139
            // Check that the file is still in the graveyard.
140
            // If it is, return the corresponding line.
141
            if util::symlink_exists(&entry.dest) {
4,000✔
142
                if !graves_to_exhume.is_empty() {
2,147,483,647✔
143
                    self.delete_lines(record_file, &graves_to_exhume)?;
10✔
144
                }
145
                return Ok(entry.dest);
2,147,483,647✔
146
            }
147
            // File is gone, mark the grave to be removed from the record
148
            graves_to_exhume.push(entry.dest);
×
149
        }
150

151
        if !graves_to_exhume.is_empty() {
2,147,483,647✔
152
            self.delete_lines(record_file, &graves_to_exhume)?;
2,147,483,647✔
153
        }
154
        Err(Error::new(ErrorKind::NotFound, "No files in graveyard"))
2,147,483,647✔
155
    }
156

157
    /// Takes a vector of grave paths and removes the respective lines from the record
158
    fn delete_lines(&self, record_file: fs::File, graves: &[PathBuf]) -> Result<(), Error> {
×
159
        // Get the lines to write back to the record, which is every line except
160
        // the ones matching the exhumed graves. Store them in a vector
161
        // since we'll be overwriting the record in-place.
162
        let reader = self.skip_header(BufReader::new(record_file))?;
20✔
163
        let lines_to_write: Vec<String> = reader
×
164
            .lines()
165
            .map_while(Result::ok)
×
166
            .filter(|line| !graves.iter().any(|y| *y == RecordItem::new(line).dest))
22✔
167
            .collect();
168
        let mut new_record_file = fs::OpenOptions::new()
2,147,483,647✔
169
            .create(true)
170
            .truncate(true)
171
            .write(true)
172
            .open(&self.path)?;
11✔
173
        if FILE_LOCK {
8✔
174
            new_record_file.lock_exclusive().unwrap();
16✔
175
        }
176
        writeln!(new_record_file, "{}", Self::HEADER)?; // Write the header back
56✔
177
        for line in lines_to_write {
48✔
178
            writeln!(new_record_file, "{line}")?;
143✔
179
        }
180
        Ok(())
2,147,483,647✔
181
    }
182

183
    pub fn log_exhumed_graves(&self, graves_to_exhume: &[PathBuf]) -> Result<(), Error> {
×
184
        // Reopen the record and then delete lines corresponding to exhumed graves
185
        let record_file = self.open()?;
10✔
186
        self.delete_lines(record_file, graves_to_exhume)
×
187
            .map_err(|e| {
24✔
188
                Error::new(
24✔
189
                    e.kind(),
48✔
190
                    format!("Failed to remove unburied files from record: {e}"),
48✔
191
                )
192
            })
193
    }
194

195
    /// Takes a vector of grave paths and returns the respective lines in the record
196
    pub fn lines_of_graves<'a>(
45✔
197
        &'a self,
198
        graves: &'a [PathBuf],
199
    ) -> impl Iterator<Item = String> + 'a {
200
        let record_file = self.open().unwrap();
180✔
201
        let reader = self.skip_header(BufReader::new(record_file)).unwrap();
270✔
202
        reader
45✔
203
            .lines()
204
            .map_while(Result::ok)
45✔
205
            .filter(move |line| graves.iter().any(|y| *y == RecordItem::new(line).dest))
135✔
206
    }
207

208
    /// Returns an iterator over all graves in the record that are under gravepath
209
    pub fn seance<'a>(
×
210
        &'a self,
211
        gravepath: &'a PathBuf,
212
    ) -> io::Result<impl Iterator<Item = RecordItem> + 'a> {
213
        let record_file = self.open()?;
×
214
        let reader = self.skip_header(BufReader::new(record_file))?;
×
215
        Ok(reader
×
216
            .lines()
×
217
            .map_while(Result::ok)
×
218
            .map(|line| RecordItem::new(&line))
108✔
219
            .filter(move |record_item| record_item.dest.starts_with(gravepath)))
51✔
220
    }
221

222
    /// Write deletion history to record
223
    pub fn write_log(&self, source: impl AsRef<Path>, dest: impl AsRef<Path>) -> io::Result<()> {
60✔
224
        let (source, dest) = (source.as_ref(), dest.as_ref());
300✔
225

226
        let mut record_file = fs::OpenOptions::new().append(true).open(&self.path)?;
242✔
227

228
        if FILE_LOCK {
41✔
229
            record_file.lock_exclusive().unwrap();
82✔
230
        }
231

232
        writeln!(
×
233
            record_file,
×
234
            "{}\t{}\t{}",
235
            Local::now().to_rfc3339(),
×
236
            source.display(),
×
237
            dest.display()
×
238
        )
239
        .map_err(|e| {
12✔
240
            Error::new(
12✔
241
                e.kind(),
24✔
242
                format!("Failed to write record at {}", &self.path.display()),
36✔
243
            )
244
        })?;
245

246
        Ok(())
2,147,483,647✔
247
    }
248

249
    /// Validates the header of a record file and returns the reader positioned after the header.
250
    /// Returns Err if the header is invalid.
251
    fn skip_header<R: BufRead>(&self, mut reader: R) -> io::Result<R> {
31✔
252
        let mut header = String::new();
62✔
253
        reader.read_line(&mut header)?;
96✔
254

255
        if header.trim() != Self::HEADER {
28✔
256
            return Err(Error::new(
×
257
                ErrorKind::InvalidData,
×
258
                format!(
×
259
                    "Invalid record file header at {}:\n  Expected: '{}'\n  Got:      '{}'",
×
260
                    &self.path.display(),
×
261
                    Self::HEADER,
×
262
                    header.trim()
×
263
                ),
264
            ));
265
        }
266
        Ok(reader)
×
267
    }
268
}
269

270
impl<const FILE_LOCK: bool> Clone for Record<FILE_LOCK> {
271
    fn clone(&self) -> Self {
4,000✔
272
        Self {
273
            path: self.path.clone(),
4,000✔
274
        }
275
    }
276
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc