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#[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 pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
23 Self::from_path_impl(path, false)
24 }
25
26 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 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 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 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 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 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 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 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 fn calculate_directory_size_fast(dir: &Path) -> Result<u64> {
141 use rayon::prelude::*;
142 use std::sync::atomic::{AtomicU64, Ordering};
143
144 let total_size = AtomicU64::new(0);
146
147 WalkDir::new(dir)
149 .into_iter()
150 .par_bridge() .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 pub fn formatted_size(&self) -> String {
164 crate::format_bytes(self.get_target_size())
165 }
166
167 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 let target_path = self.target_path();
179 Self::calculate_directory_size_fast(&target_path).unwrap_or(0)
180 }
181
182 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 pub fn target_exists(&self) -> bool {
192 self.path.join("target").exists()
193 }
194
195 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 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 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 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 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 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 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 assert!(!project.target_exists());
367
368 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}