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.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
130/// The files that removing `record` would delete (non-Ollama models).
131pub 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
147/// The full shard set a single-file model belongs to (or just the file when it
148/// isn't a shard).
149fn 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()); // the dir doesn't exist
235        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}