1use crate::color::{Color, ColorChoice};
3use crate::config;
4use crate::git::status::StatusGetter;
5use crate::git::{
6 Git,
7 status::{self, Status},
8};
9pub use builder::Builder;
10pub use charset::Charset;
11pub use entry::Entry;
12use owo_colors::AnsiColors;
13use owo_colors::OwoColorize;
14use std::fmt::Display;
15use std::io::{self, Write, stdout};
16use std::path::{self, Path, PathBuf};
17
18mod builder;
19mod charset;
20pub mod entry;
21
22pub struct Tree<'git, 'charset, P: AsRef<Path>> {
24 root: P,
26 git: Option<&'git Git>,
28 max_level: Option<usize>,
30 color_choice: Option<ColorChoice>,
32 charset: Charset<'charset>,
34 config: config::Main,
38 icons: config::Icons,
40 colors: config::Colors,
42}
43
44impl<'git, 'charset, P> Tree<'git, 'charset, P>
45where
46 P: AsRef<Path>,
47{
48 #[inline]
50 pub fn write_to_stdout(&self) -> crate::Result<()>
51 where
52 P: AsRef<Path>,
53 {
54 let mut stdout = stdout();
55 self.write(&mut stdout)?;
56 Ok(())
57 }
58
59 pub fn write<W>(&self, writer: &mut W) -> io::Result<()>
61 where
62 W: Write,
63 {
64 let Ok(entry) = Entry::new(&self.root) else {
65 let path = self.root.as_ref();
68 Self::write_path(writer, path)?;
69 return writeln!(writer);
70 };
71 self.write_depth(writer, entry, 0)?;
72 writer.flush()
73 }
74
75 fn write_depth<W, P2>(&self, writer: &mut W, entry: Entry<P2>, depth: usize) -> io::Result<()>
77 where
78 W: Write,
79 P2: AsRef<Path>,
80 {
81 let path = entry.path();
82
83 self.write_entry(writer, &entry, depth == 0)?;
85
86 writeln!(writer)?;
87 if !path.is_dir() {
88 return Ok(());
89 }
90
91 let entries = match path.read_dir() {
94 Ok(entries) => entries.filter_map(Result::ok),
95 Err(_) => return Ok(()),
96 };
97 let entries = {
98 let entries = entries.map(|entry| entry.path()).map(Entry::new);
99 let entries = entries.filter_map(Result::ok);
102
103 let entries = entries.filter(|entry| !self.should_skip_entry(entry));
106
107 let mut entries = entries.collect::<Vec<_>>();
108 entries.sort_by(|left, right| self.config.cmp(left.path(), right.path()));
109 entries
110 };
111 if self.max_level.map(|max| depth >= max).unwrap_or(false) {
112 return Ok(());
113 }
114
115 for entry in entries {
116 self.write_indentation(writer, depth)?;
117 write!(writer, "{}", self.charset.depth)?;
118 self.write_depth(writer, entry, depth + 1)?;
119 }
120
121 Ok(())
122 }
123
124 fn write_entry<W, P2>(&self, writer: &mut W, entry: &Entry<P2>, is_top: bool) -> io::Result<()>
126 where
127 W: Write,
128 P2: AsRef<Path>,
129 {
130 let path = entry.path();
131 self.write_statuses(writer, path)?;
132
133 let icon = self.icons.get_icon(entry);
134 self.write_colorized_for_entry(entry, writer, icon)?;
135 write!(writer, " ")?;
137
138 let is_ignored = !is_top && self.is_path_ignored(path);
145
146 let path = if is_top {
147 path.as_os_str()
148 } else {
149 path.file_name()
153 .expect("A directory entry should always have a file name")
154 };
155
156 if !is_ignored {
157 Self::write_path(writer, path)
158 } else {
159 const TEXT_COLOR: Option<Color> = Some(Color::Ansi(AnsiColors::Black));
160 self.color_choice()
161 .write_to(writer, path.display(), TEXT_COLOR, None)
162 }
163 }
164
165 fn write_path<W, P2>(writer: &mut W, path: P2) -> io::Result<()>
167 where
168 W: Write,
169 P2: AsRef<Path>,
170 {
171 let path = path.as_ref();
172 writer.write_all(path.as_os_str().as_encoded_bytes())
173 }
174
175 fn write_indentation<W>(&self, writer: &mut W, level: usize) -> io::Result<()>
177 where
178 W: Write,
179 {
180 for _ in 0..level {
181 write!(writer, "{}", self.charset.breadth)?;
182 }
183 Ok(())
184 }
185
186 fn should_skip_entry<P2>(&self, entry: &Entry<P2>) -> bool
192 where
193 P2: AsRef<Path>,
194 {
195 let path = entry.path();
196 self.config
197 .should_skip(entry, || self.is_path_ignored(path))
198 }
199
200 fn is_path_ignored<P2>(&self, path: P2) -> bool
202 where
203 P2: AsRef<Path>,
204 {
205 self.git
206 .and_then(|git| {
207 let path = self
210 .clean_path_for_git2(path)
211 .expect("Should be able to resolve path relative to git root");
212 git.is_ignored(path).ok()
213 })
214 .unwrap_or(false)
215 }
216
217 fn write_colorized_for_entry<W, D, P2>(
219 &self,
220 entry: &Entry<P2>,
221 writer: &mut W,
222 display: D,
223 ) -> io::Result<()>
224 where
225 W: Write,
226 D: Display + OwoColorize,
227 P2: AsRef<Path>,
228 {
229 let color_choice = self.color_choice();
230
231 if color_choice.is_off() {
233 return write!(writer, "{display}");
234 }
235
236 let fg = self.colors.for_icon(entry);
237 color_choice.write_to(writer, display, fg, None)
238 }
239
240 fn write_statuses<W>(&self, writer: &mut W, path: &Path) -> io::Result<()>
242 where
243 W: Write,
244 {
245 let Some(git) = self.git else { return Ok(()) };
246
247 let path = self
249 .clean_path_for_git2(path)
250 .expect("Should be able to resolve path relative to git root");
251
252 self.write_status::<status::Untracked, _, _>(writer, git, &path)?;
253 self.write_status::<status::Tracked, _, _>(writer, git, path)?;
254 Ok(())
255 }
256
257 fn write_status<S, W, P2>(&self, writer: &mut W, git: &Git, path: P2) -> io::Result<()>
259 where
260 S: StatusGetter + ColoredStatus,
261 W: Write,
262 P2: AsRef<Path>,
263 {
264 const NO_STATUS: &str = " ";
265
266 let status = git.status::<S, _>(path).ok().flatten();
267 let color = status.and_then(|status| S::get_color(&self.colors, status));
268 let status = status.map(|status| status.as_str()).unwrap_or(NO_STATUS);
269 self.color_choice().write_to(writer, status, color, None)
270 }
271
272 fn clean_path_for_git2<P2>(&self, path: P2) -> Option<PathBuf>
274 where
275 P2: AsRef<Path>,
276 {
277 let git_root = self.git.and_then(|git| git.root_dir())?;
278 clean_path_for_git2(git_root, path)
279 }
280
281 fn color_choice(&self) -> ColorChoice {
283 self.color_choice.unwrap_or(self.config.color_choice())
284 }
285}
286
287trait ColoredStatus {
289 fn get_color(config: &config::Colors, status: Status) -> Option<Color>;
291}
292
293impl ColoredStatus for status::Untracked {
294 #[inline]
295 fn get_color(config: &config::Colors, status: Status) -> Option<Color> {
296 config.for_untracked_git_status(status)
297 }
298}
299
300impl ColoredStatus for status::Tracked {
301 #[inline]
302 fn get_color(config: &config::Colors, status: Status) -> Option<Color> {
303 config.for_tracked_git_status(status)
304 }
305}
306
307fn clean_path_for_git2<P1, P2>(git_root: P1, path: P2) -> Option<PathBuf>
310where
311 P1: AsRef<Path>,
312 P2: AsRef<Path>,
313{
314 let git_root = git_root.as_ref();
315
316 #[cfg(windows)]
320 let git_root = path::absolute(git_root)
321 .expect("Git root should be non-empty and should be able to get the current directory");
322
323 let path = path.as_ref();
324 let path = path::absolute(path)
325 .expect("Path should be non-empty and should be able to get the current directory");
326 let path = path
327 .strip_prefix(git_root)
328 .expect("Path should have the git root as a prefix");
329 Some(path.to_path_buf())
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use rstest::rstest;
336 use std::fs::{self, File};
337 use tempfile::TempDir;
338
339 #[rstest]
340 #[cfg_attr(unix, case("repo", "repo/src/lib.rs", Some("src/lib.rs")))]
341 #[cfg_attr(windows, case("Dir/Repo", r"Dir\Repo\src\lib.rs", Some(r"src\lib.rs")))]
342 fn test_clean_path_for_git2(
343 #[case] git_root: &str,
344 #[case] path: &str,
345 #[case] expected: Option<&str>,
346 ) {
347 let container = TempDir::with_prefix("fancy-tree-").unwrap();
349 let git_root = container.path().join(git_root);
350 let path = container.path().join(path);
351 fs::create_dir_all(&git_root).unwrap();
352 fs::create_dir_all(path.parent().unwrap()).unwrap();
353 File::create_new(&path).unwrap();
354
355 let expected = expected.map(PathBuf::from);
356
357 assert_eq!(expected, clean_path_for_git2(git_root, path));
358 }
359}