1use std::path::{Path, PathBuf};
4
5use walkdir::WalkDir;
6
7use crate::{Error, Result};
8
9#[derive(Debug, Clone, Default)]
11pub struct WalkOptions {
12 pub ignore_names: Vec<String>,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum EntryKind {
19 File,
21 Symlink,
23 Other,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct WalkedEntry {
30 pub path: PathBuf,
32 pub kind: EntryKind,
34}
35
36pub fn walk_division(root: &Path, options: &WalkOptions) -> Result<Vec<WalkedEntry>> {
45 let root_meta = std::fs::symlink_metadata(root).map_err(|e| Error::io("walk", root, e))?;
46 if root_meta.file_type().is_symlink() {
47 return Err(Error::Layout {
48 message: format!("division root {} is a symlink", root.display()),
49 });
50 }
51 if !root_meta.is_dir() {
52 return Err(Error::io(
53 "walk",
54 root,
55 std::io::Error::new(std::io::ErrorKind::NotFound, "not a directory"),
56 ));
57 }
58 let mut entries = Vec::new();
59 let walker = WalkDir::new(root)
60 .follow_links(false)
61 .follow_root_links(false)
62 .sort_by_file_name()
63 .into_iter()
64 .filter_entry(|entry| {
65 if entry.depth() == 0 {
66 return true;
67 }
68 let name = entry.file_name().to_string_lossy();
69 !options.ignore_names.iter().any(|ignored| ignored == &name)
70 });
71 for entry in walker {
72 let entry = entry.map_err(|e| {
73 let path = e.path().unwrap_or(root).to_path_buf();
74 let source = e
75 .into_io_error()
76 .unwrap_or_else(|| std::io::Error::other("walk failed"));
77 Error::Io {
78 op: "walk",
79 path,
80 source,
81 }
82 })?;
83 let file_type = entry.file_type();
84 let kind = if file_type.is_file() {
85 EntryKind::File
86 } else if file_type.is_symlink() {
87 EntryKind::Symlink
88 } else if file_type.is_dir() {
89 continue;
90 } else {
91 EntryKind::Other
92 };
93 let relative = entry
94 .path()
95 .strip_prefix(root)
96 .expect("walk entries live under the root")
97 .to_path_buf();
98 entries.push(WalkedEntry {
99 path: relative,
100 kind,
101 });
102 }
103 Ok(entries)
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::fs;
110
111 fn touch(path: &Path) {
112 fs::create_dir_all(path.parent().unwrap()).unwrap();
113 fs::write(path, b"x").unwrap();
114 }
115
116 fn paths(entries: &[WalkedEntry]) -> Vec<&Path> {
117 entries.iter().map(|e| e.path.as_path()).collect()
118 }
119
120 #[test]
121 fn walk_is_sorted_and_repeatable() {
122 let dir = tempfile::tempdir().unwrap();
123 let root = dir.path();
124 touch(&root.join("zeta.txt"));
125 touch(&root.join("alpha.txt"));
126 touch(&root.join("mid/inner.txt"));
127 touch(&root.join("mid/another.txt"));
128
129 let options = WalkOptions::default();
130 let first = walk_division(root, &options).unwrap();
131 let second = walk_division(root, &options).unwrap();
132 let expected = [
133 Path::new("alpha.txt"),
134 Path::new("mid/another.txt"),
135 Path::new("mid/inner.txt"),
136 Path::new("zeta.txt"),
137 ];
138 assert_eq!(paths(&first), expected);
139 assert_eq!(first, second);
140 assert!(first.iter().all(|e| e.kind == EntryKind::File));
141 }
142
143 #[test]
144 fn walk_skips_ignored_names() {
145 let dir = tempfile::tempdir().unwrap();
146 let root = dir.path();
147 touch(&root.join("keep.txt"));
148 touch(&root.join(".cache/skip.txt"));
149 touch(&root.join("thumbs.db"));
150
151 let options = WalkOptions {
152 ignore_names: vec![".cache".to_string(), "thumbs.db".to_string()],
153 };
154 let entries = walk_division(root, &options).unwrap();
155 assert_eq!(paths(&entries), [Path::new("keep.txt")]);
156 }
157
158 #[cfg(unix)]
159 #[test]
160 fn walk_returns_symlinks_as_entries_without_following() {
161 let dir = tempfile::tempdir().unwrap();
162 let root = dir.path();
163 touch(&root.join("outside/secret.txt"));
164
165 let scoped = root.join("division");
166 fs::create_dir_all(&scoped).unwrap();
167 touch(&scoped.join("real.txt"));
168 std::os::unix::fs::symlink(root.join("outside/secret.txt"), scoped.join("link.txt"))
169 .unwrap();
170 std::os::unix::fs::symlink(root.join("outside"), scoped.join("linked-dir")).unwrap();
171
172 let entries = walk_division(&scoped, &WalkOptions::default()).unwrap();
173 assert_eq!(
174 entries,
175 vec![
176 WalkedEntry {
177 path: PathBuf::from("link.txt"),
178 kind: EntryKind::Symlink,
179 },
180 WalkedEntry {
181 path: PathBuf::from("linked-dir"),
182 kind: EntryKind::Symlink,
183 },
184 WalkedEntry {
185 path: PathBuf::from("real.txt"),
186 kind: EntryKind::File,
187 },
188 ]
189 );
190 }
191
192 #[cfg(unix)]
193 #[test]
194 fn walk_rejects_a_symlinked_root() {
195 let dir = tempfile::tempdir().unwrap();
196 let root = dir.path();
197 touch(&root.join("outside/secret.txt"));
198 let link = root.join("root-link");
199 std::os::unix::fs::symlink(root.join("outside"), &link).unwrap();
200
201 let err = walk_division(&link, &WalkOptions::default()).unwrap_err();
202 assert!(err.to_string().contains("symlink"));
203 }
204}