kernel/discovery/
habitat.rs1use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13use crate::discovery::hf_scanner::HFCacheScanner;
14use crate::discovery::lm_studio_scanner::LMStudioScanner;
15use crate::discovery::loose_file_scanner::LooseFileScanner;
16use crate::discovery::ollama_scanner::OllamaStoreScanner;
17use crate::discovery::scanner::StoreScanner;
18use crate::fs::expand_tilde;
19use crate::records::SourceKind;
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct ModelsSettings {
25 pub watched_folders: Vec<String>,
27 pub hf_cache_roots: Vec<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct ModelHabitat {
34 home: PathBuf,
35 environment: HashMap<String, String>,
36}
37
38impl ModelHabitat {
39 pub fn new(home: impl Into<PathBuf>, environment: HashMap<String, String>) -> Self {
41 Self {
42 home: home.into(),
43 environment,
44 }
45 }
46
47 pub fn detect() -> Self {
52 let home = std::env::var_os("HOME")
53 .map(PathBuf::from)
54 .unwrap_or_default();
55 let environment = std::env::vars_os()
56 .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
57 .collect();
58 Self { home, environment }
59 }
60
61 pub fn roots(&self, settings: &ModelsSettings) -> Vec<(SourceKind, PathBuf)> {
63 let mut roots = vec![(SourceKind::ollama(), self.ollama_root())];
64 for url in self.hf_default_roots(&settings.hf_cache_roots) {
65 roots.push((SourceKind::huggingface_cache(), url));
66 }
67 for url in lm_studio_roots(&self.home) {
68 roots.push((SourceKind::lm_studio(), url));
69 }
70 for url in loose_directories(&self.home) {
71 roots.push((SourceKind::file(), url));
72 }
73 for path in &settings.watched_folders {
74 roots.push((SourceKind::file(), PathBuf::from(path)));
75 }
76 roots
77 }
78
79 pub fn scanners(
82 &self,
83 kinds: Option<&[SourceKind]>,
84 settings: &ModelsSettings,
85 ) -> Vec<Box<dyn StoreScanner>> {
86 let wanted = |produced: &[SourceKind]| match kinds {
87 None => true,
88 Some(kinds) => produced.iter().any(|kind| kinds.contains(kind)),
89 };
90 let mut scanners: Vec<Box<dyn StoreScanner>> = Vec::new();
91 if wanted(&[SourceKind::ollama()]) {
92 scanners.push(Box::new(OllamaStoreScanner::new(self.ollama_root())));
93 }
94 if wanted(&[SourceKind::huggingface_cache()]) {
95 scanners.push(Box::new(HFCacheScanner::with_user_roots(
100 self.hf_default_roots(&[]),
101 self.hf_user_roots(&settings.hf_cache_roots),
102 )));
103 }
104 if wanted(&[SourceKind::lm_studio()]) {
105 scanners.push(Box::new(LMStudioScanner::new(lm_studio_roots(&self.home))));
106 }
107 if wanted(&[SourceKind::file(), SourceKind::folder()]) {
108 let watched = settings.watched_folders.iter().map(PathBuf::from).collect();
109 scanners.push(Box::new(LooseFileScanner::with_user_directories(
110 loose_directories(&self.home),
111 watched,
112 )));
113 }
114 scanners
115 }
116
117 fn ollama_root(&self) -> PathBuf {
118 match self.environment.get("OLLAMA_MODELS") {
119 Some(custom) if !custom.is_empty() => expand_tilde(custom, &self.home),
120 _ => self.home.join(".ollama/models"),
121 }
122 }
123
124 fn hf_default_roots(&self, user: &[String]) -> Vec<PathBuf> {
127 let mut candidates = vec![hf_cache_root(&self.environment, &self.home)];
128 candidates.extend(self.hf_user_roots(user));
129 dedup(candidates)
130 }
131
132 fn hf_user_roots(&self, paths: &[String]) -> Vec<PathBuf> {
135 let mut roots = Vec::new();
136 for path in paths {
137 let base = expand_tilde(path, &self.home);
138 let candidates = [base.join("hub"), base.join("huggingface/hub"), base.clone()];
139 let existing: Vec<PathBuf> = candidates
140 .into_iter()
141 .filter(|url| is_hub_directory(url))
142 .collect();
143 if existing.is_empty() {
144 roots.push(base);
145 } else {
146 roots.extend(existing);
147 }
148 }
149 dedup(roots)
150 }
151}
152
153pub fn hf_home(environment: &HashMap<String, String>, home: &Path) -> PathBuf {
156 match non_empty(environment, "HF_HOME") {
157 Some(value) => expand_tilde(value, home),
158 None => home.join(".cache/huggingface"),
159 }
160}
161
162pub fn hf_cache_root(environment: &HashMap<String, String>, home: &Path) -> PathBuf {
171 match non_empty(environment, "HF_HUB_CACHE") {
172 Some(value) => expand_tilde(value, home),
173 None => hf_home(environment, home).join("hub"),
174 }
175}
176
177fn non_empty<'a>(environment: &'a HashMap<String, String>, key: &str) -> Option<&'a str> {
178 environment
179 .get(key)
180 .map(String::as_str)
181 .filter(|value| !value.is_empty())
182}
183
184fn lm_studio_roots(home: &Path) -> Vec<PathBuf> {
185 vec![
186 home.join(".lmstudio/models"),
187 home.join(".cache/lm-studio/models"),
188 ]
189}
190
191fn loose_directories(home: &Path) -> Vec<PathBuf> {
192 vec![home.join("Downloads"), home.join("Models")]
193}
194
195fn is_hub_directory(url: &Path) -> bool {
196 url.is_dir()
197}
198
199fn dedup(paths: Vec<PathBuf>) -> Vec<PathBuf> {
201 let mut seen = HashSet::new();
202 paths
203 .into_iter()
204 .filter(|path| seen.insert(path.clone()))
205 .collect()
206}