rucksack_lib/
file.rs

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    // Make sure the path is created
55    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    // Then write the file
152    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        // Check that filenames are present
406        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        // Write initial data
439        let data1 = b"first".to_vec();
440        write(data1, path_str.clone()).unwrap();
441
442        // Overwrite with new data
443        let data2 = b"second".to_vec();
444        write(data2.clone(), path_str.clone()).unwrap();
445
446        // Verify new data
447        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}