Skip to main content

diskforge_core/
cleaner.rs

1use std::fs::{self, OpenOptions};
2use std::io::{ErrorKind, Write};
3use std::path::Path;
4
5use thiserror::Error;
6
7use crate::sizing::format_size;
8
9#[derive(Debug, Error)]
10pub enum CleanError {
11    #[error("path does not exist: {0}")]
12    NotFound(String),
13    #[error("permission denied: {0}")]
14    PermissionDenied(String),
15    #[error("io error removing {path}: {source}")]
16    Io {
17        path: String,
18        source: std::io::Error,
19    },
20}
21
22/// Result of a single deletion
23#[derive(Debug)]
24pub struct CleanedItem {
25    pub path: String,
26    pub size: u64,
27    pub success: bool,
28    pub error: Option<String>,
29}
30
31/// Run a shell command to clean (used for Docker, etc.)
32pub fn run_cleanup_command(command: &str, size: u64) -> CleanedItem {
33    let result = std::process::Command::new("sh")
34        .args(["-c", command])
35        .output();
36    let item = match result {
37        Ok(output) if output.status.success() => CleanedItem {
38            path: command.to_string(),
39            size,
40            success: true,
41            error: None,
42        },
43        Ok(output) => CleanedItem {
44            path: command.to_string(),
45            size: 0,
46            success: false,
47            error: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()),
48        },
49        Err(e) => CleanedItem {
50            path: command.to_string(),
51            size: 0,
52            success: false,
53            error: Some(e.to_string()),
54        },
55    };
56    log_operation(&item);
57    item
58}
59
60/// Delete a single file. On permission denied, retries with elevated privileges via macOS auth dialog.
61pub fn remove_file(path: &Path, size: u64) -> CleanedItem {
62    let path_str = path.to_string_lossy().to_string();
63
64    if !path.exists() {
65        return CleanedItem {
66            path: path_str,
67            size: 0,
68            success: false,
69            error: Some("path does not exist".into()),
70        };
71    }
72
73    let item = match fs::remove_file(path) {
74        Ok(()) => CleanedItem {
75            path: path_str,
76            size,
77            success: true,
78            error: None,
79        },
80        Err(e) if e.kind() == ErrorKind::PermissionDenied => elevated_remove(&path_str, size),
81        Err(e) => CleanedItem {
82            path: path_str,
83            size: 0,
84            success: false,
85            error: Some(e.to_string()),
86        },
87    };
88    log_operation(&item);
89    item
90}
91
92/// Delete a path — dispatches to `remove_file` or `remove_dir` based on path type
93pub fn remove_path(path: &Path, size: u64) -> CleanedItem {
94    if path.is_file() {
95        remove_file(path, size)
96    } else {
97        remove_dir(path, size)
98    }
99}
100
101/// Delete a directory and all its contents. On permission denied, retries with elevated privileges.
102pub fn remove_dir(path: &Path, size: u64) -> CleanedItem {
103    let path_str = path.to_string_lossy().to_string();
104
105    if !path.exists() {
106        return CleanedItem {
107            path: path_str,
108            size: 0,
109            success: false,
110            error: Some("path does not exist".into()),
111        };
112    }
113
114    let item = match fs::remove_dir_all(path) {
115        Ok(()) => CleanedItem {
116            path: path_str,
117            size,
118            success: true,
119            error: None,
120        },
121        Err(e) if e.kind() == ErrorKind::PermissionDenied => elevated_remove(&path_str, size),
122        Err(e) => CleanedItem {
123            path: path_str,
124            size: 0,
125            success: false,
126            error: Some(e.to_string()),
127        },
128    };
129    log_operation(&item);
130    item
131}
132
133/// Retry deletion with elevated privileges using macOS native auth dialog.
134/// Shows the system password prompt via `osascript`.
135fn elevated_remove(path: &str, size: u64) -> CleanedItem {
136    // Escape single quotes in the path for shell safety
137    let escaped = path.replace('\'', "'\\''");
138    let script = format!("do shell script \"rm -rf '{escaped}'\" with administrator privileges");
139
140    let result = std::process::Command::new("osascript")
141        .args(["-e", &script])
142        .output();
143
144    match result {
145        Ok(output) if output.status.success() => CleanedItem {
146            path: path.to_string(),
147            size,
148            success: true,
149            error: None,
150        },
151        Ok(output) => {
152            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
153            let error = if stderr.contains("User canceled") || stderr.contains("-128") {
154                "permission denied (auth cancelled)".to_string()
155            } else {
156                format!("elevated removal failed: {stderr}")
157            };
158            CleanedItem {
159                path: path.to_string(),
160                size: 0,
161                success: false,
162                error: Some(error),
163            }
164        }
165        Err(e) => CleanedItem {
166            path: path.to_string(),
167            size: 0,
168            success: false,
169            error: Some(format!("failed to request privileges: {e}")),
170        },
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Operation logging — every deletion is recorded to ~/.config/diskforge/history.log
176// ---------------------------------------------------------------------------
177
178/// Log a deletion operation to the history file.
179fn log_operation(item: &CleanedItem) {
180    let home = match std::env::var("HOME") {
181        Ok(h) => h,
182        Err(_) => return,
183    };
184
185    let log_dir = format!("{home}/.config/diskforge");
186    let _ = fs::create_dir_all(&log_dir);
187    let log_path = format!("{log_dir}/history.log");
188
189    let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) else {
190        return;
191    };
192
193    let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
194    let status = if item.success { "OK" } else { "FAIL" };
195    let size_str = if item.size > 0 {
196        format_size(item.size)
197    } else {
198        "0 B".into()
199    };
200    let error_str = item
201        .error
202        .as_deref()
203        .map(|e| format!(" error={e}"))
204        .unwrap_or_default();
205
206    let _ = writeln!(
207        file,
208        "[{now}] {status} size={size_str} path={}{error_str}",
209        item.path
210    );
211}