use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::discovery::hf_scanner::HFCacheScanner;
use crate::discovery::lm_studio_scanner::LMStudioScanner;
use crate::discovery::loose_file_scanner::LooseFileScanner;
use crate::discovery::ollama_scanner::OllamaStoreScanner;
use crate::discovery::scanner::StoreScanner;
use crate::fs::expand_tilde;
use crate::records::SourceKind;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelsSettings {
pub watched_folders: Vec<String>,
pub hf_cache_roots: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ModelHabitat {
home: PathBuf,
environment: HashMap<String, String>,
}
impl ModelHabitat {
pub fn new(home: impl Into<PathBuf>, environment: HashMap<String, String>) -> Self {
Self {
home: home.into(),
environment,
}
}
pub fn detect() -> Self {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default();
let environment = std::env::vars().collect();
Self { home, environment }
}
pub fn roots(&self, settings: &ModelsSettings) -> Vec<(SourceKind, PathBuf)> {
let mut roots = vec![(SourceKind::ollama(), self.ollama_root())];
for url in self.hf_default_roots(&settings.hf_cache_roots) {
roots.push((SourceKind::huggingface_cache(), url));
}
for url in lm_studio_roots(&self.home) {
roots.push((SourceKind::lm_studio(), url));
}
for url in loose_directories(&self.home) {
roots.push((SourceKind::file(), url));
}
for path in &settings.watched_folders {
roots.push((SourceKind::file(), PathBuf::from(path)));
}
roots
}
pub fn scanners(
&self,
kinds: Option<&[SourceKind]>,
settings: &ModelsSettings,
) -> Vec<Box<dyn StoreScanner>> {
let wanted = |produced: &[SourceKind]| match kinds {
None => true,
Some(kinds) => produced.iter().any(|kind| kinds.contains(kind)),
};
let mut scanners: Vec<Box<dyn StoreScanner>> = Vec::new();
if wanted(&[SourceKind::ollama()]) {
scanners.push(Box::new(OllamaStoreScanner::new(self.ollama_root())));
}
if wanted(&[SourceKind::huggingface_cache()]) {
scanners.push(Box::new(HFCacheScanner::with_user_roots(
self.hf_default_roots(&[]),
self.hf_user_roots(&settings.hf_cache_roots),
)));
}
if wanted(&[SourceKind::lm_studio()]) {
scanners.push(Box::new(LMStudioScanner::new(lm_studio_roots(&self.home))));
}
if wanted(&[SourceKind::file(), SourceKind::folder()]) {
let watched = settings.watched_folders.iter().map(PathBuf::from).collect();
scanners.push(Box::new(LooseFileScanner::with_user_directories(
loose_directories(&self.home),
watched,
)));
}
scanners
}
fn ollama_root(&self) -> PathBuf {
match self.environment.get("OLLAMA_MODELS") {
Some(custom) if !custom.is_empty() => expand_tilde(custom, &self.home),
_ => self.home.join(".ollama/models"),
}
}
fn hf_default_roots(&self, user: &[String]) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(cache) = self.environment.get("HF_HUB_CACHE")
&& !cache.is_empty()
{
candidates.push(expand_tilde(cache, &self.home));
}
if let Some(hf_home) = self.environment.get("HF_HOME")
&& !hf_home.is_empty()
{
candidates.push(expand_tilde(hf_home, &self.home).join("hub"));
}
candidates.push(self.home.join(".cache/huggingface/hub"));
candidates.extend(self.hf_user_roots(user));
dedup(candidates)
}
fn hf_user_roots(&self, paths: &[String]) -> Vec<PathBuf> {
let mut roots = Vec::new();
for path in paths {
let base = expand_tilde(path, &self.home);
let candidates = [base.join("hub"), base.join("huggingface/hub"), base.clone()];
let existing: Vec<PathBuf> = candidates
.into_iter()
.filter(|url| is_hub_directory(url))
.collect();
if existing.is_empty() {
roots.push(base);
} else {
roots.extend(existing);
}
}
dedup(roots)
}
}
fn lm_studio_roots(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".lmstudio/models"),
home.join(".cache/lm-studio/models"),
]
}
fn loose_directories(home: &Path) -> Vec<PathBuf> {
vec![home.join("Downloads"), home.join("Models")]
}
fn is_hub_directory(url: &Path) -> bool {
url.is_dir()
}
fn dedup(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect()
}