Skip to main content

pwr_core/
project.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::error::{PwrError, Result};
5use crate::metadata::ProjectMeta;
6
7/// The filename for project metadata stored inside a project directory.
8pub const PROJECT_FILE: &str = ".project.toml";
9
10/// Read a `.project.toml` from a directory.
11///
12/// Returns `None` if the file doesn't exist, allowing callers to
13/// distinguish between an untracked directory and a tracked project
14/// with no metadata file.
15pub 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
28/// Write a `.project.toml` into a directory.
29///
30/// Creates the directory and all parent directories if they don't
31/// exist. The file is written atomically by first writing to a
32/// temporary name and then renaming, preventing readers from seeing
33/// a partially-written file.
34pub 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    // Write to temp file first, then rename for atomicity
41    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
48/// Remove the `.project.toml` from a directory.
49///
50/// This indicates the project is no longer tracked. The directory
51/// contents are not modified.
52pub 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
61/// Check if a directory is an archived project placeholder.
62///
63/// Returns `true` only when all conditions are met:
64/// 1. The path exists and is a directory
65/// 2. A `.project.toml` file exists inside it
66/// 3. The metadata inside has `state = "archived"`
67pub 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
81/// Check if a project file exists in the directory and the project is local.
82pub 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
92/// Determine whether a path is tracked by pwr (has a .project.toml).
93pub fn is_tracked(dir: &Path) -> bool {
94    dir.is_dir() && dir.join(PROJECT_FILE).exists()
95}
96
97/// Find all `.project.toml` files under a root directory (non-recursive).
98///
99/// Only checks direct children of the root. Use `find_projects_recursive`
100/// for nested project structures.
101pub 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
118/// Find all `.project.toml` files recursively under a root directory.
119///
120/// Performs a depth-first traversal. A directory that itself is a
121/// tracked project may still contain nested tracked projects; both
122/// the parent and children are included in the results.
123pub 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            // Recurse into subdirectories — a project may contain
144            // nested tracked projects (e.g., a monorepo with
145            // independently archivable subprojects).
146            find_projects_recursive_inner(&path, projects)?;
147        }
148    }
149    Ok(())
150}
151
152/// Compute the total size of files in a directory, excluding the
153/// `.project.toml` metadata file itself.
154///
155/// Walks the directory tree recursively. Symlinks are not followed;
156/// their size is reported as the link target path length.
157pub 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            // Count the symlink itself, not the target
174            *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
184/// Count files in a directory recursively, excluding `.project.toml`.
185pub 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
207/// Remove all files and subdirectories in a directory except the
208/// `.project.toml` file, leaving an archived placeholder.
209///
210/// This is called after a successful archive to the server to free
211/// local disk space while preserving project identity.
212pub 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        // Before write, no .project.toml and no .tmp file
265        assert!(!proj_dir.join(PROJECT_FILE).exists());
266
267        write_project_file(&proj_dir, &meta)?;
268
269        // After write, .project.toml exists and .tmp does not
270        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        // "fn main() {}" is 12 bytes, "# Hello\n" is 7 bytes
344        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        // Write a .project.toml — should be excluded from count
359        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        // Only .project.toml should remain
381        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        // Create two tracked projects
396        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        // Create an untracked directory
404        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}