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;