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_file_with_mode(
288        &self,
289        pack: &str,
290        handler: &str,
291        filename: &str,
292        content: &[u8],
293        mode: u32,
294    ) -> Result<PathBuf> {
295        let dir = self.paths.handler_data_dir(pack, handler);
296        let relative = validate_safe_relative(filename, &dir)?;
297        let path = dir.join(&relative);
298        if let Some(parent) = path.parent() {
299            self.fs.mkdir_all(parent)?;
300        } else {
301            self.fs.mkdir_all(&dir)?;
302        }
303        // Atomic create-with-mode + chmod-empty + write — the
304        // bytes never sit on disk at a permissive mode.
305        self.fs.write_file_with_mode(&path, content, mode)?;
306        Ok(path)
307    }
308
309    fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf> {
310        let dir = self.paths.handler_data_dir(pack, handler);
311        let rel = validate_safe_relative(relative, &dir)?;
312        let path = dir.join(&rel);
313        self.fs.mkdir_all(&path)?;
314        Ok(path)
315    }
316
317    fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
318        self.paths.handler_data_dir(pack, handler).join(sentinel)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::datastore::{CommandOutput, CommandRunner};
326    use crate::testing::TempEnvironment;
327    use std::sync::Mutex;
328
329    #[test]
330    fn extract_header_block_skips_shebang() {
331        let content = "#!/bin/bash\n# Install nvm\n# Requires curl\necho hi\n";
332        assert_eq!(
333            extract_header_block(content),
334            vec!["Install nvm".to_string(), "Requires curl".to_string()]
335        );
336    }
337
338    #[test]
339    fn extract_header_block_no_shebang() {
340        let content = "# header line\necho hi\n";
341        assert_eq!(extract_header_block(content), vec!["header line"]);
342    }
343
344    #[test]
345    fn extract_header_block_blanks_between_shebang_and_comments() {
346        let content = "#!/bin/bash\n\n\n# first\n# second\nstuff\n";
347        assert_eq!(
348            extract_header_block(content),
349            vec!["first".to_string(), "second".to_string()]
350        );
351    }
352
353    #[test]
354    fn extract_header_block_stops_at_first_non_comment() {
355        let content = "# a\n# b\necho mid\n# late\n";
356        assert_eq!(
357            extract_header_block(content),
358            vec!["a".to_string(), "b".to_string()]
359        );
360    }
361
362    #[test]
363    fn extract_header_block_strips_extra_hashes_and_spaces() {
364        // Common style: `## section` or `#  spaced`. Strip all leading
365        // `#` then a single optional space.
366        let content = "## Section\n#   spaced\n";
367        assert_eq!(
368            extract_header_block(content),
369            vec!["Section".to_string(), "spaced".to_string()]
370        );
371    }
372
373    #[test]
374    fn extract_header_block_empty_input() {
375        assert!(extract_header_block("").is_empty());
376    }
377
378    #[test]
379    fn extract_header_block_no_comments() {
380        assert!(extract_header_block("#!/bin/bash\necho hi\n").is_empty());
381    }
382
383    /// Mock command runner that records calls and can be configured to
384    /// succeed or fail.
385    struct MockCommandRunner {
386        calls: Mutex<Vec<String>>,
387        should_fail: bool,
388    }
389
390    impl MockCommandRunner {
391        fn new() -> Self {
392            Self {
393                calls: Mutex::new(Vec::new()),
394                should_fail: false,
395            }
396        }
397
398        fn failing() -> Self {
399            Self {
400                calls: Mutex::new(Vec::new()),
401                should_fail: true,
402            }
403        }
404
405        fn calls(&self) -> Vec<String> {
406            self.calls.lock().unwrap().clone()
407        }
408    }
409
410    impl CommandRunner for MockCommandRunner {
411        fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
412            let cmd_str = format!("{} {}", executable, arguments.join(" "));
413            self.calls.lock().unwrap().push(cmd_str.trim().to_string());
414            if self.should_fail {
415                Err(crate::DodotError::CommandFailed {
416                    command: cmd_str.trim().to_string(),
417                    exit_code: 1,
418                    stderr: "mock failure".to_string(),
419                })
420            } else {
421                Ok(CommandOutput {
422                    exit_code: 0,
423                    stdout: String::new(),
424                    stderr: String::new(),
425                })
426            }
427        }
428    }
429
430    fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
431        let runner = Arc::new(MockCommandRunner::new());
432        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
433        (ds, runner)
434    }
435
436    // ── create_data_link ────────────────────────────────────────
437
438    #[test]
439    fn create_data_link_creates_symlink() {
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        let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
449
450        // Link should be in the handler data dir
451        assert_eq!(
452            link_path,
453            env.paths.handler_data_dir("vim", "symlink").join("vimrc")
454        );
455
456        // Link should point to source
457        env.assert_symlink(&link_path, &source);
458    }
459
460    #[test]
461    fn create_data_link_is_idempotent() {
462        let env = TempEnvironment::builder()
463            .pack("vim")
464            .file("vimrc", "set nocompatible")
465            .done()
466            .build();
467        let (ds, _) = make_datastore(&env);
468
469        let source = env.dotfiles_root.join("vim/vimrc");
470
471        let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
472        let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
473
474        assert_eq!(path1, path2);
475        env.assert_symlink(&path1, &source);
476    }
477
478    #[test]
479    fn create_data_link_replaces_wrong_target() {
480        let env = TempEnvironment::builder()
481            .pack("vim")
482            .file("vimrc", "v1")
483            .file("vimrc-new", "v2")
484            .done()
485            .build();
486        let (ds, _) = make_datastore(&env);
487
488        let source1 = env.dotfiles_root.join("vim/vimrc");
489        let source2 = env.dotfiles_root.join("vim/vimrc-new");
490
491        // Create initial link to source1
492        let link_dir = env.paths.handler_data_dir("vim", "symlink");
493        env.fs.mkdir_all(&link_dir).unwrap();
494        // Manually create a link named "vimrc-new" pointing to source1 (wrong target)
495        let wrong_link = link_dir.join("vimrc-new");
496        env.fs.symlink(&source1, &wrong_link).unwrap();
497
498        // Now create_data_link should fix it to point at source2
499        let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
500        env.assert_symlink(&link_path, &source2);
501    }
502
503    // ── create_user_link ────────────────────────────────────────
504
505    #[test]
506    fn create_user_link_creates_symlink() {
507        let env = TempEnvironment::builder().build();
508        let (ds, _) = make_datastore(&env);
509
510        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
511        let user_path = env.home.join(".vimrc");
512
513        // Create the datastore file so the symlink target exists
514        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
515        env.fs.write_file(&datastore_path, b"link target").unwrap();
516
517        ds.create_user_link(&datastore_path, &user_path).unwrap();
518
519        env.assert_symlink(&user_path, &datastore_path);
520    }
521
522    #[test]
523    fn create_user_link_is_idempotent() {
524        let env = TempEnvironment::builder().build();
525        let (ds, _) = make_datastore(&env);
526
527        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
528        let user_path = env.home.join(".vimrc");
529
530        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
531        env.fs.write_file(&datastore_path, b"x").unwrap();
532
533        ds.create_user_link(&datastore_path, &user_path).unwrap();
534        ds.create_user_link(&datastore_path, &user_path).unwrap();
535
536        env.assert_symlink(&user_path, &datastore_path);
537    }
538
539    #[test]
540    fn create_user_link_conflict_with_regular_file() {
541        let env = TempEnvironment::builder().build();
542        let (ds, _) = make_datastore(&env);
543
544        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
545        let user_path = env.home.join(".vimrc");
546
547        // Create a regular file at the user path
548        env.fs.write_file(&user_path, b"existing content").unwrap();
549
550        let err = ds
551            .create_user_link(&datastore_path, &user_path)
552            .unwrap_err();
553        assert!(
554            matches!(err, crate::DodotError::SymlinkConflict { .. }),
555            "expected SymlinkConflict, got: {err}"
556        );
557    }
558
559    #[test]
560    fn create_user_link_replaces_wrong_symlink() {
561        let env = TempEnvironment::builder().build();
562        let (ds, _) = make_datastore(&env);
563
564        let wrong_target = env.data_dir.join("wrong");
565        let correct_target = env.data_dir.join("correct");
566        let user_path = env.home.join(".vimrc");
567
568        env.fs.mkdir_all(&env.data_dir).unwrap();
569        env.fs.write_file(&wrong_target, b"wrong").unwrap();
570        env.fs.write_file(&correct_target, b"right").unwrap();
571
572        // Create wrong symlink
573        env.fs.symlink(&wrong_target, &user_path).unwrap();
574
575        // Should fix it
576        ds.create_user_link(&correct_target, &user_path).unwrap();
577        env.assert_symlink(&user_path, &correct_target);
578    }
579
580    // ── Double-link chain ───────────────────────────────────────
581
582    #[test]
583    fn full_double_link_chain() {
584        let env = TempEnvironment::builder()
585            .pack("vim")
586            .file("vimrc", "set nocompatible")
587            .done()
588            .build();
589        let (ds, _) = make_datastore(&env);
590
591        let source = env.dotfiles_root.join("vim/vimrc");
592        let user_path = env.home.join(".vimrc");
593
594        // Step 1: data link
595        let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
596
597        // Step 2: user link
598        ds.create_user_link(&datastore_path, &user_path).unwrap();
599
600        // Verify the full chain
601        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
602
603        // Reading through the chain should yield the original content
604        let content = env.fs.read_to_string(&user_path).unwrap();
605        assert_eq!(content, "set nocompatible");
606    }
607
608    // ── run_and_record / has_sentinel ───────────────────────────
609
610    #[test]
611    fn run_and_record_creates_sentinel() {
612        let env = TempEnvironment::builder().build();
613        let (ds, runner) = make_datastore(&env);
614
615        assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
616
617        ds.run_and_record(
618            "vim",
619            "install",
620            "echo",
621            &["hello".into()],
622            "install.sh-abc",
623            false,
624        )
625        .unwrap();
626
627        assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
628        assert_eq!(runner.calls(), vec!["echo hello"]);
629
630        // Sentinel file should contain "completed|..."
631        let sentinel_path = env
632            .paths
633            .handler_data_dir("vim", "install")
634            .join("install.sh-abc");
635        let content = env.fs.read_to_string(&sentinel_path).unwrap();
636        assert!(content.starts_with("completed|"), "got: {content}");
637    }
638
639    #[test]
640    fn run_and_record_is_idempotent() {
641        let env = TempEnvironment::builder().build();
642        let (ds, runner) = make_datastore(&env);
643
644        ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
645            .unwrap();
646        ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
647            .unwrap();
648
649        // Command only ran once
650        assert_eq!(runner.calls(), vec!["echo first"]);
651    }
652
653    #[test]
654    fn run_and_record_propagates_command_failure() {
655        let env = TempEnvironment::builder().build();
656        let runner = Arc::new(MockCommandRunner::failing());
657        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
658
659        let err = ds
660            .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
661            .unwrap_err();
662
663        assert!(
664            matches!(err, crate::DodotError::CommandFailed { .. }),
665            "expected CommandFailed, got: {err}"
666        );
667
668        // No sentinel should be created on failure
669        assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
670    }
671
672    // ── remove_state ────────────────────────────────────────────
673
674    #[test]
675    fn remove_state_clears_handler_dir() {
676        let env = TempEnvironment::builder()
677            .pack("vim")
678            .file("vimrc", "x")
679            .done()
680            .build();
681        let (ds, _) = make_datastore(&env);
682
683        let source = env.dotfiles_root.join("vim/vimrc");
684        ds.create_data_link("vim", "symlink", &source).unwrap();
685        assert!(ds.has_handler_state("vim", "symlink").unwrap());
686
687        ds.remove_state("vim", "symlink").unwrap();
688        env.assert_no_handler_state("vim", "symlink");
689    }
690
691    #[test]
692    fn remove_state_is_noop_when_no_state() {
693        let env = TempEnvironment::builder().build();
694        let (ds, _) = make_datastore(&env);
695
696        // Should not error
697        ds.remove_state("nonexistent", "handler").unwrap();
698    }
699
700    // ── has_handler_state ───────────────────────────────────────
701
702    #[test]
703    fn has_handler_state_false_when_no_dir() {
704        let env = TempEnvironment::builder().build();
705        let (ds, _) = make_datastore(&env);
706
707        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
708    }
709
710    #[test]
711    fn has_handler_state_false_when_empty_dir() {
712        let env = TempEnvironment::builder().build();
713        let (ds, _) = make_datastore(&env);
714
715        let dir = env.paths.handler_data_dir("vim", "symlink");
716        env.fs.mkdir_all(&dir).unwrap();
717
718        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
719    }
720
721    #[test]
722    fn has_handler_state_true_when_entries_exist() {
723        let env = TempEnvironment::builder()
724            .pack("vim")
725            .file("vimrc", "x")
726            .done()
727            .build();
728        let (ds, _) = make_datastore(&env);
729
730        let source = env.dotfiles_root.join("vim/vimrc");
731        ds.create_data_link("vim", "symlink", &source).unwrap();
732
733        assert!(ds.has_handler_state("vim", "symlink").unwrap());
734    }
735
736    // ── list_pack_handlers ──────────────────────────────────────
737
738    #[test]
739    fn list_pack_handlers_returns_handler_dirs() {
740        let env = TempEnvironment::builder()
741            .pack("vim")
742            .file("vimrc", "x")
743            .file("aliases.sh", "y")
744            .done()
745            .build();
746        let (ds, _) = make_datastore(&env);
747
748        let source1 = env.dotfiles_root.join("vim/vimrc");
749        let source2 = env.dotfiles_root.join("vim/aliases.sh");
750        ds.create_data_link("vim", "symlink", &source1).unwrap();
751        ds.create_data_link("vim", "shell", &source2).unwrap();
752
753        let mut handlers = ds.list_pack_handlers("vim").unwrap();
754        handlers.sort();
755        assert_eq!(handlers, vec!["shell", "symlink"]);
756    }
757
758    #[test]
759    fn list_pack_handlers_empty_when_no_pack_state() {
760        let env = TempEnvironment::builder().build();
761        let (ds, _) = make_datastore(&env);
762
763        let handlers = ds.list_pack_handlers("nonexistent").unwrap();
764        assert!(handlers.is_empty());
765    }
766
767    // ── list_handler_sentinels ──────────────────────────────────
768
769    #[test]
770    fn list_handler_sentinels_returns_file_names() {
771        let env = TempEnvironment::builder().build();
772        let (ds, _) = make_datastore(&env);
773
774        ds.run_and_record(
775            "vim",
776            "install",
777            "echo",
778            &["a".into()],
779            "install.sh-aaa",
780            false,
781        )
782        .unwrap();
783        ds.run_and_record(
784            "vim",
785            "install",
786            "echo",
787            &["b".into()],
788            "install.sh-bbb",
789            false,
790        )
791        .unwrap();
792
793        let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
794        sentinels.sort();
795        assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
796    }
797
798    #[test]
799    fn list_handler_sentinels_empty_when_no_state() {
800        let env = TempEnvironment::builder().build();
801        let (ds, _) = make_datastore(&env);
802
803        let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
804        assert!(sentinels.is_empty());
805    }
806
807    // ── write_rendered_file ───────────────────────────────────────
808
809    #[test]
810    fn write_rendered_file_creates_regular_file() {
811        let env = TempEnvironment::builder().build();
812        let (ds, _) = make_datastore(&env);
813
814        let path = ds
815            .write_rendered_file("app", "preprocessed", "config.toml", b"host = localhost")
816            .unwrap();
817
818        assert!(env.fs.exists(&path));
819        assert!(!env.fs.is_symlink(&path));
820        assert_eq!(env.fs.read_to_string(&path).unwrap(), "host = localhost");
821    }
822
823    #[test]
824    fn write_rendered_file_overwrites_existing() {
825        let env = TempEnvironment::builder().build();
826        let (ds, _) = make_datastore(&env);
827
828        let path1 = ds
829            .write_rendered_file("app", "preprocessed", "config.toml", b"version 1")
830            .unwrap();
831        let path2 = ds
832            .write_rendered_file("app", "preprocessed", "config.toml", b"version 2")
833            .unwrap();
834
835        assert_eq!(path1, path2);
836        assert_eq!(env.fs.read_to_string(&path1).unwrap(), "version 2");
837    }
838
839    #[test]
840    fn write_rendered_file_empty_content() {
841        let env = TempEnvironment::builder().build();
842        let (ds, _) = make_datastore(&env);
843
844        let path = ds
845            .write_rendered_file("app", "preprocessed", "empty.conf", b"")
846            .unwrap();
847
848        assert!(env.fs.exists(&path));
849        assert_eq!(env.fs.read_to_string(&path).unwrap(), "");
850    }
851
852    #[test]
853    fn write_rendered_file_binary_content() {
854        let env = TempEnvironment::builder().build();
855        let (ds, _) = make_datastore(&env);
856
857        let binary = vec![0u8, 1, 2, 255, 254, 253];
858        let path = ds
859            .write_rendered_file("app", "preprocessed", "data.bin", &binary)
860            .unwrap();
861
862        assert_eq!(env.fs.read_file(&path).unwrap(), binary);
863    }
864
865    #[test]
866    fn write_rendered_file_creates_parent_dirs() {
867        let env = TempEnvironment::builder().build();
868        let (ds, _) = make_datastore(&env);
869
870        // handler_data_dir doesn't exist yet — write_rendered_file should create it
871        let handler_dir = env.paths.handler_data_dir("newpack", "preprocessed");
872        assert!(!env.fs.exists(&handler_dir));
873
874        let path = ds
875            .write_rendered_file("newpack", "preprocessed", "file.txt", b"hello")
876            .unwrap();
877
878        assert!(env.fs.exists(&path));
879        assert_eq!(env.fs.read_to_string(&path).unwrap(), "hello");
880    }
881
882    #[test]
883    fn write_rendered_file_rejects_absolute_path() {
884        let env = TempEnvironment::builder().build();
885        let (ds, _) = make_datastore(&env);
886
887        let err = ds
888            .write_rendered_file("app", "preprocessed", "/etc/passwd", b"x")
889            .unwrap_err();
890        assert!(
891            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
892            "expected unsafe-path error, got: {err}"
893        );
894    }
895
896    #[test]
897    fn write_rendered_file_rejects_parent_dir() {
898        let env = TempEnvironment::builder().build();
899        let (ds, _) = make_datastore(&env);
900
901        let err = ds
902            .write_rendered_file("app", "preprocessed", "../escape.txt", b"x")
903            .unwrap_err();
904        assert!(
905            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
906            "expected unsafe-path error, got: {err}"
907        );
908    }
909
910    #[test]
911    fn write_rendered_dir_creates_dir() {
912        let env = TempEnvironment::builder().build();
913        let (ds, _) = make_datastore(&env);
914
915        let path = ds
916            .write_rendered_dir("app", "preprocessed", "sub/nested")
917            .unwrap();
918
919        assert!(env.fs.is_dir(&path));
920        assert!(!env.fs.is_symlink(&path));
921    }
922
923    #[test]
924    fn write_rendered_dir_is_idempotent() {
925        let env = TempEnvironment::builder().build();
926        let (ds, _) = make_datastore(&env);
927
928        let p1 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
929        let p2 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
930        assert_eq!(p1, p2);
931        assert!(env.fs.is_dir(&p1));
932    }
933
934    #[test]
935    fn write_rendered_dir_rejects_unsafe_paths() {
936        let env = TempEnvironment::builder().build();
937        let (ds, _) = make_datastore(&env);
938
939        assert!(ds
940            .write_rendered_dir("app", "preprocessed", "/abs")
941            .is_err());
942        assert!(ds
943            .write_rendered_dir("app", "preprocessed", "../esc")
944            .is_err());
945    }
946
947    #[test]
948    fn write_rendered_file_supports_nested_filename() {
949        // A filename containing `/` should be written to the corresponding
950        // nested directory under the handler data dir, creating any needed
951        // parents. This preserves source structure for preprocessor output.
952        let env = TempEnvironment::builder().build();
953        let (ds, _) = make_datastore(&env);
954
955        let path = ds
956            .write_rendered_file("app", "preprocessed", "sub/nested/file.txt", b"deep")
957            .unwrap();
958
959        assert!(env.fs.exists(&path));
960        assert!(!env.fs.is_symlink(&path));
961        assert_eq!(env.fs.read_to_string(&path).unwrap(), "deep");
962        assert!(
963            path.to_string_lossy().contains("sub/nested/file.txt"),
964            "path should contain nested structure: {}",
965            path.display()
966        );
967    }
968
969    // ── Object safety ───────────────────────────────────────────
970
971    #[allow(dead_code)]
972    fn assert_object_safe(_: &dyn DataStore) {}
973}