1use anyhow::Result;
5use ignore::WalkBuilder;
6use std::path::Path;
7
8use crate::config::compile_globs;
9
10pub struct SourceFile {
13 pub path: String,
15 pub is_symlink: bool,
17 pub bytes: Option<Vec<u8>>,
21}
22
23pub fn walk(root: &Path, ignore: &[String]) -> Result<Vec<SourceFile>> {
30 let ignore_set = compile_globs(ignore)?;
31 let canonical_root = root.canonicalize()?;
32
33 let mut files = Vec::new();
34
35 let walker = WalkBuilder::new(root).follow_links(true).build();
36
37 for entry in walker {
38 let entry = entry?;
39 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
40 continue;
41 }
42
43 let relative = entry
44 .path()
45 .strip_prefix(root)
46 .expect("path should be under root")
47 .to_string_lossy()
48 .replace('\\', "/");
49
50 if ignore_set
51 .as_ref()
52 .is_some_and(|set| set.is_match(&relative))
53 {
54 continue;
55 }
56
57 let abs = root.join(&relative);
58 let is_symlink = abs
59 .symlink_metadata()
60 .is_ok_and(|m| m.file_type().is_symlink());
61
62 let bytes = if is_symlink {
63 match abs.canonicalize() {
65 Ok(canonical) if canonical.starts_with(&canonical_root) => std::fs::read(&abs).ok(),
66 _ => None,
67 }
68 } else {
69 std::fs::read(&abs).ok()
70 };
71
72 files.push(SourceFile {
73 path: relative,
74 is_symlink,
75 bytes,
76 });
77 }
78
79 files.sort_by(|a, b| a.path.cmp(&b.path));
80 Ok(files)
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use std::fs;
87 use tempfile::TempDir;
88
89 #[test]
90 fn walks_all_files_sorted() {
91 let dir = TempDir::new().unwrap();
92 fs::write(dir.path().join("b.md"), "b").unwrap();
93 fs::write(dir.path().join("a.md"), "a").unwrap();
94 fs::write(dir.path().join("notes.txt"), "n").unwrap();
95
96 let files = walk(dir.path(), &[]).unwrap();
97 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
98 assert_eq!(paths, vec!["a.md", "b.md", "notes.txt"]);
99 assert_eq!(files[0].bytes.as_deref(), Some(&b"a"[..]));
100 }
101
102 #[test]
103 fn respects_ignore_globs() {
104 let dir = TempDir::new().unwrap();
105 fs::write(dir.path().join("keep.md"), "k").unwrap();
106 let sub = dir.path().join("target");
107 fs::create_dir(&sub).unwrap();
108 fs::write(sub.join("build.md"), "b").unwrap();
109
110 let files = walk(dir.path(), &["target/**".to_string()]).unwrap();
111 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
112 assert_eq!(paths, vec!["keep.md"]);
113 }
114
115 #[test]
116 fn respects_gitignore() {
117 let dir = TempDir::new().unwrap();
118 fs::create_dir(dir.path().join(".git")).unwrap();
119 fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
120 fs::write(dir.path().join("index.md"), "i").unwrap();
121 let vendor = dir.path().join("vendor");
122 fs::create_dir(&vendor).unwrap();
123 fs::write(vendor.join("lib.md"), "v").unwrap();
124
125 let files = walk(dir.path(), &[]).unwrap();
126 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
127 assert!(paths.contains(&"index.md"));
128 assert!(!paths.iter().any(|p| p.contains("vendor")));
129 }
130
131 #[cfg(unix)]
132 #[test]
133 fn symlink_escaping_root_has_no_bytes() {
134 let outer = TempDir::new().unwrap();
135 let root = outer.path().join("project");
136 fs::create_dir(&root).unwrap();
137 fs::write(root.join("index.md"), "i").unwrap();
138 fs::write(outer.path().join("secret.md"), "secret").unwrap();
139 std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
140
141 let files = walk(&root, &[]).unwrap();
142 let trap = files.iter().find(|f| f.path == "trap.md").unwrap();
143 assert!(
144 trap.bytes.is_none(),
145 "escaping symlink should carry no bytes"
146 );
147 }
148}