1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::fs::Fs;
14use crate::packs::Pack;
15use crate::Result;
16
17#[derive(Debug, Clone, Serialize)]
21pub struct Rule {
22 pub pattern: String,
25
26 pub handler: String,
28
29 pub priority: i32,
31
32 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34 pub options: HashMap<String, String>,
35}
36
37#[derive(Debug, Clone)]
39pub struct PackEntry {
40 pub relative_path: PathBuf,
42 pub absolute_path: PathBuf,
44 pub is_dir: bool,
46}
47
48#[derive(Debug, Clone, Serialize)]
50pub struct RuleMatch {
51 pub relative_path: PathBuf,
53
54 pub absolute_path: PathBuf,
56
57 pub pack: String,
59
60 pub handler: String,
62
63 pub is_dir: bool,
65
66 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
68 pub options: HashMap<String, String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub preprocessor_source: Option<PathBuf>,
74}
75
76pub fn group_by_handler(matches: &[RuleMatch]) -> HashMap<String, Vec<RuleMatch>> {
80 let mut groups: HashMap<String, Vec<RuleMatch>> = HashMap::new();
81 for m in matches {
82 groups.entry(m.handler.clone()).or_default().push(m.clone());
83 }
84 groups
85}
86
87pub fn handler_execution_order(groups: &HashMap<String, Vec<RuleMatch>>) -> Vec<String> {
93 use crate::handlers::{
94 HandlerCategory, HANDLER_HOMEBREW, HANDLER_INSTALL, HANDLER_PATH, HANDLER_SHELL,
95 HANDLER_SYMLINK,
96 };
97
98 fn category_of(name: &str) -> HandlerCategory {
99 match name {
100 HANDLER_INSTALL | HANDLER_HOMEBREW => HandlerCategory::CodeExecution,
101 HANDLER_SYMLINK | HANDLER_SHELL | HANDLER_PATH => HandlerCategory::Configuration,
102 _ => HandlerCategory::Configuration,
103 }
104 }
105
106 let mut names: Vec<String> = groups.keys().cloned().collect();
107 names.sort_by(|a, b| {
108 let cat_a = category_of(a);
109 let cat_b = category_of(b);
110 match (cat_a, cat_b) {
111 (HandlerCategory::CodeExecution, HandlerCategory::Configuration) => {
112 std::cmp::Ordering::Less
113 }
114 (HandlerCategory::Configuration, HandlerCategory::CodeExecution) => {
115 std::cmp::Ordering::Greater
116 }
117 _ => a.cmp(b),
118 }
119 });
120 names
121}
122
123#[derive(Debug)]
127enum CompiledPattern {
128 Exact(String),
130 Glob(glob::Pattern),
132 Directory(String),
135}
136
137#[derive(Debug)]
139struct CompiledRule {
140 pattern: CompiledPattern,
141 is_exclusion: bool,
142 handler: String,
143 priority: i32,
144 options: HashMap<String, String>,
145}
146
147fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
148 rules
149 .iter()
150 .map(|rule| {
151 let (raw_pattern, is_exclusion) = if let Some(rest) = rule.pattern.strip_prefix('!') {
152 (rest.to_string(), true)
153 } else {
154 (rule.pattern.clone(), false)
155 };
156
157 let pattern = if raw_pattern.ends_with('/') {
158 let dir_name = raw_pattern.trim_end_matches('/').to_string();
160 CompiledPattern::Directory(dir_name)
161 } else if raw_pattern.contains('*')
162 || raw_pattern.contains('?')
163 || raw_pattern.contains('[')
164 {
165 match glob::Pattern::new(&raw_pattern) {
167 Ok(p) => CompiledPattern::Glob(p),
168 Err(_) => CompiledPattern::Exact(raw_pattern),
169 }
170 } else {
171 CompiledPattern::Exact(raw_pattern)
172 };
173
174 CompiledRule {
175 pattern,
176 is_exclusion,
177 handler: rule.handler.clone(),
178 priority: rule.priority,
179 options: rule.options.clone(),
180 }
181 })
182 .collect()
183}
184
185fn matches_entry(pattern: &CompiledPattern, filename: &str, is_dir: bool) -> bool {
186 match pattern {
187 CompiledPattern::Exact(name) => filename == name,
188 CompiledPattern::Glob(glob) => glob.matches(filename),
189 CompiledPattern::Directory(dir_name) => is_dir && filename == dir_name,
190 }
191}
192
193pub const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
197
198pub fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool {
206 SPECIAL_FILES.contains(&name) || is_ignored(name, ignore_patterns)
207}
208
209pub struct Scanner<'a> {
211 fs: &'a dyn Fs,
212}
213
214impl<'a> Scanner<'a> {
215 pub fn new(fs: &'a dyn Fs) -> Self {
216 Self { fs }
217 }
218
219 pub fn scan_pack(
229 &self,
230 pack: &Pack,
231 rules: &[Rule],
232 pack_ignore: &[String],
233 ) -> Result<Vec<RuleMatch>> {
234 let entries = self.walk_pack(&pack.path, pack_ignore)?;
235 Ok(self.match_entries(&entries, rules, &pack.name))
236 }
237
238 pub fn walk_pack(
255 &self,
256 pack_path: &Path,
257 ignore_patterns: &[String],
258 ) -> Result<Vec<PackEntry>> {
259 let mut results = Vec::new();
260 self.list_top_level(pack_path, ignore_patterns, &mut results)?;
261 Ok(results)
262 }
263
264 pub fn walk_pack_recursive(
266 &self,
267 pack_path: &Path,
268 ignore_patterns: &[String],
269 ) -> Result<Vec<PackEntry>> {
270 let mut results = Vec::new();
271 self.walk_dir(pack_path, pack_path, ignore_patterns, &mut results)?;
272 Ok(results)
273 }
274
275 pub fn match_entries(
281 &self,
282 entries: &[PackEntry],
283 rules: &[Rule],
284 pack_name: &str,
285 ) -> Vec<RuleMatch> {
286 let compiled = compile_rules(rules);
287 let mut matches = Vec::new();
288
289 for entry in entries {
290 let filename = entry
291 .relative_path
292 .file_name()
293 .map(|n| n.to_string_lossy().to_string())
294 .unwrap_or_default();
295
296 if let Some(rule_match) = match_file(
297 &compiled,
298 &filename,
299 entry.is_dir,
300 &entry.relative_path,
301 &entry.absolute_path,
302 pack_name,
303 ) {
304 matches.push(rule_match);
305 }
306 }
307
308 matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
309 matches
310 }
311
312 fn list_top_level(
315 &self,
316 pack_path: &Path,
317 ignore_patterns: &[String],
318 results: &mut Vec<PackEntry>,
319 ) -> Result<()> {
320 let entries = self.fs.read_dir(pack_path)?;
321
322 for entry in entries {
323 let name = &entry.name;
324
325 if name.starts_with('.') && name != ".config" {
326 continue;
327 }
328 if SPECIAL_FILES.contains(&name.as_str()) {
329 continue;
330 }
331 if is_ignored(name, ignore_patterns) {
332 continue;
333 }
334
335 let rel_path = entry
336 .path
337 .strip_prefix(pack_path)
338 .unwrap_or(&entry.path)
339 .to_path_buf();
340
341 results.push(PackEntry {
342 relative_path: rel_path,
343 absolute_path: entry.path.clone(),
344 is_dir: entry.is_dir,
345 });
346 }
347
348 Ok(())
349 }
350
351 fn walk_dir(
352 &self,
353 base: &Path,
354 dir: &Path,
355 ignore_patterns: &[String],
356 results: &mut Vec<PackEntry>,
357 ) -> Result<()> {
358 let entries = self.fs.read_dir(dir)?;
359
360 for entry in entries {
361 let name = &entry.name;
362
363 if name.starts_with('.') && name != ".config" {
365 continue;
366 }
367
368 if SPECIAL_FILES.contains(&name.as_str()) {
370 continue;
371 }
372
373 if is_ignored(name, ignore_patterns) {
375 continue;
376 }
377
378 let rel_path = entry
379 .path
380 .strip_prefix(base)
381 .unwrap_or(&entry.path)
382 .to_path_buf();
383
384 if entry.is_dir {
385 results.push(PackEntry {
387 relative_path: rel_path.clone(),
388 absolute_path: entry.path.clone(),
389 is_dir: true,
390 });
391 self.walk_dir(base, &entry.path, ignore_patterns, results)?;
393 } else {
394 results.push(PackEntry {
395 relative_path: rel_path,
396 absolute_path: entry.path.clone(),
397 is_dir: false,
398 });
399 }
400 }
401
402 Ok(())
403 }
404}
405
406fn match_file(
411 compiled: &[CompiledRule],
412 filename: &str,
413 is_dir: bool,
414 rel_path: &Path,
415 abs_path: &Path,
416 pack: &str,
417) -> Option<RuleMatch> {
418 for rule in compiled {
420 if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
421 return None;
422 }
423 }
424
425 let mut inclusion_rules: Vec<&CompiledRule> =
428 compiled.iter().filter(|r| !r.is_exclusion).collect();
429 inclusion_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
430
431 for rule in inclusion_rules {
432 if matches_entry(&rule.pattern, filename, is_dir) {
433 return Some(RuleMatch {
434 relative_path: rel_path.to_path_buf(),
435 absolute_path: abs_path.to_path_buf(),
436 pack: pack.to_string(),
437 handler: rule.handler.clone(),
438 is_dir,
439 options: rule.options.clone(),
440 preprocessor_source: None,
441 });
442 }
443 }
444
445 None
446}
447
448fn is_ignored(name: &str, patterns: &[String]) -> bool {
449 for pattern in patterns {
450 if let Ok(glob) = glob::Pattern::new(pattern) {
451 if glob.matches(name) {
452 return true;
453 }
454 }
455 if name == pattern {
457 return true;
458 }
459 }
460 false
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::handlers::HandlerConfig;
467 use crate::testing::TempEnvironment;
468
469 fn make_pack(name: &str, path: PathBuf) -> Pack {
470 Pack {
471 name: name.into(),
472 path,
473 config: HandlerConfig::default(),
474 }
475 }
476
477 fn default_rules() -> Vec<Rule> {
478 vec![
479 Rule {
480 pattern: "bin/".into(),
481 handler: "path".into(),
482 priority: 10,
483 options: HashMap::new(),
484 },
485 Rule {
486 pattern: "install.sh".into(),
487 handler: "install".into(),
488 priority: 10,
489 options: HashMap::new(),
490 },
491 Rule {
492 pattern: "aliases.sh".into(),
493 handler: "shell".into(),
494 priority: 10,
495 options: HashMap::new(),
496 },
497 Rule {
498 pattern: "profile.sh".into(),
499 handler: "shell".into(),
500 priority: 10,
501 options: HashMap::new(),
502 },
503 Rule {
504 pattern: "Brewfile".into(),
505 handler: "homebrew".into(),
506 priority: 10,
507 options: HashMap::new(),
508 },
509 Rule {
510 pattern: "*".into(),
511 handler: "symlink".into(),
512 priority: 0,
513 options: HashMap::new(),
514 },
515 ]
516 }
517
518 #[test]
521 fn exact_match() {
522 let compiled = compile_rules(&[Rule {
523 pattern: "install.sh".into(),
524 handler: "install".into(),
525 priority: 0,
526 options: HashMap::new(),
527 }]);
528 assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
529 assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
530 }
531
532 #[test]
533 fn glob_match() {
534 let compiled = compile_rules(&[Rule {
535 pattern: "*.sh".into(),
536 handler: "shell".into(),
537 priority: 0,
538 options: HashMap::new(),
539 }]);
540 assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
541 assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
542 assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
543 }
544
545 #[test]
546 fn directory_match() {
547 let compiled = compile_rules(&[Rule {
548 pattern: "bin/".into(),
549 handler: "path".into(),
550 priority: 0,
551 options: HashMap::new(),
552 }]);
553 assert!(matches_entry(&compiled[0].pattern, "bin", true));
554 assert!(!matches_entry(&compiled[0].pattern, "bin", false));
555 assert!(!matches_entry(&compiled[0].pattern, "lib", true));
556 }
557
558 #[test]
559 fn exclusion_prefix() {
560 let compiled = compile_rules(&[Rule {
561 pattern: "!*.tmp".into(),
562 handler: "exclude".into(),
563 priority: 100,
564 options: HashMap::new(),
565 }]);
566 assert!(compiled[0].is_exclusion);
567 assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
568 }
569
570 #[test]
571 fn catchall_matches_everything() {
572 let compiled = compile_rules(&[Rule {
573 pattern: "*".into(),
574 handler: "symlink".into(),
575 priority: 0,
576 options: HashMap::new(),
577 }]);
578 assert!(matches_entry(&compiled[0].pattern, "anything", false));
579 assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
580 }
581
582 #[test]
585 fn scan_pack_basic() {
586 let env = TempEnvironment::builder()
587 .pack("vim")
588 .file("vimrc", "set nocompatible")
589 .file("gvimrc", "set guifont=Mono")
590 .file("aliases.sh", "alias vi=vim")
591 .file("install.sh", "#!/bin/sh\necho setup")
592 .done()
593 .build();
594
595 let scanner = Scanner::new(env.fs.as_ref());
596 let pack = make_pack("vim", env.dotfiles_root.join("vim"));
597 let rules = default_rules();
598
599 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
600
601 let handler_map: HashMap<String, Vec<String>> = {
602 let mut m: HashMap<String, Vec<String>> = HashMap::new();
603 for rm in &matches {
604 m.entry(rm.handler.clone())
605 .or_default()
606 .push(rm.relative_path.to_string_lossy().to_string());
607 }
608 m
609 };
610
611 assert_eq!(handler_map["install"], vec!["install.sh"]);
612 assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
613 assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
614 assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
615 }
616
617 #[test]
618 fn scan_pack_skips_hidden_files() {
619 let env = TempEnvironment::builder()
620 .pack("test")
621 .file("visible", "yes")
622 .file(".hidden", "no")
623 .done()
624 .build();
625
626 let scanner = Scanner::new(env.fs.as_ref());
627 let pack = make_pack("test", env.dotfiles_root.join("test"));
628 let rules = default_rules();
629
630 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
631 let names: Vec<String> = matches
632 .iter()
633 .map(|m| m.relative_path.to_string_lossy().to_string())
634 .collect();
635
636 assert!(names.contains(&"visible".to_string()));
637 assert!(!names.contains(&".hidden".to_string()));
638 }
639
640 #[test]
641 fn scan_pack_skips_special_files() {
642 let env = TempEnvironment::builder()
643 .pack("test")
644 .file("normal", "yes")
645 .config("[pack]\nignore = []")
646 .done()
647 .build();
648
649 let pack_dir = env.dotfiles_root.join("test");
651 env.fs
652 .write_file(&pack_dir.join(".dodotignore"), b"")
653 .unwrap();
654
655 let scanner = Scanner::new(env.fs.as_ref());
656 let pack = make_pack("test", pack_dir);
657 let rules = default_rules();
658
659 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
660 let names: Vec<String> = matches
661 .iter()
662 .map(|m| m.relative_path.to_string_lossy().to_string())
663 .collect();
664
665 assert!(names.contains(&"normal".to_string()));
666 assert!(!names.contains(&".dodot.toml".to_string()));
667 assert!(!names.contains(&".dodotignore".to_string()));
668 }
669
670 #[test]
671 fn scan_pack_with_ignore_patterns() {
672 let env = TempEnvironment::builder()
673 .pack("test")
674 .file("keep.txt", "yes")
675 .file("skip.bak", "no")
676 .file("other.bak", "no")
677 .done()
678 .build();
679
680 let scanner = Scanner::new(env.fs.as_ref());
681 let pack = make_pack("test", env.dotfiles_root.join("test"));
682 let rules = default_rules();
683
684 let matches = scanner
685 .scan_pack(&pack, &rules, &["*.bak".to_string()])
686 .unwrap();
687 let names: Vec<String> = matches
688 .iter()
689 .map(|m| m.relative_path.to_string_lossy().to_string())
690 .collect();
691
692 assert!(names.contains(&"keep.txt".to_string()));
693 assert!(!names.contains(&"skip.bak".to_string()));
694 assert!(!names.contains(&"other.bak".to_string()));
695 }
696
697 #[test]
698 fn scan_pack_exclusion_rules_override_catchall() {
699 let env = TempEnvironment::builder()
700 .pack("test")
701 .file("good.txt", "yes")
702 .file("bad.tmp", "no")
703 .done()
704 .build();
705
706 let scanner = Scanner::new(env.fs.as_ref());
707 let pack = make_pack("test", env.dotfiles_root.join("test"));
708
709 let rules = vec![
710 Rule {
711 pattern: "!*.tmp".into(),
712 handler: "exclude".into(),
713 priority: 100,
714 options: HashMap::new(),
715 },
716 Rule {
717 pattern: "*".into(),
718 handler: "symlink".into(),
719 priority: 0,
720 options: HashMap::new(),
721 },
722 ];
723
724 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
725 let names: Vec<String> = matches
726 .iter()
727 .map(|m| m.relative_path.to_string_lossy().to_string())
728 .collect();
729
730 assert!(names.contains(&"good.txt".to_string()));
731 assert!(!names.contains(&"bad.tmp".to_string()));
732 }
733
734 #[test]
735 fn scan_pack_priority_ordering() {
736 let env = TempEnvironment::builder()
737 .pack("test")
738 .file("aliases.sh", "# shell")
739 .done()
740 .build();
741
742 let scanner = Scanner::new(env.fs.as_ref());
743 let pack = make_pack("test", env.dotfiles_root.join("test"));
744
745 let rules = vec![
747 Rule {
748 pattern: "*.sh".into(),
749 handler: "generic-shell".into(),
750 priority: 5,
751 options: HashMap::new(),
752 },
753 Rule {
754 pattern: "aliases.sh".into(),
755 handler: "specific-shell".into(),
756 priority: 10,
757 options: HashMap::new(),
758 },
759 Rule {
760 pattern: "*".into(),
761 handler: "symlink".into(),
762 priority: 0,
763 options: HashMap::new(),
764 },
765 ];
766
767 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
768 assert_eq!(matches.len(), 1);
769 assert_eq!(matches[0].handler, "specific-shell");
770 }
771
772 #[test]
773 fn scan_pack_directory_entry() {
774 let env = TempEnvironment::builder()
775 .pack("test")
776 .file("bin/my-script", "#!/bin/sh")
777 .file("normal", "x")
778 .done()
779 .build();
780
781 let scanner = Scanner::new(env.fs.as_ref());
782 let pack = make_pack("test", env.dotfiles_root.join("test"));
783 let rules = default_rules();
784
785 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
786
787 let bin_match = matches
788 .iter()
789 .find(|m| m.relative_path.to_string_lossy() == "bin");
790 assert!(bin_match.is_some(), "bin directory should match");
791 assert_eq!(bin_match.unwrap().handler, "path");
792 assert!(bin_match.unwrap().is_dir);
793 }
794
795 #[test]
796 fn nested_install_sh_is_not_matched_by_install_rule() {
797 let env = TempEnvironment::builder()
801 .pack("sneaky")
802 .file("config/install.sh", "echo boom")
803 .done()
804 .build();
805
806 let scanner = Scanner::new(env.fs.as_ref());
807 let pack = make_pack("sneaky", env.dotfiles_root.join("sneaky"));
808 let rules = default_rules();
809
810 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
811
812 assert!(
813 !matches.iter().any(|m| m.handler == "install"),
814 "nested install.sh should not route to install handler: {matches:?}"
815 );
816 }
817
818 #[test]
819 fn scan_pack_returns_only_top_level_entries() {
820 let env = TempEnvironment::builder()
824 .pack("nvim")
825 .file("nvim/init.lua", "require('config')")
826 .file("nvim/lua/plugins.lua", "return {}")
827 .done()
828 .build();
829
830 let scanner = Scanner::new(env.fs.as_ref());
831 let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
832 let rules = default_rules();
833
834 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
835
836 let relpaths: Vec<String> = matches
837 .iter()
838 .map(|m| m.relative_path.to_string_lossy().to_string())
839 .collect();
840
841 assert!(
842 relpaths.iter().any(|p| p == "nvim"),
843 "top-level nvim dir should match: {relpaths:?}"
844 );
845 assert!(
846 !relpaths.iter().any(|p| p.contains('/')),
847 "no nested paths expected: {relpaths:?}"
848 );
849 }
850
851 #[test]
854 fn group_by_handler_groups_correctly() {
855 let matches = vec![
856 RuleMatch {
857 relative_path: "vimrc".into(),
858 absolute_path: "/d/vim/vimrc".into(),
859 pack: "vim".into(),
860 handler: "symlink".into(),
861 is_dir: false,
862 options: HashMap::new(),
863 preprocessor_source: None,
864 },
865 RuleMatch {
866 relative_path: "aliases.sh".into(),
867 absolute_path: "/d/vim/aliases.sh".into(),
868 pack: "vim".into(),
869 handler: "shell".into(),
870 is_dir: false,
871 options: HashMap::new(),
872 preprocessor_source: None,
873 },
874 RuleMatch {
875 relative_path: "gvimrc".into(),
876 absolute_path: "/d/vim/gvimrc".into(),
877 pack: "vim".into(),
878 handler: "symlink".into(),
879 is_dir: false,
880 options: HashMap::new(),
881 preprocessor_source: None,
882 },
883 ];
884
885 let groups = group_by_handler(&matches);
886 assert_eq!(groups.len(), 2);
887 assert_eq!(groups["symlink"].len(), 2);
888 assert_eq!(groups["shell"].len(), 1);
889 }
890
891 #[test]
892 fn handler_execution_order_code_first() {
893 let mut groups = HashMap::new();
894 groups.insert("symlink".into(), vec![]);
895 groups.insert("install".into(), vec![]);
896 groups.insert("shell".into(), vec![]);
897 groups.insert("homebrew".into(), vec![]);
898 groups.insert("path".into(), vec![]);
899
900 let order = handler_execution_order(&groups);
901
902 let install_pos = order.iter().position(|n| n == "install").unwrap();
903 let homebrew_pos = order.iter().position(|n| n == "homebrew").unwrap();
904 let symlink_pos = order.iter().position(|n| n == "symlink").unwrap();
905 let shell_pos = order.iter().position(|n| n == "shell").unwrap();
906 let path_pos = order.iter().position(|n| n == "path").unwrap();
907
908 assert!(install_pos < symlink_pos);
909 assert!(homebrew_pos < shell_pos);
910 assert!(homebrew_pos < path_pos);
911 assert!(homebrew_pos < install_pos);
912 assert!(path_pos < shell_pos);
913 assert!(shell_pos < symlink_pos);
914 }
915
916 #[test]
917 fn rule_match_serializes() {
918 let m = RuleMatch {
919 relative_path: "vimrc".into(),
920 absolute_path: "/dots/vim/vimrc".into(),
921 pack: "vim".into(),
922 handler: "symlink".into(),
923 is_dir: false,
924 options: HashMap::new(),
925 preprocessor_source: None,
926 };
927 let json = serde_json::to_string(&m).unwrap();
928 assert!(json.contains("vimrc"));
929 assert!(json.contains("symlink"));
930 assert!(!json.contains("options"));
931 }
932}