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
9fn 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
38pub struct FilesystemDataStore {
49 fs: Arc<dyn Fs>,
50 paths: Arc<dyn Pather>,
51 runner: Arc<dyn CommandRunner>,
52}
53
54impl FilesystemDataStore {
55 pub fn new(fs: Arc<dyn Fs>, paths: Arc<dyn Pather>, runner: Arc<dyn CommandRunner>) -> Self {
56 Self { fs, paths, runner }
57 }
58}
59
60impl DataStore for FilesystemDataStore {
61 fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf> {
62 let filename = source_file.file_name().ok_or_else(|| {
63 crate::DodotError::Other(format!(
64 "source file has no filename: {}",
65 source_file.display()
66 ))
67 })?;
68
69 let link_dir = self.paths.handler_data_dir(pack, handler);
70 let link_path = link_dir.join(filename);
71
72 self.fs.mkdir_all(&link_dir)?;
73
74 if self.fs.is_symlink(&link_path) {
76 if let Ok(current_target) = self.fs.readlink(&link_path) {
77 if current_target == source_file {
78 return Ok(link_path);
79 }
80 }
81 self.fs.remove_file(&link_path)?;
83 }
84
85 self.fs.symlink(source_file, &link_path)?;
86 Ok(link_path)
87 }
88
89 fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()> {
90 if let Some(parent) = user_path.parent() {
92 self.fs.mkdir_all(parent)?;
93 }
94
95 if self.fs.is_symlink(user_path) {
97 if let Ok(current_target) = self.fs.readlink(user_path) {
99 if current_target == datastore_path {
100 return Ok(()); }
102 }
103 self.fs.remove_file(user_path)?;
105 } else if self.fs.exists(user_path) {
106 return Err(crate::DodotError::SymlinkConflict {
108 path: user_path.to_path_buf(),
109 });
110 }
111
112 self.fs.symlink(datastore_path, user_path)
113 }
114
115 fn run_and_record(
116 &self,
117 pack: &str,
118 handler: &str,
119 executable: &str,
120 arguments: &[String],
121 sentinel: &str,
122 force: bool,
123 ) -> Result<()> {
124 if !force && self.has_sentinel(pack, handler, sentinel)? {
126 return Ok(());
127 }
128
129 self.runner.run(executable, arguments)?;
131
132 let sentinel_dir = self.paths.handler_data_dir(pack, handler);
134 self.fs.mkdir_all(&sentinel_dir)?;
135
136 let sentinel_path = sentinel_dir.join(sentinel);
137 let timestamp = std::time::SystemTime::now()
138 .duration_since(std::time::UNIX_EPOCH)
139 .unwrap_or_default()
140 .as_secs();
141 let content = format!("completed|{timestamp}");
142 self.fs.write_file(&sentinel_path, content.as_bytes())
143 }
144
145 fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool> {
146 let sentinel_path = self.paths.handler_data_dir(pack, handler).join(sentinel);
147 Ok(self.fs.exists(&sentinel_path))
148 }
149
150 fn remove_state(&self, pack: &str, handler: &str) -> Result<()> {
151 let state_dir = self.paths.handler_data_dir(pack, handler);
152 if !self.fs.exists(&state_dir) {
153 return Ok(());
154 }
155 self.fs.remove_dir_all(&state_dir)
156 }
157
158 fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool> {
159 let state_dir = self.paths.handler_data_dir(pack, handler);
160 if !self.fs.exists(&state_dir) {
161 return Ok(false);
162 }
163 let entries = self.fs.read_dir(&state_dir)?;
164 Ok(!entries.is_empty())
165 }
166
167 fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>> {
168 let pack_dir = self.paths.pack_data_dir(pack);
169 if !self.fs.exists(&pack_dir) {
170 return Ok(Vec::new());
171 }
172 let entries = self.fs.read_dir(&pack_dir)?;
173 Ok(entries
174 .into_iter()
175 .filter(|e| e.is_dir)
176 .map(|e| e.name)
177 .collect())
178 }
179
180 fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>> {
181 let handler_dir = self.paths.handler_data_dir(pack, handler);
182 if !self.fs.exists(&handler_dir) {
183 return Ok(Vec::new());
184 }
185 let entries = self.fs.read_dir(&handler_dir)?;
186 Ok(entries
187 .into_iter()
188 .filter(|e| e.is_file)
189 .map(|e| e.name)
190 .collect())
191 }
192
193 fn write_rendered_file(
194 &self,
195 pack: &str,
196 handler: &str,
197 filename: &str,
198 content: &[u8],
199 ) -> Result<PathBuf> {
200 let dir = self.paths.handler_data_dir(pack, handler);
201 let relative = validate_safe_relative(filename, &dir)?;
202 let path = dir.join(&relative);
203 if let Some(parent) = path.parent() {
205 self.fs.mkdir_all(parent)?;
206 } else {
207 self.fs.mkdir_all(&dir)?;
208 }
209 self.fs.write_file(&path, content)?;
210 Ok(path)
211 }
212
213 fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf> {
214 let dir = self.paths.handler_data_dir(pack, handler);
215 let rel = validate_safe_relative(relative, &dir)?;
216 let path = dir.join(&rel);
217 self.fs.mkdir_all(&path)?;
218 Ok(path)
219 }
220
221 fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
222 self.paths.handler_data_dir(pack, handler).join(sentinel)
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::datastore::{CommandOutput, CommandRunner};
230 use crate::testing::TempEnvironment;
231 use std::sync::Mutex;
232
233 struct MockCommandRunner {
236 calls: Mutex<Vec<String>>,
237 should_fail: bool,
238 }
239
240 impl MockCommandRunner {
241 fn new() -> Self {
242 Self {
243 calls: Mutex::new(Vec::new()),
244 should_fail: false,
245 }
246 }
247
248 fn failing() -> Self {
249 Self {
250 calls: Mutex::new(Vec::new()),
251 should_fail: true,
252 }
253 }
254
255 fn calls(&self) -> Vec<String> {
256 self.calls.lock().unwrap().clone()
257 }
258 }
259
260 impl CommandRunner for MockCommandRunner {
261 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
262 let cmd_str = format!("{} {}", executable, arguments.join(" "));
263 self.calls.lock().unwrap().push(cmd_str.trim().to_string());
264 if self.should_fail {
265 Err(crate::DodotError::CommandFailed {
266 command: cmd_str.trim().to_string(),
267 exit_code: 1,
268 stderr: "mock failure".to_string(),
269 })
270 } else {
271 Ok(CommandOutput {
272 exit_code: 0,
273 stdout: String::new(),
274 stderr: String::new(),
275 })
276 }
277 }
278 }
279
280 fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
281 let runner = Arc::new(MockCommandRunner::new());
282 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
283 (ds, runner)
284 }
285
286 #[test]
289 fn create_data_link_creates_symlink() {
290 let env = TempEnvironment::builder()
291 .pack("vim")
292 .file("vimrc", "set nocompatible")
293 .done()
294 .build();
295 let (ds, _) = make_datastore(&env);
296
297 let source = env.dotfiles_root.join("vim/vimrc");
298 let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
299
300 assert_eq!(
302 link_path,
303 env.paths.handler_data_dir("vim", "symlink").join("vimrc")
304 );
305
306 env.assert_symlink(&link_path, &source);
308 }
309
310 #[test]
311 fn create_data_link_is_idempotent() {
312 let env = TempEnvironment::builder()
313 .pack("vim")
314 .file("vimrc", "set nocompatible")
315 .done()
316 .build();
317 let (ds, _) = make_datastore(&env);
318
319 let source = env.dotfiles_root.join("vim/vimrc");
320
321 let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
322 let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
323
324 assert_eq!(path1, path2);
325 env.assert_symlink(&path1, &source);
326 }
327
328 #[test]
329 fn create_data_link_replaces_wrong_target() {
330 let env = TempEnvironment::builder()
331 .pack("vim")
332 .file("vimrc", "v1")
333 .file("vimrc-new", "v2")
334 .done()
335 .build();
336 let (ds, _) = make_datastore(&env);
337
338 let source1 = env.dotfiles_root.join("vim/vimrc");
339 let source2 = env.dotfiles_root.join("vim/vimrc-new");
340
341 let link_dir = env.paths.handler_data_dir("vim", "symlink");
343 env.fs.mkdir_all(&link_dir).unwrap();
344 let wrong_link = link_dir.join("vimrc-new");
346 env.fs.symlink(&source1, &wrong_link).unwrap();
347
348 let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
350 env.assert_symlink(&link_path, &source2);
351 }
352
353 #[test]
356 fn create_user_link_creates_symlink() {
357 let env = TempEnvironment::builder().build();
358 let (ds, _) = make_datastore(&env);
359
360 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
361 let user_path = env.home.join(".vimrc");
362
363 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
365 env.fs.write_file(&datastore_path, b"link target").unwrap();
366
367 ds.create_user_link(&datastore_path, &user_path).unwrap();
368
369 env.assert_symlink(&user_path, &datastore_path);
370 }
371
372 #[test]
373 fn create_user_link_is_idempotent() {
374 let env = TempEnvironment::builder().build();
375 let (ds, _) = make_datastore(&env);
376
377 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
378 let user_path = env.home.join(".vimrc");
379
380 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
381 env.fs.write_file(&datastore_path, b"x").unwrap();
382
383 ds.create_user_link(&datastore_path, &user_path).unwrap();
384 ds.create_user_link(&datastore_path, &user_path).unwrap();
385
386 env.assert_symlink(&user_path, &datastore_path);
387 }
388
389 #[test]
390 fn create_user_link_conflict_with_regular_file() {
391 let env = TempEnvironment::builder().build();
392 let (ds, _) = make_datastore(&env);
393
394 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
395 let user_path = env.home.join(".vimrc");
396
397 env.fs.write_file(&user_path, b"existing content").unwrap();
399
400 let err = ds
401 .create_user_link(&datastore_path, &user_path)
402 .unwrap_err();
403 assert!(
404 matches!(err, crate::DodotError::SymlinkConflict { .. }),
405 "expected SymlinkConflict, got: {err}"
406 );
407 }
408
409 #[test]
410 fn create_user_link_replaces_wrong_symlink() {
411 let env = TempEnvironment::builder().build();
412 let (ds, _) = make_datastore(&env);
413
414 let wrong_target = env.data_dir.join("wrong");
415 let correct_target = env.data_dir.join("correct");
416 let user_path = env.home.join(".vimrc");
417
418 env.fs.mkdir_all(&env.data_dir).unwrap();
419 env.fs.write_file(&wrong_target, b"wrong").unwrap();
420 env.fs.write_file(&correct_target, b"right").unwrap();
421
422 env.fs.symlink(&wrong_target, &user_path).unwrap();
424
425 ds.create_user_link(&correct_target, &user_path).unwrap();
427 env.assert_symlink(&user_path, &correct_target);
428 }
429
430 #[test]
433 fn full_double_link_chain() {
434 let env = TempEnvironment::builder()
435 .pack("vim")
436 .file("vimrc", "set nocompatible")
437 .done()
438 .build();
439 let (ds, _) = make_datastore(&env);
440
441 let source = env.dotfiles_root.join("vim/vimrc");
442 let user_path = env.home.join(".vimrc");
443
444 let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
446
447 ds.create_user_link(&datastore_path, &user_path).unwrap();
449
450 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
452
453 let content = env.fs.read_to_string(&user_path).unwrap();
455 assert_eq!(content, "set nocompatible");
456 }
457
458 #[test]
461 fn run_and_record_creates_sentinel() {
462 let env = TempEnvironment::builder().build();
463 let (ds, runner) = make_datastore(&env);
464
465 assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
466
467 ds.run_and_record(
468 "vim",
469 "install",
470 "echo",
471 &["hello".into()],
472 "install.sh-abc",
473 false,
474 )
475 .unwrap();
476
477 assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
478 assert_eq!(runner.calls(), vec!["echo hello"]);
479
480 let sentinel_path = env
482 .paths
483 .handler_data_dir("vim", "install")
484 .join("install.sh-abc");
485 let content = env.fs.read_to_string(&sentinel_path).unwrap();
486 assert!(content.starts_with("completed|"), "got: {content}");
487 }
488
489 #[test]
490 fn run_and_record_is_idempotent() {
491 let env = TempEnvironment::builder().build();
492 let (ds, runner) = make_datastore(&env);
493
494 ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
495 .unwrap();
496 ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
497 .unwrap();
498
499 assert_eq!(runner.calls(), vec!["echo first"]);
501 }
502
503 #[test]
504 fn run_and_record_propagates_command_failure() {
505 let env = TempEnvironment::builder().build();
506 let runner = Arc::new(MockCommandRunner::failing());
507 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
508
509 let err = ds
510 .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
511 .unwrap_err();
512
513 assert!(
514 matches!(err, crate::DodotError::CommandFailed { .. }),
515 "expected CommandFailed, got: {err}"
516 );
517
518 assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
520 }
521
522 #[test]
525 fn remove_state_clears_handler_dir() {
526 let env = TempEnvironment::builder()
527 .pack("vim")
528 .file("vimrc", "x")
529 .done()
530 .build();
531 let (ds, _) = make_datastore(&env);
532
533 let source = env.dotfiles_root.join("vim/vimrc");
534 ds.create_data_link("vim", "symlink", &source).unwrap();
535 assert!(ds.has_handler_state("vim", "symlink").unwrap());
536
537 ds.remove_state("vim", "symlink").unwrap();
538 env.assert_no_handler_state("vim", "symlink");
539 }
540
541 #[test]
542 fn remove_state_is_noop_when_no_state() {
543 let env = TempEnvironment::builder().build();
544 let (ds, _) = make_datastore(&env);
545
546 ds.remove_state("nonexistent", "handler").unwrap();
548 }
549
550 #[test]
553 fn has_handler_state_false_when_no_dir() {
554 let env = TempEnvironment::builder().build();
555 let (ds, _) = make_datastore(&env);
556
557 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
558 }
559
560 #[test]
561 fn has_handler_state_false_when_empty_dir() {
562 let env = TempEnvironment::builder().build();
563 let (ds, _) = make_datastore(&env);
564
565 let dir = env.paths.handler_data_dir("vim", "symlink");
566 env.fs.mkdir_all(&dir).unwrap();
567
568 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
569 }
570
571 #[test]
572 fn has_handler_state_true_when_entries_exist() {
573 let env = TempEnvironment::builder()
574 .pack("vim")
575 .file("vimrc", "x")
576 .done()
577 .build();
578 let (ds, _) = make_datastore(&env);
579
580 let source = env.dotfiles_root.join("vim/vimrc");
581 ds.create_data_link("vim", "symlink", &source).unwrap();
582
583 assert!(ds.has_handler_state("vim", "symlink").unwrap());
584 }
585
586 #[test]
589 fn list_pack_handlers_returns_handler_dirs() {
590 let env = TempEnvironment::builder()
591 .pack("vim")
592 .file("vimrc", "x")
593 .file("aliases.sh", "y")
594 .done()
595 .build();
596 let (ds, _) = make_datastore(&env);
597
598 let source1 = env.dotfiles_root.join("vim/vimrc");
599 let source2 = env.dotfiles_root.join("vim/aliases.sh");
600 ds.create_data_link("vim", "symlink", &source1).unwrap();
601 ds.create_data_link("vim", "shell", &source2).unwrap();
602
603 let mut handlers = ds.list_pack_handlers("vim").unwrap();
604 handlers.sort();
605 assert_eq!(handlers, vec!["shell", "symlink"]);
606 }
607
608 #[test]
609 fn list_pack_handlers_empty_when_no_pack_state() {
610 let env = TempEnvironment::builder().build();
611 let (ds, _) = make_datastore(&env);
612
613 let handlers = ds.list_pack_handlers("nonexistent").unwrap();
614 assert!(handlers.is_empty());
615 }
616
617 #[test]
620 fn list_handler_sentinels_returns_file_names() {
621 let env = TempEnvironment::builder().build();
622 let (ds, _) = make_datastore(&env);
623
624 ds.run_and_record(
625 "vim",
626 "install",
627 "echo",
628 &["a".into()],
629 "install.sh-aaa",
630 false,
631 )
632 .unwrap();
633 ds.run_and_record(
634 "vim",
635 "install",
636 "echo",
637 &["b".into()],
638 "install.sh-bbb",
639 false,
640 )
641 .unwrap();
642
643 let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
644 sentinels.sort();
645 assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
646 }
647
648 #[test]
649 fn list_handler_sentinels_empty_when_no_state() {
650 let env = TempEnvironment::builder().build();
651 let (ds, _) = make_datastore(&env);
652
653 let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
654 assert!(sentinels.is_empty());
655 }
656
657 #[test]
660 fn write_rendered_file_creates_regular_file() {
661 let env = TempEnvironment::builder().build();
662 let (ds, _) = make_datastore(&env);
663
664 let path = ds
665 .write_rendered_file("app", "preprocessed", "config.toml", b"host = localhost")
666 .unwrap();
667
668 assert!(env.fs.exists(&path));
669 assert!(!env.fs.is_symlink(&path));
670 assert_eq!(env.fs.read_to_string(&path).unwrap(), "host = localhost");
671 }
672
673 #[test]
674 fn write_rendered_file_overwrites_existing() {
675 let env = TempEnvironment::builder().build();
676 let (ds, _) = make_datastore(&env);
677
678 let path1 = ds
679 .write_rendered_file("app", "preprocessed", "config.toml", b"version 1")
680 .unwrap();
681 let path2 = ds
682 .write_rendered_file("app", "preprocessed", "config.toml", b"version 2")
683 .unwrap();
684
685 assert_eq!(path1, path2);
686 assert_eq!(env.fs.read_to_string(&path1).unwrap(), "version 2");
687 }
688
689 #[test]
690 fn write_rendered_file_empty_content() {
691 let env = TempEnvironment::builder().build();
692 let (ds, _) = make_datastore(&env);
693
694 let path = ds
695 .write_rendered_file("app", "preprocessed", "empty.conf", b"")
696 .unwrap();
697
698 assert!(env.fs.exists(&path));
699 assert_eq!(env.fs.read_to_string(&path).unwrap(), "");
700 }
701
702 #[test]
703 fn write_rendered_file_binary_content() {
704 let env = TempEnvironment::builder().build();
705 let (ds, _) = make_datastore(&env);
706
707 let binary = vec![0u8, 1, 2, 255, 254, 253];
708 let path = ds
709 .write_rendered_file("app", "preprocessed", "data.bin", &binary)
710 .unwrap();
711
712 assert_eq!(env.fs.read_file(&path).unwrap(), binary);
713 }
714
715 #[test]
716 fn write_rendered_file_creates_parent_dirs() {
717 let env = TempEnvironment::builder().build();
718 let (ds, _) = make_datastore(&env);
719
720 let handler_dir = env.paths.handler_data_dir("newpack", "preprocessed");
722 assert!(!env.fs.exists(&handler_dir));
723
724 let path = ds
725 .write_rendered_file("newpack", "preprocessed", "file.txt", b"hello")
726 .unwrap();
727
728 assert!(env.fs.exists(&path));
729 assert_eq!(env.fs.read_to_string(&path).unwrap(), "hello");
730 }
731
732 #[test]
733 fn write_rendered_file_rejects_absolute_path() {
734 let env = TempEnvironment::builder().build();
735 let (ds, _) = make_datastore(&env);
736
737 let err = ds
738 .write_rendered_file("app", "preprocessed", "/etc/passwd", b"x")
739 .unwrap_err();
740 assert!(
741 matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
742 "expected unsafe-path error, got: {err}"
743 );
744 }
745
746 #[test]
747 fn write_rendered_file_rejects_parent_dir() {
748 let env = TempEnvironment::builder().build();
749 let (ds, _) = make_datastore(&env);
750
751 let err = ds
752 .write_rendered_file("app", "preprocessed", "../escape.txt", b"x")
753 .unwrap_err();
754 assert!(
755 matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
756 "expected unsafe-path error, got: {err}"
757 );
758 }
759
760 #[test]
761 fn write_rendered_dir_creates_dir() {
762 let env = TempEnvironment::builder().build();
763 let (ds, _) = make_datastore(&env);
764
765 let path = ds
766 .write_rendered_dir("app", "preprocessed", "sub/nested")
767 .unwrap();
768
769 assert!(env.fs.is_dir(&path));
770 assert!(!env.fs.is_symlink(&path));
771 }
772
773 #[test]
774 fn write_rendered_dir_is_idempotent() {
775 let env = TempEnvironment::builder().build();
776 let (ds, _) = make_datastore(&env);
777
778 let p1 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
779 let p2 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
780 assert_eq!(p1, p2);
781 assert!(env.fs.is_dir(&p1));
782 }
783
784 #[test]
785 fn write_rendered_dir_rejects_unsafe_paths() {
786 let env = TempEnvironment::builder().build();
787 let (ds, _) = make_datastore(&env);
788
789 assert!(ds
790 .write_rendered_dir("app", "preprocessed", "/abs")
791 .is_err());
792 assert!(ds
793 .write_rendered_dir("app", "preprocessed", "../esc")
794 .is_err());
795 }
796
797 #[test]
798 fn write_rendered_file_supports_nested_filename() {
799 let env = TempEnvironment::builder().build();
803 let (ds, _) = make_datastore(&env);
804
805 let path = ds
806 .write_rendered_file("app", "preprocessed", "sub/nested/file.txt", b"deep")
807 .unwrap();
808
809 assert!(env.fs.exists(&path));
810 assert!(!env.fs.is_symlink(&path));
811 assert_eq!(env.fs.read_to_string(&path).unwrap(), "deep");
812 assert!(
813 path.to_string_lossy().contains("sub/nested/file.txt"),
814 "path should contain nested structure: {}",
815 path.display()
816 );
817 }
818
819 #[allow(dead_code)]
822 fn assert_object_safe(_: &dyn DataStore) {}
823}