backup/cli/actions/
new.rs1use crate::cli::actions::Action;
2use anyhow::Result;
3use rusqlite::{params, Connection};
4use std::path::{Path, PathBuf};
5
6pub 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_db_tables(&db_path)?;
19
20 let backup_dirs = get_unique_dir_parents(directory.unwrap_or_default());
21
22 create_db_config_direcories_table(&db_path, backup_dirs)?;
24
25 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 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 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 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 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 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
91fn get_unique_dir_parents(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
93 dirs.sort();
95
96 let mut result = Vec::new();
98 for dir in dirs {
99 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 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 let mut stmt = conn.prepare("INSERT OR IGNORE INTO config_directories (path) VALUES (?1)")?;
122
123 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 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 let mut stmt = conn.prepare("INSERT OR IGNORE INTO config_files (path) VALUES (?1)")?;
145
146 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 let dirs: Vec<String> = dirs_iter.filter_map(|result| result.ok()).collect();
152
153 for file in files {
155 let file_path = file.to_string_lossy().to_string();
156
157 let is_child = dirs.iter().any(|dir| {
159 let dir_path = Path::new(dir);
160 file.starts_with(dir_path)
161 });
162
163 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]
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}