Skip to main content

fancy_tree/tree/
mod.rs

1//! Provides the utility for generating a tree.
2use 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
22/// Generates a tree.
23pub struct Tree<'git, 'charset, P: AsRef<Path>> {
24    /// The root path to start from.
25    root: P,
26    /// The optional git state of the directory.
27    git: Option<&'git Git>,
28    /// The maximum depth level to display.
29    max_level: Option<usize>,
30    /// Overrides the configured color choice (e.g. if specified in the CLI).
31    color_choice: Option<ColorChoice>,
32    /// Provides the characters to print when traversing the directory structure.
33    charset: Charset<'charset>,
34    /// Provides configuration choices.
35    ///
36    /// When this is `None`, default behaviors will be used.
37    config: config::Main,
38    /// Provides icon configuration.
39    icons: config::Icons,
40    /// Provides color configuration.
41    colors: config::Colors,
42}
43
44impl<'git, 'charset, P> Tree<'git, 'charset, P>
45where
46    P: AsRef<Path>,
47{
48    /// Writes the tree to stdout.
49    #[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    /// Writes to the writer.
60    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            // HACK We can't read the first entry for some reason, so we'll just print
66            //      it and exit.
67            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    /// Writes the tree at a certain depth to the writer.
76    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        // NOTE For the top level, we always print the full path the user specified.
84        self.write_entry(writer, &entry, depth == 0)?;
85
86        writeln!(writer)?;
87        if !path.is_dir() {
88            return Ok(());
89        }
90
91        // NOTE We'll just skip file read errors to continue printing the rest of the
92        //      tree.
93        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            // NOTE If we can't read a directory entry, then we'll just ignore it so that
100            //      we don't stop early.
101            let entries = entries.filter_map(Result::ok);
102
103            // NOTE If the config exists and it successfully detects if a file should
104            //      be skipped, use that value. Otherwise, use default behavior.
105            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    /// Writes an entry.
125    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        // NOTE Padding for the icons
136        write!(writer, " ")?;
137
138        // HACK is_path_ignored tries to strip the prefix, which we never want to do at
139        //      the top when the path is *only* the prefix. In fact, we don't want to
140        //      check ignore status here at all since the current implementation breaks
141        //      for paths that contain the directory `.`, it seems. Also, the top
142        //      should always be a directory, and the current implementation only seems
143        //      to work for files.
144        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            // NOTE The only time the path shouldn't have a file name is at the top
150            //      level, which could be a path like "." or "..". At the top level
151            //      call, `full_name` should always receive `true`.
152            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    /// Writes a path's name.
166    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    /// Writes indentation.
176    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    /// Checks if an entry should be skipped.
187    ///
188    /// If the config exists, the config has a `skip` function, *and* that function
189    /// successfully returns a boolean value, then that value will be used. Otherwise,
190    /// it will just skip all hidden files.
191    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    /// Checks if a path is ignored.
201    fn is_path_ignored<P2>(&self, path: P2) -> bool
202    where
203        P2: AsRef<Path>,
204    {
205        self.git
206            .and_then(|git| {
207                // HACK This function doesn't expect a `./` prefix. It seems to return
208                //      `true` when it's present???
209                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    /// Writes the text in a colored style.
218    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        // HACK Optimization to avoid calculating colors when they're disabled.
232        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    /// Writes colorized git statuses.
241    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        // HACK cached status keys don't have a ./ prefix and git2 apparently doesn't expect it.
248        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    /// Writes a colorized untracked (worktree) git status.
258    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    /// Strips the root path prefix, which is necessary for git tools.
273    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    /// Gets the color choice to use.
282    fn color_choice(&self) -> ColorChoice {
283        self.color_choice.unwrap_or(self.config.color_choice())
284    }
285}
286
287/// Private trait to generalize writing statuses.
288trait ColoredStatus {
289    /// Gets the color for the status.
290    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
307/// Helper for cleaning up a file path so that it can be used with the opened
308/// [`git2::Repository`].
309fn 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    // HACK Git root seems to have `/` separators, which breaks path cleanup on
317    //      Windows. This cleans up the git root so it can be used with
318    //      strip_prefix.
319    #[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        // NOTE Create the "repository" and its files in a temporary directory.
348        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}