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 AutoDetect,
15 AsIs,
17 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}