1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use serde::Serialize;
13
14use crate::fs::Fs;
15use crate::packs::Pack;
16use crate::Result;
17
18#[derive(Debug, Clone, Serialize)]
22pub struct Rule {
23 pub pattern: String,
26
27 pub handler: String,
29
30 pub priority: i32,
32
33 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
35 pub options: HashMap<String, String>,
36}
37
38#[derive(Debug, Clone)]
40pub struct PackEntry {
41 pub relative_path: PathBuf,
43 pub absolute_path: PathBuf,
45 pub is_dir: bool,
47}
48
49#[derive(Debug, Clone, Serialize)]
51pub struct RuleMatch {
52 pub relative_path: PathBuf,
54
55 pub absolute_path: PathBuf,
57
58 pub pack: String,
60
61 pub handler: String,
63
64 pub is_dir: bool,
66
67 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69 pub options: HashMap<String, String>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
74 pub preprocessor_source: Option<PathBuf>,
75
76 #[serde(skip)]
92 pub rendered_bytes: Option<Arc<[u8]>>,
93}
94
95pub fn group_by_handler(matches: &[RuleMatch]) -> HashMap<String, Vec<RuleMatch>> {
99 let mut groups: HashMap<String, Vec<RuleMatch>> = HashMap::new();
100 for m in matches {
101 groups.entry(m.handler.clone()).or_default().push(m.clone());
102 }
103 groups
104}
105
106pub fn handler_execution_order(
119 groups: &HashMap<String, Vec<RuleMatch>>,
120 registry: &HashMap<String, Box<dyn crate::handlers::Handler + '_>>,
121) -> Vec<String> {
122 let mut names: Vec<String> = groups.keys().cloned().collect();
123 names.sort_by(|a, b| {
124 let pa = registry.get(a).map(|h| h.phase());
125 let pb = registry.get(b).map(|h| h.phase());
126 match (pa, pb) {
127 (Some(x), Some(y)) => x.cmp(&y),
128 (Some(_), None) => std::cmp::Ordering::Less,
129 (None, Some(_)) => std::cmp::Ordering::Greater,
130 (None, None) => a.cmp(b),
131 }
132 });
133 names
134}
135
136#[derive(Debug)]
140enum CompiledPattern {
141 Exact(String),
143 Glob(glob::Pattern),
145 Directory(String),
148}
149
150#[derive(Debug)]
152struct CompiledRule {
153 pattern: CompiledPattern,
154 is_exclusion: bool,
155 handler: String,
156 priority: i32,
157 options: HashMap<String, String>,
158}
159
160fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
161 rules
162 .iter()
163 .map(|rule| {
164 let (raw_pattern, is_exclusion) = if let Some(rest) = rule.pattern.strip_prefix('!') {
165 (rest.to_string(), true)
166 } else {
167 (rule.pattern.clone(), false)
168 };
169
170 let pattern = if raw_pattern.ends_with('/') {
171 let dir_name = raw_pattern.trim_end_matches('/').to_string();
173 CompiledPattern::Directory(dir_name)
174 } else if raw_pattern.contains('*')
175 || raw_pattern.contains('?')
176 || raw_pattern.contains('[')
177 {
178 match glob::Pattern::new(&raw_pattern) {
180 Ok(p) => CompiledPattern::Glob(p),
181 Err(_) => CompiledPattern::Exact(raw_pattern),
182 }
183 } else {
184 CompiledPattern::Exact(raw_pattern)
185 };
186
187 CompiledRule {
188 pattern,
189 is_exclusion,
190 handler: rule.handler.clone(),
191 priority: rule.priority,
192 options: rule.options.clone(),
193 }
194 })
195 .collect()
196}
197
198fn matches_entry(pattern: &CompiledPattern, filename: &str, is_dir: bool) -> bool {
199 match pattern {
200 CompiledPattern::Exact(name) => filename == name,
201 CompiledPattern::Glob(glob) => glob.matches(filename),
202 CompiledPattern::Directory(dir_name) => is_dir && filename == dir_name,
203 }
204}
205
206pub const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
210
211pub fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool {
219 SPECIAL_FILES.contains(&name) || is_ignored(name, ignore_patterns)
220}
221
222pub struct Scanner<'a> {
224 fs: &'a dyn Fs,
225}
226
227impl<'a> Scanner<'a> {
228 pub fn new(fs: &'a dyn Fs) -> Self {
229 Self { fs }
230 }
231
232 pub fn scan_pack(
242 &self,
243 pack: &Pack,
244 rules: &[Rule],
245 pack_ignore: &[String],
246 ) -> Result<Vec<RuleMatch>> {
247 let entries = self.walk_pack(&pack.path, pack_ignore)?;
248 Ok(self.match_entries(&entries, rules, &pack.name))
249 }
250
251 pub fn walk_pack(
268 &self,
269 pack_path: &Path,
270 ignore_patterns: &[String],
271 ) -> Result<Vec<PackEntry>> {
272 let mut results = Vec::new();
273 self.list_top_level(pack_path, ignore_patterns, &mut results)?;
274 Ok(results)
275 }
276
277 pub fn walk_pack_recursive(
279 &self,
280 pack_path: &Path,
281 ignore_patterns: &[String],
282 ) -> Result<Vec<PackEntry>> {
283 let mut results = Vec::new();
284 self.walk_dir(pack_path, pack_path, ignore_patterns, &mut results)?;
285 Ok(results)
286 }
287
288 pub fn match_entries(
294 &self,
295 entries: &[PackEntry],
296 rules: &[Rule],
297 pack_name: &str,
298 ) -> Vec<RuleMatch> {
299 let compiled = compile_rules(rules);
300 let mut matches = Vec::new();
301
302 for entry in entries {
303 let filename = entry
304 .relative_path
305 .file_name()
306 .map(|n| n.to_string_lossy().to_string())
307 .unwrap_or_default();
308
309 if let Some(rule_match) = match_file(
310 &compiled,
311 &filename,
312 entry.is_dir,
313 &entry.relative_path,
314 &entry.absolute_path,
315 pack_name,
316 ) {
317 matches.push(rule_match);
318 }
319 }
320
321 matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
322 matches
323 }
324
325 fn list_top_level(
328 &self,
329 pack_path: &Path,
330 ignore_patterns: &[String],
331 results: &mut Vec<PackEntry>,
332 ) -> Result<()> {
333 let entries = self.fs.read_dir(pack_path)?;
334
335 for entry in entries {
336 let name = &entry.name;
337
338 if name.starts_with('.') && name != ".config" {
339 continue;
340 }
341 if SPECIAL_FILES.contains(&name.as_str()) {
342 continue;
343 }
344 if is_ignored(name, ignore_patterns) {
345 continue;
346 }
347
348 let rel_path = entry
349 .path
350 .strip_prefix(pack_path)
351 .unwrap_or(&entry.path)
352 .to_path_buf();
353
354 results.push(PackEntry {
355 relative_path: rel_path,
356 absolute_path: entry.path.clone(),
357 is_dir: entry.is_dir,
358 });
359 }
360
361 Ok(())
362 }
363
364 fn walk_dir(
365 &self,
366 base: &Path,
367 dir: &Path,
368 ignore_patterns: &[String],
369 results: &mut Vec<PackEntry>,
370 ) -> Result<()> {
371 let entries = self.fs.read_dir(dir)?;
372
373 for entry in entries {
374 let name = &entry.name;
375
376 if name.starts_with('.') && name != ".config" {
378 continue;
379 }
380
381 if SPECIAL_FILES.contains(&name.as_str()) {
383 continue;
384 }
385
386 if is_ignored(name, ignore_patterns) {
388 continue;
389 }
390
391 let rel_path = entry
392 .path
393 .strip_prefix(base)
394 .unwrap_or(&entry.path)
395 .to_path_buf();
396
397 if entry.is_dir {
398 results.push(PackEntry {
400 relative_path: rel_path.clone(),
401 absolute_path: entry.path.clone(),
402 is_dir: true,
403 });
404 self.walk_dir(base, &entry.path, ignore_patterns, results)?;
406 } else {
407 results.push(PackEntry {
408 relative_path: rel_path,
409 absolute_path: entry.path.clone(),
410 is_dir: false,
411 });
412 }
413 }
414
415 Ok(())
416 }
417}
418
419fn match_file(
424 compiled: &[CompiledRule],
425 filename: &str,
426 is_dir: bool,
427 rel_path: &Path,
428 abs_path: &Path,
429 pack: &str,
430) -> Option<RuleMatch> {
431 for rule in compiled {
433 if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
434 return None;
435 }
436 }
437
438 let mut inclusion_rules: Vec<&CompiledRule> =
441 compiled.iter().filter(|r| !r.is_exclusion).collect();
442 inclusion_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
443
444 for rule in inclusion_rules {
445 if matches_entry(&rule.pattern, filename, is_dir) {
446 return Some(RuleMatch {
447 relative_path: rel_path.to_path_buf(),
448 absolute_path: abs_path.to_path_buf(),
449 pack: pack.to_string(),
450 handler: rule.handler.clone(),
451 is_dir,
452 options: rule.options.clone(),
453 preprocessor_source: None,
454 rendered_bytes: None,
455 });
456 }
457 }
458
459 None
460}
461
462fn is_ignored(name: &str, patterns: &[String]) -> bool {
463 for pattern in patterns {
464 if let Ok(glob) = glob::Pattern::new(pattern) {
465 if glob.matches(name) {
466 return true;
467 }
468 }
469 if name == pattern {
471 return true;
472 }
473 }
474 false
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use crate::handlers::HandlerConfig;
481 use crate::testing::TempEnvironment;
482
483 fn make_pack(name: &str, path: PathBuf) -> Pack {
484 Pack::new(name.into(), path, HandlerConfig::default())
485 }
486
487 fn default_rules() -> Vec<Rule> {
488 vec![
489 Rule {
490 pattern: "bin/".into(),
491 handler: "path".into(),
492 priority: 10,
493 options: HashMap::new(),
494 },
495 Rule {
496 pattern: "install.sh".into(),
497 handler: "install".into(),
498 priority: 10,
499 options: HashMap::new(),
500 },
501 Rule {
502 pattern: "aliases.sh".into(),
503 handler: "shell".into(),
504 priority: 10,
505 options: HashMap::new(),
506 },
507 Rule {
508 pattern: "profile.sh".into(),
509 handler: "shell".into(),
510 priority: 10,
511 options: HashMap::new(),
512 },
513 Rule {
514 pattern: "Brewfile".into(),
515 handler: "homebrew".into(),
516 priority: 10,
517 options: HashMap::new(),
518 },
519 Rule {
520 pattern: "*".into(),
521 handler: "symlink".into(),
522 priority: 0,
523 options: HashMap::new(),
524 },
525 ]
526 }
527
528 #[test]
531 fn exact_match() {
532 let compiled = compile_rules(&[Rule {
533 pattern: "install.sh".into(),
534 handler: "install".into(),
535 priority: 0,
536 options: HashMap::new(),
537 }]);
538 assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
539 assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
540 }
541
542 #[test]
543 fn glob_match() {
544 let compiled = compile_rules(&[Rule {
545 pattern: "*.sh".into(),
546 handler: "shell".into(),
547 priority: 0,
548 options: HashMap::new(),
549 }]);
550 assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
551 assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
552 assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
553 }
554
555 #[test]
556 fn directory_match() {
557 let compiled = compile_rules(&[Rule {
558 pattern: "bin/".into(),
559 handler: "path".into(),
560 priority: 0,
561 options: HashMap::new(),
562 }]);
563 assert!(matches_entry(&compiled[0].pattern, "bin", true));
564 assert!(!matches_entry(&compiled[0].pattern, "bin", false));
565 assert!(!matches_entry(&compiled[0].pattern, "lib", true));
566 }
567
568 #[test]
569 fn exclusion_prefix() {
570 let compiled = compile_rules(&[Rule {
571 pattern: "!*.tmp".into(),
572 handler: "exclude".into(),
573 priority: 100,
574 options: HashMap::new(),
575 }]);
576 assert!(compiled[0].is_exclusion);
577 assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
578 }
579
580 #[test]
581 fn catchall_matches_everything() {
582 let compiled = compile_rules(&[Rule {
583 pattern: "*".into(),
584 handler: "symlink".into(),
585 priority: 0,
586 options: HashMap::new(),
587 }]);
588 assert!(matches_entry(&compiled[0].pattern, "anything", false));
589 assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
590 }
591
592 #[test]
595 fn scan_pack_basic() {
596 let env = TempEnvironment::builder()
597 .pack("vim")
598 .file("vimrc", "set nocompatible")
599 .file("gvimrc", "set guifont=Mono")
600 .file("aliases.sh", "alias vi=vim")
601 .file("install.sh", "#!/bin/sh\necho setup")
602 .done()
603 .build();
604
605 let scanner = Scanner::new(env.fs.as_ref());
606 let pack = make_pack("vim", env.dotfiles_root.join("vim"));
607 let rules = default_rules();
608
609 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
610
611 let handler_map: HashMap<String, Vec<String>> = {
612 let mut m: HashMap<String, Vec<String>> = HashMap::new();
613 for rm in &matches {
614 m.entry(rm.handler.clone())
615 .or_default()
616 .push(rm.relative_path.to_string_lossy().to_string());
617 }
618 m
619 };
620
621 assert_eq!(handler_map["install"], vec!["install.sh"]);
622 assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
623 assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
624 assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
625 }
626
627 #[test]
628 fn scan_pack_skips_hidden_files() {
629 let env = TempEnvironment::builder()
630 .pack("test")
631 .file("visible", "yes")
632 .file(".hidden", "no")
633 .done()
634 .build();
635
636 let scanner = Scanner::new(env.fs.as_ref());
637 let pack = make_pack("test", env.dotfiles_root.join("test"));
638 let rules = default_rules();
639
640 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
641 let names: Vec<String> = matches
642 .iter()
643 .map(|m| m.relative_path.to_string_lossy().to_string())
644 .collect();
645
646 assert!(names.contains(&"visible".to_string()));
647 assert!(!names.contains(&".hidden".to_string()));
648 }
649
650 #[test]
651 fn scan_pack_skips_special_files() {
652 let env = TempEnvironment::builder()
653 .pack("test")
654 .file("normal", "yes")
655 .config("[pack]\nignore = []")
656 .done()
657 .build();
658
659 let pack_dir = env.dotfiles_root.join("test");
661 env.fs
662 .write_file(&pack_dir.join(".dodotignore"), b"")
663 .unwrap();
664
665 let scanner = Scanner::new(env.fs.as_ref());
666 let pack = make_pack("test", pack_dir);
667 let rules = default_rules();
668
669 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
670 let names: Vec<String> = matches
671 .iter()
672 .map(|m| m.relative_path.to_string_lossy().to_string())
673 .collect();
674
675 assert!(names.contains(&"normal".to_string()));
676 assert!(!names.contains(&".dodot.toml".to_string()));
677 assert!(!names.contains(&".dodotignore".to_string()));
678 }
679
680 #[test]
681 fn scan_pack_with_ignore_patterns() {
682 let env = TempEnvironment::builder()
683 .pack("test")
684 .file("keep.txt", "yes")
685 .file("skip.bak", "no")
686 .file("other.bak", "no")
687 .done()
688 .build();
689
690 let scanner = Scanner::new(env.fs.as_ref());
691 let pack = make_pack("test", env.dotfiles_root.join("test"));
692 let rules = default_rules();
693
694 let matches = scanner
695 .scan_pack(&pack, &rules, &["*.bak".to_string()])
696 .unwrap();
697 let names: Vec<String> = matches
698 .iter()
699 .map(|m| m.relative_path.to_string_lossy().to_string())
700 .collect();
701
702 assert!(names.contains(&"keep.txt".to_string()));
703 assert!(!names.contains(&"skip.bak".to_string()));
704 assert!(!names.contains(&"other.bak".to_string()));
705 }
706
707 #[test]
708 fn scan_pack_exclusion_rules_override_catchall() {
709 let env = TempEnvironment::builder()
710 .pack("test")
711 .file("good.txt", "yes")
712 .file("bad.tmp", "no")
713 .done()
714 .build();
715
716 let scanner = Scanner::new(env.fs.as_ref());
717 let pack = make_pack("test", env.dotfiles_root.join("test"));
718
719 let rules = vec![
720 Rule {
721 pattern: "!*.tmp".into(),
722 handler: "exclude".into(),
723 priority: 100,
724 options: HashMap::new(),
725 },
726 Rule {
727 pattern: "*".into(),
728 handler: "symlink".into(),
729 priority: 0,
730 options: HashMap::new(),
731 },
732 ];
733
734 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
735 let names: Vec<String> = matches
736 .iter()
737 .map(|m| m.relative_path.to_string_lossy().to_string())
738 .collect();
739
740 assert!(names.contains(&"good.txt".to_string()));
741 assert!(!names.contains(&"bad.tmp".to_string()));
742 }
743
744 #[test]
745 fn scan_pack_priority_ordering() {
746 let env = TempEnvironment::builder()
747 .pack("test")
748 .file("aliases.sh", "# shell")
749 .done()
750 .build();
751
752 let scanner = Scanner::new(env.fs.as_ref());
753 let pack = make_pack("test", env.dotfiles_root.join("test"));
754
755 let rules = vec![
757 Rule {
758 pattern: "*.sh".into(),
759 handler: "generic-shell".into(),
760 priority: 5,
761 options: HashMap::new(),
762 },
763 Rule {
764 pattern: "aliases.sh".into(),
765 handler: "specific-shell".into(),
766 priority: 10,
767 options: HashMap::new(),
768 },
769 Rule {
770 pattern: "*".into(),
771 handler: "symlink".into(),
772 priority: 0,
773 options: HashMap::new(),
774 },
775 ];
776
777 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
778 assert_eq!(matches.len(), 1);
779 assert_eq!(matches[0].handler, "specific-shell");
780 }
781
782 #[test]
783 fn scan_pack_directory_entry() {
784 let env = TempEnvironment::builder()
785 .pack("test")
786 .file("bin/my-script", "#!/bin/sh")
787 .file("normal", "x")
788 .done()
789 .build();
790
791 let scanner = Scanner::new(env.fs.as_ref());
792 let pack = make_pack("test", env.dotfiles_root.join("test"));
793 let rules = default_rules();
794
795 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
796
797 let bin_match = matches
798 .iter()
799 .find(|m| m.relative_path.to_string_lossy() == "bin");
800 assert!(bin_match.is_some(), "bin directory should match");
801 assert_eq!(bin_match.unwrap().handler, "path");
802 assert!(bin_match.unwrap().is_dir);
803 }
804
805 #[test]
806 fn nested_install_sh_is_not_matched_by_install_rule() {
807 let env = TempEnvironment::builder()
811 .pack("sneaky")
812 .file("config/install.sh", "echo boom")
813 .done()
814 .build();
815
816 let scanner = Scanner::new(env.fs.as_ref());
817 let pack = make_pack("sneaky", env.dotfiles_root.join("sneaky"));
818 let rules = default_rules();
819
820 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
821
822 assert!(
823 !matches.iter().any(|m| m.handler == "install"),
824 "nested install.sh should not route to install handler: {matches:?}"
825 );
826 }
827
828 #[test]
829 fn scan_pack_returns_only_top_level_entries() {
830 let env = TempEnvironment::builder()
834 .pack("nvim")
835 .file("nvim/init.lua", "require('config')")
836 .file("nvim/lua/plugins.lua", "return {}")
837 .done()
838 .build();
839
840 let scanner = Scanner::new(env.fs.as_ref());
841 let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
842 let rules = default_rules();
843
844 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
845
846 let relpaths: Vec<String> = matches
847 .iter()
848 .map(|m| m.relative_path.to_string_lossy().to_string())
849 .collect();
850
851 assert!(
852 relpaths.iter().any(|p| p == "nvim"),
853 "top-level nvim dir should match: {relpaths:?}"
854 );
855 assert!(
856 !relpaths.iter().any(|p| p.contains('/')),
857 "no nested paths expected: {relpaths:?}"
858 );
859 }
860
861 #[test]
864 fn group_by_handler_groups_correctly() {
865 let matches = vec![
866 RuleMatch {
867 relative_path: "vimrc".into(),
868 absolute_path: "/d/vim/vimrc".into(),
869 pack: "vim".into(),
870 handler: "symlink".into(),
871 is_dir: false,
872 options: HashMap::new(),
873 preprocessor_source: None,
874 rendered_bytes: None,
875 },
876 RuleMatch {
877 relative_path: "aliases.sh".into(),
878 absolute_path: "/d/vim/aliases.sh".into(),
879 pack: "vim".into(),
880 handler: "shell".into(),
881 is_dir: false,
882 options: HashMap::new(),
883 preprocessor_source: None,
884 rendered_bytes: None,
885 },
886 RuleMatch {
887 relative_path: "gvimrc".into(),
888 absolute_path: "/d/vim/gvimrc".into(),
889 pack: "vim".into(),
890 handler: "symlink".into(),
891 is_dir: false,
892 options: HashMap::new(),
893 preprocessor_source: None,
894 rendered_bytes: None,
895 },
896 ];
897
898 let groups = group_by_handler(&matches);
899 assert_eq!(groups.len(), 2);
900 assert_eq!(groups["symlink"].len(), 2);
901 assert_eq!(groups["shell"].len(), 1);
902 }
903
904 #[test]
905 fn handler_execution_order_follows_phase_declaration() {
906 let mut groups = HashMap::new();
907 groups.insert("symlink".into(), vec![]);
908 groups.insert("install".into(), vec![]);
909 groups.insert("shell".into(), vec![]);
910 groups.insert("homebrew".into(), vec![]);
911 groups.insert("path".into(), vec![]);
912
913 let fs = crate::fs::OsFs::new();
914 let registry = crate::handlers::create_registry(&fs);
915 let order = handler_execution_order(&groups, ®istry);
916
917 assert_eq!(
921 order,
922 vec!["homebrew", "install", "path", "shell", "symlink"]
923 );
924 }
925
926 #[test]
927 fn handler_execution_order_places_unknown_handlers_last() {
928 let mut groups = HashMap::new();
929 groups.insert("symlink".into(), vec![]);
930 groups.insert("zzz-unknown".into(), vec![]);
931 groups.insert("homebrew".into(), vec![]);
932
933 let fs = crate::fs::OsFs::new();
934 let registry = crate::handlers::create_registry(&fs);
935 let order = handler_execution_order(&groups, ®istry);
936
937 assert_eq!(order, vec!["homebrew", "symlink", "zzz-unknown"]);
939 }
940
941 #[test]
942 fn rule_match_serializes() {
943 let m = RuleMatch {
944 relative_path: "vimrc".into(),
945 absolute_path: "/dots/vim/vimrc".into(),
946 pack: "vim".into(),
947 handler: "symlink".into(),
948 is_dir: false,
949 options: HashMap::new(),
950 preprocessor_source: None,
951 rendered_bytes: None,
952 };
953 let json = serde_json::to_string(&m).unwrap();
954 assert!(json.contains("vimrc"));
955 assert!(json.contains("symlink"));
956 assert!(!json.contains("options"));
957 }
958}