1use std::io::Write;
2use std::os::unix::fs::PermissionsExt;
3use std::{env, fs, io, path};
4
5use anyhow::{anyhow, Context, Result};
6use chrono::offset::Local;
7use chrono::DateTime;
8use path_clean::PathClean;
9
10use crate::time;
11
12const DATA_DIR: &str = "data";
13const BACKUP_DIR: &str = "backups";
14const DEFAULT_DB_NAME: &str = "secrets";
15const DB_EXTENSION: &str = "db";
16
17#[must_use = "path operation result must be checked"]
18pub fn abs_path(path_name: String) -> io::Result<path::PathBuf> {
19 let expanded = expanded_name(path_name);
20 let path = path::Path::new(expanded.as_str());
21 let absolute_path = if path.is_absolute() {
22 path.to_path_buf()
23 } else {
24 env::current_dir()?.join(path)
25 };
26 absolute_path.clean();
27 Ok(absolute_path)
28}
29
30pub fn backup_dir(project: &str) -> path::PathBuf {
31 let mut path = dirs::data_dir().unwrap_or_else(|| path::PathBuf::from("."));
32 path.push(project);
33 path.push(BACKUP_DIR);
34 path
35}
36
37pub fn config_dir(project: &str) -> path::PathBuf {
38 let mut path = dirs::config_dir().unwrap_or_else(|| path::PathBuf::from("."));
39 path.push(project);
40 path
41}
42
43pub fn config_file(project: &str) -> String {
44 let mut path = config_dir(project);
45 path.push("config");
46 path.set_extension("toml");
47 path.to_str()
48 .expect("config file path contains invalid UTF-8")
49 .to_string()
50}
51
52#[must_use = "directory creation result must be checked"]
53pub fn create_parents(path: String) -> Result<path::PathBuf> {
54 log::debug!(path = path.as_str(), operation = "create_parent"; "Attempting to create parent directory");
56 let ap = abs_path(path.clone())?;
57 let parent = ap
58 .parent()
59 .ok_or_else(|| anyhow!("path has no parent directory: {}", path))?
60 .to_path_buf();
61 log::debug!(path = parent.to_string_lossy().as_ref(), operation = "create_dir"; "Attempting to create directory");
62 create_dirs(parent)?;
63 Ok(ap)
64}
65
66#[must_use = "directory creation result must be checked"]
67pub fn create_dirs(path: path::PathBuf) -> Result<path::PathBuf> {
68 let path_name = path.display();
69 match fs::create_dir_all(path.clone()) {
70 Ok(_) => Ok(path),
71 Err(e) => {
72 let msg = "Could not create missing parent dirs for";
73 log::error!(path = path_name.to_string().as_str(), error = e.to_string().as_str(), operation = "create_dir"; "{}", msg);
74 Err(anyhow!("{} {} ({:})", msg, path_name, e))
75 }
76 }
77}
78
79pub fn data_dir(project: &str) -> path::PathBuf {
80 let mut path = dirs::data_dir().unwrap_or_else(|| path::PathBuf::from("."));
81 path.push(project);
82 path.push(DATA_DIR);
83 path
84}
85
86pub fn db_file(project: &str) -> String {
87 let mut path = data_dir(project);
88 path.push(DEFAULT_DB_NAME);
89 path.set_extension(DB_EXTENSION);
90 path.to_str()
91 .expect("database file path contains invalid UTF-8")
92 .to_string()
93}
94
95#[must_use = "file deletion result must be checked"]
96pub fn delete(file_path: path::PathBuf) -> Result<()> {
97 match fs::remove_file(file_path) {
98 Ok(x) => {
99 log::debug!(operation = "delete"; "Deleted file");
100 Ok(x)
101 }
102 Err(e) => Err(anyhow!(e)),
103 }
104}
105
106pub fn dir_parent(dir: String) -> String {
107 let mut parent: Vec<&str> = dir.split(std::path::MAIN_SEPARATOR).collect();
108 parent.pop();
109 parent.join(std::path::MAIN_SEPARATOR.to_string().as_str())
110}
111
112pub fn expanded_name(path_name: String) -> String {
113 let expanded = shellexpand::tilde(path_name.as_str());
114 expanded.to_string()
115}
116
117pub type Data = (String, String, String);
118pub type Listing = Vec<Data>;
119
120#[must_use = "directory listing result must be checked"]
121pub fn files(dir: String) -> Result<Listing> {
122 let mut f = Vec::<(String, String, String)>::new();
123 for entry in fs::read_dir(dir)? {
124 let dir = entry?;
125 let metadata = dir.metadata()?;
126 let created: DateTime<Local> = metadata.created()?.into();
127 let file_name = dir
128 .file_name()
129 .to_str()
130 .ok_or_else(|| anyhow!("file name contains invalid UTF-8"))?
131 .to_owned();
132 f.push((
133 file_name,
134 time::format_datetime(created),
135 unix_mode::to_string(metadata.permissions().mode()),
136 ));
137 }
138 Ok(f)
139}
140
141#[must_use = "file read result must be checked"]
142pub fn read(file_name: String) -> Result<Vec<u8>> {
143 let expanded = expanded_name(file_name.clone());
144 log::debug!(file = expanded.as_str(), operation = "read"; "Reading file");
145 fs::read(&expanded).with_context(|| format!("failed to read file: {}", file_name))
146}
147
148#[must_use = "file write result must be checked"]
149pub fn write(data: Vec<u8>, path: String) -> Result<()> {
150 let ap = create_parents(path.clone())?;
151 log::debug!(file = ap.to_string_lossy().as_ref(), operation = "write"; "Writing file");
153 let mut file = std::fs::OpenOptions::new()
154 .write(true)
155 .create(true)
156 .truncate(true)
157 .open(&ap)
158 .with_context(|| format!("failed to open file for writing: {}", path))?;
159
160 file.write_all(&data[..])
161 .with_context(|| format!("failed to write data to file: {}", path))?;
162
163 file.sync_all()
164 .with_context(|| format!("failed to sync file to disk: {}", path))
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use std::fs;
171 use tempfile::TempDir;
172
173 #[test]
174 fn test_expanded_name_no_tilde() {
175 let path = "/usr/local/bin".to_string();
176 assert_eq!(expanded_name(path.clone()), path);
177 }
178
179 #[test]
180 fn test_expanded_name_with_tilde() {
181 let path = "~/test".to_string();
182 let expanded = expanded_name(path);
183 assert!(!expanded.starts_with('~'));
184 assert!(expanded.contains("test"));
185 }
186
187 #[test]
188 fn test_expanded_name_empty() {
189 let path = "".to_string();
190 assert_eq!(expanded_name(path), "");
191 }
192
193 #[test]
194 fn test_abs_path_absolute() {
195 let path = "/tmp/test".to_string();
196 let result = abs_path(path).unwrap();
197 assert!(result.is_absolute());
198 }
199
200 #[test]
201 fn test_abs_path_relative() {
202 let path = "test".to_string();
203 let result = abs_path(path).unwrap();
204 assert!(result.is_absolute());
205 }
206
207 #[test]
208 fn test_abs_path_with_tilde() {
209 let path = "~/test".to_string();
210 let result = abs_path(path).unwrap();
211 assert!(result.is_absolute());
212 assert!(!result.to_str().unwrap().contains('~'));
213 }
214
215 #[test]
216 fn test_dir_parent_basic() {
217 let dir = "/home/user/documents".to_string();
218 let parent = dir_parent(dir);
219 assert_eq!(parent, "/home/user");
220 }
221
222 #[test]
223 fn test_dir_parent_root() {
224 let dir = "/home".to_string();
225 let parent = dir_parent(dir);
226 assert_eq!(parent, "");
227 }
228
229 #[test]
230 fn test_dir_parent_nested() {
231 let dir = "/a/b/c/d/e".to_string();
232 let parent = dir_parent(dir);
233 assert_eq!(parent, "/a/b/c/d");
234 }
235
236 #[test]
237 fn test_config_dir() {
238 let project = "test_project";
239 let path = config_dir(project);
240 assert!(path.to_str().unwrap().contains(project));
241 }
242
243 #[test]
244 fn test_config_file() {
245 let project = "test_project";
246 let file = config_file(project);
247 assert!(file.contains(project));
248 assert!(file.ends_with(".toml"));
249 assert!(file.contains("config"));
250 }
251
252 #[test]
253 fn test_data_dir() {
254 let project = "test_project";
255 let path = data_dir(project);
256 let path_str = path.to_str().unwrap();
257 assert!(path_str.contains(project));
258 assert!(path_str.contains(DATA_DIR));
259 }
260
261 #[test]
262 fn test_backup_dir() {
263 let project = "test_project";
264 let path = backup_dir(project);
265 let path_str = path.to_str().unwrap();
266 assert!(path_str.contains(project));
267 assert!(path_str.contains(BACKUP_DIR));
268 }
269
270 #[test]
271 fn test_db_file() {
272 let project = "test_project";
273 let file = db_file(project);
274 assert!(file.contains(project));
275 assert!(file.contains(DEFAULT_DB_NAME));
276 assert!(file.ends_with(&format!(".{}", DB_EXTENSION)));
277 }
278
279 #[test]
280 fn test_write_and_read() {
281 let dir = TempDir::new().unwrap();
282 let file_path = dir.path().join("test.txt");
283 let data = b"Hello, World!".to_vec();
284
285 let result = write(data.clone(), file_path.to_str().unwrap().to_string());
286 assert!(result.is_ok());
287
288 let read_data = read(file_path.to_str().unwrap().to_string()).unwrap();
289 assert_eq!(read_data, data);
290 }
291
292 #[test]
293 fn test_write_empty_data() {
294 let dir = TempDir::new().unwrap();
295 let file_path = dir.path().join("empty.txt");
296 let data = Vec::new();
297
298 let result = write(data.clone(), file_path.to_str().unwrap().to_string());
299 assert!(result.is_ok());
300
301 let read_data = read(file_path.to_str().unwrap().to_string()).unwrap();
302 assert_eq!(read_data, data);
303 }
304
305 #[test]
306 fn test_write_large_data() {
307 let dir = TempDir::new().unwrap();
308 let file_path = dir.path().join("large.bin");
309 let data = vec![42u8; 10000];
310
311 let result = write(data.clone(), file_path.to_str().unwrap().to_string());
312 assert!(result.is_ok());
313
314 let read_data = read(file_path.to_str().unwrap().to_string()).unwrap();
315 assert_eq!(read_data, data);
316 }
317
318 #[test]
319 fn test_write_creates_parent_dirs() {
320 let dir = TempDir::new().unwrap();
321 let file_path = dir.path().join("nested/dirs/file.txt");
322 let data = b"test".to_vec();
323
324 let result = write(data.clone(), file_path.to_str().unwrap().to_string());
325 assert!(result.is_ok());
326 assert!(file_path.exists());
327 }
328
329 #[test]
330 fn test_read_nonexistent() {
331 let result = read("/nonexistent/file/path.txt".to_string());
332 assert!(result.is_err());
333 }
334
335 #[test]
336 fn test_create_dirs() {
337 let dir = TempDir::new().unwrap();
338 let new_dir = dir.path().join("new/nested/dirs");
339
340 let result = create_dirs(new_dir.clone());
341 assert!(result.is_ok());
342 assert!(new_dir.exists());
343 }
344
345 #[test]
346 fn test_create_dirs_already_exists() {
347 let dir = TempDir::new().unwrap();
348 let existing_dir = dir.path().to_path_buf();
349
350 let result = create_dirs(existing_dir.clone());
351 assert!(result.is_ok());
352 assert!(existing_dir.exists());
353 }
354
355 #[test]
356 fn test_create_parents() {
357 let dir = TempDir::new().unwrap();
358 let file_path = dir.path().join("nested/file.txt");
359
360 let result = create_parents(file_path.to_str().unwrap().to_string());
361 assert!(result.is_ok());
362 assert!(dir.path().join("nested").exists());
363 }
364
365 #[test]
366 fn test_delete_existing_file() {
367 let dir = TempDir::new().unwrap();
368 let file_path = dir.path().join("delete_me.txt");
369 fs::write(&file_path, b"test").unwrap();
370
371 assert!(file_path.exists());
372 let result = delete(file_path.clone());
373 assert!(result.is_ok());
374 assert!(!file_path.exists());
375 }
376
377 #[test]
378 fn test_delete_nonexistent() {
379 let dir = TempDir::new().unwrap();
380 let file_path = dir.path().join("nonexistent.txt");
381
382 let result = delete(file_path);
383 assert!(result.is_err());
384 }
385
386 #[test]
387 fn test_files_empty_dir() {
388 let dir = TempDir::new().unwrap();
389 let result = files(dir.path().to_str().unwrap().to_string());
390 assert!(result.is_ok());
391 assert_eq!(result.unwrap().len(), 0);
392 }
393
394 #[test]
395 fn test_files_with_contents() {
396 let dir = TempDir::new().unwrap();
397 let file1 = dir.path().join("file1.txt");
398 let file2 = dir.path().join("file2.txt");
399 fs::write(&file1, b"test1").unwrap();
400 fs::write(&file2, b"test2").unwrap();
401
402 let result = files(dir.path().to_str().unwrap().to_string()).unwrap();
403 assert_eq!(result.len(), 2);
404
405 let names: Vec<String> = result.iter().map(|(name, _, _)| name.clone()).collect();
407 assert!(names.contains(&"file1.txt".to_string()));
408 assert!(names.contains(&"file2.txt".to_string()));
409 }
410
411 #[test]
412 fn test_files_nonexistent_dir() {
413 let result = files("/nonexistent/directory".to_string());
414 assert!(result.is_err());
415 }
416
417 #[test]
418 fn test_files_returns_metadata() {
419 let dir = TempDir::new().unwrap();
420 let file = dir.path().join("file.txt");
421 fs::write(&file, b"test").unwrap();
422
423 let result = files(dir.path().to_str().unwrap().to_string()).unwrap();
424 assert_eq!(result.len(), 1);
425
426 let (name, timestamp, permissions) = &result[0];
427 assert_eq!(name, "file.txt");
428 assert!(!timestamp.is_empty(), "Timestamp should not be empty");
429 assert!(!permissions.is_empty(), "Permissions should not be empty");
430 }
431
432 #[test]
433 fn test_write_overwrites_existing() {
434 let dir = TempDir::new().unwrap();
435 let file_path = dir.path().join("overwrite.txt");
436 let path_str = file_path.to_str().unwrap().to_string();
437
438 let data1 = b"first".to_vec();
440 write(data1, path_str.clone()).unwrap();
441
442 let data2 = b"second".to_vec();
444 write(data2.clone(), path_str.clone()).unwrap();
445
446 let read_data = read(path_str).unwrap();
448 assert_eq!(read_data, data2);
449 }
450
451 #[test]
452 fn test_constants() {
453 assert_eq!(DATA_DIR, "data");
454 assert_eq!(BACKUP_DIR, "backups");
455 assert_eq!(DEFAULT_DB_NAME, "secrets");
456 assert_eq!(DB_EXTENSION, "db");
457 }
458}