Skip to main content

kernel/
removal.rs

1//! Deleting an installed model: what a deletion would touch (a preview) and the
2//! pure path/size logic behind it. The actual removal — trashing files or asking
3//! the Ollama daemon to delete a tag — is driven by the runtime crate.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::discovery::gguf_shards;
9use crate::records::{ModelRecord, ModelState, SourceKind};
10
11/// Why a model could not be deleted.
12#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
13pub enum RemovalError {
14    /// The model's kind is not something hedos can delete.
15    #[error("{}", not_deletable_message(kind))]
16    NotDeletable {
17        /// The kind that can't be deleted.
18        kind: SourceKind,
19    },
20    /// The model is generating right now.
21    #[error("{name} is answering right now. Stop generation, then delete.")]
22    ModelBusy {
23        /// The model's name.
24        name: String,
25    },
26    /// The model is still downloading.
27    #[error("{name} is still downloading. Cancel the download first, then delete.")]
28    StillDownloading {
29        /// The model's name.
30        name: String,
31    },
32    /// The Ollama daemon isn't available to delete its model.
33    #[error("{0}")]
34    DaemonUnavailable(String),
35    /// The daemon refused or failed the delete.
36    #[error("{0}")]
37    DaemonDeleteFailed(String),
38    /// Moving a file to the trash failed.
39    #[error("Couldn't move {path} to the trash: {reason}")]
40    TrashFailed {
41        /// The path that couldn't be trashed.
42        path: String,
43        /// Why it failed.
44        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/// What deleting a model would touch: the files (or a daemon delete), and an
59/// estimate of the space it would free.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ModelDeletionPreview {
62    /// The model's stable id.
63    pub model_id: String,
64    /// The name to show.
65    pub name: String,
66    /// The model's source kind.
67    pub kind: SourceKind,
68    /// The filesystem paths that would be removed (empty for a daemon delete).
69    pub paths: Vec<String>,
70    /// The estimated bytes freed.
71    pub bytes_estimate: i64,
72    /// Whether the delete goes through the Ollama daemon.
73    pub via_daemon: bool,
74    /// Whether the model's weights are already missing from disk.
75    pub missing: bool,
76}
77
78/// The outcome of a deletion.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ModelDeletionReport {
81    /// The model's stable id.
82    pub model_id: String,
83    /// The name shown.
84    pub name: String,
85    /// The model's source kind.
86    pub kind: SourceKind,
87    /// The paths that were trashed.
88    pub trashed_paths: Vec<String>,
89    /// The estimated bytes freed.
90    pub freed_bytes_estimate: i64,
91    /// Whether the delete went through the Ollama daemon.
92    pub daemon_deleted: bool,
93}
94
95/// Whether a model can be deleted (built-in and endpoint models can't).
96pub fn is_deletable(record: &ModelRecord) -> bool {
97    let kind = &record.source.kind;
98    kind != &SourceKind::builtin() && kind != &SourceKind::endpoint()
99}
100
101/// Preview what deleting `record` would do.
102pub 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
134/// The files that removing `record` would delete (non-Ollama models).
135pub 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
151/// The full shard set a single-file model belongs to (or just the file when it
152/// isn't a shard).
153fn 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()); // the dir doesn't exist
239        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}