Skip to main content

rattlebeaver/
backup.rs

1use crate::config;
2use crate::entry::read_dir;
3use crate::timestamp::Timestamp;
4use anyhow::{Context, Result};
5use chrono::{Local, Timelike};
6use flate2::Compression;
7use flate2::write::GzEncoder;
8use std::fs::File;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Copy, clap::ValueEnum)]
12pub enum ArchiveMode {
13    /// Tarball and compress if not already
14    AutoDetect,
15    /// Use file as-is
16    AsIs,
17    /// Tarball and compress always
18    Force,
19}
20
21#[derive(Debug, Clone, Copy, clap::ValueEnum)]
22pub enum TimestampSelection {
23    Now,
24    FileCreated,
25    FileModified,
26}
27
28#[derive(Debug)]
29pub enum BackupError {
30    TimestampConflict(String),
31    Other(anyhow::Error),
32}
33
34impl From<anyhow::Error> for BackupError {
35    fn from(value: anyhow::Error) -> Self {
36        BackupError::Other(value)
37    }
38}
39
40impl std::fmt::Display for BackupError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        let display = match self {
43            Self::TimestampConflict(s) => s.to_owned(),
44            Self::Other(e) => e.to_string(),
45        };
46        write!(f, "{display}")
47    }
48}
49
50impl std::error::Error for BackupError {
51    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
52        if let Self::Other(anyhow_error) = self {
53            Some(&**anyhow_error)
54        } else {
55            None
56        }
57    }
58}
59
60pub fn create_backup(
61    source: &Path,
62    target: &Path,
63    config: &config::Archive,
64    timestamp: TimestampSelection,
65    archive_behavior: ArchiveMode,
66) -> std::result::Result<PathBuf, BackupError> {
67    ensure_dir(target)?;
68    let timestamp = get_file_timestamp(source, timestamp)?;
69    let existing_backups = read_dir(target, config).context("read existing backups")?;
70    for existing in existing_backups {
71        if timestamp == existing.timestamp {
72            let error = BackupError::TimestampConflict(format!(
73                "timestamp {timestamp} conflicts with existing backup: {}",
74                existing.path.display()
75            ));
76            return Err(error);
77        }
78    }
79    let file_name = format!(
80        "{}{}",
81        config.prefix,
82        timestamp.as_ref().format(&config.timestamp_format),
83    );
84
85    let final_target_path = if source.is_dir() {
86        let source_stem = get_file_stem(source)?;
87        let target_path = target.join(format!("{file_name}.{source_stem}.tar.gz"));
88        let tar_gz = File::create(&target_path).context("create archive file")?;
89        let enc = GzEncoder::new(tar_gz, Compression::default());
90        let mut tarball = tar::Builder::new(enc);
91        tarball
92            .append_dir_all("", source)
93            .context("add dir to tarball")?;
94        tarball.finish().context("create tarball")?;
95        target_path
96    } else if source.is_file() {
97        let is_archive = source.display().to_string().ends_with(".tar.gz");
98        let make_archive = match (archive_behavior, is_archive) {
99            (ArchiveMode::Force, _) | (ArchiveMode::AutoDetect, false) => true,
100            (ArchiveMode::AsIs, _) | (ArchiveMode::AutoDetect, true) => false,
101        };
102        if make_archive {
103            let source_stem = get_file_stem(source)?;
104            let mut source_file = std::fs::File::open(source).context("open source file")?;
105            let target_path = target.join(format!("{file_name}.{source_stem}.tar.gz"));
106            let tar_gz = File::create(&target_path).context("create archive file")?;
107            let enc = GzEncoder::new(tar_gz, Compression::default());
108            let mut tarball = tar::Builder::new(enc);
109            tarball
110                .append_file(
111                    source.file_name().context("missing file name")?,
112                    &mut source_file,
113                )
114                .context("add dir to tarball")?;
115            tarball.finish().context("create tarball")?;
116            target_path
117        } else {
118            let source_name = source
119                .file_name()
120                .context("get file name")?
121                .to_string_lossy();
122            let target_path = target.join(format!("{file_name}.{source_name}"));
123            std::fs::copy(source, &target_path).context("copy file")?;
124            target_path
125        }
126    } else {
127        return Err(anyhow::anyhow!("source file is neither a file nor directory").into());
128    };
129
130    Ok(final_target_path)
131}
132
133fn get_file_timestamp(file: &Path, selection: TimestampSelection) -> Result<Timestamp> {
134    let timestamp = match selection {
135        TimestampSelection::Now => Local::now(),
136        TimestampSelection::FileCreated => {
137            let metadata = file.metadata().context("get file metadata")?;
138            metadata.created().context("get file created time")?.into()
139        }
140        TimestampSelection::FileModified => {
141            let metadata = file.metadata().context("get file metadata")?;
142            metadata
143                .modified()
144                .context("get file modified time")?
145                .into()
146        }
147    };
148    let timestamp = timestamp.with_nanosecond(0).context("zero nanoseconds")?;
149    Ok(Timestamp(timestamp))
150}
151
152fn get_file_stem(source: &Path) -> Result<String> {
153    Ok(source
154        .file_stem()
155        .context("get file stem")?
156        .to_string_lossy()
157        .to_string())
158}
159
160fn ensure_dir(target: &Path) -> Result<()> {
161    if !target.exists() {
162        std::fs::create_dir_all(target).context("create target dir")?;
163    } else if !target.is_dir() {
164        anyhow::bail!("{target:?} is not a directory");
165    }
166    Ok(())
167}