backitup/
lib.rs

1// Released under MIT License.
2// Copyright (c) 2023 Ladislav Bartos
3
4//! # Back It Up!
5//!
6//! Stupidly simple crate for backing up files and directories.
7//!
8//! ## What is this?
9//! This crate helps in a common scenario where an application generates an output file that could overwrite another file.
10//! Using the `backup` function provided by this crate, you can create backups before any overwriting happens, ensuring the data remains safe.
11//! Back It Up! makes sure that even if you write an output file with the same name repeatedly, no data will be lost.
12//!
13//! ## Usage
14//!
15//! Run
16//!
17//! ```bash
18//! $ cargo add backitup
19//! ```
20//!
21//! Import the crate in your Rust code:
22//!
23//! ```rust
24//! use backitup::backup;
25//! ```
26//!
27//! ### Creating a Backup
28//!
29//! To create a backup of a file or directory, use the `backup` function. The function takes the path
30//! to the file or directory as an argument and returns the path to the backup file if successful,
31//! or an error if the backup operation fails.
32//!
33//! Note that the content of the file (or directory) is not copied, the file (or directory) is simply **renamed**.
34//!
35//! ```rust
36//! use crate::backitup::backup;
37//!
38//! let path = "data.txt";
39//! match backup(path) {
40//!     Ok(backup_path) => println!("Backup created: {:?}", backup_path),
41//!     Err(err) => eprintln!("Failed to create backup: {:?}", err),
42//! }
43//! ```
44//!
45//! ### Name of the Backup
46//! The backup file or directory name is generated based on the original `path`, appending a timestamp
47//! in the format "YYYY-MM-DD-HH-MM-SS". If multiple backups are created within the same second, additional
48//! information about the microseconds will be appended. The backup name follows the pattern:
49//!
50//! For files: `"#<parent_directory>/<filename>-<timestamp>(-<microseconds>)#"`
51//!
52//! For directories: `"#<parent_directory>/<directory_name>-<timestamp>(-<microseconds>)#"`
53//!
54//! For instance, file `data.txt` backed up on 2023/06/27 at 21:01:13 (local time) will be
55//! renamed as `#data.txt-2023-06-27-21-01-13#.
56//!
57//! ## License
58//!
59//! This crate is distributed under the terms of the MIT license.
60//!
61
62use std::fs;
63use std::io::{Error, ErrorKind};
64use std::path::{Path, PathBuf};
65
66use chrono::prelude::*;
67
68/// Creates a backup of the specified file or directory.
69/// Returns the path to the backup file if successful, otherwise returns an error.
70///
71/// # Arguments
72///
73/// * `path` - The path to the file or directory to be backed up.
74///
75/// # Errors
76///
77/// This function can return the following errors:
78///
79/// * `NotFound` - If the specified `path` does not exist.
80/// * `Unsupported` - If the `path` is not valid (i.e. not UTF-8, root or ends with '..').
81/// * `Io` - If an I/O error occurs during the backup process.
82///
83/// # Name of the Backup
84/// The backup file or directory name is generated based on the original `path`, appending a timestamp
85/// in the format "YYYY-MM-DD-HH-MM-SS". If multiple backups are created within the same second, additional
86/// information about the microseconds will be appended. The backup name follows the pattern:
87///
88/// For files: `"#<parent_directory>/<filename>-<timestamp>(-<microseconds>)#"`
89///
90/// For directories: `"#<parent_directory>/<directory_name>-<timestamp>(-<microseconds>)#"`
91///
92/// For instance, file `data.txt` backed up on 2023/06/27 at 21:01:13 (local time) will be
93/// renamed as `#data.txt-2023-06-27-21-01-13#.
94///
95/// # Examples
96///
97/// ```no_run
98/// use crate::backitup::backup;
99///
100/// let path = "data.txt";
101/// match backup(path) {
102///     Ok(backup_path) => println!("Backup created: {:?}", backup_path),
103///     Err(err) => eprintln!("Failed to create backup: {:?}", err),
104/// }
105/// ```
106pub fn backup(path: impl AsRef<Path>) -> Result<PathBuf, std::io::Error> {
107    // check if the path exists
108    if !path.as_ref().exists() {
109        return Err(Error::new(ErrorKind::NotFound, "Path does not exist."));
110    }
111
112    // cannot backup the current working directory
113    if path.as_ref().canonicalize()? == std::env::current_dir()?.canonicalize()? {
114        return Err(Error::new(
115            ErrorKind::PermissionDenied,
116            "Cannot backup the current working directory.",
117        ));
118    }
119
120    // get the parent directory of the path
121    let parent = match path.as_ref().parent() {
122        Some(x) => match x.to_str() {
123            Some("") => ".",
124            Some(x) => x,
125            None => {
126                return Err(Error::new(
127                    ErrorKind::Unsupported,
128                    "Path is not a valid UTF-8.",
129                ))
130            }
131        },
132        None => return Err(Error::new(ErrorKind::Unsupported, "Path is root.")),
133    };
134
135    // get the filename from the path
136    let filename = match path.as_ref().file_name() {
137        Some(x) => match x.to_str() {
138            Some(x) => x,
139            None => {
140                return Err(Error::new(
141                    ErrorKind::Unsupported,
142                    "Path is not a valid UTF-8.",
143                ))
144            }
145        },
146        None => return Err(Error::new(ErrorKind::Unsupported, "Path ends in '..'.")),
147    };
148
149    // generate the backup file name with a timestamp
150    let time = Local::now().format("%Y-%m-%d-%H-%M-%S").to_string();
151    let mut backup_name = Path::new(&format!("{}/#{}-{}#", parent, filename, &time)).to_path_buf();
152
153    // if a file with the same name already exists, append microseconds
154    // repeat until the name of the backup is unique
155    while backup_name.exists() {
156        let time = Local::now();
157        let micros = time.timestamp_subsec_micros();
158        let time_fmt = time.format("%Y-%m-%d-%H-%M-%S").to_string();
159
160        backup_name = Path::new(&format!(
161            "{}/#{}-{}-{}#",
162            parent, filename, &time_fmt, micros
163        ))
164        .to_path_buf();
165    }
166
167    // rename the original file to the backup name
168    match fs::rename(path, &backup_name) {
169        Ok(()) => Ok(backup_name),
170        Err(e) => Err(e),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::fs::{self, File};
178    use std::io::prelude::*;
179
180    #[test]
181    fn file() {
182        let mut file = File::create("test_file1.txt").unwrap();
183        file.write_all(b"Some content to test.").unwrap();
184
185        let backup = match backup("test_file1.txt") {
186            Ok(x) => x,
187            Err(_) => panic!("Backup failed."),
188        };
189
190        drop(file);
191
192        let mut content = String::new();
193        let mut read = File::open(&backup).unwrap();
194        read.read_to_string(&mut content).unwrap();
195
196        assert_eq!(content, "Some content to test.");
197
198        fs::remove_file(backup).unwrap();
199    }
200
201    #[test]
202    fn file_multiple_backups() {
203        let mut backups = Vec::new();
204        for i in 0..20 {
205            let mut file = File::create("test_file2.txt").unwrap();
206            let text = format!("Unique string for file {}", i);
207            file.write_all(text.as_bytes()).unwrap();
208
209            let backup = match backup("test_file2.txt") {
210                Ok(x) => x,
211                Err(_) => panic!("Backup failed."),
212            };
213
214            backups.push(backup);
215        }
216
217        for (i, path) in backups.iter().enumerate() {
218            let mut content = String::new();
219            let mut read = File::open(&path).unwrap();
220
221            read.read_to_string(&mut content).unwrap();
222
223            let test = format!("Unique string for file {}", i);
224            assert_eq!(content, test);
225
226            fs::remove_file(path).unwrap();
227        }
228    }
229
230    #[test]
231    fn file_in_different_directory() {
232        fs::create_dir("test_dir").unwrap();
233
234        let mut file = File::create("test_dir/test_file.txt").unwrap();
235        file.write_all(b"Some content to test.").unwrap();
236
237        let backup = match backup("test_dir/test_file.txt") {
238            Ok(x) => x,
239            Err(_) => panic!("Backup failed."),
240        };
241
242        drop(file);
243
244        let mut content = String::new();
245        let mut read = File::open(&backup).unwrap();
246        read.read_to_string(&mut content).unwrap();
247
248        assert_eq!(content, "Some content to test.");
249
250        fs::remove_file(&backup).unwrap();
251        fs::remove_dir("test_dir").unwrap();
252    }
253
254    #[test]
255    fn file_multiple_backups_in_different_directory() {
256        fs::create_dir("test_dir2").unwrap();
257
258        let mut backups = Vec::new();
259        for i in 0..20 {
260            let mut file = File::create("test_dir2/test_file.txt").unwrap();
261            let text = format!("Unique string for file {}", i);
262            file.write_all(text.as_bytes()).unwrap();
263
264            let backup = match backup("test_dir2/test_file.txt") {
265                Ok(x) => x,
266                Err(_) => panic!("Backup failed."),
267            };
268
269            backups.push(backup);
270        }
271
272        for (i, path) in backups.iter().enumerate() {
273            let mut content = String::new();
274            let mut read = File::open(&path).unwrap();
275
276            read.read_to_string(&mut content).unwrap();
277
278            let test = format!("Unique string for file {}", i);
279            assert_eq!(content, test);
280
281            fs::remove_file(path).unwrap();
282        }
283
284        fs::remove_dir("test_dir2").unwrap();
285    }
286
287    #[test]
288    fn directory() {
289        fs::create_dir("test_dir3").unwrap();
290
291        let mut file = File::create("test_dir3/test_file.txt").unwrap();
292        file.write_all(b"Some content to test.").unwrap();
293
294        let backup = match backup("test_dir3") {
295            Ok(x) => x,
296            Err(_) => panic!("Backup failed."),
297        };
298
299        drop(file);
300
301        let mut content = String::new();
302        let file_in_backup = backup.join(Path::new("test_file.txt"));
303        let mut read = File::open(&file_in_backup).unwrap();
304        read.read_to_string(&mut content).unwrap();
305
306        assert_eq!(content, "Some content to test.");
307
308        fs::remove_file(&file_in_backup).unwrap();
309        fs::remove_dir(&backup).unwrap();
310    }
311
312    #[test]
313    fn directory_multiple_backups() {
314        let mut backups = Vec::new();
315        for i in 0..10 {
316            fs::create_dir("test_dir4").unwrap();
317
318            let mut file = File::create("test_dir4/test_file.txt").unwrap();
319            let text = format!("Unique string for file {}", i);
320            file.write_all(text.as_bytes()).unwrap();
321
322            let backup = match backup("test_dir4") {
323                Ok(x) => x,
324                Err(_) => panic!("Backup failed."),
325            };
326
327            backups.push(backup);
328        }
329
330        for (i, path) in backups.iter().enumerate() {
331            let file_in_backup = path.join(Path::new("test_file.txt"));
332
333            let mut content = String::new();
334            let mut read = File::open(&file_in_backup).unwrap();
335
336            read.read_to_string(&mut content).unwrap();
337
338            let test = format!("Unique string for file {}", i);
339            assert_eq!(content, test);
340
341            fs::remove_file(&file_in_backup).unwrap();
342            fs::remove_dir(&path).unwrap();
343        }
344    }
345
346    #[test]
347    fn nonexistent() {
348        match backup("nonexistent.txt") {
349            Ok(_) => panic!("Backup should have failed, but it was successful."),
350            Err(e) => assert_eq!(e.to_string(), "Path does not exist."),
351        };
352    }
353
354    #[test]
355    fn root() {
356        match backup("/") {
357            Ok(_) => panic!("Backup should have failed, but it was successful."),
358            Err(e) => assert_eq!(e.to_string(), "Path is root."),
359        };
360    }
361
362    #[test]
363    fn empty() {
364        match backup("") {
365            Ok(_) => panic!("Backup should have failed, but it was successful."),
366            Err(e) => assert_eq!(e.to_string(), "Path does not exist."),
367        };
368    }
369
370    #[test]
371    fn dotdot() {
372        match backup("..") {
373            Ok(_) => panic!("Backup should have failed, but it was successful."),
374            Err(e) => assert_eq!(e.to_string(), "Path ends in '..'."),
375        };
376    }
377
378    #[test]
379    fn current_working_directory1() {
380        match backup(".") {
381            Ok(_) => panic!("Backup should have failed, but it was successful."),
382            Err(e) => assert_eq!(
383                e.to_string(),
384                "Cannot backup the current working directory."
385            ),
386        }
387    }
388
389    #[test]
390    fn current_working_directory2() {
391        match backup("../backitup/../backitup") {
392            Ok(_) => panic!("Backup should have failed, but it was successful."),
393            Err(e) => assert_eq!(
394                e.to_string(),
395                "Cannot backup the current working directory."
396            ),
397        }
398    }
399}