1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::error::{PwrError, Result};
5use crate::metadata::ProjectMeta;
6
7pub const PROJECT_FILE: &str = ".project.toml";
9
10pub fn read_project_file(dir: &Path) -> Result<Option<ProjectMeta>> {
16 let file_path = dir.join(PROJECT_FILE);
17 if !file_path.exists() {
18 return Ok(None);
19 }
20 let contents = fs::read_to_string(&file_path)?;
21 let meta: ProjectMeta = toml::from_str(&contents).map_err(|e| PwrError::TomlParse {
22 path: file_path.to_string_lossy().to_string(),
23 source: e,
24 })?;
25 Ok(Some(meta))
26}
27
28pub fn write_project_file(dir: &Path, meta: &ProjectMeta) -> Result<()> {
35 fs::create_dir_all(dir)?;
36 let contents = toml::to_string_pretty(meta)?;
37 let file_path = dir.join(PROJECT_FILE);
38 let tmp_path = dir.join(format!(".project.toml.tmp"));
39
40 fs::write(&tmp_path, &contents)?;
42 fs::rename(&tmp_path, &file_path)?;
43
44 log::info!("Wrote project file: {}", file_path.display());
45 Ok(())
46}
47
48pub fn remove_project_file(dir: &Path) -> Result<()> {
53 let file_path = dir.join(PROJECT_FILE);
54 if file_path.exists() {
55 fs::remove_file(&file_path)?;
56 log::info!("Removed project file: {}", file_path.display());
57 }
58 Ok(())
59}
60
61pub fn is_archived_placeholder(dir: &Path) -> bool {
68 if !dir.is_dir() {
69 return false;
70 }
71 let project_file = dir.join(PROJECT_FILE);
72 if !project_file.exists() {
73 return false;
74 }
75 match read_project_file(dir) {
76 Ok(Some(meta)) => meta.is_archived(),
77 _ => false,
78 }
79}
80
81pub fn is_local_project(dir: &Path) -> bool {
83 if !dir.is_dir() {
84 return false;
85 }
86 match read_project_file(dir) {
87 Ok(Some(meta)) => meta.is_local(),
88 _ => false,
89 }
90}
91
92pub fn is_tracked(dir: &Path) -> bool {
94 dir.is_dir() && dir.join(PROJECT_FILE).exists()
95}
96
97pub fn find_projects(root: &Path) -> Result<Vec<(PathBuf, ProjectMeta)>> {
102 let mut projects = Vec::new();
103 if !root.is_dir() {
104 return Ok(projects);
105 }
106 for entry in fs::read_dir(root)? {
107 let entry = entry?;
108 let path = entry.path();
109 if path.is_dir() {
110 if let Some(meta) = read_project_file(&path)? {
111 projects.push((path, meta));
112 }
113 }
114 }
115 Ok(projects)
116}
117
118pub fn find_projects_recursive(root: &Path) -> Result<Vec<(PathBuf, ProjectMeta)>> {
124 let mut projects = Vec::new();
125 if !root.is_dir() {
126 return Ok(projects);
127 }
128 find_projects_recursive_inner(root, &mut projects)?;
129 Ok(projects)
130}
131
132fn find_projects_recursive_inner(
133 dir: &Path,
134 projects: &mut Vec<(PathBuf, ProjectMeta)>,
135) -> Result<()> {
136 for entry in fs::read_dir(dir)? {
137 let entry = entry?;
138 let path = entry.path();
139 if path.is_dir() {
140 if let Some(meta) = read_project_file(&path)? {
141 projects.push((path.clone(), meta));
142 }
143 find_projects_recursive_inner(&path, projects)?;
147 }
148 }
149 Ok(())
150}
151
152pub fn dir_size(dir: &Path) -> Result<u64> {
158 let mut total = 0u64;
159 compute_dir_size(dir, &mut total)?;
160 Ok(total)
161}
162
163fn compute_dir_size(dir: &Path, total: &mut u64) -> Result<()> {
164 for entry in fs::read_dir(dir)? {
165 let entry = entry?;
166 let path = entry.path();
167 if path.file_name().map_or(false, |n| n == PROJECT_FILE) {
168 continue;
169 }
170 if path.is_dir() {
171 compute_dir_size(&path, total)?;
172 } else if path.is_symlink() {
173 *total += fs::read_link(&path)
175 .map(|p| p.as_os_str().len() as u64)
176 .unwrap_or(0);
177 } else {
178 *total += entry.metadata()?.len();
179 }
180 }
181 Ok(())
182}
183
184pub fn file_count(dir: &Path) -> Result<u32> {
186 let mut count = 0u32;
187 count_files(dir, &mut count)?;
188 Ok(count)
189}
190
191fn count_files(dir: &Path, count: &mut u32) -> Result<()> {
192 for entry in fs::read_dir(dir)? {
193 let entry = entry?;
194 let path = entry.path();
195 if path.file_name().map_or(false, |n| n == PROJECT_FILE) {
196 continue;
197 }
198 if path.is_dir() {
199 count_files(&path, count)?;
200 } else {
201 *count += 1;
202 }
203 }
204 Ok(())
205}
206
207pub fn remove_dir_contents_except_project(dir: &Path) -> Result<()> {
213 for entry in fs::read_dir(dir)? {
214 let entry = entry?;
215 let path = entry.path();
216 let file_name = path.file_name().and_then(|n| n.to_str());
217
218 if file_name == Some(PROJECT_FILE) {
219 continue;
220 }
221
222 if path.is_dir() {
223 fs::remove_dir_all(&path)?;
224 } else {
225 fs::remove_file(&path)?;
226 }
227 }
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use tempfile::TempDir;
235
236 fn make_test_project(dir: &Path, name: &str) -> ProjectMeta {
237 ProjectMeta::new_local(
238 name.into(),
239 dir.to_string_lossy().to_string(),
240 format!("server:9742:/srv/pwr/projects/{}", name),
241 )
242 }
243
244 #[test]
245 fn test_read_write_round_trip() -> Result<()> {
246 let tmp = TempDir::new()?;
247 let proj_dir = tmp.path().join("testproj");
248 let meta = make_test_project(&proj_dir, "testproj");
249
250 write_project_file(&proj_dir, &meta)?;
251 let read_back = read_project_file(&proj_dir)?;
252 assert!(read_back.is_some());
253 assert_eq!(read_back.unwrap().name, "testproj");
254
255 Ok(())
256 }
257
258 #[test]
259 fn test_atomic_write_no_partial_read() -> Result<()> {
260 let tmp = TempDir::new()?;
261 let proj_dir = tmp.path().join("atomic");
262 let meta = make_test_project(&proj_dir, "atomic");
263
264 assert!(!proj_dir.join(PROJECT_FILE).exists());
266
267 write_project_file(&proj_dir, &meta)?;
268
269 assert!(proj_dir.join(PROJECT_FILE).exists());
271 assert!(!proj_dir.join(".project.toml.tmp").exists());
272
273 Ok(())
274 }
275
276 #[test]
277 fn test_remove_project_file() -> Result<()> {
278 let tmp = TempDir::new()?;
279 let proj_dir = tmp.path().join("remove-test");
280 let meta = make_test_project(&proj_dir, "remove-test");
281
282 write_project_file(&proj_dir, &meta)?;
283 assert!(is_tracked(&proj_dir));
284
285 remove_project_file(&proj_dir)?;
286 assert!(!is_tracked(&proj_dir));
287
288 Ok(())
289 }
290
291 #[test]
292 fn test_is_archived_placeholder() -> Result<()> {
293 let tmp = TempDir::new()?;
294 let proj_dir = tmp.path().join("archived-proj");
295 let mut meta = make_test_project(&proj_dir, "archived-proj");
296 meta.mark_archived(1024, 3, false);
297
298 write_project_file(&proj_dir, &meta)?;
299 assert!(is_archived_placeholder(&proj_dir));
300 assert!(!is_local_project(&proj_dir));
301
302 Ok(())
303 }
304
305 #[test]
306 fn test_is_local_project() -> Result<()> {
307 let tmp = TempDir::new()?;
308 let proj_dir = tmp.path().join("local-proj");
309 let meta = make_test_project(&proj_dir, "local-proj");
310
311 write_project_file(&proj_dir, &meta)?;
312 assert!(is_local_project(&proj_dir));
313 assert!(!is_archived_placeholder(&proj_dir));
314
315 Ok(())
316 }
317
318 #[test]
319 fn test_is_tracked() -> Result<()> {
320 let tmp = TempDir::new()?;
321 let proj_dir = tmp.path().join("tracked");
322
323 assert!(!is_tracked(&proj_dir));
324
325 let meta = make_test_project(&proj_dir, "tracked");
326 write_project_file(&proj_dir, &meta)?;
327
328 assert!(is_tracked(&proj_dir));
329
330 Ok(())
331 }
332
333 #[test]
334 fn test_dir_size() -> Result<()> {
335 let tmp = TempDir::new()?;
336 let proj_dir = tmp.path().join("sized");
337 fs::create_dir_all(proj_dir.join("src"))?;
338 fs::write(proj_dir.join("src").join("main.rs"), b"fn main() {}")?;
339 fs::write(proj_dir.join("README.md"), b"# Hello")?;
340
341 let size = dir_size(&proj_dir)?;
342 assert!(size > 0);
343 assert_eq!(size, 19);
345
346 Ok(())
347 }
348
349 #[test]
350 fn test_file_count() -> Result<()> {
351 let tmp = TempDir::new()?;
352 let proj_dir = tmp.path().join("counted");
353 fs::create_dir_all(proj_dir.join("subdir"))?;
354 fs::write(proj_dir.join("a.txt"), b"a")?;
355 fs::write(proj_dir.join("b.txt"), b"b")?;
356 fs::write(proj_dir.join("subdir").join("c.txt"), b"c")?;
357
358 let meta = make_test_project(&proj_dir, "counted");
360 write_project_file(&proj_dir, &meta)?;
361
362 assert_eq!(file_count(&proj_dir)?, 3);
363
364 Ok(())
365 }
366
367 #[test]
368 fn test_remove_dir_contents_except_project() -> Result<()> {
369 let tmp = TempDir::new()?;
370 let proj_dir = tmp.path().join("stripped");
371 fs::create_dir_all(proj_dir.join("src"))?;
372 fs::write(proj_dir.join("src").join("lib.rs"), b"pub fn foo() {}")?;
373 fs::write(proj_dir.join("Cargo.toml"), b"[package]")?;
374
375 let meta = make_test_project(&proj_dir, "stripped");
376 write_project_file(&proj_dir, &meta)?;
377
378 remove_dir_contents_except_project(&proj_dir)?;
379
380 let entries: Vec<_> = fs::read_dir(&proj_dir)?
382 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
383 .collect();
384 assert_eq!(entries, vec![PROJECT_FILE.to_string()]);
385
386 Ok(())
387 }
388
389 #[test]
390 fn test_find_projects() -> Result<()> {
391 let tmp = TempDir::new()?;
392 let root = tmp.path().join("projects");
393 fs::create_dir_all(&root)?;
394
395 for name in &["alpha", "beta"] {
397 let dir = root.join(name);
398 fs::create_dir(&dir)?;
399 let meta = make_test_project(&dir, name);
400 write_project_file(&dir, &meta)?;
401 }
402
403 fs::create_dir(root.join("random"))?;
405
406 let found = find_projects(&root)?;
407 assert_eq!(found.len(), 2);
408
409 let names: Vec<String> = found.iter().map(|(_, m)| m.name.clone()).collect();
410 assert!(names.contains(&"alpha".into()));
411 assert!(names.contains(&"beta".into()));
412
413 Ok(())
414 }
415
416 #[test]
417 fn test_find_projects_recursive() -> Result<()> {
418 let tmp = TempDir::new()?;
419 let root = tmp.path().join("nested");
420 fs::create_dir_all(root.join("outer").join("inner"))?;
421
422 let outer_meta = make_test_project(&root.join("outer"), "outer");
423 write_project_file(&root.join("outer"), &outer_meta)?;
424
425 let inner_meta = make_test_project(&root.join("outer").join("inner"), "inner");
426 write_project_file(&root.join("outer").join("inner"), &inner_meta)?;
427
428 let found = find_projects_recursive(&root)?;
429 assert_eq!(found.len(), 2);
430
431 Ok(())
432 }
433}