1use crate::io::{archive, file_checksum, files_all, read_file, write_file, ApiResult, StringConversion};
8use crate::prelude::{copy, create_dir_all, PathBuf};
9use crate::util::{ChecksumAlgorithm, Label};
10use bon::Builder;
11use color_eyre::eyre::eyre;
12use core::fmt;
13use derive_more::Display;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use tracing::{error, warn};
17
18const LINE_ENDING: &str = if cfg!(windows) { "\r\n" } else { "\n" };
19pub trait Save {
21 fn save<P>(&self, destination: P) -> ApiResult<PathBuf>
23 where
24 P: Into<PathBuf> + Clone;
25}
26#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
34#[builder(start_fn = init)]
35pub struct Bag {
36 pub base_directory: String,
38 #[builder(default = Vec::new())]
40 pub payload: Vec<String>,
41 #[builder(default)]
43 pub checksum_algorithm: ChecksumAlgorithm,
44 #[builder(default = String::from("1.0"))]
46 pub version: String,
47 #[builder(default = String::from("UTF-8"))]
49 pub encoding: String,
50 pub info: Option<BagInfo>,
52}
53#[derive(Builder, Clone, Debug, Display, Serialize, Deserialize, JsonSchema)]
60#[display("Bag-Info")]
61#[builder(start_fn = init)]
62pub struct BagInfo {
63 pub organization: Option<Vec<String>>,
65 pub organization_address: Option<Vec<String>>,
67 pub contact_name: Option<Vec<String>>,
69 pub contact_phone: Option<Vec<String>>,
71 pub contact_email: Option<Vec<String>>,
73 pub description: Option<Vec<String>>,
75 pub date: Option<String>,
77 pub identifier: Option<Vec<String>>,
79 pub size: Option<String>,
81 pub count: Option<Vec<(u32, Option<u32>)>>,
84}
85impl Bag {
86 pub fn verify<P>(path: P) -> ApiResult<()>
93 where
94 P: Into<PathBuf>,
95 {
96 let base_directory = path.into();
97 let files = files_all(base_directory.clone(), None)
98 .into_iter()
99 .filter(|x| x.is_file())
100 .collect::<Vec<_>>();
101 match files
102 .iter()
103 .flat_map(|x| x.file_name())
104 .flat_map(|x| x.to_str())
105 .find(|x| x.starts_with("manifest"))
106 {
107 | Some(name) => {
108 let algorithm = name["manifest.".len()..]
109 .split(".")
110 .collect::<Vec<_>>()
111 .first()
112 .unwrap_or(&"")
113 .to_lowercase();
114 let manifest = format!("manifest-{}.txt", algorithm.clone());
115 let checksum_algorithm: &'static ring::digest::Algorithm = ChecksumAlgorithm::from(algorithm).into();
116 let required = ["bagit.txt", &manifest, "data/"]
117 .iter()
118 .map(|x| base_directory.join(x))
119 .collect::<Vec<_>>();
120 if required.iter().all(|x| x.exists()) {
121 let manifest_path = base_directory.join(manifest);
122 let content = read_file(manifest_path.clone()).unwrap_or_default();
123 let pairs = content
124 .lines()
125 .map(|line| line.split_whitespace().collect::<Vec<_>>())
126 .collect::<Vec<_>>();
127 let compare_checksums = pairs
128 .iter()
129 .map(|x| {
130 let checksum = x.first().copied().unwrap_or("");
131 let payload_path = base_directory.join(x.get(1).copied().unwrap_or(""));
132 let calculated = file_checksum(payload_path, Some(checksum_algorithm))
133 .map(|checksum| checksum.checksum_value)
134 .unwrap_or_default();
135 calculated.eq(checksum)
136 })
137 .collect::<Vec<_>>();
138 match compare_checksums.iter().enumerate().find(|(_, result)| !*result) {
139 | Some((index, _)) => {
140 let path = pairs.get(index).and_then(|p| p.get(1)).copied().unwrap_or("");
141 Err(eyre!("Checksum mismatch in payload file = {}", path))
142 }
143 | None => Ok(()),
144 }
145 } else {
146 Err(eyre!("Missing required file(s) and/or folder(s)"))
147 }
148 }
149 | None => Err(eyre!("No manifest file found")),
150 }
151 }
152 pub fn with_payload(&self) -> Self {
154 let base_directory = PathBuf::from(self.base_directory.clone());
155 let payload = files_all(base_directory.clone(), None)
156 .into_iter()
157 .filter(|x| x.is_file())
158 .flat_map(|x| x.strip_prefix(base_directory.to_absolute_string()).ok().map(|p| p.to_path_buf()))
159 .map(|x| x.display().to_string())
160 .collect::<Vec<_>>();
161 let Bag {
162 checksum_algorithm,
163 version,
164 encoding,
165 info,
166 ..
167 } = self;
168 Bag::init()
169 .base_directory(self.base_directory.clone())
170 .checksum_algorithm(checksum_algorithm.clone())
171 .version(version.clone())
172 .encoding(encoding.clone())
173 .maybe_info(info.clone())
174 .payload(payload)
175 .build()
176 }
177}
178impl Save for Bag {
179 fn save<P>(&self, destination: P) -> ApiResult<PathBuf>
181 where
182 P: Into<PathBuf> + Clone,
183 {
184 let Bag { info, version, encoding, .. } = self;
185 let dest = destination.clone().into();
186 let payload_directory = dest.join("data");
187 match create_dir_all(payload_directory.clone()) {
188 | Ok(_) => {
189 let base = PathBuf::from(self.base_directory.clone());
190 let bag = self.with_payload();
191 let checksum_algorithm: &'static ring::digest::Algorithm = self.checksum_algorithm.clone().into();
192 bag.payload.clone().into_iter().for_each(|x| {
193 let payload = PathBuf::from(x);
194 let from = base.join(&payload);
195 let to = dest.join("data").join(&payload);
196 if let Some(parent) = to.parent() {
197 if let Err(why) = create_dir_all(parent) {
198 error!(
199 directory = parent.to_path_buf().to_absolute_string(),
200 "=> {} Create - {why}",
201 Label::fail()
202 );
203 return;
204 }
205 if let Err(why) = copy(from.clone(), to.clone()) {
206 error!(
207 from = from.to_absolute_string(),
208 to = to.to_absolute_string(),
209 "=> {} Copy - {why}",
210 Label::fail()
211 );
212 }
213 }
214 });
215 let bag_declaration_content = format!("BagIt-Version: {version}\nTag-File-Character-Encoding: {encoding}\n");
216 let payload_manifest_content = bag
217 .payload
218 .into_iter()
219 .fold(String::new(), |acc, x| {
220 let payload = PathBuf::from(x.clone());
221 let to = dest.join("data").join(&payload);
222 match file_checksum(to.clone(), Some(checksum_algorithm)) {
223 | Some(checksum) => {
224 format!("{acc}{checksum} data/{x}{LINE_ENDING}")
225 }
226 | None => {
227 warn!(payload = payload.to_absolute_string(), "=> {} Calculate checksum", Label::fail());
228 acc
229 }
230 }
231 })
232 .replace("\\", "/");
233 write_file(dest.clone().join("bagit.txt"), bag_declaration_content)
234 .map(|_| dest.clone())
235 .and_then(|_| match info.clone() {
236 | Some(bag_info) => bag_info.save(destination.clone()),
237 | None => Ok(dest.clone()),
238 })
239 .and_then(|_| {
240 let file_name = format!("manifest-{}.txt", self.checksum_algorithm);
241 let file_path = dest.clone().join(&file_name);
242 write_file(file_path, payload_manifest_content).map(|_| dest.clone())
243 })
244 .and_then(|_| archive(dest.clone(), None))
245 }
246 | Err(why) => Err(eyre!("Failed to create bag - {why}")),
247 }
248 }
249}
250impl fmt::Display for Bag {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 write!(f, "Bag v{} ({}): {}", self.version, self.checksum_algorithm, self.base_directory)
253 }
254}
255impl BagInfo {
256 pub fn entries(&self) -> Vec<(String, String)> {
258 let BagInfo {
259 organization,
260 organization_address,
261 contact_name,
262 contact_phone,
263 contact_email,
264 description,
265 date,
266 identifier,
267 size,
268 count,
269 } = self;
270 let repeatable = [
271 ("Source-Organization", organization),
272 ("Organization-Address", organization_address),
273 ("Contact-Name", contact_name),
274 ("Contact-Phone", contact_phone),
275 ("Contact-Email", contact_email),
276 ("External-Description", description),
277 ("External-Identifier", identifier),
278 ]
279 .into_iter()
280 .flat_map(|(key, values)| {
281 values
282 .iter()
283 .flat_map(|items| items.iter())
284 .map(move |value| (key.to_string(), value.clone()))
285 });
286 let count = count.iter().flat_map(|items| items.iter()).map(|(index, total)| {
287 let total = total.map_or_else(|| "?".to_string(), |value| value.to_string());
288 ("Bag-Count".to_string(), format!("{index} of {total}"))
289 });
290 let single = [("Bagging-Date", date.clone()), ("Bag-Size", size.clone())]
291 .into_iter()
292 .filter_map(|(key, value)| value.map(|v| (key.to_string(), v)));
293 repeatable.chain(count).chain(single).collect()
294 }
295}
296impl Default for BagInfo {
297 fn default() -> Self {
298 BagInfo::init().build()
299 }
300}
301impl Save for BagInfo {
302 fn save<P>(&self, destination: P) -> ApiResult<PathBuf>
303 where
304 P: Into<PathBuf> + Clone,
305 {
306 let dest = destination.clone().into();
307 let content = self
308 .entries()
309 .into_iter()
310 .fold(String::new(), |acc, (key, value)| format!("{acc}{key}: {value}{LINE_ENDING}"));
311 write_file(dest.join("bag-info.txt"), content).map(|_| dest)
312 }
313}
314impl From<ChecksumAlgorithm> for &'static ring::digest::Algorithm {
315 fn from(value: ChecksumAlgorithm) -> Self {
316 match value {
317 | ChecksumAlgorithm::Sha256 => &ring::digest::SHA256,
318 | ChecksumAlgorithm::Sha512 => &ring::digest::SHA512,
319 | _ => &ring::digest::SHA256,
320 }
321 }
322}