Skip to main content

kernel/discovery/
habitat.rs

1//! [`ModelHabitat`]: where models live on this machine. It computes the store
2//! roots (from the environment, the home directory, and user settings) and
3//! assembles the [`StoreScanner`]s that sweep them. This is the single place the
4//! four scanners are wired together and pointed at their default locations.
5//!
6//! The Apple-Foundation (builtin) scanner is not assembled here: its
7//! availability probe lives in the runtime crate's backend bridge, so callers
8//! append it to this list (see the runtime's `apple_foundation_scanner`).
9
10use 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/// The discovery-relevant model settings: user-added folders to watch and extra
22/// Hugging Face cache roots. (The full settings domain is not yet ported.)
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct ModelsSettings {
25    /// Extra directories the user asked to scan for loose files.
26    pub watched_folders: Vec<String>,
27    /// Extra Hugging Face cache roots the user configured.
28    pub hf_cache_roots: Vec<String>,
29}
30
31/// The machine's model locations, resolved from `home` and `environment`.
32#[derive(Debug, Clone)]
33pub struct ModelHabitat {
34    home: PathBuf,
35    environment: HashMap<String, String>,
36}
37
38impl ModelHabitat {
39    /// A habitat rooted at `home` with the given `environment`.
40    pub fn new(home: impl Into<PathBuf>, environment: HashMap<String, String>) -> Self {
41        Self {
42            home: home.into(),
43            environment,
44        }
45    }
46
47    /// A habitat detected from the process: `$HOME` and the current
48    /// environment, less any variable this process cannot read as text.
49    /// `std::env::vars` panics on one of those, and a store path is never
50    /// among them.
51    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    /// Every `(kind, root)` this habitat would scan, in order.
62    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    /// The scanners to run. If `kinds` is given, only scanners producing at least
80    /// one of those kinds are included; `None` includes all.
81    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            // The user roots go ONLY through `user_roots` (scanned as required);
96            // the default roots must exclude them (empty user list) so the scanner
97            // doesn't sweep a user root twice. `roots()` above intentionally does
98            // include them, since it enumerates the full set.
99            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    /// The machine's one hub cache, plus the user's configured roots,
125    /// de-duplicated in order.
126    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    /// For each user path, the hub subdirectories that exist (`hub`,
133    /// `huggingface/hub`, or the path itself), falling back to the bare path.
134    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
153/// The Hugging Face home directory: `$HF_HOME`, else `~/.cache/huggingface`.
154/// Both the hub cache and the login token file hang off it.
155pub 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
162/// The machine's Hugging Face hub cache: `$HF_HUB_CACHE`, else `$HF_HOME/hub`,
163/// else `~/.cache/huggingface/hub`.
164///
165/// There is exactly one, because that is the rule the hub's own tooling
166/// follows: the environment says where the cache *is*, replacing the default
167/// rather than adding to it. A cache elsewhere that should also be swept is a
168/// setting (`hf_cache_roots`), not an environment variable, so what a pull
169/// writes and what discovery reads can never come apart.
170pub 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
199/// De-duplicate paths, preserving first-seen order.
200fn 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}