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.size_on_disk().unwrap_or(0)
118 };
119 ModelDeletionPreview {
120 model_id: record.id.clone(),
121 name: record.display_name().to_owned(),
122 kind: record.source.kind.clone(),
123 paths,
124 bytes_estimate,
125 via_daemon,
126 missing,
127 }
128}
129
130pub fn removable_paths(record: &ModelRecord) -> Vec<PathBuf> {
132 let source = PathBuf::from(&record.source.path);
133 let kind = &record.source.kind;
134 if kind == &SourceKind::huggingface_cache() || kind == &SourceKind::folder() {
135 if source.exists() {
136 vec![source]
137 } else {
138 vec![]
139 }
140 } else if kind == &SourceKind::lm_studio() || kind == &SourceKind::file() {
141 shard_group(&source)
142 } else {
143 vec![]
144 }
145}
146
147fn shard_group(path: &Path) -> Vec<PathBuf> {
150 let filename = path
151 .file_name()
152 .map(|name| name.to_string_lossy().into_owned())
153 .unwrap_or_default();
154 let Some(shard) = gguf_shards::parse(&filename) else {
155 return if path.exists() {
156 vec![path.to_path_buf()]
157 } else {
158 vec![]
159 };
160 };
161 let Some(directory) = path.parent() else {
162 return vec![];
163 };
164 let Ok(entries) = fs::read_dir(directory) else {
165 return vec![];
166 };
167 let mut group: Vec<PathBuf> = entries
168 .flatten()
169 .filter_map(|entry| {
170 let name = entry.file_name().to_string_lossy().into_owned();
171 gguf_shards::parse(&name)
172 .filter(|candidate| candidate.base == shard.base && candidate.total == shard.total)
173 .map(|_| entry.path())
174 })
175 .collect();
176 group.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
177 group
178}
179
180fn on_disk_bytes(paths: &[String]) -> i64 {
181 paths.iter().fold(0i64, |total, path| {
182 let size = fs::metadata(path)
183 .map(|meta| meta.len() as i64)
184 .unwrap_or(0);
185 total.saturating_add(size.max(0))
186 })
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::records::{Modality, ModelRecord, ModelSource};
193
194 fn record(kind: SourceKind, path: &str) -> ModelRecord {
195 ModelRecord::new(
196 "Model",
197 Modality::text(),
198 Vec::new(),
199 ModelSource::new(kind, path),
200 )
201 }
202
203 #[test]
204 fn built_in_and_endpoint_models_are_not_deletable() {
205 assert!(!is_deletable(&record(SourceKind::builtin(), "")));
206 assert!(!is_deletable(&record(SourceKind::endpoint(), "")));
207 assert!(is_deletable(&record(SourceKind::ollama(), "")));
208 assert!(is_deletable(&record(SourceKind::huggingface_cache(), "/x")));
209 }
210
211 #[test]
212 fn not_deletable_messages_are_kind_specific() {
213 assert!(not_deletable_message(&SourceKind::builtin()).contains("built-in"));
214 assert!(not_deletable_message(&SourceKind::endpoint()).contains("connections"));
215 assert!(not_deletable_message(&SourceKind::lm_studio()).contains("can't be deleted"));
216 }
217
218 #[test]
219 fn an_ollama_model_previews_a_daemon_delete_with_no_paths() {
220 let mut rec = record(SourceKind::ollama(), "");
221 rec.footprint_bytes = Some(2048 * (1 << 20));
222 let preview = preview(&rec);
223 assert!(preview.via_daemon);
224 assert!(preview.paths.is_empty());
225 assert_eq!(preview.bytes_estimate, 2048i64 << 20);
226 }
227
228 #[test]
229 fn a_missing_folder_model_previews_no_paths_and_zero_bytes() {
230 let mut rec = record(SourceKind::folder(), "/no/such/model/dir");
231 rec.state = ModelState::Missing;
232 let preview = preview(&rec);
233 assert!(!preview.via_daemon);
234 assert!(preview.paths.is_empty()); assert_eq!(preview.bytes_estimate, 0);
236 }
237
238 #[test]
239 fn a_present_folder_model_lists_its_directory() {
240 let dir =
241 std::env::temp_dir().join(format!("hedos-removal-{:?}", std::thread::current().id()));
242 std::fs::create_dir_all(&dir).unwrap();
243 let mut rec = record(SourceKind::folder(), dir.to_str().unwrap());
244 rec.footprint_bytes = Some(10 * (1 << 20));
245 let preview = preview(&rec);
246 assert_eq!(preview.paths, vec![dir.to_string_lossy().into_owned()]);
247 assert_eq!(preview.bytes_estimate, 10i64 << 20);
248 std::fs::remove_dir_all(&dir).ok();
249 }
250
251 #[test]
252 fn a_non_shard_single_file_model_lists_just_the_file() {
253 let dir = std::env::temp_dir().join(format!(
254 "hedos-removal-single-{:?}",
255 std::thread::current().id()
256 ));
257 std::fs::create_dir_all(&dir).unwrap();
258 let file = dir.join("model.gguf");
259 std::fs::write(&file, b"x").unwrap();
260 let rec = record(SourceKind::file(), file.to_str().unwrap());
261 let preview = preview(&rec);
262 assert_eq!(preview.paths, vec![file.to_string_lossy().into_owned()]);
263 std::fs::remove_dir_all(&dir).ok();
264 }
265
266 #[test]
267 fn a_sharded_file_model_groups_all_shards() {
268 let dir = std::env::temp_dir().join(format!(
269 "hedos-removal-shards-{:?}",
270 std::thread::current().id()
271 ));
272 std::fs::create_dir_all(&dir).unwrap();
273 for name in [
274 "model-00001-of-00003.gguf",
275 "model-00002-of-00003.gguf",
276 "model-00003-of-00003.gguf",
277 "unrelated.gguf",
278 ] {
279 std::fs::write(dir.join(name), b"x").unwrap();
280 }
281 let first = dir.join("model-00001-of-00003.gguf");
282 let rec = record(SourceKind::file(), first.to_str().unwrap());
283 let preview = preview(&rec);
284 assert_eq!(preview.paths.len(), 3, "{:?}", preview.paths);
285 assert!(preview.paths.iter().all(|p| p.contains("of-00003")));
286 std::fs::remove_dir_all(&dir).ok();
287 }
288}