ent_tree/
fs.rs

1use std::path::{Path, PathBuf};
2use ignore::WalkBuilder;
3use anyhow::{bail, Result};
4use serde::Serialize;
5use crate::cli::{Cli, Format};
6
7#[derive(Debug, Serialize, Clone)]
8pub struct TreeEntry {
9    pub name: String,
10    pub is_dir: bool,
11    pub children: Option<Vec<TreeEntry>>,
12}
13
14impl TreeEntry {
15    pub fn new(name: String, is_dir: bool) -> Self {
16        Self {
17            name,
18            is_dir,
19            children: None,
20        }
21    }
22    pub fn new_file(name: String) -> Self {
23        Self::new(name, false)
24    }
25    pub fn new_dir(name: String) -> Self {
26        let mut new = Self::new(name, true);
27        new.children = Some(Vec::new());
28        new
29    }
30
31    pub fn build(cli: &Cli, path: &str) -> Result<TreeEntry> {
32        let path = PathBuf::from(path);
33        if !path.exists() {
34            bail!("Path does not exist: {}", path.display());
35        }
36
37        let root_name = path.file_name()
38            .map(|s| s.to_string_lossy().into_owned())
39            .unwrap_or_else(|| ".".to_string());
40
41        let mut root = TreeEntry::new_dir(root_name);
42        let depth = 1;
43        Self::build_recursive(cli, &path, &mut root, depth)?;
44        Ok(root)
45
46    }
47
48    fn build_recursive(cli: &Cli, path: &Path, parent: &mut TreeEntry, depth: usize) -> Result<()> {
49        let walker = WalkBuilder::new(path)
50            .hidden(!cli.hidden && !cli.all)
51            .git_ignore(!cli.ignored && !cli.all)
52            .git_exclude(!cli.ignored && !cli.all)
53            .parents(true)
54            .max_depth(Some(1))
55            .build();
56
57        match &cli.depth {
58            Some(d) if d < &depth => return Ok(()),
59            _ => (),
60        }
61
62        for result in walker {
63            let entry = match result {
64                Ok(entry) => entry,
65                Err(e) => {
66                    eprintln!("Error walking directory: {}", e);
67                    continue;
68                }
69            };
70            if entry.path() == path {
71                continue;
72            }
73            let path = entry.path();
74            let name = path.file_name()
75                .map(|s| s.to_string_lossy().into_owned())
76                .unwrap_or_default();
77
78            if !cli.hidden && is_hidden(&path) {
79                continue;
80            }
81
82            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
83
84            if cli.dirs_only && !is_dir {
85                continue;
86            }
87            if cli.files_only && is_dir {
88                continue;
89            }
90            let mut child: TreeEntry = if is_dir {
91                TreeEntry::new_dir(name)
92            }
93            else {
94                TreeEntry::new_file(name)
95            };
96
97            if is_dir {
98                let new_depth = depth + 1;
99                Self::build_recursive(cli, &path, &mut child, new_depth)?;
100            }
101
102            if let Some(children) = &mut parent.children {
103                children.push(child);
104            }
105        }
106
107        Ok(())
108    }
109
110    pub fn export<P: AsRef<Path>>(&self, path: P, format: Format) -> Result<()> {
111        match format {
112            Format::Json => {
113                let export_str = serde_json::to_string_pretty(self)?;
114                std::fs::write(path, export_str)?;
115            },
116        }
117        Ok(())
118    }
119}
120
121pub fn is_hidden(path: &Path) -> bool {
122    path.file_name()
123        .map(|s| s.to_string_lossy().starts_with('.'))
124        .unwrap_or(false)
125}
126
127
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use tempfile::tempdir;
133    use std::fs::{create_dir, File};
134    use std::io::Write;
135
136    #[test]
137    fn test_new_file() {
138        let file = TreeEntry::new_file("test.txt".to_string());
139        assert_eq!(file.name, "test.txt");
140        assert!(!file.is_dir);
141        assert!(file.children.is_none());
142    }
143
144    #[test]
145    fn test_new_dir() {
146        let dir = TreeEntry::new_dir("src".to_string());
147        assert_eq!(dir.name, "src");
148        assert!(dir.is_dir);
149        assert!(dir.children.is_some());
150    }
151
152    #[test]
153    fn test_is_hidden() {
154        assert!(is_hidden(&PathBuf::from(".hidden")));
155        assert!(!is_hidden(&PathBuf::from("visible")));
156    }
157
158    #[test]
159    fn test_build_simple_tree() -> Result<()> {
160        let dir = tempdir()?;
161        let root_path = dir.path();
162
163        // Create structure:
164        // root/
165        // ├── file.txt
166        // └── subdir/
167        //     └── nested.txt
168        File::create(root_path.join("file.txt"))?.write_all(b"Hello")?;
169        create_dir(root_path.join("subdir"))?;
170        File::create(root_path.join("subdir/nested.txt"))?;
171
172        let cli = Cli {
173            hidden: false,
174            all: false,
175            ignored: false,
176            depth: None,
177            dirs_only: false,
178            files_only: false,
179            ..Default::default()
180        };
181
182        let tree = TreeEntry::build(&cli, root_path.to_str().unwrap())?;
183        assert_eq!(tree.is_dir, true);
184        assert!(tree.children.as_ref().unwrap().iter().any(|e| e.name == "file.txt"));
185        assert!(tree.children.as_ref().unwrap().iter().any(|e| e.name == "subdir"));
186
187        Ok(())
188    }
189
190    #[test]
191    fn test_export_json() -> Result<()> {
192        let tree = TreeEntry::new_dir("project".to_string());
193        let tmp_file = tempfile::NamedTempFile::new()?;
194
195        tree.export(tmp_file.path(), Format::Json)?;
196        let contents = std::fs::read_to_string(tmp_file.path())?;
197        assert!(contents.contains("\"name\": \"project\""));
198        Ok(())
199    }
200}
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219