1use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::discovery::gguf_shards;
9use crate::records::{ModelRecord, ModelState, SourceKind};
10
11#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
13pub enum RemovalError {
14 #[error("{}", not_deletable_message(kind))]
16 NotDeletable {
17 kind: SourceKind,
19 },
20 #[error("{name} is answering right now. Stop generation, then delete.")]
22 ModelBusy {
23 name: String,
25 },
26 #[error("{name} is still downloading. Cancel the download first, then delete.")]
28 StillDownloading {
29 name: String,
31 },
32 #[error("{0}")]
34 DaemonUnavailable(String),
35 #[error("{0}")]
37 DaemonDeleteFailed(String),
38 #[error("Couldn't move {path} to the trash: {reason}")]
40 TrashFailed {
41 path: String,
43 reason: String,
45 },
46}
47
48fn not_deletable_message(kind: &SourceKind) -> String {
49 if kind == &SourceKind::builtin() {
50 "The built-in model ships with the OS and can't be deleted.".to_owned()
51 } else if kind == &SourceKind::endpoint() {
52 "Server models are connections, not files. Remove them from the servers list.".to_owned()
53 } else {
54 format!("{} models can't be deleted.", kind.as_str())
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ModelDeletionPreview {
62 pub model_id: String,
64 pub name: String,
66 pub kind: SourceKind,
68 pub paths: Vec<String>,
70 pub bytes_estimate: i64,
72 pub via_daemon: bool,
74 pub missing: bool,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ModelDeletionReport {
81 pub model_id: String,
83 pub name: String,
85 pub kind: SourceKind,
87 pub trashed_paths: Vec<String>,
89 pub freed_bytes_estimate: i64,
91 pub daemon_deleted: bool,
93}
94
95pub fn is_deletable(record: &ModelRecord) -> bool {
97 let kind = &record.source.kind;
98 kind != &SourceKind::builtin() && kind != &SourceKind::endpoint()
99}
100
101pub fn preview(record: &ModelRecord) -> ModelDeletionPreview {
103 let missing = record.state == ModelState::Missing;
104 let is_ollama = record.source.kind == SourceKind::ollama();
105 let via_daemon = !missing && is_ollama;
106 let paths: Vec<String> = if is_ollama {
107 Vec::new()
108 } else {
109 removable_paths(record)
110 .into_iter()
111 .map(|path| path.to_string_lossy().into_owned())
112 .collect()
113 };
114 let bytes_estimate = if missing {
115 on_disk_bytes(&paths)
116 } else {
117 record
118 .footprint_mb
119 .unwrap_or(0)
120 .max(0)
121 .saturating_mul(1 << 20)
122 };
123 ModelDeletionPreview {
124 model_id: record.id.clone(),
125 name: record.display_name().to_owned(),
126 kind: record.source.kind.clone(),
127 paths,
128 bytes_estimate,
129 via_daemon,
130 missing,
131 }
132}
133
134pub fn removable_paths(record: &ModelRecord) -> Vec<PathBuf> {
136 let source = PathBuf::from(&record.source.path);
137 let kind = &record.source.kind;
138 if kind == &SourceKind::huggingface_cache() || kind == &SourceKind::folder() {
139 if source.exists() {
140 vec![source]
141 } else {
142 vec![]
143 }
144 } else if kind == &SourceKind::lm_studio() || kind == &SourceKind::file() {
145 shard_group(&source)
146 } else {
147 vec![]
148 }
149}
150
151fn shard_group(path: &Path) -> Vec<PathBuf> {
154 let filename = path
155 .file_name()
156 .map(|name| name.to_string_lossy().into_owned())
157 .unwrap_or_default();
158 let Some(shard) = gguf_shards::parse(&filename) else {
159 return if path.exists() {
160 vec![path.to_path_buf()]
161 } else {
162 vec![]
163 };
164 };
165 let Some(directory) = path.parent() else {
166 return vec![];
167 };
168 let Ok(entries) = fs::read_dir(directory) else {
169 return vec![];
170 };
171 let mut group: Vec<PathBuf> = entries
172 .flatten()
173 .filter_map(|entry| {
174 let name = entry.file_name().to_string_lossy().into_owned();
175 gguf_shards::parse(&name)
176 .filter(|candidate| candidate.base == shard.base && candidate.total == shard.total)
177 .map(|_| entry.path())
178 })
179 .collect();
180 group.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
181 group
182}
183
184fn on_disk_bytes(paths: &[String]) -> i64 {
185 paths.iter().fold(0i64, |total, path| {
186 let size = fs::metadata(path)
187 .map(|meta| meta.len() as i64)
188 .unwrap_or(0);
189 total.saturating_add(size.max(0))
190 })
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::records::{Modality, ModelRecord, ModelSource};
197
198 fn record(kind: SourceKind, path: &str) -> ModelRecord {
199 ModelRecord::new(
200 "Model",
201 Modality::text(),
202 Vec::new(),
203 ModelSource::new(kind, path),
204 )
205 }
206
207 #[test]
208 fn built_in_and_endpoint_models_are_not_deletable() {
209 assert!(!is_deletable(&record(SourceKind::builtin(), "")));
210 assert!(!is_deletable(&record(SourceKind::endpoint(), "")));
211 assert!(is_deletable(&record(SourceKind::ollama(), "")));
212 assert!(is_deletable(&record(SourceKind::huggingface_cache(), "/x")));
213 }
214
215 #[test]
216 fn not_deletable_messages_are_kind_specific() {
217 assert!(not_deletable_message(&SourceKind::builtin()).contains("built-in"));
218 assert!(not_deletable_message(&SourceKind::endpoint()).contains("connections"));
219 assert!(not_deletable_message(&SourceKind::lm_studio()).contains("can't be deleted"));
220 }
221
222 #[test]
223 fn an_ollama_model_previews_a_daemon_delete_with_no_paths() {
224 let mut rec = record(SourceKind::ollama(), "");
225 rec.footprint_mb = Some(2048);
226 let preview = preview(&rec);
227 assert!(preview.via_daemon);
228 assert!(preview.paths.is_empty());
229 assert_eq!(preview.bytes_estimate, 2048i64 << 20);
230 }
231
232 #[test]
233 fn a_missing_folder_model_previews_no_paths_and_zero_bytes() {
234 let mut rec = record(SourceKind::folder(), "/no/such/model/dir");
235 rec.state = ModelState::Missing;
236 let preview = preview(&rec);
237 assert!(!preview.via_daemon);
238 assert!(preview.paths.is_empty()); assert_eq!(preview.bytes_estimate, 0);
240 }
241
242 #[test]
243 fn a_present_folder_model_lists_its_directory() {
244 let dir =
245 std::env::temp_dir().join(format!("hedos-removal-{:?}", std::thread::current().id()));
246 std::fs::create_dir_all(&dir).unwrap();
247 let mut rec = record(SourceKind::folder(), dir.to_str().unwrap());
248 rec.footprint_mb = Some(10);
249 let preview = preview(&rec);
250 assert_eq!(preview.paths, vec![dir.to_string_lossy().into_owned()]);
251 assert_eq!(preview.bytes_estimate, 10i64 << 20);
252 std::fs::remove_dir_all(&dir).ok();
253 }
254
255 #[test]
256 fn a_non_shard_single_file_model_lists_just_the_file() {
257 let dir = std::env::temp_dir().join(format!(
258 "hedos-removal-single-{:?}",
259 std::thread::current().id()
260 ));
261 std::fs::create_dir_all(&dir).unwrap();
262 let file = dir.join("model.gguf");
263 std::fs::write(&file, b"x").unwrap();
264 let rec = record(SourceKind::file(), file.to_str().unwrap());
265 let preview = preview(&rec);
266 assert_eq!(preview.paths, vec![file.to_string_lossy().into_owned()]);
267 std::fs::remove_dir_all(&dir).ok();
268 }
269
270 #[test]
271 fn a_sharded_file_model_groups_all_shards() {
272 let dir = std::env::temp_dir().join(format!(
273 "hedos-removal-shards-{:?}",
274 std::thread::current().id()
275 ));
276 std::fs::create_dir_all(&dir).unwrap();
277 for name in [
278 "model-00001-of-00003.gguf",
279 "model-00002-of-00003.gguf",
280 "model-00003-of-00003.gguf",
281 "unrelated.gguf",
282 ] {
283 std::fs::write(dir.join(name), b"x").unwrap();
284 }
285 let first = dir.join("model-00001-of-00003.gguf");
286 let rec = record(SourceKind::file(), first.to_str().unwrap());
287 let preview = preview(&rec);
288 assert_eq!(preview.paths.len(), 3, "{:?}", preview.paths);
289 assert!(preview.paths.iter().all(|p| p.contains("of-00003")));
290 std::fs::remove_dir_all(&dir).ok();
291 }
292}