use std::collections::{HashMap, HashSet};
use std::io;
use std::path::PathBuf;
use crate::manifest::known_manifests;
use crate::Config;
pub fn file_size(path: &str) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
pub fn build_ref_counts(config: &Config) -> HashMap<String, Vec<String>> {
let mut refs: HashMap<String, Vec<String>> = HashMap::new();
for (model_name, model_config) in &config.models {
for path in model_config.all_file_paths() {
refs.entry(path).or_default().push(model_name.clone());
}
}
for manifest in known_manifests() {
if config.models.contains_key(&manifest.name) {
continue; }
if config.manifest_model_is_downloaded(&manifest.name) {
for path in config.model_config(&manifest.name).all_file_paths() {
refs.entry(path).or_default().push(manifest.name.clone());
}
}
}
refs
}
pub fn is_model_installed(config: &Config, canonical: &str) -> bool {
config.models.contains_key(canonical) || config.manifest_model_is_downloaded(canonical)
}
#[derive(Debug, Clone)]
pub struct RemovalPlan {
pub model: String,
pub unique_files: Vec<(String, u64)>,
pub shared_files: Vec<(String, Vec<String>)>,
pub transformer: Option<String>,
}
impl RemovalPlan {
pub fn total_unique_bytes(&self) -> u64 {
self.unique_files.iter().map(|(_, size)| size).sum()
}
}
pub fn plan_removal(config: &Config, canonical: &str) -> RemovalPlan {
let ref_counts = build_ref_counts(config);
let model_config = if let Some(cfg) = config.models.get(canonical) {
cfg.clone()
} else {
config.resolved_model_config(canonical)
};
let mut unique_files: Vec<(String, u64)> = Vec::new();
let mut shared_files: Vec<(String, Vec<String>)> = Vec::new();
for path in &model_config.all_file_paths() {
let refs = ref_counts.get(path).cloned().unwrap_or_default();
let other_refs: Vec<String> = refs.into_iter().filter(|name| name != canonical).collect();
if other_refs.is_empty() {
unique_files.push((path.clone(), file_size(path)));
} else {
shared_files.push((path.clone(), other_refs));
}
}
RemovalPlan {
model: canonical.to_string(),
unique_files,
shared_files,
transformer: model_config.transformer.clone(),
}
}
pub fn collect_hf_cache_blob_paths(
config: &Config,
model_name: &str,
unique_clean_paths: &[(String, u64)],
) -> Vec<PathBuf> {
let manifest = match crate::manifest::find_manifest(model_name) {
Some(m) => m,
None => return Vec::new(),
};
let models_dir = config.resolved_models_dir();
let cache_dir = models_dir.join(".hf-cache");
if !cache_dir.is_dir() {
return Vec::new();
}
let unique_set: HashSet<String> = unique_clean_paths.iter().map(|(p, _)| p.clone()).collect();
let mut blobs = Vec::new();
for file in &manifest.files {
let clean_path = models_dir
.join(crate::manifest::storage_path(manifest, file))
.to_string_lossy()
.to_string();
if !unique_set.contains(&clean_path) {
continue;
}
let repo_dir_name = format!("models--{}", file.hf_repo.replace('/', "--"));
let repo_dir = cache_dir.join(&repo_dir_name);
if !repo_dir.is_dir() {
continue;
}
let snapshots_dir = repo_dir.join("snapshots");
if !snapshots_dir.is_dir() {
continue;
}
if let Ok(revisions) = std::fs::read_dir(&snapshots_dir) {
for rev in revisions.flatten() {
let snap_file = rev.path().join(&file.hf_filename);
if snap_file.symlink_metadata().is_ok() {
if let Ok(blob) = snap_file.canonicalize() {
blobs.push(blob);
}
blobs.push(snap_file);
}
}
}
}
blobs
}
#[derive(Debug, Clone, Default)]
pub struct RemovalOutcome {
pub removed: Vec<String>,
pub freed_bytes: u64,
pub warnings: Vec<String>,
}
pub fn execute_removal(config: &Config, plan: &RemovalPlan) -> RemovalOutcome {
let mut outcome = RemovalOutcome::default();
let hf_cache_blobs = collect_hf_cache_blob_paths(config, &plan.model, &plan.unique_files);
for (path, _size) in &plan.unique_files {
match std::fs::remove_file(path) {
Ok(()) => outcome.removed.push(path.clone()),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
outcome.warnings.push(format!("{path} already deleted"));
}
Err(e) => {
outcome
.warnings
.push(format!("failed to delete {path}: {e}"));
}
}
}
for blob_path in &hf_cache_blobs {
if blob_path.exists() {
let size = file_size(&blob_path.to_string_lossy());
match std::fs::remove_file(blob_path) {
Ok(()) => outcome.freed_bytes += size,
Err(e) => {
outcome.warnings.push(format!(
"failed to clean up cache file {}: {e}",
blob_path.display()
));
}
}
}
}
if hf_cache_blobs.is_empty() {
outcome.freed_bytes = plan.total_unique_bytes();
}
if let Some(ref t) = plan.transformer {
if let Some(parent) = std::path::Path::new(t).parent() {
let _ = std::fs::remove_dir(parent); }
}
outcome
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ModelConfig;
fn tmp_dir(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"mold-removal-{}-{}",
label,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
fn two_model_config(tmp: &std::path::Path) -> Config {
let shared_vae = tmp.join("shared-vae.safetensors");
let mut config = Config::default();
config.models.insert(
"model-a".into(),
ModelConfig {
transformer: Some(tmp.join("unique-a.gguf").to_string_lossy().into_owned()),
vae: Some(shared_vae.to_string_lossy().into_owned()),
..Default::default()
},
);
config.models.insert(
"model-b".into(),
ModelConfig {
transformer: Some(tmp.join("unique-b.gguf").to_string_lossy().into_owned()),
vae: Some(shared_vae.to_string_lossy().into_owned()),
..Default::default()
},
);
config
}
#[test]
fn build_ref_counts_counts_shared_files_across_models() {
let tmp = tmp_dir("refs");
let config = two_model_config(&tmp);
let refs = build_ref_counts(&config);
let shared = tmp.join("shared-vae.safetensors");
assert_eq!(refs[&shared.to_string_lossy().into_owned()].len(), 2);
assert_eq!(
refs[&tmp.join("unique-a.gguf").to_string_lossy().into_owned()].len(),
1
);
}
#[test]
fn plan_classifies_unique_and_shared_files() {
let tmp = tmp_dir("plan");
let config = two_model_config(&tmp);
let plan = plan_removal(&config, "model-a");
assert_eq!(plan.model, "model-a");
assert_eq!(plan.unique_files.len(), 1, "only the transformer is unique");
assert!(plan.unique_files[0].0.ends_with("unique-a.gguf"));
assert_eq!(plan.shared_files.len(), 1, "the VAE is shared");
assert!(plan.shared_files[0].0.ends_with("shared-vae.safetensors"));
assert_eq!(plan.shared_files[0].1, vec!["model-b".to_string()]);
}
#[test]
fn execute_removal_deletes_unique_files_and_keeps_shared() {
let tmp = tmp_dir("exec");
std::fs::create_dir_all(&tmp).unwrap();
let unique_a = tmp.join("unique-a.gguf");
let unique_b = tmp.join("unique-b.gguf");
let shared_vae = tmp.join("shared-vae.safetensors");
std::fs::write(&unique_a, b"transformer-a").unwrap(); std::fs::write(&unique_b, b"transformer-b").unwrap();
std::fs::write(&shared_vae, b"vae").unwrap();
let config = two_model_config(&tmp);
let plan = plan_removal(&config, "model-a");
let outcome = execute_removal(&config, &plan);
assert!(!unique_a.exists(), "exclusive file must be deleted");
assert!(shared_vae.exists(), "shared file must survive");
assert!(unique_b.exists(), "other model's file must survive");
assert_eq!(
outcome.removed,
vec![unique_a.to_string_lossy().into_owned()]
);
assert_eq!(outcome.freed_bytes, 13, "freed = unique file size");
assert!(outcome.warnings.is_empty(), "got: {:?}", outcome.warnings);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn execute_removal_reports_missing_files_as_warnings() {
let tmp = tmp_dir("missing");
let config = two_model_config(&tmp);
let plan = plan_removal(&config, "model-a");
let outcome = execute_removal(&config, &plan);
assert!(outcome.removed.is_empty());
assert_eq!(outcome.warnings.len(), 1);
assert!(
outcome.warnings[0].ends_with("already deleted"),
"got: {:?}",
outcome.warnings
);
}
}