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#[derive(Debug)]
24pub struct CleanedItem {
25 pub path: String,
26 pub size: u64,
27 pub success: bool,
28 pub error: Option<String>,
29}
30
31pub 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
60pub 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
92pub 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
101pub 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
133fn elevated_remove(path: &str, size: u64) -> CleanedItem {
136 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
174fn 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}