Skip to main content

anon_flatten/
flatten.rs

1use crate::conflict::resolve_name_conflicts;
2use crate::error::{FlattenError, Result};
3use crate::file_ops;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7/// 扁平化配置
8pub struct FlattenConfig {
9    /// 源目录
10    pub input: PathBuf,
11
12    /// 目标目录
13    pub output: PathBuf,
14
15    /// 是否为预览模式
16    pub preview: bool,
17
18    /// 是否为剪切模式
19    pub cut: bool,
20
21    /// 排除的文件扩展名
22    pub exclude_extensions: Vec<String>,
23}
24
25impl FlattenConfig {
26    pub fn validate(&self) -> Result<()> {
27        if !self.input.exists() {
28            return Err(FlattenError::SourceNotFound(
29                self.input.display().to_string(),
30            ));
31        }
32
33        if !self.input.is_dir() {
34            return Err(FlattenError::SourceNotDirectory(
35                self.input.display().to_string(),
36            ));
37        }
38
39        if self.output.starts_with(&self.input) {
40            return Err(FlattenError::TargetInsideSource);
41        }
42
43        if !self.preview && !self.output.exists() {
44            std::fs::create_dir_all(&self.output).map_err(|_| {
45                FlattenError::CreateTargetDirFailed(self.output.display().to_string())
46            })?;
47        }
48
49        Ok(())
50    }
51}
52
53pub fn collect_files(input_dir: &Path, exclude_extensions: &[String]) -> Result<Vec<PathBuf>> {
54    let mut files = Vec::new();
55
56    for entry in WalkDir::new(input_dir).into_iter().filter_map(|e| e.ok()) {
57        if entry.file_type().is_file() {
58            let path = entry.path();
59
60            if let Some(extension) = path.extension() {
61                let ext = extension.to_string_lossy().to_lowercase();
62                if exclude_extensions.iter().any(|e| e.to_lowercase() == ext) {
63                    continue;
64                }
65            }
66
67            files.push(path.to_path_buf());
68        }
69    }
70
71    Ok(files)
72}
73
74pub fn execute_flatten<F>(config: &FlattenConfig, mut progress_callback: Option<F>) -> Result<usize>
75where
76    F: FnMut(&str, usize, usize),
77{
78    let files = collect_files(&config.input, &config.exclude_extensions)?;
79
80    if files.is_empty() {
81        return Ok(0);
82    }
83
84    let name_map = resolve_name_conflicts(&files);
85    let total = name_map.len();
86
87    for (index, (source_path, target_name)) in name_map.iter().enumerate() {
88        let target_path = config.output.join(target_name);
89
90        if let Some(ref mut callback) = progress_callback {
91            callback(target_name, index + 1, total);
92        }
93
94        if !config.preview {
95            if config.cut {
96                file_ops::move_file(source_path, &target_path)?;
97            } else {
98                file_ops::copy_file(source_path, &target_path)?;
99            }
100        }
101    }
102
103    Ok(total)
104}
105
106pub fn preview_operations(config: &FlattenConfig) -> Result<Vec<(PathBuf, String)>> {
107    let files = collect_files(&config.input, &config.exclude_extensions)?;
108    Ok(resolve_name_conflicts(&files))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::fs;
115    use tempfile::TempDir;
116
117    fn create_test_structure() -> (TempDir, PathBuf) {
118        let temp_dir = TempDir::new().expect("Failed to create temp dir");
119        let source_dir = temp_dir.path().join("source");
120
121        fs::create_dir_all(&source_dir).unwrap();
122        fs::create_dir_all(source_dir.join("docs/notes")).unwrap();
123        fs::create_dir_all(source_dir.join("images/screenshots")).unwrap();
124        fs::create_dir_all(source_dir.join("duplicate")).unwrap();
125
126        fs::write(source_dir.join("file1.txt"), "content1").unwrap();
127        fs::write(source_dir.join("docs/report.pdf"), "pdf content").unwrap();
128        fs::write(source_dir.join("docs/notes/meeting.txt"), "meeting notes").unwrap();
129        fs::write(source_dir.join("images/photo.jpg"), "photo data").unwrap();
130        fs::write(
131            source_dir.join("images/screenshots/screen1.png"),
132            "screenshot1",
133        )
134        .unwrap();
135        fs::write(
136            source_dir.join("images/screenshots/screen2.png"),
137            "screenshot2",
138        )
139        .unwrap();
140        fs::write(source_dir.join("duplicate/file1.txt"), "duplicate content").unwrap();
141
142        (temp_dir, source_dir)
143    }
144
145    #[test]
146    fn test_collect_files() {
147        let (_temp_dir, source_dir) = create_test_structure();
148        let files = collect_files(&source_dir, &[]).unwrap();
149        assert_eq!(files.len(), 7);
150
151        let file_names: Vec<String> = files
152            .iter()
153            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
154            .collect();
155
156        assert!(file_names.contains(&"file1.txt".to_string()));
157        assert_eq!(
158            file_names
159                .iter()
160                .filter(|&name| name == "file1.txt")
161                .count(),
162            2
163        );
164    }
165
166    #[test]
167    fn test_collect_files_with_exclusion() {
168        let (_temp_dir, source_dir) = create_test_structure();
169
170        // 排除 .txt 文件
171        let files = collect_files(&source_dir, &["txt".to_string()]).unwrap();
172        assert_eq!(files.len(), 4); // 7 - 3个txt文件 = 4
173
174        let file_names: Vec<String> = files
175            .iter()
176            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
177            .collect();
178
179        assert!(!file_names.iter().any(|name| name.ends_with(".txt")));
180        assert!(file_names.contains(&"report.pdf".to_string()));
181        assert!(file_names.contains(&"photo.jpg".to_string()));
182    }
183
184    #[test]
185    fn test_flatten_config_validation() {
186        let temp_dir = TempDir::new().unwrap();
187        let source_dir = temp_dir.path().join("source");
188        let target_dir = temp_dir.path().join("target");
189
190        fs::create_dir_all(&source_dir).unwrap();
191
192        let config = FlattenConfig {
193            input: source_dir.clone(),
194            output: target_dir.clone(),
195            preview: false,
196            cut: false,
197            exclude_extensions: vec![],
198        };
199
200        assert!(config.validate().is_ok());
201    }
202
203    #[test]
204    fn test_flatten_config_target_inside_source() {
205        let temp_dir = TempDir::new().unwrap();
206        let source_dir = temp_dir.path().join("source");
207        let target_dir = source_dir.join("target");
208
209        fs::create_dir_all(&source_dir).unwrap();
210
211        let config = FlattenConfig {
212            input: source_dir,
213            output: target_dir,
214            preview: false,
215            cut: false,
216            exclude_extensions: vec![],
217        };
218
219        assert!(matches!(
220            config.validate(),
221            Err(FlattenError::TargetInsideSource)
222        ));
223    }
224
225    #[test]
226    fn test_execute_flatten_copy() {
227        let (_temp_dir, source_dir) = create_test_structure();
228        let target_dir = _temp_dir.path().join("target");
229
230        let config = FlattenConfig {
231            input: source_dir.clone(),
232            output: target_dir.clone(),
233            preview: false,
234            cut: false,
235            exclude_extensions: vec![],
236        };
237
238        config.validate().unwrap();
239        let count = execute_flatten(&config, None::<fn(&str, usize, usize)>).unwrap();
240
241        assert_eq!(count, 7);
242        assert!(target_dir.join("file1.txt").exists());
243        assert!(target_dir.join("report.pdf").exists());
244
245        // 源文件应该还在
246        assert!(source_dir.join("file1.txt").exists());
247    }
248
249    #[test]
250    fn test_preview_operations() {
251        let (_temp_dir, source_dir) = create_test_structure();
252        let target_dir = _temp_dir.path().join("target");
253
254        let config = FlattenConfig {
255            input: source_dir,
256            output: target_dir,
257            preview: true,
258            cut: false,
259            exclude_extensions: vec![],
260        };
261
262        let operations = preview_operations(&config).unwrap();
263        assert_eq!(operations.len(), 7);
264
265        let target_names: Vec<String> = operations.iter().map(|(_, name)| name.clone()).collect();
266
267        assert!(target_names.contains(&"file1.txt".to_string()));
268        assert!(target_names.contains(&"report.pdf".to_string()));
269    }
270}