backup/cli/actions/
new.rs

1use crate::cli::actions::Action;
2use anyhow::Result;
3use rusqlite::{params, Connection};
4use std::path::{Path, PathBuf};
5
6/// Handle the create action
7pub fn handle(action: Action) -> Result<()> {
8    if let Action::New {
9        name,
10        config,
11        directory,
12        file,
13    } = action
14    {
15        let db_path = config.join(format!("{}.db", name));
16
17        // Create the backup database tables
18        create_db_tables(&db_path)?;
19
20        let backup_dirs = get_unique_dir_parents(directory.unwrap_or_default());
21
22        // create the config_directories table
23        create_db_config_direcories_table(&db_path, backup_dirs)?;
24
25        // create the config_files tables
26        // exclude files if they are within the directories that are being backed up
27        create_db_config_files_table(&db_path, file.unwrap_or_default())?;
28    }
29
30    Ok(())
31}
32
33fn create_db_tables(db_path: &PathBuf) -> Result<()> {
34    let conn = Connection::open(db_path)?;
35
36    // table to store unique file content, using content hash to avoid duplicates
37    conn.execute(
38        "CREATE TABLE IF NOT EXISTS Files (
39    file_id INTEGER PRIMARY KEY,
40    hash TEXT NOT NULL UNIQUE
41)",
42        [],
43    )?;
44
45    // table to store directory paths
46    conn.execute(
47        "CREATE TABLE IF NOT EXISTS Paths(
48    path_id INTEGER PRIMARY KEY,
49    path TEXT NOT NULL UNIQUE
50)",
51        [],
52    )?;
53
54    // table to store files with version tracking
55    conn.execute(
56        "CREATE TABLE IF NOT EXISTS FileNames (
57    name_id INTEGER PRIMARY KEY,
58    path_id INTEGER NOT NULL,        -- Foreign key referencing Paths table
59    name TEXT NOT NULL,              -- Name of the file in the Path
60    file_id INTEGER NOT NULL,        -- Foreign key referencing Files for content hash
61    first_version INTEGER NOT NULL,  -- The version in which this file path first appeared
62    last_version INTEGER,            -- The last version this file path was valid (NULL if still valid)
63    is_deleted BOOLEAN DEFAULT 0,    -- 1 if the file was deleted in this version, 0 otherwise
64
65    FOREIGN KEY (path_id) REFERENCES Paths(path_id),
66    FOREIGN KEY (file_id) REFERENCES Files(file_id),
67
68    UNIQUE(path_id, name, first_version) -- Ensure unique entries by path and version
69)",
70        [],
71    )?;
72
73    // Table to track each backup version
74    conn.execute(
75        "CREATE TABLE IF NOT EXISTS BackupVersions (
76    version_id INTEGER PRIMARY KEY,
77    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP -- Timestamp when the backup was created
78)",
79        [],
80    )?;
81
82    // Index for efficient file retrieval by version
83    conn.execute(
84        "CREATE INDEX IF NOT EXISTS idx_files_version ON FileNames (first_version, last_version, is_deleted)",
85        [],
86    )?;
87
88    Ok(())
89}
90
91// extract the parent directory of each path and return only the unique parent directories
92fn get_unique_dir_parents(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
93    // Sort the input directories lexicographically (shorter paths come first for easier comparison)
94    dirs.sort();
95
96    // Filter out subdirectories or descendants
97    let mut result = Vec::new();
98    for dir in dirs {
99        // Only add the directory if it is not a descendant of any directory already in the result
100        if !result.iter().any(|parent| dir.starts_with(parent)) {
101            result.push(dir);
102        }
103    }
104
105    result
106}
107
108fn create_db_config_direcories_table(db_path: &PathBuf, dirs: Vec<PathBuf>) -> Result<()> {
109    let conn = Connection::open(db_path)?;
110
111    // Table to track each backup version
112    conn.execute(
113        "CREATE TABLE IF NOT EXISTS config_directories (
114    id INTEGER PRIMARY KEY,
115    path TEXT NOT NULL UNIQUE
116)",
117        [],
118    )?;
119
120    // Prepare the insert statement
121    let mut stmt = conn.prepare("INSERT OR IGNORE INTO config_directories (path) VALUES (?1)")?;
122
123    // Insert each directory into the database
124    for dir in dirs {
125        stmt.execute(params![dir.to_string_lossy().to_string()])?;
126    }
127
128    Ok(())
129}
130
131fn create_db_config_files_table(db_path: &PathBuf, files: Vec<PathBuf>) -> Result<()> {
132    let conn = Connection::open(db_path)?;
133
134    // Table to track each backup version
135    conn.execute(
136        "CREATE TABLE IF NOT EXISTS config_files (
137    id INTEGER PRIMARY KEY,
138    path TEXT NOT NULL UNIQUE
139)",
140        [],
141    )?;
142
143    // Prepare the insert statement
144    let mut stmt = conn.prepare("INSERT OR IGNORE INTO config_files (path) VALUES (?1)")?;
145
146    // Get all directory paths from config_directories table
147    let mut dirs_stmt = conn.prepare("SELECT path FROM config_directories")?;
148    let dirs_iter = dirs_stmt.query_map([], |row| row.get::<_, String>(0))?;
149
150    // Collect all directory paths
151    let dirs: Vec<String> = dirs_iter.filter_map(|result| result.ok()).collect();
152
153    // Insert files only if they are not children of any of the directories
154    for file in files {
155        let file_path = file.to_string_lossy().to_string();
156
157        // Check if file is a child of any of the directories
158        let is_child = dirs.iter().any(|dir| {
159            let dir_path = Path::new(dir);
160            file.starts_with(dir_path)
161        });
162
163        // Only insert file if it is not a child of any directory
164        if !is_child {
165            stmt.execute(params![file_path])?;
166        }
167    }
168
169    Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_get_unique_dir_parents() {
178        let dirs = vec![
179            PathBuf::from("/a/b/c"),
180            PathBuf::from("/a/b/d"),
181            PathBuf::from("/a/b/c/d"),
182            PathBuf::from("/a/b"),
183            PathBuf::from("/b"),
184            PathBuf::from("/b/c"),
185            PathBuf::from("/b/cc"),
186            PathBuf::from("/b/d"),
187        ];
188
189        let result = get_unique_dir_parents(dirs);
190
191        assert_eq!(result.len(), 2);
192        assert_eq!(result[0], PathBuf::from("/a/b"));
193        assert_eq!(result[1], PathBuf::from("/b"));
194    }
195
196    // test the create_config_directories_table function
197    #[test]
198    fn test_create_db_config_directoris_and_files_table() {
199        let temp_dir = tempfile::tempdir().unwrap();
200
201        let db_path = temp_dir.path().join("test.db");
202
203        let dirs = vec![
204            PathBuf::from("/a/b/c"),
205            PathBuf::from("/a/b/d"),
206            PathBuf::from("/a/b/c/d"),
207            PathBuf::from("/a/b"),
208            PathBuf::from("/b"),
209            PathBuf::from("/b/c"),
210            PathBuf::from("/b/cc"),
211            PathBuf::from("/b/d"),
212        ];
213
214        create_db_tables(&db_path).unwrap();
215
216        let backup_dirs = get_unique_dir_parents(dirs);
217
218        create_db_config_direcories_table(&db_path, backup_dirs).unwrap();
219
220        let conn = Connection::open(&db_path).unwrap();
221
222        let mut stmt = conn.prepare("SELECT path FROM config_directories").unwrap();
223
224        let dirs_iter = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
225
226        let result: Vec<String> = dirs_iter.filter_map(|result| result.ok()).collect();
227
228        assert_eq!(result.len(), 2);
229        assert!(result.contains(&"/a/b".to_string()));
230        assert!(result.contains(&"/b".to_string()));
231
232        let files = vec![
233            PathBuf::from("/a/b/c/file1.txt"),
234            PathBuf::from("/a/b/c/d/file2.txt"),
235            PathBuf::from("/a/file3.txt"),
236            PathBuf::from("/z/file4.txt"),
237        ];
238
239        create_db_config_files_table(&db_path, files).unwrap();
240
241        let mut stmt = conn.prepare("SELECT path FROM config_files").unwrap();
242
243        let files_iter = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
244
245        let result: Vec<String> = files_iter.filter_map(|result| result.ok()).collect();
246
247        assert_eq!(result.len(), 2);
248        assert!(result.contains(&"/a/file3.txt".to_string()));
249        assert!(result.contains(&"/z/file4.txt".to_string()));
250    }
251}