use std::fs;
use std::path::{Path, PathBuf};
use crate::discovery::gguf_shards;
use crate::records::{ModelRecord, ModelState, SourceKind};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RemovalError {
#[error("{}", not_deletable_message(kind))]
NotDeletable {
kind: SourceKind,
},
#[error("{name} is answering right now. Stop generation, then delete.")]
ModelBusy {
name: String,
},
#[error("{name} is still downloading. Cancel the download first, then delete.")]
StillDownloading {
name: String,
},
#[error("{0}")]
DaemonUnavailable(String),
#[error("{0}")]
DaemonDeleteFailed(String),
#[error("Couldn't move {path} to the trash: {reason}")]
TrashFailed {
path: String,
reason: String,
},
}
fn not_deletable_message(kind: &SourceKind) -> String {
if kind == &SourceKind::builtin() {
"The built-in model ships with the OS and can't be deleted.".to_owned()
} else if kind == &SourceKind::endpoint() {
"Server models are connections, not files. Remove them from the servers list.".to_owned()
} else {
format!("{} models can't be deleted.", kind.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelDeletionPreview {
pub model_id: String,
pub name: String,
pub kind: SourceKind,
pub paths: Vec<String>,
pub bytes_estimate: i64,
pub via_daemon: bool,
pub missing: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelDeletionReport {
pub model_id: String,
pub name: String,
pub kind: SourceKind,
pub trashed_paths: Vec<String>,
pub freed_bytes_estimate: i64,
pub daemon_deleted: bool,
}
pub fn is_deletable(record: &ModelRecord) -> bool {
let kind = &record.source.kind;
kind != &SourceKind::builtin() && kind != &SourceKind::endpoint()
}
pub fn preview(record: &ModelRecord) -> ModelDeletionPreview {
let missing = record.state == ModelState::Missing;
let is_ollama = record.source.kind == SourceKind::ollama();
let via_daemon = !missing && is_ollama;
let paths: Vec<String> = if is_ollama {
Vec::new()
} else {
removable_paths(record)
.into_iter()
.map(|path| path.to_string_lossy().into_owned())
.collect()
};
let bytes_estimate = if missing {
on_disk_bytes(&paths)
} else {
record.size_on_disk().unwrap_or(0)
};
ModelDeletionPreview {
model_id: record.id.clone(),
name: record.display_name().to_owned(),
kind: record.source.kind.clone(),
paths,
bytes_estimate,
via_daemon,
missing,
}
}
pub fn removable_paths(record: &ModelRecord) -> Vec<PathBuf> {
let source = PathBuf::from(&record.source.path);
let kind = &record.source.kind;
if kind == &SourceKind::huggingface_cache() || kind == &SourceKind::folder() {
if source.exists() {
vec![source]
} else {
vec![]
}
} else if kind == &SourceKind::lm_studio() || kind == &SourceKind::file() {
shard_group(&source)
} else {
vec![]
}
}
fn shard_group(path: &Path) -> Vec<PathBuf> {
let filename = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let Some(shard) = gguf_shards::parse(&filename) else {
return if path.exists() {
vec![path.to_path_buf()]
} else {
vec![]
};
};
let Some(directory) = path.parent() else {
return vec![];
};
let Ok(entries) = fs::read_dir(directory) else {
return vec![];
};
let mut group: Vec<PathBuf> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
gguf_shards::parse(&name)
.filter(|candidate| candidate.base == shard.base && candidate.total == shard.total)
.map(|_| entry.path())
})
.collect();
group.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
group
}
fn on_disk_bytes(paths: &[String]) -> i64 {
paths.iter().fold(0i64, |total, path| {
let size = fs::metadata(path)
.map(|meta| meta.len() as i64)
.unwrap_or(0);
total.saturating_add(size.max(0))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::records::{Modality, ModelRecord, ModelSource};
fn record(kind: SourceKind, path: &str) -> ModelRecord {
ModelRecord::new(
"Model",
Modality::text(),
Vec::new(),
ModelSource::new(kind, path),
)
}
#[test]
fn built_in_and_endpoint_models_are_not_deletable() {
assert!(!is_deletable(&record(SourceKind::builtin(), "")));
assert!(!is_deletable(&record(SourceKind::endpoint(), "")));
assert!(is_deletable(&record(SourceKind::ollama(), "")));
assert!(is_deletable(&record(SourceKind::huggingface_cache(), "/x")));
}
#[test]
fn not_deletable_messages_are_kind_specific() {
assert!(not_deletable_message(&SourceKind::builtin()).contains("built-in"));
assert!(not_deletable_message(&SourceKind::endpoint()).contains("connections"));
assert!(not_deletable_message(&SourceKind::lm_studio()).contains("can't be deleted"));
}
#[test]
fn an_ollama_model_previews_a_daemon_delete_with_no_paths() {
let mut rec = record(SourceKind::ollama(), "");
rec.footprint_bytes = Some(2048 * (1 << 20));
let preview = preview(&rec);
assert!(preview.via_daemon);
assert!(preview.paths.is_empty());
assert_eq!(preview.bytes_estimate, 2048i64 << 20);
}
#[test]
fn a_missing_folder_model_previews_no_paths_and_zero_bytes() {
let mut rec = record(SourceKind::folder(), "/no/such/model/dir");
rec.state = ModelState::Missing;
let preview = preview(&rec);
assert!(!preview.via_daemon);
assert!(preview.paths.is_empty()); assert_eq!(preview.bytes_estimate, 0);
}
#[test]
fn a_present_folder_model_lists_its_directory() {
let dir =
std::env::temp_dir().join(format!("hedos-removal-{:?}", std::thread::current().id()));
std::fs::create_dir_all(&dir).unwrap();
let mut rec = record(SourceKind::folder(), dir.to_str().unwrap());
rec.footprint_bytes = Some(10 * (1 << 20));
let preview = preview(&rec);
assert_eq!(preview.paths, vec![dir.to_string_lossy().into_owned()]);
assert_eq!(preview.bytes_estimate, 10i64 << 20);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_non_shard_single_file_model_lists_just_the_file() {
let dir = std::env::temp_dir().join(format!(
"hedos-removal-single-{:?}",
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("model.gguf");
std::fs::write(&file, b"x").unwrap();
let rec = record(SourceKind::file(), file.to_str().unwrap());
let preview = preview(&rec);
assert_eq!(preview.paths, vec![file.to_string_lossy().into_owned()]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_sharded_file_model_groups_all_shards() {
let dir = std::env::temp_dir().join(format!(
"hedos-removal-shards-{:?}",
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
for name in [
"model-00001-of-00003.gguf",
"model-00002-of-00003.gguf",
"model-00003-of-00003.gguf",
"unrelated.gguf",
] {
std::fs::write(dir.join(name), b"x").unwrap();
}
let first = dir.join("model-00001-of-00003.gguf");
let rec = record(SourceKind::file(), first.to_str().unwrap());
let preview = preview(&rec);
assert_eq!(preview.paths.len(), 3, "{:?}", preview.paths);
assert!(preview.paths.iter().all(|p| p.contains("of-00003")));
std::fs::remove_dir_all(&dir).ok();
}
}