Skip to main content

dodot_lib/datastore/
filesystem.rs

1use std::path::{Component, Path, PathBuf};
2use std::sync::Arc;
3
4use crate::datastore::{CommandRunner, DataStore};
5use crate::fs::Fs;
6use crate::paths::Pather;
7use crate::{DodotError, Result};
8
9/// Validate that `raw` is a safe relative path to be used under `base`.
10///
11/// Defense-in-depth against path traversal: rejects absolute paths, `..`
12/// components, and anything that would escape `base`. Returns the
13/// normalised relative `PathBuf` on success.
14fn validate_safe_relative(raw: &str, base: &Path) -> Result<PathBuf> {
15    let candidate = Path::new(raw);
16    let mut cleaned = PathBuf::new();
17    for component in candidate.components() {
18        match component {
19            Component::Normal(n) => cleaned.push(n),
20            Component::CurDir => {}
21            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
22                return Err(DodotError::Other(format!(
23                    "unsafe datastore path: {} (would escape {})",
24                    raw,
25                    base.display()
26                )));
27            }
28        }
29    }
30    if cleaned.as_os_str().is_empty() {
31        return Err(DodotError::Other(format!(
32            "empty datastore path (from {raw:?})"
33        )));
34    }
35    Ok(cleaned)
36}
37
38/// Extract the leading documentation comment block from a script.
39///
40/// Skips an optional `#!` shebang and any blank lines, then collects
41/// contiguous `#`-prefixed lines. From each line, every leading `#`
42/// character and any following whitespace is stripped, so `## Section`
43/// becomes `Section`. Stops at the first non-comment, non-blank line.
44/// Used to print "what does this script do" before kicking it off, so
45/// the user has context while it runs.
46pub(crate) fn extract_header_block(content: &str) -> Vec<String> {
47    let mut out = Vec::new();
48    let mut iter = content.lines().peekable();
49    if matches!(iter.peek(), Some(l) if l.starts_with("#!")) {
50        iter.next();
51    }
52    while matches!(iter.peek(), Some(l) if l.trim().is_empty()) {
53        iter.next();
54    }
55    for line in iter {
56        let t = line.trim_start();
57        if !t.starts_with('#') || t.starts_with("#!") {
58            break;
59        }
60        let stripped = t.trim_start_matches('#').trim_start().to_string();
61        out.push(stripped);
62    }
63    out
64}
65
66/// [`DataStore`] implementation backed by the real filesystem.
67///
68/// State is stored as symlinks and sentinel files under the XDG data
69/// directory. The double-link architecture works as follows:
70///
71/// ```text
72/// ~/dotfiles/vim/vimrc                              (source)
73///   -> ~/.local/share/dodot/packs/vim/symlink/vimrc (data link)
74///     -> ~/.vimrc                                   (user link)
75/// ```
76pub struct FilesystemDataStore {
77    fs: Arc<dyn Fs>,
78    paths: Arc<dyn Pather>,
79    runner: Arc<dyn CommandRunner>,
80}
81
82impl FilesystemDataStore {
83    pub fn new(fs: Arc<dyn Fs>, paths: Arc<dyn Pather>, runner: Arc<dyn CommandRunner>) -> Self {
84        Self { fs, paths, runner }
85    }
86}
87
88impl DataStore for FilesystemDataStore {
89    fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf> {
90        let filename = source_file.file_name().ok_or_else(|| {
91            crate::DodotError::Other(format!(
92                "source file has no filename: {}",
93                source_file.display()
94            ))
95        })?;
96
97        let link_dir = self.paths.handler_data_dir(pack, handler);
98        let link_path = link_dir.join(filename);
99
100        self.fs.mkdir_all(&link_dir)?;
101
102        // Idempotent: if the link already points to the correct source, skip.
103        if self.fs.is_symlink(&link_path) {
104            if let Ok(current_target) = self.fs.readlink(&link_path) {
105                if current_target == source_file {
106                    return Ok(link_path);
107                }
108            }
109            // Wrong target — remove and re-create.
110            self.fs.remove_file(&link_path)?;
111        }
112
113        self.fs.symlink(source_file, &link_path)?;
114        Ok(link_path)
115    }
116
117    fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()> {
118        // Create parent directory
119        if let Some(parent) = user_path.parent() {
120            self.fs.mkdir_all(parent)?;
121        }
122
123        // If something already exists at user_path, handle it
124        if self.fs.is_symlink(user_path) {
125            // Existing symlink — check if it's correct
126            if let Ok(current_target) = self.fs.readlink(user_path) {
127                if current_target == datastore_path {
128                    return Ok(()); // Already correct
129                }
130            }
131            // Wrong target — remove and re-create
132            self.fs.remove_file(user_path)?;
133        } else if self.fs.exists(user_path) {
134            // Exists but is not a symlink — conflict
135            return Err(crate::DodotError::SymlinkConflict {
136                path: user_path.to_path_buf(),
137            });
138        }
139
140        self.fs.symlink(datastore_path, user_path)
141    }
142
143    fn run_and_record(
144        &self,
145        pack: &str,
146        handler: &str,
147        executable: &str,
148        arguments: &[String],
149        sentinel: &str,
150        force: bool,
151    ) -> Result<()> {
152        // Idempotent: skip if sentinel exists
153        if !force && self.has_sentinel(pack, handler, sentinel)? {
154            return Ok(());
155        }
156
157        // Provisioning scripts are consequential and can take a while; surface
158        // start/end markers on stderr so the user knows what's running and
159        // whether it succeeded. The script's own stdout/stderr still flows
160        // through the runner as before.
161        //
162        // The script path is the last argument by convention: install
163        // invokes `bash -- <path>` / `zsh -- <path>` and homebrew invokes
164        // `brew bundle --file <path>`. Using the last arg directly (vs a
165        // filename-with-dot heuristic) means extensionless install scripts
166        // — which the install handler explicitly supports — also get a
167        // header block printed.
168        let script_path = arguments.last().cloned();
169        let display_name = script_path
170            .as_deref()
171            .and_then(|p| Path::new(p).file_name())
172            .map(|n| n.to_string_lossy().into_owned())
173            .unwrap_or_else(|| executable.to_string());
174        let header = format!("==== {pack} → {handler} → {display_name}");
175        let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
176        let dim = if tty { "\x1b[2m" } else { "" };
177        let green = if tty { "\x1b[32m" } else { "" };
178        let red = if tty { "\x1b[31m" } else { "" };
179        let reset = if tty { "\x1b[0m" } else { "" };
180        eprintln!("{header}  {dim}running…{reset}");
181
182        // Best-effort: print the script's leading comment block so the
183        // user knows what's about to run. Silently skipped on read error
184        // (script could be a binary, missing, or unreadable).
185        if let Some(path_str) = script_path.as_deref() {
186            if let Ok(content) = self.fs.read_to_string(Path::new(path_str)) {
187                let lines = extract_header_block(&content);
188                if !lines.is_empty() {
189                    let stdout = std::io::stdout();
190                    let mut h = stdout.lock();
191                    use std::io::Write;
192                    for line in lines {
193                        let _ = writeln!(h, "{dim}    {line}{reset}");
194                    }
195                }
196            }
197        }
198
199        let result = self.runner.run(executable, arguments);
200        match &result {
201            Ok(_) => eprintln!("{header}  {green}OK{reset}"),
202            Err(_) => eprintln!("{header}  {red}FAILED{reset}"),
203        }
204        result?;
205
206        // Record sentinel
207        let sentinel_dir = self.paths.handler_data_dir(pack, handler);
208        self.fs.mkdir_all(&sentinel_dir)?;
209
210        let sentinel_path = sentinel_dir.join(sentinel);
211        let timestamp = std::time::SystemTime::now()
212            .duration_since(std::time::UNIX_EPOCH)
213            .unwrap_or_default()
214            .as_secs();
215        let content = format!("completed|{timestamp}");
216        self.fs.write_file(&sentinel_path, content.as_bytes())
217    }
218
219    fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool> {
220        let sentinel_path = self.paths.handler_data_dir(pack, handler).join(sentinel);
221        Ok(self.fs.exists(&sentinel_path))
222    }
223
224    fn remove_state(&self, pack: &str, handler: &str) -> Result<()> {
225        let state_dir = self.paths.handler_data_dir(pack, handler);
226        if !self.fs.exists(&state_dir) {
227            return Ok(());
228        }
229        self.fs.remove_dir_all(&state_dir)
230    }
231
232    fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool> {
233        let state_dir = self.paths.handler_data_dir(pack, handler);
234        if !self.fs.exists(&state_dir) {
235            return Ok(false);
236        }
237        let entries = self.fs.read_dir(&state_dir)?;
238        Ok(!entries.is_empty())
239    }
240
241    fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>> {
242        let pack_dir = self.paths.pack_data_dir(pack);
243        if !self.fs.exists(&pack_dir) {
244            return Ok(Vec::new());
245        }
246        let entries = self.fs.read_dir(&pack_dir)?;
247        Ok(entries
248            .into_iter()
249            .filter(|e| e.is_dir)
250            .map(|e| e.name)
251            .collect())
252    }
253
254    fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>> {
255        let handler_dir = self.paths.handler_data_dir(pack, handler);
256        if !self.fs.exists(&handler_dir) {
257            return Ok(Vec::new());
258        }
259        let entries = self.fs.read_dir(&handler_dir)?;
260        Ok(entries
261            .into_iter()
262            .filter(|e| e.is_file)
263            .map(|e| e.name)
264            .collect())
265    }
266
267    fn write_rendered_file(
268        &self,
269        pack: &str,
270        handler: &str,
271        filename: &str,
272        content: &[u8],
273    ) -> Result<PathBuf> {
274        let dir = self.paths.handler_data_dir(pack, handler);
275        let relative = validate_safe_relative(filename, &dir)?;
276        let path = dir.join(&relative);
277        // Create the full parent chain (supports nested filenames like "sub/file.txt")
278        if let Some(parent) = path.parent() {
279            self.fs.mkdir_all(parent)?;
280        } else {
281            self.fs.mkdir_all(&dir)?;
282        }
283        self.fs.write_file(&path, content)?;
284        Ok(path)
285    }
286
287    fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf> {
288        let dir = self.paths.handler_data_dir(pack, handler);
289        let rel = validate_safe_relative(relative, &dir)?;
290        let path = dir.join(&rel);
291        self.fs.mkdir_all(&path)?;
292        Ok(path)
293    }
294
295    fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
296        self.paths.handler_data_dir(pack, handler).join(sentinel)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::datastore::{CommandOutput, CommandRunner};
304    use crate::testing::TempEnvironment;
305    use std::sync::Mutex;
306
307    #[test]
308    fn extract_header_block_skips_shebang() {
309        let content = "#!/bin/bash\n# Install nvm\n# Requires curl\necho hi\n";
310        assert_eq!(
311            extract_header_block(content),
312            vec!["Install nvm".to_string(), "Requires curl".to_string()]
313        );
314    }
315
316    #[test]
317    fn extract_header_block_no_shebang() {
318        let content = "# header line\necho hi\n";
319        assert_eq!(extract_header_block(content), vec!["header line"]);
320    }
321
322    #[test]
323    fn extract_header_block_blanks_between_shebang_and_comments() {
324        let content = "#!/bin/bash\n\n\n# first\n# second\nstuff\n";
325        assert_eq!(
326            extract_header_block(content),
327            vec!["first".to_string(), "second".to_string()]
328        );
329    }
330
331    #[test]
332    fn extract_header_block_stops_at_first_non_comment() {
333        let content = "# a\n# b\necho mid\n# late\n";
334        assert_eq!(
335            extract_header_block(content),
336            vec!["a".to_string(), "b".to_string()]
337        );
338    }
339
340    #[test]
341    fn extract_header_block_strips_extra_hashes_and_spaces() {
342        // Common style: `## section` or `#  spaced`. Strip all leading
343        // `#` then a single optional space.
344        let content = "## Section\n#   spaced\n";
345        assert_eq!(
346            extract_header_block(content),
347            vec!["Section".to_string(), "spaced".to_string()]
348        );
349    }
350
351    #[test]
352    fn extract_header_block_empty_input() {
353        assert!(extract_header_block("").is_empty());
354    }
355
356    #[test]
357    fn extract_header_block_no_comments() {
358        assert!(extract_header_block("#!/bin/bash\necho hi\n").is_empty());
359    }
360
361    /// Mock command runner that records calls and can be configured to
362    /// succeed or fail.
363    struct MockCommandRunner {
364        calls: Mutex<Vec<String>>,
365        should_fail: bool,
366    }
367
368    impl MockCommandRunner {
369        fn new() -> Self {
370            Self {
371                calls: Mutex::new(Vec::new()),
372                should_fail: false,
373            }
374        }
375
376        fn failing() -> Self {
377            Self {
378                calls: Mutex::new(Vec::new()),
379                should_fail: true,
380            }
381        }
382
383        fn calls(&self) -> Vec<String> {
384            self.calls.lock().unwrap().clone()
385        }
386    }
387
388    impl CommandRunner for MockCommandRunner {
389        fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
390            let cmd_str = format!("{} {}", executable, arguments.join(" "));
391            self.calls.lock().unwrap().push(cmd_str.trim().to_string());
392            if self.should_fail {
393                Err(crate::DodotError::CommandFailed {
394                    command: cmd_str.trim().to_string(),
395                    exit_code: 1,
396                    stderr: "mock failure".to_string(),
397                })
398            } else {
399                Ok(CommandOutput {
400                    exit_code: 0,
401                    stdout: String::new(),
402                    stderr: String::new(),
403                })
404            }
405        }
406    }
407
408    fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
409        let runner = Arc::new(MockCommandRunner::new());
410        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
411        (ds, runner)
412    }
413
414    // ── create_data_link ────────────────────────────────────────
415
416    #[test]
417    fn create_data_link_creates_symlink() {
418        let env = TempEnvironment::builder()
419            .pack("vim")
420            .file("vimrc", "set nocompatible")
421            .done()
422            .build();
423        let (ds, _) = make_datastore(&env);
424
425        let source = env.dotfiles_root.join("vim/vimrc");
426        let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
427
428        // Link should be in the handler data dir
429        assert_eq!(
430            link_path,
431            env.paths.handler_data_dir("vim", "symlink").join("vimrc")
432        );
433
434        // Link should point to source
435        env.assert_symlink(&link_path, &source);
436    }
437
438    #[test]
439    fn create_data_link_is_idempotent() {
440        let env = TempEnvironment::builder()
441            .pack("vim")
442            .file("vimrc", "set nocompatible")
443            .done()
444            .build();
445        let (ds, _) = make_datastore(&env);
446
447        let source = env.dotfiles_root.join("vim/vimrc");
448
449        let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
450        let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
451
452        assert_eq!(path1, path2);
453        env.assert_symlink(&path1, &source);
454    }
455
456    #[test]
457    fn create_data_link_replaces_wrong_target() {
458        let env = TempEnvironment::builder()
459            .pack("vim")
460            .file("vimrc", "v1")
461            .file("vimrc-new", "v2")
462            .done()
463            .build();
464        let (ds, _) = make_datastore(&env);
465
466        let source1 = env.dotfiles_root.join("vim/vimrc");
467        let source2 = env.dotfiles_root.join("vim/vimrc-new");
468
469        // Create initial link to source1
470        let link_dir = env.paths.handler_data_dir("vim", "symlink");
471        env.fs.mkdir_all(&link_dir).unwrap();
472        // Manually create a link named "vimrc-new" pointing to source1 (wrong target)
473        let wrong_link = link_dir.join("vimrc-new");
474        env.fs.symlink(&source1, &wrong_link).unwrap();
475
476        // Now create_data_link should fix it to point at source2
477        let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
478        env.assert_symlink(&link_path, &source2);
479    }
480
481    // ── create_user_link ────────────────────────────────────────
482
483    #[test]
484    fn create_user_link_creates_symlink() {
485        let env = TempEnvironment::builder().build();
486        let (ds, _) = make_datastore(&env);
487
488        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
489        let user_path = env.home.join(".vimrc");
490
491        // Create the datastore file so the symlink target exists
492        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
493        env.fs.write_file(&datastore_path, b"link target").unwrap();
494
495        ds.create_user_link(&datastore_path, &user_path).unwrap();
496
497        env.assert_symlink(&user_path, &datastore_path);
498    }
499
500    #[test]
501    fn create_user_link_is_idempotent() {
502        let env = TempEnvironment::builder().build();
503        let (ds, _) = make_datastore(&env);
504
505        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
506        let user_path = env.home.join(".vimrc");
507
508        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
509        env.fs.write_file(&datastore_path, b"x").unwrap();
510
511        ds.create_user_link(&datastore_path, &user_path).unwrap();
512        ds.create_user_link(&datastore_path, &user_path).unwrap();
513
514        env.assert_symlink(&user_path, &datastore_path);
515    }
516
517    #[test]
518    fn create_user_link_conflict_with_regular_file() {
519        let env = TempEnvironment::builder().build();
520        let (ds, _) = make_datastore(&env);
521
522        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
523        let user_path = env.home.join(".vimrc");
524
525        // Create a regular file at the user path
526        env.fs.write_file(&user_path, b"existing content").unwrap();
527
528        let err = ds
529            .create_user_link(&datastore_path, &user_path)
530            .unwrap_err();
531        assert!(
532            matches!(err, crate::DodotError::SymlinkConflict { .. }),
533            "expected SymlinkConflict, got: {err}"
534        );
535    }
536
537    #[test]
538    fn create_user_link_replaces_wrong_symlink() {
539        let env = TempEnvironment::builder().build();
540        let (ds, _) = make_datastore(&env);
541
542        let wrong_target = env.data_dir.join("wrong");
543        let correct_target = env.data_dir.join("correct");
544        let user_path = env.home.join(".vimrc");
545
546        env.fs.mkdir_all(&env.data_dir).unwrap();
547        env.fs.write_file(&wrong_target, b"wrong").unwrap();
548        env.fs.write_file(&correct_target, b"right").unwrap();
549
550        // Create wrong symlink
551        env.fs.symlink(&wrong_target, &user_path).unwrap();
552
553        // Should fix it
554        ds.create_user_link(&correct_target, &user_path).unwrap();
555        env.assert_symlink(&user_path, &correct_target);
556    }
557
558    // ── Double-link chain ───────────────────────────────────────
559
560    #[test]
561    fn full_double_link_chain() {
562        let env = TempEnvironment::builder()
563            .pack("vim")
564            .file("vimrc", "set nocompatible")
565            .done()
566            .build();
567        let (ds, _) = make_datastore(&env);
568
569        let source = env.dotfiles_root.join("vim/vimrc");
570        let user_path = env.home.join(".vimrc");
571
572        // Step 1: data link
573        let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
574
575        // Step 2: user link
576        ds.create_user_link(&datastore_path, &user_path).unwrap();
577
578        // Verify the full chain
579        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
580
581        // Reading through the chain should yield the original content
582        let content = env.fs.read_to_string(&user_path).unwrap();
583        assert_eq!(content, "set nocompatible");
584    }
585
586    // ── run_and_record / has_sentinel ───────────────────────────
587
588    #[test]
589    fn run_and_record_creates_sentinel() {
590        let env = TempEnvironment::builder().build();
591        let (ds, runner) = make_datastore(&env);
592
593        assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
594
595        ds.run_and_record(
596            "vim",
597            "install",
598            "echo",
599            &["hello".into()],
600            "install.sh-abc",
601            false,
602        )
603        .unwrap();
604
605        assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
606        assert_eq!(runner.calls(), vec!["echo hello"]);
607
608        // Sentinel file should contain "completed|..."
609        let sentinel_path = env
610            .paths
611            .handler_data_dir("vim", "install")
612            .join("install.sh-abc");
613        let content = env.fs.read_to_string(&sentinel_path).unwrap();
614        assert!(content.starts_with("completed|"), "got: {content}");
615    }
616
617    #[test]
618    fn run_and_record_is_idempotent() {
619        let env = TempEnvironment::builder().build();
620        let (ds, runner) = make_datastore(&env);
621
622        ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
623            .unwrap();
624        ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
625            .unwrap();
626
627        // Command only ran once
628        assert_eq!(runner.calls(), vec!["echo first"]);
629    }
630
631    #[test]
632    fn run_and_record_propagates_command_failure() {
633        let env = TempEnvironment::builder().build();
634        let runner = Arc::new(MockCommandRunner::failing());
635        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
636
637        let err = ds
638            .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
639            .unwrap_err();
640
641        assert!(
642            matches!(err, crate::DodotError::CommandFailed { .. }),
643            "expected CommandFailed, got: {err}"
644        );
645
646        // No sentinel should be created on failure
647        assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
648    }
649
650    // ── remove_state ────────────────────────────────────────────
651
652    #[test]
653    fn remove_state_clears_handler_dir() {
654        let env = TempEnvironment::builder()
655            .pack("vim")
656            .file("vimrc", "x")
657            .done()
658            .build();
659        let (ds, _) = make_datastore(&env);
660
661        let source = env.dotfiles_root.join("vim/vimrc");
662        ds.create_data_link("vim", "symlink", &source).unwrap();
663        assert!(ds.has_handler_state("vim", "symlink").unwrap());
664
665        ds.remove_state("vim", "symlink").unwrap();
666        env.assert_no_handler_state("vim", "symlink");
667    }
668
669    #[test]
670    fn remove_state_is_noop_when_no_state() {
671        let env = TempEnvironment::builder().build();
672        let (ds, _) = make_datastore(&env);
673
674        // Should not error
675        ds.remove_state("nonexistent", "handler").unwrap();
676    }
677
678    // ── has_handler_state ───────────────────────────────────────
679
680    #[test]
681    fn has_handler_state_false_when_no_dir() {
682        let env = TempEnvironment::builder().build();
683        let (ds, _) = make_datastore(&env);
684
685        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
686    }
687
688    #[test]
689    fn has_handler_state_false_when_empty_dir() {
690        let env = TempEnvironment::builder().build();
691        let (ds, _) = make_datastore(&env);
692
693        let dir = env.paths.handler_data_dir("vim", "symlink");
694        env.fs.mkdir_all(&dir).unwrap();
695
696        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
697    }
698
699    #[test]
700    fn has_handler_state_true_when_entries_exist() {
701        let env = TempEnvironment::builder()
702            .pack("vim")
703            .file("vimrc", "x")
704            .done()
705            .build();
706        let (ds, _) = make_datastore(&env);
707
708        let source = env.dotfiles_root.join("vim/vimrc");
709        ds.create_data_link("vim", "symlink", &source).unwrap();
710
711        assert!(ds.has_handler_state("vim", "symlink").unwrap());
712    }
713
714    // ── list_pack_handlers ──────────────────────────────────────
715
716    #[test]
717    fn list_pack_handlers_returns_handler_dirs() {
718        let env = TempEnvironment::builder()
719            .pack("vim")
720            .file("vimrc", "x")
721            .file("aliases.sh", "y")
722            .done()
723            .build();
724        let (ds, _) = make_datastore(&env);
725
726        let source1 = env.dotfiles_root.join("vim/vimrc");
727        let source2 = env.dotfiles_root.join("vim/aliases.sh");
728        ds.create_data_link("vim", "symlink", &source1).unwrap();
729        ds.create_data_link("vim", "shell", &source2).unwrap();
730
731        let mut handlers = ds.list_pack_handlers("vim").unwrap();
732        handlers.sort();
733        assert_eq!(handlers, vec!["shell", "symlink"]);
734    }
735
736    #[test]
737    fn list_pack_handlers_empty_when_no_pack_state() {
738        let env = TempEnvironment::builder().build();
739        let (ds, _) = make_datastore(&env);
740
741        let handlers = ds.list_pack_handlers("nonexistent").unwrap();
742        assert!(handlers.is_empty());
743    }
744
745    // ── list_handler_sentinels ──────────────────────────────────
746
747    #[test]
748    fn list_handler_sentinels_returns_file_names() {
749        let env = TempEnvironment::builder().build();
750        let (ds, _) = make_datastore(&env);
751
752        ds.run_and_record(
753            "vim",
754            "install",
755            "echo",
756            &["a".into()],
757            "install.sh-aaa",
758            false,
759        )
760        .unwrap();
761        ds.run_and_record(
762            "vim",
763            "install",
764            "echo",
765            &["b".into()],
766            "install.sh-bbb",
767            false,
768        )
769        .unwrap();
770
771        let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
772        sentinels.sort();
773        assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
774    }
775
776    #[test]
777    fn list_handler_sentinels_empty_when_no_state() {
778        let env = TempEnvironment::builder().build();
779        let (ds, _) = make_datastore(&env);
780
781        let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
782        assert!(sentinels.is_empty());
783    }
784
785    // ── write_rendered_file ───────────────────────────────────────
786
787    #[test]
788    fn write_rendered_file_creates_regular_file() {
789        let env = TempEnvironment::builder().build();
790        let (ds, _) = make_datastore(&env);
791
792        let path = ds
793            .write_rendered_file("app", "preprocessed", "config.toml", b"host = localhost")
794            .unwrap();
795
796        assert!(env.fs.exists(&path));
797        assert!(!env.fs.is_symlink(&path));
798        assert_eq!(env.fs.read_to_string(&path).unwrap(), "host = localhost");
799    }
800
801    #[test]
802    fn write_rendered_file_overwrites_existing() {
803        let env = TempEnvironment::builder().build();
804        let (ds, _) = make_datastore(&env);
805
806        let path1 = ds
807            .write_rendered_file("app", "preprocessed", "config.toml", b"version 1")
808            .unwrap();
809        let path2 = ds
810            .write_rendered_file("app", "preprocessed", "config.toml", b"version 2")
811            .unwrap();
812
813        assert_eq!(path1, path2);
814        assert_eq!(env.fs.read_to_string(&path1).unwrap(), "version 2");
815    }
816
817    #[test]
818    fn write_rendered_file_empty_content() {
819        let env = TempEnvironment::builder().build();
820        let (ds, _) = make_datastore(&env);
821
822        let path = ds
823            .write_rendered_file("app", "preprocessed", "empty.conf", b"")
824            .unwrap();
825
826        assert!(env.fs.exists(&path));
827        assert_eq!(env.fs.read_to_string(&path).unwrap(), "");
828    }
829
830    #[test]
831    fn write_rendered_file_binary_content() {
832        let env = TempEnvironment::builder().build();
833        let (ds, _) = make_datastore(&env);
834
835        let binary = vec![0u8, 1, 2, 255, 254, 253];
836        let path = ds
837            .write_rendered_file("app", "preprocessed", "data.bin", &binary)
838            .unwrap();
839
840        assert_eq!(env.fs.read_file(&path).unwrap(), binary);
841    }
842
843    #[test]
844    fn write_rendered_file_creates_parent_dirs() {
845        let env = TempEnvironment::builder().build();
846        let (ds, _) = make_datastore(&env);
847
848        // handler_data_dir doesn't exist yet — write_rendered_file should create it
849        let handler_dir = env.paths.handler_data_dir("newpack", "preprocessed");
850        assert!(!env.fs.exists(&handler_dir));
851
852        let path = ds
853            .write_rendered_file("newpack", "preprocessed", "file.txt", b"hello")
854            .unwrap();
855
856        assert!(env.fs.exists(&path));
857        assert_eq!(env.fs.read_to_string(&path).unwrap(), "hello");
858    }
859
860    #[test]
861    fn write_rendered_file_rejects_absolute_path() {
862        let env = TempEnvironment::builder().build();
863        let (ds, _) = make_datastore(&env);
864
865        let err = ds
866            .write_rendered_file("app", "preprocessed", "/etc/passwd", b"x")
867            .unwrap_err();
868        assert!(
869            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
870            "expected unsafe-path error, got: {err}"
871        );
872    }
873
874    #[test]
875    fn write_rendered_file_rejects_parent_dir() {
876        let env = TempEnvironment::builder().build();
877        let (ds, _) = make_datastore(&env);
878
879        let err = ds
880            .write_rendered_file("app", "preprocessed", "../escape.txt", b"x")
881            .unwrap_err();
882        assert!(
883            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
884            "expected unsafe-path error, got: {err}"
885        );
886    }
887
888    #[test]
889    fn write_rendered_dir_creates_dir() {
890        let env = TempEnvironment::builder().build();
891        let (ds, _) = make_datastore(&env);
892
893        let path = ds
894            .write_rendered_dir("app", "preprocessed", "sub/nested")
895            .unwrap();
896
897        assert!(env.fs.is_dir(&path));
898        assert!(!env.fs.is_symlink(&path));
899    }
900
901    #[test]
902    fn write_rendered_dir_is_idempotent() {
903        let env = TempEnvironment::builder().build();
904        let (ds, _) = make_datastore(&env);
905
906        let p1 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
907        let p2 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
908        assert_eq!(p1, p2);
909        assert!(env.fs.is_dir(&p1));
910    }
911
912    #[test]
913    fn write_rendered_dir_rejects_unsafe_paths() {
914        let env = TempEnvironment::builder().build();
915        let (ds, _) = make_datastore(&env);
916
917        assert!(ds
918            .write_rendered_dir("app", "preprocessed", "/abs")
919            .is_err());
920        assert!(ds
921            .write_rendered_dir("app", "preprocessed", "../esc")
922            .is_err());
923    }
924
925    #[test]
926    fn write_rendered_file_supports_nested_filename() {
927        // A filename containing `/` should be written to the corresponding
928        // nested directory under the handler data dir, creating any needed
929        // parents. This preserves source structure for preprocessor output.
930        let env = TempEnvironment::builder().build();
931        let (ds, _) = make_datastore(&env);
932
933        let path = ds
934            .write_rendered_file("app", "preprocessed", "sub/nested/file.txt", b"deep")
935            .unwrap();
936
937        assert!(env.fs.exists(&path));
938        assert!(!env.fs.is_symlink(&path));
939        assert_eq!(env.fs.read_to_string(&path).unwrap(), "deep");
940        assert!(
941            path.to_string_lossy().contains("sub/nested/file.txt"),
942            "path should contain nested structure: {}",
943            path.display()
944        );
945    }
946
947    // ── Object safety ───────────────────────────────────────────
948
949    #[allow(dead_code)]
950    fn assert_object_safe(_: &dyn DataStore) {}
951}