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

MilesCranmer / rip2 / #652

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

push

MilesCranmer
test: reformat for windows

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) -> RecordItem {
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
        RecordItem {
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) -> Record<FILE_LOCK> {
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
        Record { 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
            } else {
147
                // File is gone, mark the grave to be removed from the record
148
                graves_to_exhume.push(entry.dest);
×
149
            }
150
        }
151

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

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

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

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

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

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

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

229
        if FILE_LOCK {
×
230
            record_file.lock_exclusive().unwrap();
×
231
        }
232

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

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

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

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

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