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

MilesCranmer / rip2 / #655

14 Jan 2025 03:33AM UTC coverage: 75.103% (-2.1%) from 77.16%
#655

push

365 of 486 relevant lines covered (75.1%)

1586309936.81 hits per line

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

68.6
/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 {
2,147,483,647✔
21
        let mut tokens = line.split('\t');
2,147,483,647✔
22
        let time = tokens.next().expect("Bad format: column 1").to_string();
2,147,483,647✔
23
        let orig = tokens.next().expect("Bad format: column 2").to_string();
2,147,483,647✔
24
        let dest = tokens.next().expect("Bad format: column 3").to_string();
2,147,483,647✔
25
        Self {
26
            time,
27
            orig: PathBuf::from(orig),
2,147,483,647✔
28
            dest: PathBuf::from(dest),
2,147,483,647✔
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> {
2,147,483,647✔
35
        // Try parsing as RFC3339 first
36
        if let Ok(dt) = DateTime::parse_from_rfc3339(&self.time) {
2,147,483,647✔
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
2,147,483,647✔
42
            && self
2,147,483,647✔
43
                .time
2,147,483,647✔
44
                .chars()
2,147,483,647✔
45
                .all(|c| c.is_ascii_alphanumeric() || c.is_whitespace() || c == ':');
2,147,483,647✔
46
        if is_old_format {
2,147,483,647✔
47
            Err(Error::new(
2,147,483,647✔
48
                ErrorKind::InvalidData,
2,147,483,647✔
49
                format!(
2,147,483,647✔
50
                    "Found timestamp '{}' from old rip format. \
2,147,483,647✔
51
                    You will need to delete the `.record` file \
2,147,483,647✔
52
                    and start over with rip2. \
2,147,483,647✔
53
                    You can see the path with `rip graveyard`.",
2,147,483,647✔
54
                    self.time
2,147,483,647✔
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> {
2,147,483,647✔
67
        self.parse_timestamp()
2,147,483,647✔
68
            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
2,147,483,647✔
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 {
2,147,483,647✔
95
        let path = graveyard.join(RECORD);
2,147,483,647✔
96
        // Create the record file if it doesn't exist
97
        if !path.exists() {
2,147,483,647✔
98
            // Write a header to the record file
99
            let mut record_file = fs::OpenOptions::new()
2,147,483,647✔
100
                .truncate(true)
101
                .create(true)
102
                .write(true)
103
                .open(&path)
2,147,483,647✔
104
                .expect("Failed to open record file");
105
            if FILE_LOCK {
2,147,483,647✔
106
                record_file.lock_exclusive().unwrap();
×
107
            }
108
            writeln!(record_file, "{}", Self::HEADER)
2,147,483,647✔
109
                .expect("Failed to write header to record file");
110
        }
111
        Self { path }
112
    }
113

114
    pub fn open(&self) -> Result<fs::File, Error> {
2,147,483,647✔
115
        let file = fs::File::open(&self.path)
2,147,483,647✔
116
            .map_err(|_| Error::new(ErrorKind::NotFound, "Failed to read record!"))?;
2,147,483,647✔
117
        if FILE_LOCK {
×
118
            file.lock_exclusive().unwrap();
×
119
        }
120
        Ok(file)
2,147,483,647✔
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,147,483,647✔
127
        // record: impl AsRef<Path>
128
        let record_file = self.open()?;
2,147,483,647✔
129
        let mut contents = String::new();
×
130
        {
131
            let mut reader = self.skip_header(BufReader::new(&record_file))?;
2,147,483,647✔
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,147,483,647✔
138
        for entry in contents.lines().rev().map(RecordItem::new) {
2,147,483,647✔
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) {
2,147,483,647✔
142
                if !graves_to_exhume.is_empty() {
2,147,483,647✔
143
                    self.delete_lines(record_file, &graves_to_exhume)?;
×
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() {
152
            self.delete_lines(record_file, &graves_to_exhume)?;
×
153
        }
×
154
        Err(Error::new(ErrorKind::NotFound, "No files in graveyard"))
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
2,147,483,647✔
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))?;
163
        let lines_to_write: Vec<String> = reader
2,147,483,647✔
164
            .lines()
×
165
            .map_while(Result::ok)
166
            .filter(|line| !graves.iter().any(|y| *y == RecordItem::new(line).dest))
×
167
            .collect();
2,147,483,647✔
168
        let mut new_record_file = fs::OpenOptions::new()
169
            .create(true)
2,147,483,647✔
170
            .truncate(true)
171
            .write(true)
172
            .open(&self.path)?;
173
        if FILE_LOCK {
×
174
            new_record_file.lock_exclusive().unwrap();
×
175
        }
×
176
        writeln!(new_record_file, "{}", Self::HEADER)?; // Write the header back
177
        for line in lines_to_write {
×
178
            writeln!(new_record_file, "{line}")?;
2,147,483,647✔
179
        }
×
180
        Ok(())
181
    }
2,147,483,647✔
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
2,147,483,647✔
185
        let record_file = self.open()?;
186
        self.delete_lines(record_file, graves_to_exhume)
2,147,483,647✔
187
            .map_err(|e| {
×
188
                Error::new(
×
189
                    e.kind(),
×
190
                    format!("Failed to remove unburied files from record: {e}"),
×
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>(
197
        &'a self,
2,147,483,647✔
198
        graves: &'a [PathBuf],
199
    ) -> impl Iterator<Item = String> + 'a {
200
        let record_file = self.open().unwrap();
201
        let reader = self.skip_header(BufReader::new(record_file)).unwrap();
2,147,483,647✔
202
        reader
2,147,483,647✔
203
            .lines()
2,147,483,647✔
204
            .map_while(Result::ok)
205
            .filter(move |line| graves.iter().any(|y| *y == RecordItem::new(line).dest))
2,147,483,647✔
206
    }
2,147,483,647✔
207

208
    /// Returns an iterator over all graves in the record that are under gravepath
209
    pub fn seance<'a>(
210
        &'a self,
2,147,483,647✔
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))?;
2,147,483,647✔
215
        Ok(reader
2,147,483,647✔
216
            .lines()
2,147,483,647✔
217
            .map_while(Result::ok)
2,147,483,647✔
218
            .map(|line| RecordItem::new(&line))
2,147,483,647✔
219
            .filter(move |record_item| record_item.dest.starts_with(gravepath)))
2,147,483,647✔
220
    }
2,147,483,647✔
221

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

2,147,483,647✔
226
        let mut record_file = fs::OpenOptions::new().append(true).open(&self.path)?;
227

2,147,483,647✔
228
        if FILE_LOCK {
229
            record_file.lock_exclusive().unwrap();
×
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| {
240
            Error::new(
×
241
                e.kind(),
×
242
                format!("Failed to write record at {}", &self.path.display()),
×
243
            )
×
244
        })?;
245

246
        Ok(())
247
    }
2,147,483,647✔
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> {
252
        let mut header = String::new();
2,147,483,647✔
253
        reader.read_line(&mut header)?;
2,147,483,647✔
254

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

270
impl<const FILE_LOCK: bool> Clone for Record<FILE_LOCK> {
271
    fn clone(&self) -> Self {
272
        Self {
×
273
            path: self.path.clone(),
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