in_place/
lib.rs

1//! The `in_place` library provides an `InPlace` type for reading & writing a
2//! file "in-place": data that you write ends up at the same filepath that you
3//! read from, and `in_place` takes care of all the necessary mucking about
4//! with temporary files for you.
5//!
6//! For example, given the file `somefile.txt`:
7//!
8//! ```text
9//! 'Twas brillig, and the slithy toves
10//!     Did gyre and gimble in the wabe;
11//! All mimsy were the borogoves,
12//!     And the mome raths outgrabe.
13//! ```
14//!
15//! and the following program:
16//!
17//! ```no_run
18//! use in_place::InPlace;
19//! use std::io::{BufRead, BufReader, Write};
20//!
21//! fn main() -> Result<(), Box<dyn std::error::Error>> {
22//!     let inp = InPlace::new("somefile.txt").open()?;
23//!     let reader = BufReader::new(inp.reader());
24//!     let mut writer = inp.writer();
25//!     for line in reader.lines() {
26//!         let mut line = line?;
27//!         line.retain(|ch| !"AEIOUaeiou".contains(ch));
28//!         writeln!(writer, "{line}")?;
29//!     }
30//!     inp.save()?;
31//!     Ok(())
32//! }
33//! ```
34//!
35//! after running the program, `somefile.txt` will have been edited in place,
36//! reducing it to just:
37//!
38//! ```text
39//! 'Tws brllg, nd th slthy tvs
40//!     Dd gyr nd gmbl n th wb;
41//! ll mmsy wr th brgvs,
42//!     nd th mm rths tgrb.
43//! ```
44//!
45//! and no sign of those pesky vowels remains!  If you want a sign of those
46//! pesky vowels to remain, you can instead save the file's original contents
47//! in, say, `somefile.txt~` by opening the file with:
48//!
49//! ```compile_fail
50//! let inp = InPlace::new("somefile.txt")
51//!     .backup(in_place::Backup::Append("~".into()))
52//!     .open()?;
53//! ```
54//!
55//! or save to `someotherfile.txt` with:
56//!
57//! ```compile_fail
58//! let inp = InPlace::new("somefile.txt")
59//!     .backup(in_place::Backup::Path("someotherfile.txt".into()))
60//!     .open()?;
61//! ```
62//!
63//! If you decide halfway through that you don't want to edit the file (say,
64//! because an unrecoverable error occurs), calling `inp.discard()` instead of
65//! `inp.save()` will close the file handles and reset things to the way they
66//! were before.  Any changes are also discarded if `inp` is dropped without
67//! saving, except that in that case any errors are silently ignored.
68
69use std::error;
70use std::ffi::OsString;
71use std::fmt;
72use std::fs::{metadata, rename, symlink_metadata, File};
73use std::io;
74use std::path::{Path, PathBuf};
75use tempfile::{Builder, NamedTempFile, PersistError};
76
77/// A builder for opening & editing a file in-place.
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct InPlace {
80    path: PathBuf,
81    backup: Option<Backup>,
82    follow_symlinks: bool,
83}
84
85impl InPlace {
86    /// Create a new `InPlace` instance for editing the given path (hereafter
87    /// called the "edited path") in-place.
88    pub fn new<P: AsRef<Path>>(path: P) -> InPlace {
89        InPlace {
90            path: path.as_ref().into(),
91            backup: None,
92            follow_symlinks: true,
93        }
94    }
95
96    /// Move the edited file to the path given by `backup` when
97    /// [`InPlaceFile::save()`] is called.
98    ///
99    /// Note that `in_place` does not create any parent directories of the
100    /// backup path; it is the user's responsibility to ensure that the backup
101    /// location is somewhere that a file can be moved to.
102    ///
103    /// If the backup path is the same as the edited path, the net effect will
104    /// be as though no backup was configured.
105    pub fn backup(&mut self, backup: Backup) -> &mut Self {
106        self.backup = Some(backup);
107        self
108    }
109
110    /// Do not move the edited file to a backup path.  This is the default
111    /// behavior.
112    ///
113    /// This overrides any previous calls to [`InPlace::backup()`].
114    pub fn no_backup(&mut self) -> &mut Self {
115        self.backup = None;
116        self
117    }
118
119    /// If `flag` is true (the default), the edited file path will be
120    /// canonicalized, resolving any symlinks, before opening.  As a result, if
121    /// the edited path points to a symlink, the file that the symlink points
122    /// to will be the one edited (and moved to a backup location if so
123    /// configured).
124    ///
125    /// If `flag` is false, the edited file path will not be canonicalized,
126    /// though the file that it points to will still be edited through the
127    /// symlink.  If a backup is configured, the symlink itself will be moved
128    /// to the backup location.
129    ///
130    /// Note that this option only applies to the edited path; any symlinks in
131    /// the backup path are not resolved.  As a result, if a backup path points
132    /// to a symlink, backing up will obliterate the symlink (but not the file
133    /// it points to) and replace it with the unmodified edited file.
134    pub fn follow_symlinks(&mut self, flag: bool) -> &mut Self {
135        self.follow_symlinks = flag;
136        self
137    }
138
139    /// Open the edited path for reading and create a temporary file for
140    /// writing.
141    ///
142    /// The exact set & order of operations may change in a future version, but
143    /// currently it is as follows:
144    ///
145    /// - If `follow_symlinks` is true, the edited path is canonicalized.
146    ///   Otherwise, if it is relative, the current directory is prepended.
147    ///   (This ensures that changing the current directory while the
148    ///   [`InPlaceFile`] is open will not mess anything up.)
149    ///
150    /// - If a backup is set, determine the backup path based on the
151    ///   canonicalized/absolutized edited path.  If the result is a relative
152    ///   path, the current directory is prepended.
153    ///
154    /// - Create a named temporary file in the edited path's parent directory.
155    ///
156    /// - If the edited path is not a symlink, copy its permission bits to the
157    ///   temporary file.
158    ///
159    /// - Open the edited path for reading.
160    ///
161    /// # Errors
162    ///
163    /// See the documentation for the variants of [`InPlaceErrorKind`] for the
164    /// operations & checks that this method can fail on.
165    pub fn open(&self) -> Result<InPlaceFile, InPlaceError> {
166        let path = if self.follow_symlinks {
167            self.path
168                .canonicalize()
169                .map_err(InPlaceError::canonicalize)?
170        } else {
171            absolutize(&self.path)?
172        };
173        // Don't try to canonicalize backup_path, as it likely won't exist,
174        // which would lead to an error
175        let backup_path = match self.backup.as_ref() {
176            Some(bkp) => Some(absolutize(&bkp.apply(&path)?)?),
177            None => None,
178        };
179        let writer = mktemp(&path)?;
180        copystats(&path, writer.as_file(), self.follow_symlinks)?;
181        let reader = File::open(&path).map_err(InPlaceError::open)?;
182        Ok(InPlaceFile {
183            reader,
184            writer,
185            path,
186            backup_path,
187        })
188    }
189}
190
191/// A path or path computation specifying where to back up an edited file.
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub enum Backup {
194    /// An explicit path at which to back up the edited file
195    Path(PathBuf),
196    /// Determine the path at which to backup the edited file by changing the
197    /// file's filename to the given value
198    FileName(OsString),
199    /// Determine the path at which to backup the edited file by changing the
200    /// file's extension (using [`Path::with_extension()`]) to the given value.
201    /// Note that the value should generally not start with a period.
202    Extension(OsString),
203    /// Determine the path at which to backup the edited file by appending the
204    /// given value to the filename
205    Append(OsString),
206}
207
208impl Backup {
209    fn apply(&self, path: &Path) -> Result<PathBuf, InPlaceError> {
210        match self {
211            Backup::Path(p) => {
212                if p == Path::new("") {
213                    Err(InPlaceError::empty_backup())
214                } else {
215                    Ok(p.clone())
216                }
217            }
218            Backup::FileName(fname) => {
219                if fname.is_empty() {
220                    Err(InPlaceError::empty_backup())
221                } else {
222                    Ok(path.with_file_name(fname))
223                }
224            }
225            Backup::Extension(ext) => Ok(path.with_extension(ext)),
226            Backup::Append(ext) => {
227                if ext.is_empty() {
228                    Err(InPlaceError::empty_backup())
229                } else {
230                    match path.file_name() {
231                        Some(fname) => {
232                            let mut fname = fname.to_os_string();
233                            fname.push(ext);
234                            Ok(path.with_file_name(&fname))
235                        }
236                        None => Err(InPlaceError::no_filename()),
237                    }
238                }
239            }
240        }
241    }
242}
243
244/// A file that is currently being edited in-place.
245///
246/// An `InPlaceFile` instance can be obtained via [`InPlace::open()`].
247///
248/// An `InPlaceFile` provides two file handles, one for reading the contents of
249/// the edited file and for writing its new contents.  In order to update the
250/// edited file with the written bytes, [`InPlaceFile::save()`] must be called
251/// once writing is complete.  Alternatively, calling
252/// [`InPlaceFile::discard()`] will discard all written bytes and leave the
253/// edited file unmodified.
254///
255/// Dropping an `InPlaceFile` without calling `save()` has the same effect as
256/// calling `discard()`, except that any errors are ignored.
257#[derive(Debug)]
258pub struct InPlaceFile {
259    reader: File,
260    writer: NamedTempFile,
261    path: PathBuf,
262    backup_path: Option<PathBuf>,
263}
264
265impl InPlaceFile {
266    /// The reader file handle
267    pub fn reader(&self) -> &File {
268        &self.reader
269    }
270
271    /// The writer file handle
272    pub fn writer(&self) -> &File {
273        self.writer.as_file()
274    }
275
276    /// The path to the edited file.  If `follow_symlinks` was set to `true`,
277    /// this will be a canonical path; otherwise, the path is only guaranteed
278    /// to be absolute.
279    pub fn path(&self) -> &Path {
280        &self.path
281    }
282
283    /// The path, if any, where the edited file will be backed up once
284    /// [`InPlaceFile::save()`] is called.  This is an absolute path.
285    pub fn backup_path(&self) -> Option<&Path> {
286        self.backup_path.as_deref()
287    }
288
289    /// Save the unmodified edited file at the backup path, if any, and replace
290    /// the edited file with the temporary output file.
291    ///
292    /// The exact set & order of operations may change in a future version, but
293    /// currently it is as follows:
294    ///
295    /// - The file handle for the edited file is closed.
296    ///
297    /// - If a backup path is set, move the edited file to that location.
298    ///
299    /// - Persist the temporary file at the edited file's original location.
300    ///   If this fails, and a backup path is set, try to move the backup back
301    ///   to the original location, ignoring any errors.
302    ///
303    /// # Errors
304    ///
305    /// See the documentation for the variants of [`InPlaceErrorKind`] for the
306    /// operations that this method can fail on.
307    pub fn save(self) -> Result<(), InPlaceError> {
308        drop(self.reader);
309        if let Some(bp) = self.backup_path.as_ref() {
310            rename(&self.path, bp).map_err(InPlaceError::save_backup)?;
311        }
312        match self.writer.persist(&self.path) {
313            Ok(_) => Ok(()),
314            Err(e) => {
315                if let Some(bp) = self.backup_path.as_ref() {
316                    let _ = rename(bp, &self.path);
317                }
318                Err(InPlaceError::persist(e))
319            }
320        }
321    }
322
323    /// Close all filehandles and do not update or back up the edited file.
324    ///
325    /// # Errors
326    ///
327    /// See the documentation for the variants of [`InPlaceErrorKind`] for the
328    /// operations that this method can fail on.
329    pub fn discard(self) -> Result<(), InPlaceError> {
330        self.writer.close().map_err(InPlaceError::rmtemp)
331    }
332}
333
334/// An error that can occur while opening, saving, or discarding an
335/// [`InPlaceFile`].
336///
337/// Some errors are caused by failed I/O operations, while others are responses
338/// to invalid paths or backup specifiers.  Only the first kind have source
339/// errors, available via [`InPlaceError::as_io_error()`] and
340/// [`InPlaceError::into_io_error()`] in addition to
341/// [`std::error::Error::source()`].
342#[derive(Debug)]
343pub struct InPlaceError {
344    kind: InPlaceErrorKind,
345    source: Option<io::Error>,
346}
347
348impl InPlaceError {
349    /// Returns an enum value describing the operation or check that failed
350    pub fn kind(&self) -> InPlaceErrorKind {
351        self.kind
352    }
353
354    /// Returns the [`std::io::Error`] that occurred, if any.  See the
355    /// documentation of [`InPlaceErrorKind`] to find out which error kinds
356    /// have source errors.
357    pub fn as_io_error(&self) -> Option<&io::Error> {
358        self.source.as_ref()
359    }
360
361    /// Consumes the [`InPlaceError`] and returns the inner [`std::io::Error`],
362    /// if any.
363    pub fn into_io_error(self) -> Option<io::Error> {
364        self.source
365    }
366
367    fn get_metadata(source: io::Error) -> InPlaceError {
368        InPlaceError {
369            kind: InPlaceErrorKind::GetMetadata,
370            source: Some(source),
371        }
372    }
373
374    fn set_metadata(source: io::Error) -> InPlaceError {
375        InPlaceError {
376            kind: InPlaceErrorKind::SetMetadata,
377            source: Some(source),
378        }
379    }
380
381    fn no_parent() -> InPlaceError {
382        InPlaceError {
383            kind: InPlaceErrorKind::NoParent,
384            source: None,
385        }
386    }
387
388    fn mktemp(source: io::Error) -> InPlaceError {
389        InPlaceError {
390            kind: InPlaceErrorKind::Mktemp,
391            source: Some(source),
392        }
393    }
394
395    fn canonicalize(source: io::Error) -> InPlaceError {
396        InPlaceError {
397            kind: InPlaceErrorKind::Canonicalize,
398            source: Some(source),
399        }
400    }
401
402    fn cwd(source: io::Error) -> InPlaceError {
403        InPlaceError {
404            kind: InPlaceErrorKind::CurrentDir,
405            source: Some(source),
406        }
407    }
408
409    fn open(source: io::Error) -> InPlaceError {
410        InPlaceError {
411            kind: InPlaceErrorKind::Open,
412            source: Some(source),
413        }
414    }
415
416    fn empty_backup() -> InPlaceError {
417        InPlaceError {
418            kind: InPlaceErrorKind::EmptyBackup,
419            source: None,
420        }
421    }
422
423    fn no_filename() -> InPlaceError {
424        InPlaceError {
425            kind: InPlaceErrorKind::NoFilename,
426            source: None,
427        }
428    }
429
430    fn save_backup(source: io::Error) -> InPlaceError {
431        InPlaceError {
432            kind: InPlaceErrorKind::SaveBackup,
433            source: Some(source),
434        }
435    }
436
437    fn persist(source: PersistError) -> InPlaceError {
438        InPlaceError {
439            kind: InPlaceErrorKind::PersistTemp,
440            source: Some(source.error),
441        }
442    }
443
444    fn rmtemp(source: io::Error) -> InPlaceError {
445        InPlaceError {
446            kind: InPlaceErrorKind::Rmtemp,
447            source: Some(source),
448        }
449    }
450}
451
452impl fmt::Display for InPlaceError {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        write!(f, "{}", self.kind.message())
455    }
456}
457
458impl error::Error for InPlaceError {
459    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
460        self.source.as_ref().map(|e| {
461            let e2: &dyn error::Error = e;
462            e2
463        })
464    }
465}
466
467/// An enumeration of the operations & checks that can fail while opening,
468/// saving, or discarding an [`InPlaceFile`].
469#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
470#[non_exhaustive]
471pub enum InPlaceErrorKind {
472    /// Returned by [`InPlace::open()`] if attempting to canonicalize the
473    /// edited path failed.
474    ///
475    /// This error kind occurs when the edited path does not exist and
476    /// `follow_symlinks` is true.
477    Canonicalize,
478
479    /// Returned by [`InPlace::open()`] if attempting to fetch the current
480    /// directory failed
481    CurrentDir,
482
483    /// Returned by [`InPlace::open()`] if the value within a [`Backup::Path`],
484    /// [`Backup::FileName`], or [`Backup::Append`] backup specifier was empty.
485    ///
486    /// This error kind does not have a source error.
487    EmptyBackup,
488
489    /// Returned by [`InPlace::open()`] if attempting to fetch metadata &
490    /// permission details about the edited file failed.
491    ///
492    /// This error kind occurs when the edited path does not exist and
493    /// `follow_symlinks` is false.
494    GetMetadata,
495
496    /// Returned by [`InPlace::open()`] if attempting to create the temporary
497    /// file failed
498    Mktemp,
499
500    /// Returned by [`InPlace::open()`] if a [`Backup::Append`] specifier was
501    /// given and [`Path::file_name`] returned `None` for the edited path.
502    ///
503    /// This error kind does not have a source error.
504    NoFilename,
505
506    /// Returned by [`InPlace::open()`] if [`Path::parent`] returned `None` for
507    /// the edited path (after canonicalization or absolutization).
508    ///
509    /// This error kind does not have a source error.
510    NoParent,
511
512    /// Returned by [`InPlace::open()`] if attempting to open the edited file
513    /// for reading failed
514    Open,
515
516    /// Returned by [`InPlace::open()`] if attempting to copy the edited file's
517    /// permissions to the temporary file failed
518    SetMetadata,
519
520    /// Returned by [`InPlaceFile::save()`] if attempting to persist the
521    /// temporary file at the edited path failed
522    PersistTemp,
523
524    /// Returned by [`InPlaceFile::save()`] if attempting to move the edited
525    /// file to the backup path failed
526    SaveBackup,
527
528    /// Returned by [`InPlaceFile::discard()`] if attempting to delete the
529    /// temporary file failed
530    Rmtemp,
531}
532
533impl InPlaceErrorKind {
534    fn message(&self) -> &'static str {
535        use InPlaceErrorKind::*;
536        match self {
537            Canonicalize => "failed to canonicalize path",
538            CurrentDir => "failed to fetch current directory",
539            EmptyBackup => "backup path is empty",
540            GetMetadata => "failed to get metadata for path",
541            Mktemp => "failed to create temporary file",
542            NoFilename => "path does not have a filename",
543            NoParent => "path does not have a parent directory",
544            Open => "failed to open file for reading",
545            SetMetadata => "failed to set metadata on temporary file",
546            PersistTemp => "failed to save temporary file at path",
547            SaveBackup => "failed to move file to backup path",
548            Rmtemp => "failed to delete temporary file",
549        }
550    }
551}
552
553fn absolutize(filepath: &Path) -> Result<PathBuf, InPlaceError> {
554    if filepath.is_absolute() {
555        Ok(filepath.into())
556    } else {
557        let cwd = std::env::current_dir().map_err(InPlaceError::cwd)?;
558        Ok(cwd.join(filepath))
559    }
560}
561
562fn mktemp(filepath: &Path) -> Result<NamedTempFile, InPlaceError> {
563    let dirpath = filepath.parent().ok_or_else(InPlaceError::no_parent)?;
564    Builder::new()
565        .prefix("._in_place-")
566        .tempfile_in(dirpath)
567        .map_err(InPlaceError::mktemp)
568}
569
570fn copystats(src: &Path, dest: &File, follow_symlinks: bool) -> Result<(), InPlaceError> {
571    let md = if follow_symlinks {
572        metadata(src)
573    } else {
574        symlink_metadata(src)
575    }
576    .map_err(InPlaceError::get_metadata)?;
577    if !md.is_symlink() {
578        dest.set_permissions(md.permissions())
579            .map_err(InPlaceError::set_metadata)?;
580    }
581    Ok(())
582}
583
584#[cfg(test)]
585mod tests;