Skip to main content

dodot_lib/datastore/
filesystem.rs

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
9/// [`DataStore`] implementation backed by the real filesystem.
10///
11/// State is stored as symlinks and sentinel files under the XDG data
12/// directory. The double-link architecture works as follows:
13///
14/// ```text
15/// ~/dotfiles/vim/vimrc                              (source)
16///   -> ~/.local/share/dodot/packs/vim/symlink/vimrc (data link)
17///     -> ~/.vimrc                                   (user link)
18/// ```
19pub 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        // Idempotent: if the link already points to the correct source, skip.
46        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            // Wrong target — remove and re-create.
53            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        // Create parent directory
62        if let Some(parent) = user_path.parent() {
63            self.fs.mkdir_all(parent)?;
64        }
65
66        // If something already exists at user_path, handle it
67        if self.fs.is_symlink(user_path) {
68            // Existing symlink — check if it's correct
69            if let Ok(current_target) = self.fs.readlink(user_path) {
70                if current_target == datastore_path {
71                    return Ok(()); // Already correct
72                }
73            }
74            // Wrong target — remove and re-create
75            self.fs.remove_file(user_path)?;
76        } else if self.fs.exists(user_path) {
77            // Exists but is not a symlink — conflict
78            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        // Idempotent: skip if sentinel exists
96        if !force && self.has_sentinel(pack, handler, sentinel)? {
97            return Ok(());
98        }
99
100        // Run the command
101        self.runner.run(executable, arguments)?;
102
103        // Record sentinel
104        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    /// Mock command runner that records calls and can be configured to
177    /// succeed or fail.
178    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    // ── create_data_link ────────────────────────────────────────
230
231    #[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        // Link should be in the handler data dir
244        assert_eq!(
245            link_path,
246            env.paths.handler_data_dir("vim", "symlink").join("vimrc")
247        );
248
249        // Link should point to source
250        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        // Create initial link to source1
285        let link_dir = env.paths.handler_data_dir("vim", "symlink");
286        env.fs.mkdir_all(&link_dir).unwrap();
287        // Manually create a link named "vimrc-new" pointing to source1 (wrong target)
288        let wrong_link = link_dir.join("vimrc-new");
289        env.fs.symlink(&source1, &wrong_link).unwrap();
290
291        // Now create_data_link should fix it to point at source2
292        let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
293        env.assert_symlink(&link_path, &source2);
294    }
295
296    // ── create_user_link ────────────────────────────────────────
297
298    #[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        // Create the datastore file so the symlink target exists
307        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        // Create a regular file at the user path
341        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        // Create wrong symlink
366        env.fs.symlink(&wrong_target, &user_path).unwrap();
367
368        // Should fix it
369        ds.create_user_link(&correct_target, &user_path).unwrap();
370        env.assert_symlink(&user_path, &correct_target);
371    }
372
373    // ── Double-link chain ───────────────────────────────────────
374
375    #[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        // Step 1: data link
388        let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
389
390        // Step 2: user link
391        ds.create_user_link(&datastore_path, &user_path).unwrap();
392
393        // Verify the full chain
394        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
395
396        // Reading through the chain should yield the original content
397        let content = env.fs.read_to_string(&user_path).unwrap();
398        assert_eq!(content, "set nocompatible");
399    }
400
401    // ── run_and_record / has_sentinel ───────────────────────────
402
403    #[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        // Sentinel file should contain "completed|..."
424        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        // Command only ran once
443        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        // No sentinel should be created on failure
462        assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
463    }
464
465    // ── remove_state ────────────────────────────────────────────
466
467    #[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        // Should not error
490        ds.remove_state("nonexistent", "handler").unwrap();
491    }
492
493    // ── has_handler_state ───────────────────────────────────────
494
495    #[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    // ── list_pack_handlers ──────────────────────────────────────
530
531    #[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    // ── list_handler_sentinels ──────────────────────────────────
561
562    #[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    // ── Object safety ───────────────────────────────────────────
601
602    #[allow(dead_code)]
603    fn assert_object_safe(_: &dyn DataStore) {}
604}