Skip to main content

purger_core/
project.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6use tracing::debug;
7use walkdir::WalkDir;
8
9/// Rust project metadata
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RustProject {
12    pub path: PathBuf,
13    pub name: String,
14    pub target_size: u64,
15    pub last_modified: SystemTime,
16    pub is_workspace: bool,
17    pub has_target: bool,
18}
19
20impl RustProject {
21    /// Create a `RustProject` from a directory path
22    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
23        Self::from_path_impl(path, false)
24    }
25
26    /// Create a `RustProject` from a directory path, without computing target size
27    pub fn from_path_lazy<P: AsRef<Path>>(path: P) -> Result<Self> {
28        Self::from_path_impl(path, true)
29    }
30
31    fn from_path_impl<P: AsRef<Path>>(path: P, lazy_size: bool) -> Result<Self> {
32        let path = path.as_ref().to_path_buf();
33        let cargo_toml_path = path.join("Cargo.toml");
34
35        if !cargo_toml_path.exists() {
36            anyhow::bail!("No Cargo.toml found at {:?}", path);
37        }
38
39        // 一次性读取和解析 TOML,避免重复 IO
40        let (name, is_workspace) = match Self::parse_cargo_toml(&cargo_toml_path, &path) {
41            Ok(result) => result,
42            Err(err) => {
43                debug!(
44                    "Failed to parse Cargo.toml at {:?}: {}",
45                    cargo_toml_path, err
46                );
47                (Self::fallback_project_name(&path), false)
48            }
49        };
50        let target_path = path.join("target");
51        let has_target = target_path.exists();
52
53        let (target_size, last_modified) = if has_target {
54            let modified = fs::metadata(&target_path)
55                .context("Failed to get target directory metadata")?
56                .modified()
57                .context("Failed to get target directory modification time")?;
58            let size = if lazy_size {
59                0
60            } else {
61                Self::calculate_directory_size_fast(&target_path).unwrap_or(0)
62            };
63            (size, modified)
64        } else {
65            (0, SystemTime::UNIX_EPOCH)
66        };
67
68        Ok(RustProject {
69            path,
70            name,
71            target_size,
72            last_modified,
73            is_workspace,
74            has_target,
75        })
76    }
77
78    /// Parse Cargo.toml once to extract package name and workspace info
79    fn parse_cargo_toml(cargo_toml_path: &Path, project_path: &Path) -> Result<(String, bool)> {
80        let content = fs::read_to_string(cargo_toml_path).context("Failed to read Cargo.toml")?;
81        let parsed: toml::Value = toml::from_str(&content).context("Failed to parse Cargo.toml")?;
82
83        // 提取项目名称
84        let name = parsed
85            .get("package")
86            .and_then(|p| p.get("name"))
87            .and_then(|n| n.as_str())
88            .map(|s| s.to_string())
89            .unwrap_or_else(|| Self::fallback_project_name(project_path));
90
91        // 检查是否为workspace项目
92        let is_workspace = parsed.get("workspace").is_some();
93
94        Ok((name, is_workspace))
95    }
96
97    fn fallback_project_name(project_path: &Path) -> String {
98        project_path
99            .file_name()
100            .and_then(|n| n.to_str())
101            .unwrap_or("unknown")
102            .to_string()
103    }
104
105    /// Check whether this is a workspace project (kept for backward compatibility)
106    fn is_workspace_project(cargo_toml_path: &Path) -> Result<bool> {
107        let content = fs::read_to_string(cargo_toml_path).context("Failed to read Cargo.toml")?;
108        let parsed: toml::Value = toml::from_str(&content).context("Failed to parse Cargo.toml")?;
109        Ok(parsed.get("workspace").is_some())
110    }
111
112    /// Extract project name from Cargo.toml (kept for backward compatibility)
113    fn extract_project_name(cargo_toml_path: &Path) -> Option<String> {
114        let content = fs::read_to_string(cargo_toml_path).ok()?;
115        let parsed: toml::Value = toml::from_str(&content).ok()?;
116
117        parsed
118            .get("package")
119            .and_then(|p| p.get("name"))
120            .and_then(|n| n.as_str())
121            .map(|s| s.to_string())
122    }
123
124    /// Calculate directory size
125    fn calculate_directory_size(dir: &Path) -> Result<u64> {
126        let mut total_size = 0u64;
127
128        for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
129            if entry.file_type().is_file() {
130                if let Ok(metadata) = entry.metadata() {
131                    total_size += metadata.len();
132                }
133            }
134        }
135
136        Ok(total_size)
137    }
138
139    /// Calculate directory size (parallelized)
140    fn calculate_directory_size_fast(dir: &Path) -> Result<u64> {
141        use rayon::prelude::*;
142        use std::sync::atomic::{AtomicU64, Ordering};
143
144        // 使用原子计数器避免收集所有条目到 Vec
145        let total_size = AtomicU64::new(0);
146
147        // 并行遍历,直接累加大小
148        WalkDir::new(dir)
149            .into_iter()
150            .par_bridge() // 将串行迭代器转换为并行迭代器
151            .filter_map(|entry| entry.ok())
152            .filter(|entry| entry.file_type().is_file())
153            .for_each(|entry| {
154                if let Ok(metadata) = entry.metadata() {
155                    total_size.fetch_add(metadata.len(), Ordering::Relaxed);
156                }
157            });
158
159        Ok(total_size.into_inner())
160    }
161
162    /// Get a human-readable target size string
163    pub fn formatted_size(&self) -> String {
164        crate::format_bytes(self.get_target_size())
165    }
166
167    /// Get target directory size (compute on demand if needed)
168    pub fn get_target_size(&self) -> u64 {
169        if self.target_size > 0 {
170            return self.target_size;
171        }
172
173        if !self.has_target {
174            return 0;
175        }
176
177        // 按需计算大小
178        let target_path = self.target_path();
179        Self::calculate_directory_size_fast(&target_path).unwrap_or(0)
180    }
181
182    /// Get relative path from a base directory
183    pub fn relative_path(&self, base: &Path) -> PathBuf {
184        self.path
185            .strip_prefix(base)
186            .unwrap_or(&self.path)
187            .to_path_buf()
188    }
189
190    /// Check if target directory exists
191    pub fn target_exists(&self) -> bool {
192        self.path.join("target").exists()
193    }
194
195    /// Get target directory path
196    pub fn target_path(&self) -> PathBuf {
197        self.path.join("target")
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::fs;
205    use tempfile::TempDir;
206
207    #[test]
208    fn test_is_workspace_project() {
209        let temp_dir = TempDir::new().unwrap();
210        let cargo_toml = temp_dir.path().join("Cargo.toml");
211
212        // 测试普通项目
213        fs::write(
214            &cargo_toml,
215            r#"
216[package]
217name = "test"
218version = "0.1.0"
219"#,
220        )
221        .unwrap();
222
223        assert!(!RustProject::is_workspace_project(&cargo_toml).unwrap());
224
225        // 测试workspace项目
226        fs::write(
227            &cargo_toml,
228            r#"
229[workspace]
230members = ["crate1", "crate2"]
231"#,
232        )
233        .unwrap();
234
235        assert!(RustProject::is_workspace_project(&cargo_toml).unwrap());
236    }
237
238    #[test]
239    fn test_extract_project_name() {
240        let temp_dir = TempDir::new().unwrap();
241        let cargo_toml = temp_dir.path().join("Cargo.toml");
242
243        fs::write(
244            &cargo_toml,
245            r#"
246[package]
247name = "my-awesome-project"
248version = "0.1.0"
249"#,
250        )
251        .unwrap();
252
253        let name = RustProject::extract_project_name(&cargo_toml);
254        assert_eq!(name, Some("my-awesome-project".to_string()));
255    }
256
257    #[test]
258    fn test_from_path_with_target() -> Result<()> {
259        let temp_dir = TempDir::new()?;
260        let project_dir = temp_dir.path().join("test_project");
261        std::fs::create_dir_all(&project_dir)?;
262
263        // 创建Cargo.toml
264        let cargo_toml = r#"
265[package]
266name = "test_project"
267version = "0.1.0"
268edition = "2021"
269"#;
270        std::fs::write(project_dir.join("Cargo.toml"), cargo_toml)?;
271
272        // 创建target目录和一些文件
273        let target_dir = project_dir.join("target");
274        std::fs::create_dir_all(&target_dir)?;
275        std::fs::write(target_dir.join("test.txt"), "test content")?;
276
277        let project = RustProject::from_path(&project_dir)?;
278        assert_eq!(project.name, "test_project");
279        assert!(project.has_target);
280        assert!(project.target_size > 0);
281
282        Ok(())
283    }
284
285    #[test]
286    fn test_from_path_without_target() -> Result<()> {
287        let temp_dir = TempDir::new()?;
288        let project_dir = temp_dir.path().join("test_project");
289        std::fs::create_dir_all(&project_dir)?;
290
291        // 创建Cargo.toml
292        let cargo_toml = r#"
293[package]
294name = "test_project"
295version = "0.1.0"
296edition = "2021"
297"#;
298        std::fs::write(project_dir.join("Cargo.toml"), cargo_toml)?;
299
300        let project = RustProject::from_path(&project_dir)?;
301        assert_eq!(project.name, "test_project");
302        assert!(!project.has_target);
303        assert_eq!(project.target_size, 0);
304
305        Ok(())
306    }
307
308    #[test]
309    fn test_from_path_invalid() {
310        let temp_dir = TempDir::new().unwrap();
311        let project_dir = temp_dir.path().join("invalid_project");
312        std::fs::create_dir_all(&project_dir).unwrap();
313
314        // 不创建Cargo.toml
315        let result = RustProject::from_path(&project_dir);
316        assert!(result.is_err());
317    }
318
319    #[test]
320    fn test_formatted_size() {
321        let project = RustProject {
322            path: PathBuf::from("/test"),
323            name: "test".to_string(),
324            target_size: 1024,
325            last_modified: SystemTime::now(),
326            is_workspace: false,
327            has_target: true,
328        };
329
330        let formatted = project.formatted_size();
331        assert_eq!(formatted, "1.00 KB");
332    }
333
334    #[test]
335    fn test_relative_path() {
336        let project = RustProject {
337            path: PathBuf::from("/home/user/projects/my_project"),
338            name: "my_project".to_string(),
339            target_size: 0,
340            last_modified: SystemTime::now(),
341            is_workspace: false,
342            has_target: false,
343        };
344
345        let base = Path::new("/home/user/projects");
346        let relative = project.relative_path(base);
347        assert_eq!(relative, PathBuf::from("my_project"));
348    }
349
350    #[test]
351    fn test_target_exists() -> Result<()> {
352        let temp_dir = TempDir::new()?;
353        let project_dir = temp_dir.path().join("test_project");
354        std::fs::create_dir_all(&project_dir)?;
355
356        let project = RustProject {
357            path: project_dir.clone(),
358            name: "test".to_string(),
359            target_size: 0,
360            last_modified: SystemTime::now(),
361            is_workspace: false,
362            has_target: false,
363        };
364
365        // 最初target不存在
366        assert!(!project.target_exists());
367
368        // 创建target目录
369        std::fs::create_dir_all(project_dir.join("target"))?;
370        assert!(project.target_exists());
371
372        Ok(())
373    }
374
375    #[test]
376    fn test_target_path() {
377        let project = RustProject {
378            path: PathBuf::from("/test/project"),
379            name: "test".to_string(),
380            target_size: 0,
381            last_modified: SystemTime::now(),
382            is_workspace: false,
383            has_target: false,
384        };
385
386        let target_path = project.target_path();
387        assert_eq!(target_path, PathBuf::from("/test/project/target"));
388    }
389}