1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use crate::datastore::{CommandRunner, DataStore};
5use crate::fs::Fs;
6use crate::paths::Pather;
7use crate::Result;
8
9pub struct FilesystemDataStore {
20 fs: Arc<dyn Fs>,
21 paths: Arc<dyn Pather>,
22 runner: Arc<dyn CommandRunner>,
23}
24
25impl FilesystemDataStore {
26 pub fn new(fs: Arc<dyn Fs>, paths: Arc<dyn Pather>, runner: Arc<dyn CommandRunner>) -> Self {
27 Self { fs, paths, runner }
28 }
29}
30
31impl DataStore for FilesystemDataStore {
32 fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf> {
33 let filename = source_file.file_name().ok_or_else(|| {
34 crate::DodotError::Other(format!(
35 "source file has no filename: {}",
36 source_file.display()
37 ))
38 })?;
39
40 let link_dir = self.paths.handler_data_dir(pack, handler);
41 let link_path = link_dir.join(filename);
42
43 self.fs.mkdir_all(&link_dir)?;
44
45 if self.fs.is_symlink(&link_path) {
47 if let Ok(current_target) = self.fs.readlink(&link_path) {
48 if current_target == source_file {
49 return Ok(link_path);
50 }
51 }
52 self.fs.remove_file(&link_path)?;
54 }
55
56 self.fs.symlink(source_file, &link_path)?;
57 Ok(link_path)
58 }
59
60 fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()> {
61 if let Some(parent) = user_path.parent() {
63 self.fs.mkdir_all(parent)?;
64 }
65
66 if self.fs.is_symlink(user_path) {
68 if let Ok(current_target) = self.fs.readlink(user_path) {
70 if current_target == datastore_path {
71 return Ok(()); }
73 }
74 self.fs.remove_file(user_path)?;
76 } else if self.fs.exists(user_path) {
77 return Err(crate::DodotError::SymlinkConflict {
79 path: user_path.to_path_buf(),
80 });
81 }
82
83 self.fs.symlink(datastore_path, user_path)
84 }
85
86 fn run_and_record(
87 &self,
88 pack: &str,
89 handler: &str,
90 executable: &str,
91 arguments: &[String],
92 sentinel: &str,
93 force: bool,
94 ) -> Result<()> {
95 if !force && self.has_sentinel(pack, handler, sentinel)? {
97 return Ok(());
98 }
99
100 self.runner.run(executable, arguments)?;
102
103 let sentinel_dir = self.paths.handler_data_dir(pack, handler);
105 self.fs.mkdir_all(&sentinel_dir)?;
106
107 let sentinel_path = sentinel_dir.join(sentinel);
108 let timestamp = std::time::SystemTime::now()
109 .duration_since(std::time::UNIX_EPOCH)
110 .unwrap_or_default()
111 .as_secs();
112 let content = format!("completed|{timestamp}");
113 self.fs.write_file(&sentinel_path, content.as_bytes())
114 }
115
116 fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool> {
117 let sentinel_path = self.paths.handler_data_dir(pack, handler).join(sentinel);
118 Ok(self.fs.exists(&sentinel_path))
119 }
120
121 fn remove_state(&self, pack: &str, handler: &str) -> Result<()> {
122 let state_dir = self.paths.handler_data_dir(pack, handler);
123 if !self.fs.exists(&state_dir) {
124 return Ok(());
125 }
126 self.fs.remove_dir_all(&state_dir)
127 }
128
129 fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool> {
130 let state_dir = self.paths.handler_data_dir(pack, handler);
131 if !self.fs.exists(&state_dir) {
132 return Ok(false);
133 }
134 let entries = self.fs.read_dir(&state_dir)?;
135 Ok(!entries.is_empty())
136 }
137
138 fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>> {
139 let pack_dir = self.paths.pack_data_dir(pack);
140 if !self.fs.exists(&pack_dir) {
141 return Ok(Vec::new());
142 }
143 let entries = self.fs.read_dir(&pack_dir)?;
144 Ok(entries
145 .into_iter()
146 .filter(|e| e.is_dir)
147 .map(|e| e.name)
148 .collect())
149 }
150
151 fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>> {
152 let handler_dir = self.paths.handler_data_dir(pack, handler);
153 if !self.fs.exists(&handler_dir) {
154 return Ok(Vec::new());
155 }
156 let entries = self.fs.read_dir(&handler_dir)?;
157 Ok(entries
158 .into_iter()
159 .filter(|e| e.is_file)
160 .map(|e| e.name)
161 .collect())
162 }
163
164 fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
165 self.paths.handler_data_dir(pack, handler).join(sentinel)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::datastore::{CommandOutput, CommandRunner};
173 use crate::testing::TempEnvironment;
174 use std::sync::Mutex;
175
176 struct MockCommandRunner {
179 calls: Mutex<Vec<String>>,
180 should_fail: bool,
181 }
182
183 impl MockCommandRunner {
184 fn new() -> Self {
185 Self {
186 calls: Mutex::new(Vec::new()),
187 should_fail: false,
188 }
189 }
190
191 fn failing() -> Self {
192 Self {
193 calls: Mutex::new(Vec::new()),
194 should_fail: true,
195 }
196 }
197
198 fn calls(&self) -> Vec<String> {
199 self.calls.lock().unwrap().clone()
200 }
201 }
202
203 impl CommandRunner for MockCommandRunner {
204 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
205 let cmd_str = format!("{} {}", executable, arguments.join(" "));
206 self.calls.lock().unwrap().push(cmd_str.trim().to_string());
207 if self.should_fail {
208 Err(crate::DodotError::CommandFailed {
209 command: cmd_str.trim().to_string(),
210 exit_code: 1,
211 stderr: "mock failure".to_string(),
212 })
213 } else {
214 Ok(CommandOutput {
215 exit_code: 0,
216 stdout: String::new(),
217 stderr: String::new(),
218 })
219 }
220 }
221 }
222
223 fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
224 let runner = Arc::new(MockCommandRunner::new());
225 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
226 (ds, runner)
227 }
228
229 #[test]
232 fn create_data_link_creates_symlink() {
233 let env = TempEnvironment::builder()
234 .pack("vim")
235 .file("vimrc", "set nocompatible")
236 .done()
237 .build();
238 let (ds, _) = make_datastore(&env);
239
240 let source = env.dotfiles_root.join("vim/vimrc");
241 let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
242
243 assert_eq!(
245 link_path,
246 env.paths.handler_data_dir("vim", "symlink").join("vimrc")
247 );
248
249 env.assert_symlink(&link_path, &source);
251 }
252
253 #[test]
254 fn create_data_link_is_idempotent() {
255 let env = TempEnvironment::builder()
256 .pack("vim")
257 .file("vimrc", "set nocompatible")
258 .done()
259 .build();
260 let (ds, _) = make_datastore(&env);
261
262 let source = env.dotfiles_root.join("vim/vimrc");
263
264 let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
265 let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
266
267 assert_eq!(path1, path2);
268 env.assert_symlink(&path1, &source);
269 }
270
271 #[test]
272 fn create_data_link_replaces_wrong_target() {
273 let env = TempEnvironment::builder()
274 .pack("vim")
275 .file("vimrc", "v1")
276 .file("vimrc-new", "v2")
277 .done()
278 .build();
279 let (ds, _) = make_datastore(&env);
280
281 let source1 = env.dotfiles_root.join("vim/vimrc");
282 let source2 = env.dotfiles_root.join("vim/vimrc-new");
283
284 let link_dir = env.paths.handler_data_dir("vim", "symlink");
286 env.fs.mkdir_all(&link_dir).unwrap();
287 let wrong_link = link_dir.join("vimrc-new");
289 env.fs.symlink(&source1, &wrong_link).unwrap();
290
291 let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
293 env.assert_symlink(&link_path, &source2);
294 }
295
296 #[test]
299 fn create_user_link_creates_symlink() {
300 let env = TempEnvironment::builder().build();
301 let (ds, _) = make_datastore(&env);
302
303 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
304 let user_path = env.home.join(".vimrc");
305
306 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
308 env.fs.write_file(&datastore_path, b"link target").unwrap();
309
310 ds.create_user_link(&datastore_path, &user_path).unwrap();
311
312 env.assert_symlink(&user_path, &datastore_path);
313 }
314
315 #[test]
316 fn create_user_link_is_idempotent() {
317 let env = TempEnvironment::builder().build();
318 let (ds, _) = make_datastore(&env);
319
320 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
321 let user_path = env.home.join(".vimrc");
322
323 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
324 env.fs.write_file(&datastore_path, b"x").unwrap();
325
326 ds.create_user_link(&datastore_path, &user_path).unwrap();
327 ds.create_user_link(&datastore_path, &user_path).unwrap();
328
329 env.assert_symlink(&user_path, &datastore_path);
330 }
331
332 #[test]
333 fn create_user_link_conflict_with_regular_file() {
334 let env = TempEnvironment::builder().build();
335 let (ds, _) = make_datastore(&env);
336
337 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
338 let user_path = env.home.join(".vimrc");
339
340 env.fs.write_file(&user_path, b"existing content").unwrap();
342
343 let err = ds
344 .create_user_link(&datastore_path, &user_path)
345 .unwrap_err();
346 assert!(
347 matches!(err, crate::DodotError::SymlinkConflict { .. }),
348 "expected SymlinkConflict, got: {err}"
349 );
350 }
351
352 #[test]
353 fn create_user_link_replaces_wrong_symlink() {
354 let env = TempEnvironment::builder().build();
355 let (ds, _) = make_datastore(&env);
356
357 let wrong_target = env.data_dir.join("wrong");
358 let correct_target = env.data_dir.join("correct");
359 let user_path = env.home.join(".vimrc");
360
361 env.fs.mkdir_all(&env.data_dir).unwrap();
362 env.fs.write_file(&wrong_target, b"wrong").unwrap();
363 env.fs.write_file(&correct_target, b"right").unwrap();
364
365 env.fs.symlink(&wrong_target, &user_path).unwrap();
367
368 ds.create_user_link(&correct_target, &user_path).unwrap();
370 env.assert_symlink(&user_path, &correct_target);
371 }
372
373 #[test]
376 fn full_double_link_chain() {
377 let env = TempEnvironment::builder()
378 .pack("vim")
379 .file("vimrc", "set nocompatible")
380 .done()
381 .build();
382 let (ds, _) = make_datastore(&env);
383
384 let source = env.dotfiles_root.join("vim/vimrc");
385 let user_path = env.home.join(".vimrc");
386
387 let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
389
390 ds.create_user_link(&datastore_path, &user_path).unwrap();
392
393 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
395
396 let content = env.fs.read_to_string(&user_path).unwrap();
398 assert_eq!(content, "set nocompatible");
399 }
400
401 #[test]
404 fn run_and_record_creates_sentinel() {
405 let env = TempEnvironment::builder().build();
406 let (ds, runner) = make_datastore(&env);
407
408 assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
409
410 ds.run_and_record(
411 "vim",
412 "install",
413 "echo",
414 &["hello".into()],
415 "install.sh-abc",
416 false,
417 )
418 .unwrap();
419
420 assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
421 assert_eq!(runner.calls(), vec!["echo hello"]);
422
423 let sentinel_path = env
425 .paths
426 .handler_data_dir("vim", "install")
427 .join("install.sh-abc");
428 let content = env.fs.read_to_string(&sentinel_path).unwrap();
429 assert!(content.starts_with("completed|"), "got: {content}");
430 }
431
432 #[test]
433 fn run_and_record_is_idempotent() {
434 let env = TempEnvironment::builder().build();
435 let (ds, runner) = make_datastore(&env);
436
437 ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
438 .unwrap();
439 ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
440 .unwrap();
441
442 assert_eq!(runner.calls(), vec!["echo first"]);
444 }
445
446 #[test]
447 fn run_and_record_propagates_command_failure() {
448 let env = TempEnvironment::builder().build();
449 let runner = Arc::new(MockCommandRunner::failing());
450 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
451
452 let err = ds
453 .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
454 .unwrap_err();
455
456 assert!(
457 matches!(err, crate::DodotError::CommandFailed { .. }),
458 "expected CommandFailed, got: {err}"
459 );
460
461 assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
463 }
464
465 #[test]
468 fn remove_state_clears_handler_dir() {
469 let env = TempEnvironment::builder()
470 .pack("vim")
471 .file("vimrc", "x")
472 .done()
473 .build();
474 let (ds, _) = make_datastore(&env);
475
476 let source = env.dotfiles_root.join("vim/vimrc");
477 ds.create_data_link("vim", "symlink", &source).unwrap();
478 assert!(ds.has_handler_state("vim", "symlink").unwrap());
479
480 ds.remove_state("vim", "symlink").unwrap();
481 env.assert_no_handler_state("vim", "symlink");
482 }
483
484 #[test]
485 fn remove_state_is_noop_when_no_state() {
486 let env = TempEnvironment::builder().build();
487 let (ds, _) = make_datastore(&env);
488
489 ds.remove_state("nonexistent", "handler").unwrap();
491 }
492
493 #[test]
496 fn has_handler_state_false_when_no_dir() {
497 let env = TempEnvironment::builder().build();
498 let (ds, _) = make_datastore(&env);
499
500 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
501 }
502
503 #[test]
504 fn has_handler_state_false_when_empty_dir() {
505 let env = TempEnvironment::builder().build();
506 let (ds, _) = make_datastore(&env);
507
508 let dir = env.paths.handler_data_dir("vim", "symlink");
509 env.fs.mkdir_all(&dir).unwrap();
510
511 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
512 }
513
514 #[test]
515 fn has_handler_state_true_when_entries_exist() {
516 let env = TempEnvironment::builder()
517 .pack("vim")
518 .file("vimrc", "x")
519 .done()
520 .build();
521 let (ds, _) = make_datastore(&env);
522
523 let source = env.dotfiles_root.join("vim/vimrc");
524 ds.create_data_link("vim", "symlink", &source).unwrap();
525
526 assert!(ds.has_handler_state("vim", "symlink").unwrap());
527 }
528
529 #[test]
532 fn list_pack_handlers_returns_handler_dirs() {
533 let env = TempEnvironment::builder()
534 .pack("vim")
535 .file("vimrc", "x")
536 .file("aliases.sh", "y")
537 .done()
538 .build();
539 let (ds, _) = make_datastore(&env);
540
541 let source1 = env.dotfiles_root.join("vim/vimrc");
542 let source2 = env.dotfiles_root.join("vim/aliases.sh");
543 ds.create_data_link("vim", "symlink", &source1).unwrap();
544 ds.create_data_link("vim", "shell", &source2).unwrap();
545
546 let mut handlers = ds.list_pack_handlers("vim").unwrap();
547 handlers.sort();
548 assert_eq!(handlers, vec!["shell", "symlink"]);
549 }
550
551 #[test]
552 fn list_pack_handlers_empty_when_no_pack_state() {
553 let env = TempEnvironment::builder().build();
554 let (ds, _) = make_datastore(&env);
555
556 let handlers = ds.list_pack_handlers("nonexistent").unwrap();
557 assert!(handlers.is_empty());
558 }
559
560 #[test]
563 fn list_handler_sentinels_returns_file_names() {
564 let env = TempEnvironment::builder().build();
565 let (ds, _) = make_datastore(&env);
566
567 ds.run_and_record(
568 "vim",
569 "install",
570 "echo",
571 &["a".into()],
572 "install.sh-aaa",
573 false,
574 )
575 .unwrap();
576 ds.run_and_record(
577 "vim",
578 "install",
579 "echo",
580 &["b".into()],
581 "install.sh-bbb",
582 false,
583 )
584 .unwrap();
585
586 let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
587 sentinels.sort();
588 assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
589 }
590
591 #[test]
592 fn list_handler_sentinels_empty_when_no_state() {
593 let env = TempEnvironment::builder().build();
594 let (ds, _) = make_datastore(&env);
595
596 let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
597 assert!(sentinels.is_empty());
598 }
599
600 #[allow(dead_code)]
603 fn assert_object_safe(_: &dyn DataStore) {}
604}